diff --git a/backend/src/__mocks__/prisma.mock.ts b/backend/src/__mocks__/prisma.mock.ts new file mode 100644 index 0000000..af73948 --- /dev/null +++ b/backend/src/__mocks__/prisma.mock.ts @@ -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; + 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; +} diff --git a/backend/src/auth/admin.guard.spec.ts b/backend/src/auth/admin.guard.spec.ts new file mode 100644 index 0000000..a07034b --- /dev/null +++ b/backend/src/auth/admin.guard.spec.ts @@ -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); + }); +}); diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..b5434ae --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -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 + }); + }); +}); diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..040cc0c --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -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); + }); + }); +}); diff --git a/backend/src/auth/jwt-auth.guard.spec.ts b/backend/src/auth/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..0940c5a --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.spec.ts @@ -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); + }); +}); diff --git a/backend/src/auth/ws-auth.guard.spec.ts b/backend/src/auth/ws-auth.guard.spec.ts new file mode 100644 index 0000000..d78ebf8 --- /dev/null +++ b/backend/src/auth/ws-auth.guard.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/vault/credentials.service.spec.ts b/backend/src/vault/credentials.service.spec.ts new file mode 100644 index 0000000..73ae8fe --- /dev/null +++ b/backend/src/vault/credentials.service.spec.ts @@ -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); + }); + }); +}); diff --git a/backend/src/vault/encryption.service.spec.ts b/backend/src/vault/encryption.service.spec.ts new file mode 100644 index 0000000..2d45475 --- /dev/null +++ b/backend/src/vault/encryption.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/backend/src/vault/ssh-keys.service.spec.ts b/backend/src/vault/ssh-keys.service.spec.ts new file mode 100644 index 0000000..3882c75 --- /dev/null +++ b/backend/src/vault/ssh-keys.service.spec.ts @@ -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); + }); + }); +}); diff --git a/backend/test/auth.service.spec.ts b/backend/test/auth.service.spec.ts deleted file mode 100644 index be98f40..0000000 --- a/backend/test/auth.service.spec.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/backend/test/encryption.service.spec.ts b/backend/test/encryption.service.spec.ts deleted file mode 100644 index 6e2db86..0000000 --- a/backend/test/encryption.service.spec.ts +++ /dev/null @@ -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); - }); -});