Files
mcpctl/src/mcpd/tests/warm-secret-cache.test.ts
Michal 0fbfc72d68 feat(secrets): report real backend health instead of a hard-coded tick
`mcpctl status` derived its Secrets verdict entirely from
`tokenMeta.lastRotationError`. The rotator writes that field only for
`auth: 'token'` backends (SecretBackendRotator.isRotatable), so a
`kubernetes`-auth backend never wrote it and the line rendered a green
tick unconditionally — including with OpenBao sealed, unreachable, or
answering 403 to every read. The one signal we had was structurally
incapable of going red for the backend we actually run.

New `GET /api/v1/secretbackends/:id/health` reports two signals, kept
separate on purpose:

  live   — reachable at all?          (unauthenticated sys/health)
  ready  — can we read through it?    (uses our credentials)

live-but-not-ready is the exact shape of a re-initialised OpenBao handing
back valid-looking tokens that grant nothing; collapsing both into one
boolean is what hid that for four days. Needs no RBAC mapping — it falls
through to the generic `secretbackends` resource, so a GET is
`view:secretbackends`.

`mcpctl status` now renders four states — reachable / degraded (serving N
cached secrets) / unreachable / auth failed — with rotation error demoted
to a trailing clause rather than the verdict. A failed probe renders
"? unknown", never green: not knowing is not health. JSON output carries
the same probe, so scripts stop being told every k8s-auth backend is fine.

Also adds a boot-time cache warm. The stale-while-error cache can only
absorb an outage for secrets it has already seen, so a cold mcpd during a
backend outage still fails; resolving each running server's refs once at
startup closes that for the common case. Best-effort and deliberately
partial — if the backend is also down at boot this is a no-op and
instances fail loudly, which is correct. Persisting last-known-good to
Postgres or disk would just be plaintext-at-rest again.

Tests: 15 new. The five status assertions were confirmed to fail against
the old rotation-only logic before being kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:14:12 +01:00

67 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { warmSecretCache } from '../src/bootstrap/warm-secret-cache.js';
import type { PrismaClient } from '@prisma/client';
import type { SecretService } from '../src/services/secret.service.js';
function prismaWith(servers: Array<{ name: string; env: unknown }>): PrismaClient {
return { mcpServer: { findMany: vi.fn(async () => servers) } } as unknown as PrismaClient;
}
const noLog = { info: (): void => undefined, warn: (): void => undefined };
const envRef = (name: string, secret: string, key: string): unknown =>
({ name, valueFrom: { secretRef: { name: secret, key } } });
describe('warmSecretCache', () => {
it('resolves every distinct secret ref exactly once', async () => {
const prisma = prismaWith([
{ name: 'gitea', env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN')] },
// Two servers sharing one secret must not cost two reads.
{ name: 'a', env: [envRef('T', 'shared', 'TOKEN')] },
{ name: 'b', env: [envRef('T', 'shared', 'TOKEN')] },
]);
const resolve = vi.fn(async () => 'value');
const result = await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog);
expect(resolve).toHaveBeenCalledTimes(2);
expect(result).toEqual({ warmed: 2, failed: 0 });
});
it('ignores inline env values', async () => {
const prisma = prismaWith([{ name: 's', env: [{ name: 'PLAIN', value: 'x' }] }]);
const resolve = vi.fn(async () => 'v');
expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
.toEqual({ warmed: 0, failed: 0 });
expect(resolve).not.toHaveBeenCalled();
});
it('never throws when the backend is down — startup must not block', async () => {
const prisma = prismaWith([
{ name: 'a', env: [envRef('T', 's1', 'K')] },
{ name: 'b', env: [envRef('T', 's2', 'K')] },
]);
const resolve = vi.fn(async () => { throw new Error('bao unreachable'); });
await expect(warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
.resolves.toEqual({ warmed: 0, failed: 2 });
});
it('keeps going after one bad reference', async () => {
const prisma = prismaWith([
{ name: 'a', env: [envRef('T', 'missing', 'K')] },
{ name: 'b', env: [envRef('T', 'present', 'K')] },
]);
const resolve = vi.fn(async (n: string) => {
if (n === 'missing') throw new Error('no such secret');
return 'v';
});
expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
.toEqual({ warmed: 1, failed: 1 });
});
it('only considers servers with replicas > 0', async () => {
const prisma = prismaWith([]);
await warmSecretCache(prisma, { resolve: vi.fn() } as unknown as SecretService, noLog);
const findMany = (prisma.mcpServer.findMany as unknown as ReturnType<typeof vi.fn>);
expect(findMany.mock.calls[0]?.[0]).toMatchObject({ where: { replicas: { gt: 0 } } });
});
});