/** * Phase 3b post-review — admin mutations drop per-key Prometheus * gauge labels so the registry doesn't grow unbounded over the key * lifecycle (issue → revoke → issue → revoke … leaves a permanent * `budgetUsedRatio{team, key_prefix}` series for every dead key * without this fix). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import express, { type Request, type RequestHandler } from 'express'; import request from 'supertest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Registry } from 'prom-client'; import { Repository } from '../db/repository.js'; import { createAdminGatewayApi } from './admin-gateway-api.js'; import { createGatewayMetrics, type GatewayMetrics } from '../metrics/gateway-metrics.js'; function buildApp(repo: Repository, metrics: GatewayMetrics): express.Application { const app = express(); app.use(express.json({ limit: '4kb' })); const guard: RequestHandler = (req, _res, next) => { (req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' }; next(); }; const router = createAdminGatewayApi({ repo, requireAdmin: guard, getUserId: (req) => { const u = (req as Request & { user?: { id?: string } }).user; return u?.id ?? null; }, gatewayMetrics: metrics, }); app.use('/api/admin/gateway/keys', router); return app; } describe('admin-gateway-api metric label removal (Phase 3b post-review)', () => { let tmpDir: string; let repo: Repository; let reg: Registry; let metrics: GatewayMetrics; let app: express.Application; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-metrics-')); repo = new Repository(join(tmpDir, 'test.db')); reg = new Registry(); metrics = createGatewayMetrics(reg, 'aao_gateway_ml'); app = buildApp(repo, metrics); }); afterEach(() => { repo.close(); rmSync(tmpDir, { recursive: true, force: true }); }); it('revoke removes the budgetUsedRatio label for the revoked key', async () => { const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' }); expect(create.status).toBe(201); const id = create.body.id as string; const prefix = id.slice(0, 8); // Simulate the gauge being set as it would by the bootstrap recordUsage callback. metrics.budgetUsedRatio.labels({ team: 'alpha', key_prefix: prefix }).set(0.42); let dump = await reg.metrics(); expect(dump).toMatch( new RegExp(`aao_gateway_ml_virtual_key_budget_used_ratio\\{team="alpha",key_prefix="${prefix}"\\} 0\\.42`), ); // Now revoke. const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`); expect(rev.status).toBe(200); // After remove() the label series should no longer appear. dump = await reg.metrics(); expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`)); }); it('rotate removes the OLD key prefix label (new key creates its own on next usage)', async () => { const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' }); const oldId = create.body.id as string; const oldPrefix = oldId.slice(0, 8); metrics.budgetUsedRatio.labels({ team: 'beta', key_prefix: oldPrefix }).set(0.55); let dump = await reg.metrics(); expect(dump).toMatch(new RegExp(`key_prefix="${oldPrefix}"`)); const rot = await request(app).post(`/api/admin/gateway/keys/${oldId}/rotate`); expect(rot.status).toBe(201); dump = await reg.metrics(); expect(dump).not.toMatch(new RegExp(`key_prefix="${oldPrefix}"`)); }); it('delete removes the label too', async () => { const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'gamma' }); const id = create.body.id as string; const prefix = id.slice(0, 8); metrics.budgetUsedRatio.labels({ team: 'gamma', key_prefix: prefix }).set(0.7); const del = await request(app).delete(`/api/admin/gateway/keys/${id}`); expect(del.status).toBe(204); const dump = await reg.metrics(); expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`)); }); it('revoke without an existing gauge label is a safe no-op', async () => { const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'delta' }); const id = create.body.id as string; // Don't pre-set the gauge — remove() of an unknown label is a noop. const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`); expect(rev.status).toBe(200); }); it('missing gatewayMetrics handle (cross-process deploy) does not block mutations', async () => { // Build a separate app without a metrics handle to verify the // admin API stays functional when the gateway runs in a different // process. const app2 = express(); app2.use(express.json({ limit: '4kb' })); const guard: RequestHandler = (req, _res, next) => { (req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' }; next(); }; const router = createAdminGatewayApi({ repo, requireAdmin: guard, getUserId: (req) => { const u = (req as Request & { user?: { id?: string } }).user; return u?.id ?? null; }, // No gatewayMetrics. dropKeyMetricLabels is a no-op. }); app2.use('/api/admin/gateway/keys', router); const create = await request(app2).post('/api/admin/gateway/keys').send({ team: 'eps' }); expect(create.status).toBe(201); const id = create.body.id as string; const rev = await request(app2).post(`/api/admin/gateway/keys/${id}/revoke`); expect(rev.status).toBe(200); }); });