/** * AAO Gateway Phase 2a — admin REST API integration tests. * * Covers: * - POST issues fresh sk-aao-* with raw key once; subsequent GETs hide it * - GET supports ?team= and ?activeOnly=true * - Revoke is idempotent (409 on second call) and hides from active list * - Rotate is atomic: new key active, old key revoked, raw key returned * - DELETE rejects source='config-import' * - requireAdmin guard blocks non-admin callers * - Validation: team regex, allowedModels shape */ 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 { Repository } from '../db/repository.js'; import { createAdminGatewayApi } from './admin-gateway-api.js'; function buildAppWithAdmin(repo: Repository, role: 'admin' | 'user' = 'admin'): express.Application { const app = express(); app.use(express.json({ limit: '4kb' })); // Stub admin guard inline; mirrors the auth flow without Passport. const guard: RequestHandler = (req, res, next) => { if (role !== 'admin') { res.status(403).json({ error: 'Forbidden' }); return; } (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; }, }); app.use('/api/admin/gateway/keys', router); return app; } describe('admin-gateway-api', () => { let tmpDir: string; let repo: Repository; let app: express.Application; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-test-')); repo = new Repository(join(tmpDir, 'test.db')); app = buildAppWithAdmin(repo); }); afterEach(() => { repo.close(); rmSync(tmpDir, { recursive: true, force: true }); }); describe('POST /', () => { it('issues a fresh key with sk-aao prefix and returns raw once', async () => { const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' }); expect(res.status).toBe(201); expect(res.body.team).toBe('alpha'); expect(res.body.source).toBe('admin'); expect(typeof res.body.key).toBe('string'); expect(res.body.key.startsWith('sk-aao-')).toBe(true); expect(res.body.keyPrefix.startsWith('sk-aao-')).toBe(true); expect(res.body.allowedModels).toBeNull(); // GET should not include raw key. const list = await request(app).get('/api/admin/gateway/keys'); expect(list.body.keys[0].key).toBeUndefined(); }); it('validates team format', async () => { const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'has spaces' }); expect(res.status).toBe(400); }); it('accepts allowedModels and round-trips it', async () => { const res = await request(app) .post('/api/admin/gateway/keys') .send({ team: 'alpha', allowedModels: ['qwen3:8b', 'qwen3:14b'] }); expect(res.status).toBe(201); expect(res.body.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']); }); it('rejects malformed allowedModels', async () => { const res = await request(app) .post('/api/admin/gateway/keys') .send({ team: 'alpha', allowedModels: [42, ''] }); expect(res.status).toBe(400); }); }); describe('GET / and GET /:id', () => { it('lists with team filter and hides revoked when activeOnly=true', async () => { const a = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body; await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' }); const old = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body; await request(app).post(`/api/admin/gateway/keys/${old.id}/revoke`).send({}); const alphaAll = await request(app).get('/api/admin/gateway/keys?team=alpha'); expect(alphaAll.body.keys).toHaveLength(2); const alphaActive = await request(app).get('/api/admin/gateway/keys?team=alpha&activeOnly=true'); expect(alphaActive.body.keys).toHaveLength(1); expect(alphaActive.body.keys[0].id).toBe(a.id); }); it('GET /:id returns 404 for unknown id', async () => { const res = await request(app).get('/api/admin/gateway/keys/nope'); expect(res.status).toBe(404); }); }); describe('POST /:id/revoke', () => { it('revokes and is idempotent (409 second time)', async () => { const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body; const first = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({}); expect(first.status).toBe(200); expect(first.body.ok).toBe(true); expect(first.body.revokedAt).toBeTruthy(); const second = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({}); expect(second.status).toBe(409); }); it('returns 404 for unknown id', async () => { const res = await request(app).post('/api/admin/gateway/keys/nope/revoke').send({}); expect(res.status).toBe(404); }); }); describe('POST /:id/rotate', () => { it('atomically issues a new key and revokes the old', async () => { const old = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha', allowedModels: ['qwen3:8b'], })).body; const res = await request(app).post(`/api/admin/gateway/keys/${old.id}/rotate`).send({}); expect(res.status).toBe(201); expect(typeof res.body.key).toBe('string'); expect(res.body.id).not.toBe(old.id); expect(res.body.allowedModels).toEqual(['qwen3:8b']); expect(res.body.team).toBe('alpha'); const oldRefetch = await request(app).get(`/api/admin/gateway/keys/${old.id}`); expect(oldRefetch.body.revokedAt).toBeTruthy(); }); it('refuses to rotate a revoked key', async () => { const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body; await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({}); const res = await request(app).post(`/api/admin/gateway/keys/${created.id}/rotate`).send({}); expect(res.status).toBe(409); }); }); describe('DELETE /:id', () => { it('hard-deletes an admin-issued key', async () => { const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body; const res = await request(app).delete(`/api/admin/gateway/keys/${created.id}`); expect(res.status).toBe(204); const after = await request(app).get(`/api/admin/gateway/keys/${created.id}`); expect(after.status).toBe(404); }); it('refuses to delete a config-import key', async () => { const k = repo.createGatewayVirtualKey({ keyHash: 'cfg-hash', keyPrefix: 'sk-conf-import', team: 'imported', source: 'config-import', createdBy: 'config', }); const res = await request(app).delete(`/api/admin/gateway/keys/${k.id}`); expect(res.status).toBe(400); expect(repo.findGatewayVirtualKeyById(k.id)).not.toBeNull(); }); }); describe('auth gating', () => { it('non-admin caller receives 403', async () => { const adminLess = buildAppWithAdmin(repo, 'user'); const res = await request(adminLess).get('/api/admin/gateway/keys'); expect(res.status).toBe(403); }); }); describe('auth-disabled mount policy', () => { // Server-level guard: createCoreServer refuses to mount this router // when authActive=false because mounting it with a passthrough guard // would let any anonymous caller mint valid sk-aao-* bearer tokens. // We simulate the no-auth path by NOT mounting the router and // asserting requests 404, matching the production behavior. function buildAppWithoutAuth(): express.Application { const app = express(); app.use(express.json({ limit: '4kb' })); // Intentionally do NOT mount /api/admin/gateway/keys — this is the // path server.ts takes when authActive===false. return app; } it('returns 404 for POST when auth is disabled (route not mounted)', async () => { const noAuthApp = buildAppWithoutAuth(); const res = await request(noAuthApp) .post('/api/admin/gateway/keys') .send({ team: 'alpha' }); expect(res.status).toBe(404); }); it('returns 404 for GET when auth is disabled (route not mounted)', async () => { const noAuthApp = buildAppWithoutAuth(); const res = await request(noAuthApp).get('/api/admin/gateway/keys'); expect(res.status).toBe(404); }); it('returns 401 when auth IS active but caller is unauthenticated', async () => { // With auth active, server.ts mounts requireAdmin (which 401s for // missing user) BEFORE the router. We simulate that by wiring a // requireAdmin that 401s without a user, then the router behind it. const app = express(); app.use(express.json({ limit: '4kb' })); const requireAdminLike: RequestHandler = (req, res, next) => { const u = (req as Request & { user?: unknown }).user; if (!u) { res.status(401).json({ error: 'authentication required' }); return; } next(); }; app.use( '/api/admin/gateway/keys', requireAdminLike, createAdminGatewayApi({ repo, requireAdmin: (_req, _res, next) => next(), getUserId: (req) => (req as Request & { user?: { id?: string } }).user?.id ?? null, }), ); const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' }); expect(res.status).toBe(401); }); }); });