/** * 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); }); });