test: backend test suite — 8 spec files covering vault encryption, credentials, SSH keys, auth service, controller, guards

63 tests across 8 spec files, all passing. Removes 2 stale stub files from
backend/test/ that were incompatible with the current async EncryptionService
and 3-argument AuthService constructor. New suite lives in src/ co-located
with source files per NestJS convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 19:10:12 -04:00
parent f01e357647
commit 63315f94c4
11 changed files with 855 additions and 102 deletions

View File

@ -0,0 +1,43 @@
// backend/src/__mocks__/prisma.mock.ts
export const mockPrismaService = {
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
credential: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sshKey: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
connectionLog: {
create: jest.fn(),
updateMany: jest.fn(),
},
};
export function createMockPrisma() {
// Deep clone to prevent test bleed — re-attach jest.fn() since JSON.parse loses functions
const models = Object.keys(mockPrismaService) as Array<keyof typeof mockPrismaService>;
const mock: any = {};
for (const model of models) {
mock[model] = {};
const methods = Object.keys(mockPrismaService[model]);
for (const method of methods) {
mock[model][method] = jest.fn();
}
}
return mock;
}

View File

@ -0,0 +1,30 @@
// backend/src/auth/admin.guard.spec.ts
import { AdminGuard } from './admin.guard';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
function createMockContext(user: any): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as any;
}
describe('AdminGuard', () => {
const guard = new AdminGuard();
it('should allow request when user has admin role', () => {
const ctx = createMockContext({ role: 'admin' });
expect(guard.canActivate(ctx)).toBe(true);
});
it('should throw ForbiddenException for non-admin role', () => {
const ctx = createMockContext({ role: 'user' });
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('should throw ForbiddenException when user is undefined (unauthenticated)', () => {
const ctx = createMockContext(undefined);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
});

View File

@ -0,0 +1,99 @@
// backend/src/auth/auth.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
describe('AuthController', () => {
let controller: AuthController;
let authService: any;
beforeEach(async () => {
authService = {
login: jest.fn(),
getProfile: jest.fn(),
totpSetup: jest.fn(),
listUsers: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: JwtService, useValue: { sign: jest.fn(), verify: jest.fn() } },
],
}).compile();
controller = module.get(AuthController);
});
describe('login', () => {
it('should set httpOnly cookie and return user without access_token in body', async () => {
authService.login.mockResolvedValue({
access_token: 'jwt-token',
user: { id: 1, email: 'test@test.com', displayName: 'Test', role: 'admin' },
});
const res = { cookie: jest.fn() };
const result = await controller.login(
{ email: 'test@test.com', password: 'pass' } as any,
res,
);
expect(res.cookie).toHaveBeenCalledWith(
'wraith_token',
'jwt-token',
expect.objectContaining({
httpOnly: true,
sameSite: 'strict',
path: '/',
}),
);
expect(result).toEqual({ user: expect.objectContaining({ email: 'test@test.com' }) });
// Token must NOT be in the response body (C-2 security requirement)
expect(result).not.toHaveProperty('access_token');
});
it('should pass through requires_totp without setting cookie', async () => {
authService.login.mockResolvedValue({ requires_totp: true });
const res = { cookie: jest.fn() };
const result = await controller.login(
{ email: 'test@test.com', password: 'pass' } as any,
res,
);
expect(res.cookie).not.toHaveBeenCalled();
expect(result).toEqual({ requires_totp: true });
});
});
describe('logout', () => {
it('should clear the wraith_token cookie', () => {
const res = { clearCookie: jest.fn() };
const result = controller.logout(res);
expect(res.clearCookie).toHaveBeenCalledWith('wraith_token', { path: '/' });
expect(result).toEqual({ message: 'Logged out' });
});
});
describe('ws-ticket', () => {
it('should issue a 64-char hex ticket', () => {
const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } };
const result = controller.issueWsTicket(req);
expect(result).toHaveProperty('ticket');
expect(result.ticket).toHaveLength(64); // 32 random bytes → 64 hex chars
});
it('should consume ticket exactly once (single-use)', () => {
const req = { user: { sub: 5, email: 'once@test.com', role: 'user' } };
const { ticket } = controller.issueWsTicket(req);
const first = AuthController.consumeWsTicket(ticket);
expect(first).toEqual(expect.objectContaining({ sub: 5, email: 'once@test.com' }));
const second = AuthController.consumeWsTicket(ticket);
expect(second).toBeNull(); // Ticket is deleted after first use
});
});
});

View File

