feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user