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:
parent
f01e357647
commit
63315f94c4
43
backend/src/__mocks__/prisma.mock.ts
Normal file
43
backend/src/__mocks__/prisma.mock.ts
Normal 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;
|
||||
}
|
||||
30
backend/src/auth/admin.guard.spec.ts
Normal file
30
backend/src/auth/admin.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
99
backend/src/auth/auth.controller.spec.ts
Normal file
99
backend/src/auth/auth.controller.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
204
backend/src/auth/auth.service.spec.ts
Normal file
204
backend/src/auth/auth.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
backend/src/auth/jwt-auth.guard.spec.ts
Normal file
13
backend/src/auth/jwt-auth.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
backend/src/auth/ws-auth.guard.spec.ts
Normal file
56
backend/src/auth/ws-auth.guard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
178
backend/src/vault/credentials.service.spec.ts
Normal file
178
backend/src/vault/credentials.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
backend/src/vault/encryption.service.spec.ts
Normal file
85
backend/src/vault/encryption.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
backend/src/vault/ssh-keys.service.spec.ts
Normal file
147
backend/src/vault/ssh-keys.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user