277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
|
|
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> = {}): 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> = {}): 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<string, string>;
|
||
|
|
lastTokenMeta: Record<string, unknown> | null;
|
||
|
|
lastSecretUpdate: Record<string, unknown> | 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<string, unknown>) => {
|
||
|
|
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<string, string> }) => {
|
||
|
|
expect(id).toBe(state.secret.id);
|
||
|
|
state.secretData = { ...input.data };
|
||
|
|
state.lastSecretUpdate = input as unknown as Record<string, unknown>;
|
||
|
|
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<typeof SecretBackendRotator.prototype.rotateOne>[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/);
|
||
|
|
});
|
||
|
|
});
|