1372 lines
44 KiB
Markdown
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.
|