wraith/docs/plans/2026-03-14-test-suite-buildout.md
Vantz Stockwell 8a096d7f7b
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Wraith v0.1.0 — Desktop SSH + RDP + SFTP Client
Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego)
183 tests, 76 source files, 9,910 lines of code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:19:29 -04:00

1372 lines
44 KiB
Markdown

# 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.