@ -0,0 +1,204 @@
// backend/src/auth/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { EncryptionService } from '../vault/encryption.service';
import {
UnauthorizedException,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import * as argon2 from 'argon2';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
let prisma: any;
let jwt: any;
let encryption: any;
beforeEach(async () => {
prisma = {
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
jwt = { sign: jest.fn().mockReturnValue('mock-jwt-token') };
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:encrypted'),
decrypt: jest.fn().mockResolvedValue('decrypted-secret'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: JwtService, useValue: jwt },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(AuthService);
// onModuleInit pre-computes the dummy hash for timing-safe login
await service.onModuleInit();
});
describe('login', () => {
it('should return access_token and user for valid Argon2id credentials', async () => {
const hash = await argon2.hash('correct-password', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'test@test.com',
passwordHash: hash,
displayName: 'Test User',
role: 'admin',
totpEnabled: false,
});
const result = await service.login('test@test.com', 'correct-password');
expect(result).toHaveProperty('access_token', 'mock-jwt-token');
expect((result as any).user.email).toBe('test@test.com');
});
it('should throw UnauthorizedException for wrong password', async () => {
const hash = await argon2.hash('correct', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'test@test.com',
passwordHash: hash,
totpEnabled: false,
});
await expect(service.login('test@test.com', 'wrong')).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for non-existent user (constant time defense)', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(service.login('nobody@test.com', 'pass')).rejects.toThrow(UnauthorizedException);
});
it('should auto-upgrade bcrypt hash to Argon2id on successful login', async () => {
const bcryptHash = await bcrypt.hash('password', 10);
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'legacy@test.com',
passwordHash: bcryptHash,
displayName: 'Legacy User',
role: 'user',
totpEnabled: false,
});
prisma.user.update.mockResolvedValue({});
await service.login('legacy@test.com', 'password');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
data: expect.objectContaining({
passwordHash: expect.stringContaining('$argon2id$'),
}),
}),
);
});
it('should return { requires_totp: true } when TOTP enabled but no code provided', async () => {
const hash = await argon2.hash('pass', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'totp@test.com',
passwordHash: hash,
totpEnabled: true,
totpSecret: 'v2:encrypted-secret',
});
const result = await service.login('totp@test.com', 'pass');
expect(result).toEqual({ requires_totp: true });
});
});
describe('createUser', () => {
it('should hash password with Argon2id before storage', async () => {
prisma.user.findUnique.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({
id: 1,
email: 'new@test.com',
displayName: null,
role: 'user',
});
await service.createUser({ email: 'new@test.com', password: 'StrongPass1!' });
const createCall = prisma.user.create.mock.calls[0][0];
expect(createCall.data.passwordHash).toMatch(/^\$argon2id\$/);
});
it('should throw BadRequestException for duplicate email', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1 });
await expect(
service.createUser({ email: 'dup@test.com', password: 'pass' }),
).rejects.toThrow(BadRequestException);
});
});
describe('adminDeleteUser', () => {
it('should throw ForbiddenException on self-deletion attempt', async () => {
await expect(service.adminDeleteUser(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should delete a different user', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 2 });
prisma.user.delete.mockResolvedValue({ id: 2 });
await service.adminDeleteUser(2, 1);
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 2 } });
});
it('should throw NotFoundException when target user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(service.adminDeleteUser(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('totpSetup', () => {
it('should encrypt TOTP secret before storage and return secret + qrCode', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, email: 'test@test.com', totpEnabled: false });
prisma.user.update.mockResolvedValue({});
const result = await service.totpSetup(1);
expect(encryption.encrypt).toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ totpSecret: 'v2:encrypted' }),
}),
);
expect(result).toHaveProperty('secret');
expect(result).toHaveProperty('qrCode');
});
it('should throw BadRequestException if TOTP already enabled', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: true });
await expect(service.totpSetup(1)).rejects.toThrow(BadRequestException);
});
});
describe('updateProfile', () => {
it('should throw BadRequestException when changing password without providing current password', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, passwordHash: 'hash' });
await expect(
service.updateProfile(1, { newPassword: 'new-password' }),
).rejects.toThrow(BadRequestException);
});
});
describe('adminUpdateUser', () => {
it('should throw ForbiddenException when admin tries to remove their own admin role', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, role: 'admin' });
await expect(service.adminUpdateUser(1, 1, { role: 'user' })).rejects.toThrow(ForbiddenException);
});
});
});

View File

