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

44 KiB

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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
// 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
// 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
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

// 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
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

// 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
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
// 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
// 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:

"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
  • Step 5: Commit
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

// 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
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

// 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
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

// 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
// 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
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
git add backend/package.json
git commit -m "chore: add test:cov script to backend"

Task 13: Final Push

  • Step 1: Push everything
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.