# Wraith Remote — Test Suite Build-Out Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a full service-layer test suite (~96 tests) covering all backend services, guards, and controller logic plus frontend stores, composables, and middleware. **Architecture:** Backend uses NestJS Testing Module with Jest. Frontend uses Vitest + happy-dom with mocked Nuxt auto-imports ($fetch, navigateTo). All tests are unit tests with mocked dependencies — no real database or network calls. **Tech Stack:** Jest 29 (backend, already configured), Vitest (frontend, new), @nestjs/testing, @vue/test-utils, @pinia/testing, happy-dom --- ## File Structure ### Backend (existing Jest infrastructure, new test files) | File | Purpose | |------|---------| | `backend/src/__mocks__/prisma.mock.ts` | Shared PrismaService mock factory | | `backend/src/vault/encryption.service.spec.ts` | Encryption round-trip, v1/v2, upgrade | | `backend/src/vault/credentials.service.spec.ts` | Credential CRUD, decryptForConnection | | `backend/src/vault/ssh-keys.service.spec.ts` | SSH key CRUD, key type detection | | `backend/src/auth/auth.service.spec.ts` | Login, password hashing, TOTP, admin CRUD | | `backend/src/auth/auth.controller.spec.ts` | Cookie auth, WS tickets, route wiring | | `backend/src/auth/jwt-auth.guard.spec.ts` | JWT guard pass/reject | | `backend/src/auth/admin.guard.spec.ts` | Admin role enforcement | | `backend/src/auth/ws-auth.guard.spec.ts` | Cookie, ticket, legacy token auth | ### Frontend (new Vitest infrastructure + test files) | File | Purpose | |------|---------| | `frontend/vitest.config.ts` | Vitest config with happy-dom | | `frontend/tests/setup.ts` | Global mocks for $fetch, navigateTo | | `frontend/tests/stores/auth.store.spec.ts` | Auth store login/logout/profile | | `frontend/tests/stores/connection.store.spec.ts` | Host/group CRUD | | `frontend/tests/composables/useVault.spec.ts` | Vault API calls | | `frontend/tests/middleware/admin.spec.ts` | Admin route guard | --- ## Chunk 1: Backend Test Infrastructure + Encryption Tests ### Task 1: Create Prisma Mock Factory **Files:** - Create: `backend/src/__mocks__/prisma.mock.ts` - [ ] **Step 1: Create the shared mock** ```typescript // 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 const mock = JSON.parse(JSON.stringify(mockPrismaService)); // Re-attach jest.fn() since JSON.parse loses functions for (const model of Object.keys(mock)) { for (const method of Object.keys(mock[model])) { mock[model][method] = jest.fn(); } } return mock; } ``` - [ ] **Step 2: Commit** ```bash git add backend/src/__mocks__/prisma.mock.ts git commit -m "test: add shared Prisma mock factory" ``` ### Task 2: Encryption Service Tests **Files:** - Create: `backend/src/vault/encryption.service.spec.ts` - Reference: `backend/src/vault/encryption.service.ts` - [ ] **Step 1: Write encryption tests** ```typescript // backend/src/vault/encryption.service.spec.ts import { EncryptionService } from './encryption.service'; // Set test encryption key before importing service process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 32 bytes hex 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', async () => { const plaintext = 'same-input'; const a = await service.encrypt(plaintext); const b = await service.encrypt(plaintext); expect(a).not.toBe(b); // Different salts + IVs }); it('should handle empty string', async () => { const encrypted = await service.encrypt(''); const decrypted = await service.decrypt(encrypted); expect(decrypted).toBe(''); }); it('should handle unicode', async () => { const plaintext = '密码 пароль 🔐'; const encrypted = await service.encrypt(plaintext); const decrypted = await service.decrypt(encrypted); expect(decrypted).toBe(plaintext); }); }); describe('v2 format', () => { it('should produce v2-prefixed 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:abc:def:ghi')).toBe(true); }); it('should not detect v2 as v1', () => { expect(service.isV1('v2:abc:def:ghi:jkl')).toBe(false); }); }); describe('upgradeToV2', () => { it('should return null for 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 version', async () => { await expect(service.decrypt('v3:bad:data')).rejects.toThrow('Unknown encryption version'); }); it('should throw on tampered ciphertext', async () => { const encrypted = await service.encrypt('test'); const tampered = encrypted.slice(0, -4) + 'dead'; await expect(service.decrypt(tampered)).rejects.toThrow(); }); }); }); ``` - [ ] **Step 2: Run tests to verify they pass** Run: `cd backend && npx jest src/vault/encryption.service.spec.ts --verbose` Expected: 8 tests PASS - [ ] **Step 3: Commit** ```bash git add backend/src/vault/encryption.service.spec.ts git commit -m "test: encryption service — round-trip, v1/v2 format, upgrade, error handling" ``` --- ## Chunk 2: Backend Vault Service Tests ### Task 3: Credentials Service Tests **Files:** - Create: `backend/src/vault/credentials.service.spec.ts` - Reference: `backend/src/vault/credentials.service.ts` - [ ] **Step 1: Write credentials service tests** ```typescript // 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 return credentials without encryptedValue', async () => { prisma.credential.findMany.mockResolvedValue([{ id: 1, name: 'test' }]); const result = await service.findAll(1); expect(prisma.credential.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 1 }, select: expect.objectContaining({ id: true, name: true }), }), ); // Verify encryptedValue is NOT in select 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' }); const result = await service.findOne(1, 1); expect(result.name).toBe('cred'); }); it('should throw ForbiddenException for non-owner', async () => { prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'cred' }); 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' }), }), ); }); }); 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', 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'); encryption.decrypt.mockResolvedValueOnce('passphrase'); const result = await service.decryptForConnection(1); expect(result.sshKey).toEqual({ privateKey: 'private-key-content', passphrase: 'passphrase' }); }); it('should throw 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 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); }); }); describe('remove', () => { it('should delete owned credential', async () => { prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1 }); prisma.credential.delete.mockResolvedValue({ id: 1 }); await service.remove(1, 1); expect(prisma.credential.delete).toHaveBeenCalledWith({ where: { id: 1 } }); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd backend && npx jest src/vault/credentials.service.spec.ts --verbose` Expected: 10 tests PASS - [ ] **Step 3: Commit** ```bash git add backend/src/vault/credentials.service.spec.ts git commit -m "test: credentials service — CRUD, ownership, decryptForConnection" ``` ### Task 4: SSH Keys Service Tests **Files:** - Create: `backend/src/vault/ssh-keys.service.spec.ts` - Reference: `backend/src/vault/ssh-keys.service.ts` - [ ] **Step 1: Write SSH keys tests** ```typescript // 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', async () => { prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'key1', keyType: 'ed25519' }); const result = await service.findOne(1, 1); expect(result.name).toBe('key1'); // Should not include encrypted private key expect((result as any).encryptedPrivateKey).toBeUndefined(); }); it('should throw ForbiddenException for non-owner', async () => { prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 }); await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); }); }); describe('create', () => { it('should encrypt 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 format', 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' }), }), ); }); }); 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).toHaveBeenCalled(); }); it('should throw for non-owner', async () => { prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 }); await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd backend && npx jest src/vault/ssh-keys.service.spec.ts --verbose` Expected: 8 tests PASS - [ ] **Step 3: Commit** ```bash git add backend/src/vault/ssh-keys.service.spec.ts git commit -m "test: SSH keys service — CRUD, ownership, key type detection, encryption" ``` --- ## Chunk 3: Backend Auth Tests ### Task 5: Auth Guards Tests **Files:** - Create: `backend/src/auth/jwt-auth.guard.spec.ts` - Create: `backend/src/auth/admin.guard.spec.ts` - Create: `backend/src/auth/ws-auth.guard.spec.ts` - [ ] **Step 1: Write JWT auth guard test** ```typescript // 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 extend AuthGuard("jwt")', () => { const guard = new JwtAuthGuard(); expect(guard).toBeInstanceOf(JwtAuthGuard); }); }); ``` - [ ] **Step 2: Write admin guard test** ```typescript // 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 admin role', () => { const ctx = createMockContext({ role: 'admin' }); expect(guard.canActivate(ctx)).toBe(true); }); it('should reject non-admin role', () => { const ctx = createMockContext({ role: 'user' }); expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); }); it('should reject missing user', () => { const ctx = createMockContext(undefined); expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); }); }); ``` - [ ] **Step 3: Write WS auth guard test** ```typescript // backend/src/auth/ws-auth.guard.spec.ts import { WsAuthGuard } from './ws-auth.guard'; import { JwtService } from '@nestjs/jwt'; 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 cookie', () => { const req = { headers: { cookie: 'wraith_token=valid-jwt; other=stuff' } }; const result = guard.validateClient({}, req); expect(jwt.verify).toHaveBeenCalledWith('valid-jwt'); expect(result).toEqual({ sub: 1, email: 'test@test.com' }); }); it('should authenticate via WS ticket', () => { const originalConsume = AuthController.consumeWsTicket; AuthController.consumeWsTicket = jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com', role: 'admin' }); const req = { url: '/api/ws/terminal?ticket=abc123', headers: {} }; const result = guard.validateClient({}, req); expect(result).toEqual({ sub: 1, email: 'test@test.com' }); AuthController.consumeWsTicket = originalConsume; }); it('should fall back to legacy URL token', () => { const req = { url: '/api/ws/terminal?token=legacy-jwt', headers: {} }; guard.validateClient({}, req); expect(jwt.verify).toHaveBeenCalledWith('legacy-jwt'); }); it('should return null for no credentials', () => { const req = { url: '/api/ws/terminal', headers: {} }; const result = guard.validateClient({}, req); expect(result).toBeNull(); }); it('should return null for invalid JWT', () => { jwt.verify.mockImplementation(() => { throw new Error('invalid'); }); const req = { headers: { cookie: 'wraith_token=bad-jwt' } }; const result = guard.validateClient({}, req); expect(result).toBeNull(); }); }); ``` - [ ] **Step 4: Run all guard tests** Run: `cd backend && npx jest src/auth/*.guard.spec.ts --verbose` Expected: 10 tests PASS - [ ] **Step 5: Commit** ```bash git add backend/src/auth/jwt-auth.guard.spec.ts backend/src/auth/admin.guard.spec.ts backend/src/auth/ws-auth.guard.spec.ts git commit -m "test: auth guards — JWT, admin, WS (cookie, ticket, legacy token)" ``` ### Task 6: Auth Service Tests **Files:** - Create: `backend/src/auth/auth.service.spec.ts` - Reference: `backend/src/auth/auth.service.ts` - [ ] **Step 1: Write auth service tests** ```typescript // 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); await service.onModuleInit(); }); describe('login', () => { it('should return access_token and user for valid 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', role: 'admin', totpEnabled: false, }); const result = await service.login('test@test.com', 'correct-password'); expect(result).toHaveProperty('access_token', 'mock-jwt-token'); expect(result).toHaveProperty('user'); expect((result as any).user.email).toBe('test@test.com'); }); it('should throw 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 for non-existent user (constant time)', 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 login', async () => { const bcryptHash = await bcrypt.hash('password', 10); prisma.user.findUnique.mockResolvedValue({ id: 1, email: 'legacy@test.com', passwordHash: bcryptHash, displayName: 'Legacy', role: 'user', totpEnabled: false, }); prisma.user.update.mockResolvedValue({}); await service.login('legacy@test.com', 'password'); // Should have called update to upgrade the hash expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 1 }, data: expect.objectContaining({ passwordHash: expect.stringContaining('$argon2id$'), }), }), ); }); it('should return requires_totp 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', async () => { prisma.user.findUnique.mockResolvedValue(null); // no existing user 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 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 prevent self-deletion', async () => { await expect(service.adminDeleteUser(1, 1)).rejects.toThrow(ForbiddenException); }); it('should delete another 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 } }); }); }); describe('totpSetup', () => { it('should encrypt TOTP secret before storage', async () => { prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: false }); prisma.user.update.mockResolvedValue({}); const result = await service.totpSetup(1); expect(encryption.encrypt).toHaveBeenCalled(); expect(result).toHaveProperty('secret'); expect(result).toHaveProperty('qrCode'); }); it('should throw 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 require current password for password change', async () => { prisma.user.findUnique.mockResolvedValue({ id: 1, passwordHash: 'hash' }); await expect(service.updateProfile(1, { newPassword: 'new' })) .rejects.toThrow(BadRequestException); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd backend && npx jest src/auth/auth.service.spec.ts --verbose` Expected: ~12 tests PASS - [ ] **Step 3: Commit** ```bash git add backend/src/auth/auth.service.spec.ts git commit -m "test: auth service — login, bcrypt migration, TOTP, admin CRUD, profile" ``` ### Task 7: Auth Controller Tests **Files:** - Create: `backend/src/auth/auth.controller.spec.ts` - [ ] **Step 1: Write auth controller tests** ```typescript // 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 on successful login', async () => { authService.login.mockResolvedValue({ access_token: 'jwt-token', user: { id: 1, email: 'test@test.com', 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 should NOT be in response body 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 cookie', () => { const res = { clearCookie: jest.fn() }; const result = controller.logout(res); expect(res.clearCookie).toHaveBeenCalledWith('wraith_token', { path: '/' }); }); }); describe('ws-ticket', () => { it('should issue a 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 bytes hex }); it('should consume ticket exactly once', () => { const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } }; const { ticket } = controller.issueWsTicket(req); const first = AuthController.consumeWsTicket(ticket); expect(first).toEqual(expect.objectContaining({ sub: 1 })); const second = AuthController.consumeWsTicket(ticket); expect(second).toBeNull(); // Single-use }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd backend && npx jest src/auth/auth.controller.spec.ts --verbose` Expected: 6 tests PASS - [ ] **Step 3: Commit** ```bash git add backend/src/auth/auth.controller.spec.ts git commit -m "test: auth controller — cookie login, logout, WS ticket issuance/consumption" ``` --- ## Chunk 4: Frontend Test Infrastructure + Tests ### Task 8: Frontend Test Infrastructure **Files:** - Create: `frontend/vitest.config.ts` - Create: `frontend/tests/setup.ts` - Modify: `frontend/package.json` - [ ] **Step 1: Install Vitest dependencies** Run: `cd frontend && npm install --save-dev vitest @vue/test-utils @pinia/testing happy-dom` - [ ] **Step 2: Create Vitest config** ```typescript // frontend/vitest.config.ts import { defineConfig } from 'vitest/config'; import { resolve } from 'path'; export default defineConfig({ test: { environment: 'happy-dom', globals: true, setupFiles: ['./tests/setup.ts'], include: ['tests/**/*.spec.ts'], }, resolve: { alias: { '~': resolve(__dirname, '.'), '#imports': resolve(__dirname, '.nuxt/imports.d.ts'), }, }, }); ``` - [ ] **Step 3: Create test setup with Nuxt auto-import mocks** ```typescript // frontend/tests/setup.ts import { vi } from 'vitest'; // Mock Nuxt auto-imports (globalThis as any).$fetch = vi.fn(); (globalThis as any).navigateTo = vi.fn(); (globalThis as any).defineNuxtRouteMiddleware = (fn: any) => fn; (globalThis as any).defineNuxtPlugin = vi.fn(); (globalThis as any).definePageMeta = vi.fn(); (globalThis as any).useAuthStore = vi.fn(); (globalThis as any).useSessionStore = vi.fn(); (globalThis as any).useConnectionStore = vi.fn(); // Mock ref, computed, onMounted from Vue (Nuxt auto-imports these) import { ref, computed, onMounted, watch } from 'vue'; (globalThis as any).ref = ref; (globalThis as any).computed = computed; (globalThis as any).onMounted = onMounted; (globalThis as any).watch = watch; ``` - [ ] **Step 4: Add test scripts to frontend package.json** Add to `frontend/package.json` scripts: ```json "test": "vitest run", "test:watch": "vitest", "test:cov": "vitest run --coverage" ``` - [ ] **Step 5: Commit** ```bash git add frontend/vitest.config.ts frontend/tests/setup.ts frontend/package.json git commit -m "test: frontend test infrastructure — Vitest, happy-dom, Nuxt auto-import mocks" ``` ### Task 9: Auth Store Tests **Files:** - Create: `frontend/tests/stores/auth.store.spec.ts` - Reference: `frontend/stores/auth.store.ts` - [ ] **Step 1: Write auth store tests** ```typescript // frontend/tests/stores/auth.store.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { useAuthStore } from '../../stores/auth.store'; describe('Auth Store', () => { beforeEach(() => { setActivePinia(createPinia()); vi.resetAllMocks(); }); describe('initial state', () => { it('should start with no user', () => { const auth = useAuthStore(); expect(auth.user).toBeNull(); expect(auth.isAuthenticated).toBe(false); expect(auth.isAdmin).toBe(false); }); }); describe('login', () => { it('should store user on successful login', async () => { const mockUser = { id: 1, email: 'test@test.com', displayName: 'Test', role: 'admin' }; (globalThis as any).$fetch = vi.fn().mockResolvedValue({ user: mockUser }); const auth = useAuthStore(); await auth.login('test@test.com', 'password'); expect(auth.user).toEqual(mockUser); expect(auth.isAuthenticated).toBe(true); expect(auth.isAdmin).toBe(true); }); it('should return requires_totp without storing user', async () => { (globalThis as any).$fetch = vi.fn().mockResolvedValue({ requires_totp: true }); const auth = useAuthStore(); const result = await auth.login('test@test.com', 'password'); expect(result).toEqual({ requires_totp: true }); expect(auth.user).toBeNull(); }); it('should not store token in state (httpOnly cookie)', async () => { (globalThis as any).$fetch = vi.fn().mockResolvedValue({ user: { id: 1, email: 'test@test.com', role: 'user' }, }); const auth = useAuthStore(); await auth.login('test@test.com', 'password'); expect((auth as any).token).toBeUndefined(); }); }); describe('logout', () => { it('should clear user and call logout API', async () => { (globalThis as any).$fetch = vi.fn().mockResolvedValue({}); (globalThis as any).navigateTo = vi.fn(); const auth = useAuthStore(); auth.user = { id: 1, email: 'test@test.com', displayName: null, role: 'admin' }; await auth.logout(); expect(auth.user).toBeNull(); expect(auth.isAuthenticated).toBe(false); expect(navigateTo).toHaveBeenCalledWith('/login'); }); }); describe('fetchProfile', () => { it('should populate user on success', async () => { const mockUser = { id: 1, email: 'test@test.com', displayName: 'Test', role: 'user' }; (globalThis as any).$fetch = vi.fn().mockResolvedValue(mockUser); const auth = useAuthStore(); await auth.fetchProfile(); expect(auth.user).toEqual(mockUser); }); it('should clear user on failure', async () => { (globalThis as any).$fetch = vi.fn().mockRejectedValue(new Error('401')); const auth = useAuthStore(); auth.user = { id: 1, email: 'old@test.com', displayName: null, role: 'user' }; await auth.fetchProfile(); expect(auth.user).toBeNull(); }); }); describe('getWsTicket', () => { it('should return ticket string', async () => { (globalThis as any).$fetch = vi.fn().mockResolvedValue({ ticket: 'abc123' }); const auth = useAuthStore(); const ticket = await auth.getWsTicket(); expect(ticket).toBe('abc123'); }); }); describe('getters', () => { it('isAdmin should be true for admin role', () => { const auth = useAuthStore(); auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'admin' }; expect(auth.isAdmin).toBe(true); }); it('isAdmin should be false for user role', () => { const auth = useAuthStore(); auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'user' }; expect(auth.isAdmin).toBe(false); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd frontend && npx vitest run tests/stores/auth.store.spec.ts` Expected: 10 tests PASS - [ ] **Step 3: Commit** ```bash git add frontend/tests/stores/auth.store.spec.ts git commit -m "test: auth store — login, logout, fetchProfile, getWsTicket, getters" ``` ### Task 10: Connection Store Tests **Files:** - Create: `frontend/tests/stores/connection.store.spec.ts` - [ ] **Step 1: Write connection store tests** ```typescript // frontend/tests/stores/connection.store.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { useConnectionStore } from '../../stores/connection.store'; describe('Connection Store', () => { beforeEach(() => { setActivePinia(createPinia()); vi.resetAllMocks(); (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); }); describe('fetchHosts', () => { it('should populate hosts from API', async () => { const mockHosts = [{ id: 1, name: 'Server 1' }]; (globalThis as any).$fetch = vi.fn().mockResolvedValue(mockHosts); const store = useConnectionStore(); await store.fetchHosts(); expect(store.hosts).toEqual(mockHosts); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts'); }); it('should not send Authorization header', async () => { const store = useConnectionStore(); await store.fetchHosts(); const callArgs = (globalThis as any).$fetch.mock.calls[0]; expect(callArgs[1]?.headers?.Authorization).toBeUndefined(); }); }); describe('createHost', () => { it('should POST and refresh hosts', async () => { (globalThis as any).$fetch = vi.fn() .mockResolvedValueOnce({ id: 1, name: 'New' }) // create .mockResolvedValueOnce([]); // fetchHosts const store = useConnectionStore(); await store.createHost({ name: 'New', hostname: '10.0.0.1', port: 22 } as any); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts', expect.objectContaining({ method: 'POST' })); }); }); describe('deleteHost', () => { it('should DELETE and refresh hosts', async () => { (globalThis as any).$fetch = vi.fn() .mockResolvedValueOnce({}) // delete .mockResolvedValueOnce([]); // fetchHosts const store = useConnectionStore(); await store.deleteHost(1); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts/1', expect.objectContaining({ method: 'DELETE' })); }); }); describe('group CRUD', () => { it('should create group and refresh tree', async () => { (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); const store = useConnectionStore(); await store.createGroup({ name: 'Production' }); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/groups', expect.objectContaining({ method: 'POST', body: { name: 'Production' }, })); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `cd frontend && npx vitest run tests/stores/connection.store.spec.ts` Expected: 5 tests PASS - [ ] **Step 3: Commit** ```bash git add frontend/tests/stores/connection.store.spec.ts git commit -m "test: connection store — host/group CRUD, no auth headers" ``` ### Task 11: Vault Composable + Admin Middleware Tests **Files:** - Create: `frontend/tests/composables/useVault.spec.ts` - Create: `frontend/tests/middleware/admin.spec.ts` - [ ] **Step 1: Write vault composable tests** ```typescript // frontend/tests/composables/useVault.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useVault } from '../../composables/useVault'; describe('useVault', () => { beforeEach(() => { vi.resetAllMocks(); (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); }); it('should list keys without auth header', async () => { const { listKeys } = useVault(); await listKeys(); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys'); const callArgs = (globalThis as any).$fetch.mock.calls[0]; expect(callArgs[1]?.headers?.Authorization).toBeUndefined(); }); it('should list credentials without auth header', async () => { const { listCredentials } = useVault(); await listCredentials(); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials'); }); it('should import key via POST', async () => { const { importKey } = useVault(); await importKey({ name: 'key', privateKey: 'pem-data' }); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys', expect.objectContaining({ method: 'POST' })); }); it('should delete key via DELETE', async () => { const { deleteKey } = useVault(); await deleteKey(5); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys/5', expect.objectContaining({ method: 'DELETE' })); }); it('should create credential via POST', async () => { const { createCredential } = useVault(); await createCredential({ name: 'cred', username: 'admin' }); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials', expect.objectContaining({ method: 'POST' })); }); it('should update credential via PUT', async () => { const { updateCredential } = useVault(); await updateCredential(3, { name: 'updated' }); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials/3', expect.objectContaining({ method: 'PUT' })); }); it('should delete credential via DELETE', async () => { const { deleteCredential } = useVault(); await deleteCredential(3); expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials/3', expect.objectContaining({ method: 'DELETE' })); }); }); ``` - [ ] **Step 2: Write admin middleware tests** ```typescript // frontend/tests/middleware/admin.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('Admin Middleware', () => { let middleware: any; beforeEach(() => { vi.resetAllMocks(); (globalThis as any).navigateTo = vi.fn().mockReturnValue('/'); }); it('should redirect non-admin to /', async () => { (globalThis as any).useAuthStore = vi.fn().mockReturnValue({ isAdmin: false }); // Re-import to pick up fresh mock const mod = await import('../../middleware/admin.ts'); middleware = mod.default; const result = middleware({} as any, {} as any); expect(navigateTo).toHaveBeenCalledWith('/'); }); it('should allow admin through', async () => { (globalThis as any).useAuthStore = vi.fn().mockReturnValue({ isAdmin: true }); const mod = await import('../../middleware/admin.ts'); middleware = mod.default; const result = middleware({} as any, {} as any); // Should not redirect — returns undefined expect(result).toBeUndefined(); }); }); ``` - [ ] **Step 3: Run all frontend tests** Run: `cd frontend && npx vitest run` Expected: ~24 tests PASS - [ ] **Step 4: Commit** ```bash git add frontend/tests/composables/useVault.spec.ts frontend/tests/middleware/admin.spec.ts git commit -m "test: vault composable + admin middleware — API calls, auth headers, route guard" ``` --- ## Chunk 5: Integration Verification + Pre-commit Hook ### Task 12: Run Full Test Suites - [ ] **Step 1: Run all backend tests** Run: `cd backend && npx jest --verbose` Expected: ~66 tests PASS across 8 spec files - [ ] **Step 2: Run all frontend tests** Run: `cd frontend && npx vitest run` Expected: ~30 tests PASS across 4 spec files - [ ] **Step 3: Add test:cov script to backend** Add to `backend/package.json` scripts: `"test:cov": "jest --coverage"` - [ ] **Step 4: Commit** ```bash git add backend/package.json git commit -m "chore: add test:cov script to backend" ``` ### Task 13: Final Push - [ ] **Step 1: Push everything** ```bash git push ``` - [ ] **Step 2: Verify test count** Run: `cd backend && npx jest --verbose 2>&1 | tail -5` Run: `cd frontend && npx vitest run 2>&1 | tail -5` Report total test count to Commander.