@ -0,0 +1,13 @@
// backend/src/auth/jwt-auth.guard.spec.ts
import { JwtAuthGuard } from './jwt-auth.guard';
describe('JwtAuthGuard', () => {
it('should be defined', () => {
expect(new JwtAuthGuard()).toBeDefined();
});
it('should be an instance of JwtAuthGuard', () => {
const guard = new JwtAuthGuard();
expect(guard).toBeInstanceOf(JwtAuthGuard);
});
});

View File

@ -0,0 +1,56 @@
// backend/src/auth/ws-auth.guard.spec.ts
import { WsAuthGuard } from './ws-auth.guard';
import { AuthController } from './auth.controller';
describe('WsAuthGuard', () => {
let guard: WsAuthGuard;
let jwt: any;
beforeEach(() => {
jwt = {
verify: jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com' }),
};
guard = new WsAuthGuard(jwt as any);
});
it('should authenticate via httpOnly cookie', () => {
const req = { headers: { cookie: 'wraith_token=valid-jwt; other=stuff' }, url: '/ws' };
const result = guard.validateClient({}, req);
expect(jwt.verify).toHaveBeenCalledWith('valid-jwt');
expect(result).toEqual({ sub: 1, email: 'test@test.com' });
});
it('should authenticate via single-use WS ticket', () => {
const originalConsume = AuthController.consumeWsTicket;
AuthController.consumeWsTicket = jest.fn().mockReturnValue({
sub: 42,
email: 'ticket@test.com',
role: 'user',
});
const req = { url: '/api/ws/terminal?ticket=abc123', headers: {} };
const result = guard.validateClient({}, req);
expect(AuthController.consumeWsTicket).toHaveBeenCalledWith('abc123');
expect(result).toEqual({ sub: 42, email: 'ticket@test.com' });
// Restore
AuthController.consumeWsTicket = originalConsume;
});
it('should fall back to legacy URL token when no cookie or ticket', () => {
const req = { url: '/api/ws/terminal?token=legacy-jwt', headers: {} };
guard.validateClient({}, req);
expect(jwt.verify).toHaveBeenCalledWith('legacy-jwt');
});
it('should return null when no credentials are present', () => {
const req = { url: '/api/ws/terminal', headers: {} };
const result = guard.validateClient({}, req);
expect(result).toBeNull();
});
it('should return null when JWT verification fails', () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid signature'); });
const req = { headers: { cookie: 'wraith_token=bad-jwt' }, url: '/ws' };
const result = guard.validateClient({}, req);
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,178 @@
// backend/src/vault/credentials.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CredentialsService } from './credentials.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('CredentialsService', () => {
let service: CredentialsService;
let prisma: any;
let encryption: any;
beforeEach(async () => {
prisma = {
credential: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
decrypt: jest.fn().mockResolvedValue('decrypted-password'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
CredentialsService,
{ provide: PrismaService, useValue: prisma },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(CredentialsService);
});
describe('findAll', () => {
it('should query by userId and exclude encryptedValue from select', async () => {
prisma.credential.findMany.mockResolvedValue([{ id: 1, name: 'test' }]);
await service.findAll(1);
expect(prisma.credential.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 1 },
select: expect.objectContaining({ id: true, name: true }),
}),
);
// encryptedValue must never appear in the list response (H-10)
const call = prisma.credential.findMany.mock.calls[0][0];
expect(call.select.encryptedValue).toBeUndefined();
});
});
describe('findOne', () => {
it('should return credential for owner', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'cred', sshKey: null, hosts: [] });
const result = await service.findOne(1, 1);
expect(result.name).toBe('cred');
});
it('should throw ForbiddenException when userId does not match', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'cred', sshKey: null, hosts: [] });
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing credential', async () => {
prisma.credential.findUnique.mockResolvedValue(null);
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should encrypt password before storage', async () => {
prisma.credential.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'test',
username: 'admin',
password: 'secret',
type: 'password' as any,
});
expect(encryption.encrypt).toHaveBeenCalledWith('secret');
expect(prisma.credential.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ encryptedValue: 'v2:mock:encrypted' }),
}),
);
});
it('should store null encryptedValue when no password provided', async () => {
prisma.credential.create.mockResolvedValue({ id: 2 });
await service.create(1, { name: 'ssh-only', type: 'ssh_key' as any, sshKeyId: 5 });
expect(encryption.encrypt).not.toHaveBeenCalled();
expect(prisma.credential.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ encryptedValue: null }),
}),
);
});
});
describe('decryptForConnection', () => {
it('should decrypt password credential', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
username: 'admin',
domain: null,
encryptedValue: 'v2:encrypted',
sshKey: null,
sshKeyId: null,
});
const result = await service.decryptForConnection(1);
expect(result.password).toBe('decrypted-password');
expect(result.username).toBe('admin');
});
it('should decrypt SSH key credential with passphrase', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
username: 'root',
domain: null,
encryptedValue: null,
sshKey: { encryptedPrivateKey: 'v2:key', passphraseEncrypted: 'v2:pass' },
sshKeyId: 5,
});
encryption.decrypt
.mockResolvedValueOnce('private-key-content')
.mockResolvedValueOnce('passphrase');
const result = await service.decryptForConnection(1);
expect(result.sshKey).toEqual({ privateKey: 'private-key-content', passphrase: 'passphrase' });
});
it('should throw NotFoundException for orphaned SSH key reference', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
name: 'orphan',
username: 'root',
domain: null,
encryptedValue: null,
sshKey: null,
sshKeyId: 99,
});
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for credential with no auth method', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
name: 'empty',
username: 'root',
domain: null,
encryptedValue: null,
sshKey: null,
sshKeyId: null,
});
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for missing credential', async () => {
prisma.credential.findUnique.mockResolvedValue(null);
await expect(service.decryptForConnection(99)).rejects.toThrow(NotFoundException);
});
});
describe('remove', () => {
it('should delete owned credential', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, sshKey: null, hosts: [] });
prisma.credential.delete.mockResolvedValue({ id: 1 });
await service.remove(1, 1);
expect(prisma.credential.delete).toHaveBeenCalledWith({ where: { id: 1 } });
});
it('should throw ForbiddenException when removing non-owned credential', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, sshKey: null, hosts: [] });
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
});
});
});

