import { describe, it, expect, vi } from 'vitest'; import { SecretBackendRotator } from '../src/services/secret-backend-rotator.service.js'; import type { SecretBackend, Secret } from '@prisma/client'; function makeBackend(overrides: Partial = {}): SecretBackend { return { id: 'backend-1', name: 'bao', type: 'openbao', config: { url: 'http://bao.example:8200', auth: 'token', mount: 'secret', pathPrefix: 'mcpd', tokenSecretRef: { name: 'bao-creds', key: 'token' }, rotation: { enabled: true, tokenRole: 'app-mcpd-role', intervalHours: 24 }, } as unknown as SecretBackend['config'], tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'], isDefault: false, description: '', version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function makeSecret(overrides: Partial = {}): Secret { return { id: 'sec-1', name: 'bao-creds', backendId: 'backend-plaintext', data: { token: 'old.token.value' }, externalRef: '', version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } interface MockState { backend: SecretBackend; secret: Secret; secretData: Record; lastTokenMeta: Record | null; lastSecretUpdate: Record | null; } function mockDeps(state: MockState, vaultResponses: Array<{ match: RegExp; status: number; body?: unknown }>) { const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => { const key = `${init?.method ?? 'GET'} ${String(url)}`; const match = vaultResponses.find((r) => r.match.test(key) || r.match.test(String(url))); if (!match) throw new Error(`unexpected vault call: ${key}`); const body = match.body !== undefined ? JSON.stringify(match.body) : ''; return new Response(body, { status: match.status }); }); const backends = { getById: vi.fn(async (id: string) => { if (id === state.backend.id) return state.backend; throw new Error(`not found: ${id}`); }), updateTokenMeta: vi.fn(async (id: string, meta: Record) => { expect(id).toBe(state.backend.id); state.lastTokenMeta = meta; state.backend = { ...state.backend, tokenMeta: meta as unknown as SecretBackend['tokenMeta'] }; return state.backend; }), }; const secrets = { getByName: vi.fn(async (name: string) => { if (name === state.secret.name) return state.secret; throw new Error(`secret not found: ${name}`); }), resolveData: vi.fn(async () => ({ ...state.secretData })), update: vi.fn(async (id: string, input: { data: Record }) => { expect(id).toBe(state.secret.id); state.secretData = { ...input.data }; state.lastSecretUpdate = input as unknown as Record; return state.secret; }), }; return { fetchFn, backends, secrets }; } describe('SecretBackendRotator', () => { it('isRotatable: true for wizard-provisioned openbao', () => { const state: MockState = { backend: makeBackend(), secret: makeSecret(), secretData: { token: 'x' }, lastTokenMeta: null, lastSecretUpdate: null, }; const { backends, secrets } = mockDeps(state, []); const r = new SecretBackendRotator({ backends: backends as unknown as Parameters[0] extends never ? never : never, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); // Use a real rotator with both deps filled. const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, }); expect(rotator.isRotatable(state.backend)).toBe(true); expect(r).toBeDefined(); }); it('isRotatable: false for kubernetes-auth openbao', () => { const state: MockState = { backend: makeBackend({ config: { url: 'http://bao', auth: 'kubernetes', role: 'r', rotation: { enabled: true, tokenRole: 'app-mcpd-role' }, } as unknown as SecretBackend['config'], }), secret: makeSecret(), secretData: {}, lastTokenMeta: null, lastSecretUpdate: null, }; const { backends, secrets } = mockDeps(state, []); const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never }); expect(rotator.isRotatable(state.backend)).toBe(false); }); it('rotateOne: mints → verifies → persists → revokes old → updates tokenMeta', async () => { const state: MockState = { backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }), secret: makeSecret({ data: { token: 'old.token.value' } as Secret['data'] }), secretData: { token: 'old.token.value' }, lastTokenMeta: null, lastSecretUpdate: null, }; const { fetchFn, backends, secrets } = mockDeps(state, [ { match: /POST .*auth\/token\/create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new.token.value', accessor: 'new-accessor', lease_duration: 720 * 3600, renewable: true } } }, { match: /GET .*auth\/token\/lookup-self$/, status: 200, body: { data: { accessor: 'new-accessor', ttl: 720 * 3600 } } }, { match: /POST .*auth\/token\/revoke-accessor$/, status: 200 }, ]); const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, fetch: fetchFn as unknown as typeof fetch, now: () => new Date('2026-04-20T10:00:00Z'), }); const meta = await rotator.rotateOne(state.backend.id); // Correct order of HTTP calls: create (with OLD token) → lookup (with NEW token) → revoke (with NEW token) const calls = fetchFn.mock.calls.map((c) => `${(c[1] as RequestInit).method ?? 'GET'} ${String(c[0])}`); expect(calls[0]).toMatch(/POST .*create\/app-mcpd-role/); expect(calls[1]).toMatch(/GET .*lookup-self/); expect(calls[2]).toMatch(/POST .*revoke-accessor/); expect((fetchFn.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'old.token.value' }); expect((fetchFn.mock.calls[1]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' }); expect((fetchFn.mock.calls[2]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' }); // Secret was updated BEFORE revoke — state reflects ordering by sequence above. expect(state.secretData.token).toBe('new.token.value'); // tokenMeta carries fresh timestamps + accessor expect(meta.currentAccessor).toBe('new-accessor'); expect(meta.lastRotationError).toBeNull(); expect(meta.generatedAt).toBe('2026-04-20T10:00:00.000Z'); expect(meta.nextRenewalAt).toBe('2026-04-21T10:00:00.000Z'); expect(meta.validUntil).toBe('2026-05-20T10:00:00.000Z'); expect(state.lastTokenMeta?.rotatable).toBe(true); }); it('rotateOne: on mint failure, records lastRotationError and keeps old token', async () => { const state: MockState = { backend: makeBackend(), secret: makeSecret({ data: { token: 'old.token' } as Secret['data'] }), secretData: { token: 'old.token' }, lastTokenMeta: null, lastSecretUpdate: null, }; const { fetchFn, backends, secrets } = mockDeps(state, [ { match: /create\/app-mcpd-role$/, status: 403, body: { errors: ['permission denied'] } }, ]); const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, fetch: fetchFn as unknown as typeof fetch, }); await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/HTTP 403/); // Secret was NOT updated expect(state.secretData.token).toBe('old.token'); expect(secrets.update).not.toHaveBeenCalled(); // tokenMeta records the error expect(state.lastTokenMeta?.lastRotationError).toMatch(/HTTP 403/); }); it('rotateOne: rejects when minted token is not renewable', async () => { const state: MockState = { backend: makeBackend(), secret: makeSecret({ data: { token: 'old' } as Secret['data'] }), secretData: { token: 'old' }, lastTokenMeta: null, lastSecretUpdate: null, }; const { fetchFn, backends, secrets } = mockDeps(state, [ { match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'a', lease_duration: 100, renewable: false } } }, ]); const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, fetch: fetchFn as unknown as typeof fetch, }); await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/not renewable/); expect(state.secretData.token).toBe('old'); }); it('rotateOne: continues despite revoke-accessor failure (old token expires anyway)', async () => { const state: MockState = { backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }), secret: makeSecret({ data: { token: 'old' } as Secret['data'] }), secretData: { token: 'old' }, lastTokenMeta: null, lastSecretUpdate: null, }; const { fetchFn, backends, secrets } = mockDeps(state, [ { match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'new-a', lease_duration: 3600, renewable: true } } }, { match: /lookup-self$/, status: 200, body: { data: { accessor: 'new-a', ttl: 3600 } } }, { match: /revoke-accessor$/, status: 502 }, ]); const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, fetch: fetchFn as unknown as typeof fetch, }); const meta = await rotator.rotateOne(state.backend.id); expect(state.secretData.token).toBe('new'); expect(meta.lastRotationError).toBeNull(); }); it('isOverdue: true when lastRotationAt missing or >24h old', () => { const state: MockState = { backend: makeBackend({ tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'] }), secret: makeSecret(), secretData: {}, lastTokenMeta: null, lastSecretUpdate: null, }; const { backends, secrets } = mockDeps(state, []); const now = () => new Date('2026-04-20T10:00:00Z'); const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, now }); expect(r.isOverdue(state.backend)).toBe(true); const fresh = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-20T09:00:00Z' } as unknown as SecretBackend['tokenMeta'] }; expect(r.isOverdue(fresh)).toBe(false); const stale = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-18T10:00:00Z' } as unknown as SecretBackend['tokenMeta'] }; expect(r.isOverdue(stale)).toBe(true); }); it('rotateOne: throws when backend is not rotatable', async () => { const state: MockState = { backend: makeBackend({ type: 'plaintext', config: {} as SecretBackend['config'] }), secret: makeSecret(), secretData: {}, lastTokenMeta: null, lastSecretUpdate: null, }; const { backends, secrets } = mockDeps(state, []); const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never }); await expect(r.rotateOne(state.backend.id)).rejects.toThrow(/not rotatable/); }); });