141 lines
5.7 KiB
TypeScript
141 lines
5.7 KiB
TypeScript
/**
|
|
* APIS-052 — Gateway proxy auth boundary.
|
|
*
|
|
* The gateway proxies LLM traffic. gateway-mount.test.ts covers path
|
|
* classification + config equivalence, but NOT the authentication of proxied
|
|
* requests. An unauthenticated request slipping past the gateway's auth layer
|
|
* would expose backend LLM spend.
|
|
*
|
|
* This file asserts the boundary at the gateway's real auth middleware
|
|
* (`buildAuthMiddleware` from src/gateway/auth.ts — the exact handler the
|
|
* gateway sub-app installs ahead of the proxy). We mount it on a bare express
|
|
* app in front of a SENTINEL "backend" handler that records whether it was
|
|
* reached. No real LLM call is made.
|
|
*
|
|
* - missing Bearer → 401, backend NOT reached (no passthrough)
|
|
* - malformed Authorization → 401, backend NOT reached
|
|
* - wrong/unknown key → 401, backend NOT reached
|
|
* - valid config key → 200, backend reached, gatewayAuth populated
|
|
* - valid DB key (dbLookup) → 200, backend reached
|
|
* - 401 body does not leak which step failed
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { createHash } from 'node:crypto';
|
|
import { buildAuthMiddleware } from '../gateway/auth.js';
|
|
import type { GatewayVirtualKey } from '../gateway/config.js';
|
|
|
|
const VALID_CONFIG_KEY = 'sk-aao-valid-config-key';
|
|
const VALID_DB_KEY = 'sk-aao-valid-db-key';
|
|
|
|
/** Same hashing the middleware uses for dbLookup (sha256 hex of the bearer). */
|
|
function sha256hex(s: string): string {
|
|
return createHash('sha256').update(s).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Bare app: [json] → [gateway auth middleware] → [sentinel backend].
|
|
* The sentinel is the stand-in for the proxied LLM backend; if auth lets a
|
|
* request through, `backend.mock.calls.length` becomes > 0.
|
|
*/
|
|
function makeGatewayApp(opts: {
|
|
keys?: GatewayVirtualKey[];
|
|
dbLookup?: (keyHash: string) => { id: string; team: string; allowedModels?: string[] | null } | null;
|
|
}) {
|
|
const backend = vi.fn((_req: express.Request, res: express.Response) => {
|
|
res.status(200).json({ ok: true, served: 'fake-backend' });
|
|
});
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(buildAuthMiddleware({ keys: opts.keys ?? [], dbLookup: opts.dbLookup }));
|
|
// Stand-in for the proxied LLM endpoint.
|
|
app.post('/v1/chat/completions', backend);
|
|
return { app, backend };
|
|
}
|
|
|
|
const configKeys: GatewayVirtualKey[] = [
|
|
{ key: VALID_CONFIG_KEY, team: 'team-a' },
|
|
];
|
|
|
|
describe('APIS-052 gateway proxy auth boundary', () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('missing Authorization header → 401 and the backend is never reached', async () => {
|
|
const { app, backend } = makeGatewayApp({ keys: configKeys });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.send({ model: 'auto', messages: [] });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid api key' });
|
|
expect(backend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('malformed Authorization (non-Bearer scheme) → 401, no passthrough', async () => {
|
|
const { app, backend } = makeGatewayApp({ keys: configKeys });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', `Basic ${VALID_CONFIG_KEY}`)
|
|
.send({ model: 'auto' });
|
|
expect(res.status).toBe(401);
|
|
expect(backend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('wrong/unknown Bearer key → 401, no passthrough', async () => {
|
|
const { app, backend } = makeGatewayApp({ keys: configKeys });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', 'Bearer sk-aao-totally-wrong')
|
|
.send({ model: 'auto' });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toEqual({ error: 'invalid api key' });
|
|
expect(backend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('valid config key → 200, backend reached', async () => {
|
|
const { app, backend } = makeGatewayApp({ keys: configKeys });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', `Bearer ${VALID_CONFIG_KEY}`)
|
|
.send({ model: 'auto' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.served).toBe('fake-backend');
|
|
expect(backend).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('valid DB key (via dbLookup) → 200, backend reached', async () => {
|
|
const dbLookup = (hash: string) =>
|
|
hash === sha256hex(VALID_DB_KEY) ? { id: 'k1', team: 'team-db', allowedModels: null } : null;
|
|
const { app, backend } = makeGatewayApp({ keys: [], dbLookup });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', `Bearer ${VALID_DB_KEY}`)
|
|
.send({ model: 'auto' });
|
|
expect(res.status).toBe(200);
|
|
expect(backend).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('a key revoked from the DB (dbLookup miss) and absent from config → 401', async () => {
|
|
// Simulate post-revocation: dbLookup returns null for everything, no config keys.
|
|
const dbLookup = () => null;
|
|
const { app, backend } = makeGatewayApp({ keys: [], dbLookup });
|
|
const res = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', `Bearer ${VALID_DB_KEY}`)
|
|
.send({ model: 'auto' });
|
|
expect(res.status).toBe(401);
|
|
expect(backend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('401 body is identical for missing vs wrong key (no information leak)', async () => {
|
|
const { app } = makeGatewayApp({ keys: configKeys });
|
|
const missing = await request(app).post('/v1/chat/completions').send({});
|
|
const wrong = await request(app)
|
|
.post('/v1/chat/completions')
|
|
.set('Authorization', 'Bearer sk-aao-nope')
|
|
.send({});
|
|
expect(missing.body).toEqual(wrong.body);
|
|
expect(missing.status).toBe(wrong.status);
|
|
});
|
|
});
|