View File

@ -0,0 +1,85 @@
// backend/src/vault/encryption.service.spec.ts
// Set test encryption key before importing the service — constructor reads process.env.ENCRYPTION_KEY
process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 64 hex chars = 32 bytes
import { EncryptionService } from './encryption.service';
describe('EncryptionService', () => {
let service: EncryptionService;
beforeAll(async () => {
service = new EncryptionService();
await service.onModuleInit();
});
describe('encrypt/decrypt round-trip', () => {
it('should encrypt and decrypt a string', async () => {
const plaintext = 'my-secret-password';
const encrypted = await service.encrypt(plaintext);
expect(encrypted).toMatch(/^v2:/);
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext (random salt + IV)', async () => {
const plaintext = 'same-input';
const a = await service.encrypt(plaintext);
const b = await service.encrypt(plaintext);
expect(a).not.toBe(b);
});
it('should handle empty string', async () => {
const encrypted = await service.encrypt('');
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe('');
});
it('should handle unicode and emoji', async () => {
const plaintext = '密码 пароль 🔐';
const encrypted = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
});
describe('v2 format structure', () => {
it('should produce a 5-part colon-delimited ciphertext', async () => {
const encrypted = await service.encrypt('test');
const parts = encrypted.split(':');
expect(parts[0]).toBe('v2');
expect(parts).toHaveLength(5); // v2:salt:iv:authTag:ciphertext
});
});
describe('isV1', () => {
it('should detect v1 format', () => {
expect(service.isV1('v1:abc123:def456:ghi789')).toBe(true);
});
it('should not classify v2 as v1', () => {
expect(service.isV1('v2:abc:def:ghi:jkl')).toBe(false);
});
});
describe('upgradeToV2', () => {
it('should return null for already-v2 ciphertext', async () => {
const v2 = await service.encrypt('test');
const result = await service.upgradeToV2(v2);
expect(result).toBeNull();
});
});
describe('error handling', () => {
it('should throw on unknown encryption version', async () => {
await expect(service.decrypt('v3:bad:data')).rejects.toThrow('Unknown encryption version');
});
it('should throw on tampered ciphertext (auth tag mismatch)', async () => {
const encrypted = await service.encrypt('test');
// Corrupt the last 4 hex chars of the ciphertext segment
const tampered = encrypted.slice(0, -4) + 'dead';
await expect(service.decrypt(tampered)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,147 @@
// backend/src/vault/ssh-keys.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { SshKeysService } from './ssh-keys.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('SshKeysService', () => {
let service: SshKeysService;
let prisma: any;
let encryption: any;
beforeEach(async () => {
prisma = {
sshKey: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SshKeysService,
{ provide: PrismaService, useValue: prisma },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(SshKeysService);
});
describe('findAll', () => {
it('should return keys without private key data', async () => {
prisma.sshKey.findMany.mockResolvedValue([{ id: 1, name: 'key1' }]);
await service.findAll(1);
const call = prisma.sshKey.findMany.mock.calls[0][0];
expect(call.select.encryptedPrivateKey).toBeUndefined();
expect(call.select.passphraseEncrypted).toBeUndefined();
});
});
describe('findOne', () => {
it('should return key for owner without encrypted private key field', async () => {
prisma.sshKey.findUnique.mockResolvedValue({
id: 1,
userId: 1,
name: 'key1',
keyType: 'ed25519',
fingerprint: 'SHA256:abc',
publicKey: null,
credentials: [],
createdAt: new Date(),
encryptedPrivateKey: 'v2:secret',
});
const result = await service.findOne(1, 1);
expect(result.name).toBe('key1');
// findOne strips encryptedPrivateKey from the returned object
expect((result as any).encryptedPrivateKey).toBeUndefined();
});
it('should throw ForbiddenException for non-owner', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'key1', credentials: [] });
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing key', async () => {
prisma.sshKey.findUnique.mockResolvedValue(null);
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should encrypt both private key and passphrase', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'my-key',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
passphrase: 'secret',
});
expect(encryption.encrypt).toHaveBeenCalledTimes(2);
});
it('should detect RSA key type', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'rsa-key',
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'rsa' }),
}),
);
});
it('should detect ed25519 key type from OPENSSH header', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'ed25519-key',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'ed25519' }),
}),
);
});
it('should detect ECDSA key type', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'ecdsa-key',
privateKey: '-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'ecdsa' }),
}),
);
});
});
describe('remove', () => {
it('should delete owned key', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1 });
prisma.sshKey.delete.mockResolvedValue({ id: 1 });
await service.remove(1, 1);
expect(prisma.sshKey.delete).toHaveBeenCalledWith({ where: { id: 1 } });
});
it('should throw ForbiddenException for non-owner', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 });
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing key', async () => {
prisma.sshKey.findUnique.mockResolvedValue(null);
await expect(service.remove(99, 1)).rejects.toThrow(NotFoundException);
});
});
});

View File

@ -1,56 +0,0 @@
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../src/auth/auth.service';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(),
},
};
const mockJwt = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
};
beforeEach(() => {
service = new AuthService(mockPrisma as any, mockJwt as any);
jest.clearAllMocks();
});
it('returns token for valid credentials', async () => {
const hash = await bcrypt.hash('password123', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'admin@wraith.local',
passwordHash: hash,
displayName: 'Admin',
});
const result = await service.login('admin@wraith.local', 'password123');
expect(result).toEqual({
access_token: 'mock-jwt-token',
user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' },
});
});
it('throws on wrong password', async () => {
const hash = await bcrypt.hash('correct', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'admin@wraith.local',
passwordHash: hash,
});
await expect(service.login('admin@wraith.local', 'wrong'))
.rejects.toThrow('Invalid credentials');
});
it('throws on unknown user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(service.login('nobody@wraith.local', 'pass'))
.rejects.toThrow('Invalid credentials');
});
});

View File

@ -1,46 +0,0 @@
import { EncryptionService } from '../src/vault/encryption.service';
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(() => {
// 32-byte key as 64-char hex string
process.env.ENCRYPTION_KEY = 'a'.repeat(64);
service = new EncryptionService();
});
it('encrypts and decrypts a string', () => {
const plaintext = 'my-secret-password';
const encrypted = service.encrypt(plaintext);
expect(encrypted).not.toEqual(plaintext);
expect(encrypted.startsWith('v1:')).toBe(true);
expect(service.decrypt(encrypted)).toEqual(plaintext);
});
it('produces different ciphertext for same plaintext (random IV)', () => {
const plaintext = 'same-input';
const a = service.encrypt(plaintext);
const b = service.encrypt(plaintext);
expect(a).not.toEqual(b);
expect(service.decrypt(a)).toEqual(plaintext);
expect(service.decrypt(b)).toEqual(plaintext);
});
it('throws on tampered ciphertext', () => {
const encrypted = service.encrypt('test');
const parts = encrypted.split(':');
parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext
expect(() => service.decrypt(parts.join(':'))).toThrow();
});
it('handles empty string', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toEqual('');
});
it('handles unicode', () => {
const plaintext = 'p@$$w0rd-日本語-🔑';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toEqual(plaintext);
});
});