import assert from 'node:assert/strict'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import test from 'node:test'; import { Hono } from 'hono'; import { platformSettings, teamMemberships, users, type Database } from '@pig/db'; import { createApp } from '../src/app'; import type { Principal } from '../src/lib/auth'; import { loadConfig } from '../src/lib/config'; import type { ApiEnv } from '../src/lib/mutation'; import { createPiggyChatRoutes, type PiggyChatProxyOptions, } from '../src/routes/piggy-chat'; const principal: Principal = { userId: '10000000-0000-4000-8000-000000000001', email: 'member@example.com', name: 'Member', isPlatformAdmin: false, teams: [{ team: 'demand', role: 'member' }], via: 'jwt', scopes: ['read', 'write'], }; /** Below `member`, so `economics:read` is refused and `book:read` is not. */ const viewer: Principal = { ...principal, userId: '10000000-0000-4000-8000-000000000002', teams: [{ team: 'demand', role: 'viewer' }], }; function appFor( fetchImpl: typeof fetch, identity: Principal = principal, overrides: Partial = {}, ) { const app = new Hono(); app.use('*', async (context, next) => { context.set('principal', identity); await next(); }); app.route( '/', createPiggyChatRoutes({ enabled: true, internalUrl: 'http://127.0.0.1:8931', internalToken: 'internal-token-with-at-least-32-characters', fetchImpl, ...overrides, }), ); return app; } const ndjson = () => new Response(`${JSON.stringify({ type: 'done', inputTokens: 1, outputTokens: 1 })}\n`, { status: 200, headers: { 'content-type': 'application/x-ndjson' }, }); /** * A chat server that answers the health probe. * * Every route now probes `/internal/health` before it will relay anything, so * a fake that answers only `/internal/chat` makes the relay correctly decide * the service is down and 503 the test it was meant to support. */ function relay(chat: typeof fetch = async () => ndjson()): typeof fetch { return async (input, init) => { if (String(input).endsWith('/internal/health')) return new Response('{"ok":true}'); return chat(input, init); }; } /** Refuses to relay at all: what a dead or key-less Piggy process looks like. */ const unhealthy: typeof fetch = async (input, init) => { if (String(input).endsWith('/internal/health')) return new Response('', { status: 503 }); return relay()(input, init); }; test('the authenticated proxy forwards bounded identity and relays NDJSON unchanged', async () => { let forwarded: Record | undefined; const fetchImpl = relay(async (input, init) => { assert.equal(String(input), 'http://127.0.0.1:8931/internal/chat'); assert.equal( new Headers(init?.headers).get('authorization'), 'Bearer internal-token-with-at-least-32-characters', ); forwarded = JSON.parse(String(init?.body)) as Record; return new Response( `${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` + `${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`, { status: 200, headers: { 'content-type': 'application/x-ndjson' } }, ); }); const app = appFor(fetchImpl); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Summarise this contract.', context: { type: 'contract', id: '20000000-0000-4000-8000-000000000002', label: 'Order form', }, }), }); assert.equal(response.status, 200); assert.match(response.headers.get('content-type') ?? '', /application\/x-ndjson/); assert.deepEqual(forwarded, { principalUserId: principal.userId, message: 'Summarise this contract.', context: { type: 'contract', id: '20000000-0000-4000-8000-000000000002', label: 'Order form', }, }); assert.equal( await response.text(), `${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` + `${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`, ); }); test('a credential without read scope never reaches the internal service', async () => { let fetched = false; const app = appFor( relay(async () => { fetched = true; return ndjson(); }), { ...principal, scopes: ['write'] }, ); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Read the book.' }), }); assert.equal(response.status, 403); assert.equal(fetched, false); }); test('a docked page context reaches the chat service unaltered', async () => { let forwarded: Record | undefined; const app = appFor( relay(async (_input, init) => { forwarded = JSON.parse(String(init?.body)) as Record; return ndjson(); }), ); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'What is idle?', context: { type: 'page', route: '/capacity', label: 'Capacity' }, }), }); assert.equal(response.status, 200); assert.deepEqual(forwarded?.context, { type: 'page', route: '/capacity', label: 'Capacity', }); }); // A page context carries no record, so admitting one would put a nonsense // shape in front of the model rather than failing at the boundary. test('a page context may not smuggle a record id, and an unknown route is refused', async () => { let fetched = false; const app = appFor( relay(async () => { fetched = true; return ndjson(); }), ); for (const context of [ { type: 'page', route: '/not-a-page' }, { type: 'page', route: '/margin', id: '20000000-0000-4000-8000-000000000002' }, { type: 'page' }, ]) { const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Where are we?', context }), }); assert.equal(response.status, 400); assert.equal(((await response.json()) as { code: string }).code, 'invalid_request'); } assert.equal(fetched, false); }); test('the stored admin toggle disables chat without the environment changing', async () => { let fetched = false; let piggyEnabled = true; const app = appFor( relay(async () => { fetched = true; return ndjson(); }), principal, { resolvePiggyEnabled: async () => piggyEnabled }, ); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: true, canUse: true, }); piggyEnabled = false; assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Where are we?' }), }); assert.equal(response.status, 503); assert.equal(fetched, false); }); // Losing the settings row must degrade to the environment gate. A dock on // every page turns one failed query into a site-wide outage otherwise. test('an unreadable settings row falls back to the environment gate', async () => { const app = appFor(relay(), principal, { resolvePiggyEnabled: async () => { throw new Error('platform settings unavailable'); }, }); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: true, canUse: true, }); }); test('the environment gate still overrides a stored toggle that says yes', async () => { const app = appFor(relay(), principal, { enabled: false, resolvePiggyEnabled: async () => true, }); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); }); // --------------------------------------------------------------------------- // Read authorisation // --------------------------------------------------------------------------- /** * The hole this suite exists for. * * A demand VIEWER is correctly 403'd on `GET /api/capacity/margin` by * `READ_RULES`. Before this, the same person could open the dock on /margin * and have `pig_get_margin_summary` read back book revenue, supplier cost and * break-even — because the relay checked the credential's `read` scope and * never the person's capability, and the chat server receives a bare user id * with no memberships attached to check. */ async function chatWith( identity: Principal, context: unknown, onFetch: () => void = () => {}, ) { const app = appFor( relay(async () => { onFetch(); return ndjson(); }), identity, ); return app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(context === undefined ? { message: 'Go on.' } : { message: 'Go on.', context }), }); } test('a viewer cannot reach the cost book through the dock', async () => { let fetched = false; const denied = [ { type: 'page', route: '/margin' }, { type: 'page', route: '/capacity' }, { type: 'page', route: '/' }, // The workspace summary carries book margin, so the page it is served on // does not make it cheaper to read. { type: 'page', route: '/accounts' }, { type: 'commitment', id: '20000000-0000-4000-8000-000000000003' }, // No context at all is the dashboard by another name, and must not be the // way round the gate. undefined, ]; for (const context of denied) { const response = await chatWith(viewer, context, () => { fetched = true; }); assert.equal(response.status, 403, JSON.stringify(context)); assert.equal( ((await response.json()) as { code: string }).code, 'insufficient_permission', JSON.stringify(context), ); } assert.equal(fetched, false); }); test('a viewer still reaches the book contexts they can already read', async () => { for (const context of [ { type: 'page', route: '/demand' }, { type: 'page', route: '/contracts' }, { type: 'account', id: '20000000-0000-4000-8000-000000000004' }, ]) { const response = await chatWith(viewer, context); assert.equal(response.status, 200, JSON.stringify(context)); } }); test('a research lead reads the book but not the margin dock', async () => { const researcher: Principal = { ...viewer, teams: [{ team: 'research', role: 'lead' }] }; assert.equal((await chatWith(researcher, { type: 'page', route: '/demand' })).status, 200); assert.equal((await chatWith(researcher, { type: 'page', route: '/margin' })).status, 403); }); test('a commercial member keeps the margin dock', async () => { assert.equal((await chatWith(principal, { type: 'page', route: '/margin' })).status, 200); }); test('status tells a viewer the dock is usable and a stranger that it is not', async () => { const stranger: Principal = { ...viewer, teams: [] }; assert.deepEqual(await (await appFor(relay(), viewer).request('/api/piggy/status')).json(), { enabled: true, canUse: true, }); assert.deepEqual(await (await appFor(relay(), stranger).request('/api/piggy/status')).json(), { enabled: true, canUse: false, }); }); // --------------------------------------------------------------------------- // Rate limiting // --------------------------------------------------------------------------- test('a user is capped per hour and told how long to wait', async () => { let relayed = 0; const app = appFor( relay(async () => { relayed += 1; return ndjson(); }), principal, { messagesPerHour: 2 }, ); const send = () => app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Again.', context: { type: 'page', route: '/margin' } }), }); assert.equal((await send()).status, 200); assert.equal((await send()).status, 200); const limited = await send(); assert.equal(limited.status, 429); const body = (await limited.json()) as { code: string; retryAfterSeconds: number }; assert.equal(body.code, 'piggy_rate_limited'); assert.ok(body.retryAfterSeconds > 0); assert.equal(limited.headers.get('retry-after'), String(body.retryAfterSeconds)); // The quota is a spend limit, so nothing past it may reach inference. assert.equal(relayed, 2); }); /** * Keyed on the user, not the address. Everyone in one office shares an * `X-Forwarded-For`, and one colleague exhausting the credit for the floor is * the failure an address key would produce. */ test('one user exhausting the quota does not silence another', async () => { const routes = createPiggyChatRoutes({ enabled: true, internalUrl: 'http://127.0.0.1:8931', internalToken: 'internal-token-with-at-least-32-characters', fetchImpl: relay(), messagesPerHour: 1, }); const app = new Hono(); let identity = principal; app.use('*', async (context, next) => { context.set('principal', identity); await next(); }); app.route('/', routes); const send = () => app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Again.' }), }); assert.equal((await send()).status, 200); assert.equal((await send()).status, 429); identity = { ...principal, userId: '10000000-0000-4000-8000-000000000009' }; assert.equal((await send()).status, 200); }); test('a refused request does not spend the quota it was never going to use', async () => { const app = appFor(relay(), viewer, { messagesPerHour: 1 }); const send = (route: string) => app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Again.', context: { type: 'page', route } }), }); assert.equal((await send('/margin')).status, 403); assert.equal((await send('/margin')).status, 403); // The one message they are entitled to is still there. assert.equal((await send('/demand')).status, 200); assert.equal((await send('/demand')).status, 429); }); // --------------------------------------------------------------------------- // Availability // --------------------------------------------------------------------------- test('a dead chat server is reported as unavailable rather than usable', async () => { const app = appFor(unhealthy); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Anyone there?' }), }); assert.equal(response.status, 503); assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable'); }); /** * The bug in its original form: `configured` is true, the probe is cached * healthy, and then the socket is refused. That rejection used to reach * `app.onError` and render as a red "Internal error" bubble, which reads as * "Piggy broke on your question" rather than "Piggy is not running". */ test('a connection failure mid-request becomes the clean 503, not an internal error', async () => { const app = appFor( relay(async () => { throw Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); }), ); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Anyone there?' }), }); assert.equal(response.status, 503); assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable'); // And the status endpoint stops lying immediately, rather than after the // health cache expires. assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); }); test('a genuinely unreachable port 503s without an injected fetch', async () => { const closed = createServer(); await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); const port = (closed.address() as AddressInfo).port; await new Promise((resolve) => closed.close(() => resolve())); const app = new Hono(); app.use('*', async (context, next) => { context.set('principal', principal); await next(); }); app.route( '/', createPiggyChatRoutes({ enabled: true, internalUrl: `http://127.0.0.1:${port}`, internalToken: 'internal-token-with-at-least-32-characters', }), ); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Anyone there?' }), }); assert.equal(response.status, 503); assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable'); }); // A dock on every page means a status call on every navigation; probing the // chat server on each one would be a loopback flood for no extra truth. test('the health probe is cached and never runs concurrently', async () => { let probes = 0; const app = appFor(async (input) => { if (String(input).endsWith('/internal/health')) { probes += 1; return new Response('{"ok":true}'); } return ndjson(); }); await Promise.all(Array.from({ length: 8 }, () => app.request('/api/piggy/status'))); assert.equal(probes, 1); await app.request('/api/piggy/status'); assert.equal(probes, 1); }); test('a stale health verdict is re-probed once the cache lapses', async () => { let probes = 0; const app = appFor( async (input) => { if (String(input).endsWith('/internal/health')) { probes += 1; return new Response('{"ok":true}'); } return ndjson(); }, principal, { healthCacheMs: 0 }, ); await app.request('/api/piggy/status'); await app.request('/api/piggy/status'); assert.equal(probes, 2); }); // --------------------------------------------------------------------------- // Composition // --------------------------------------------------------------------------- /** * Enough of a Database to authenticate a development request and read the * settings row, and nothing more. * * Predicates are ignored on purpose: this asserts a WIRING, and a fake that * tried to execute SQL semantics would be a worse test of the wiring and a * pointless test of Drizzle. Anything the app queries beyond these three * tables comes back empty, which is what an untouched deployment looks like. */ function stubDatabase( store: { piggyEnabled: boolean }, memberships: Record[] = [{ team: 'demand', role: 'member' }], ): Database { const rowsFor = (table: unknown): Record[] => { if (table === users) { return [ { id: principal.userId, email: principal.email, name: principal.name, isPlatformAdmin: false, deactivatedAt: null, }, ]; } if (table === teamMemberships) return memberships; if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }]; return []; }; const query = (rows: Record[]): Record => { const chain: Record = { from: (table: unknown) => query(rowsFor(table)), where: () => chain, limit: () => chain, orderBy: () => chain, innerJoin: () => chain, leftJoin: () => chain, values: () => chain, onConflictDoNothing: () => chain, returning: () => chain, then: (resolve: (value: Record[]) => unknown) => resolve(rows), }; return chain; }; return { select: () => query([]), insert: (table: unknown) => query(rowsFor(table)), } as unknown as Database; } /** A chat server that is up, on a port nothing else in the suite is using. */ async function healthServer(): Promise<{ url: string; close: () => Promise }> { const server: Server = createServer((request, response) => { if (request.url === '/internal/health') { response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); return; } response.writeHead(404).end(); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, close: () => new Promise((resolve) => server.close(() => resolve())), }; } /** * The regression this file could not previously catch. * * The three toggle tests above build the routes themselves and inject a * resolver, so every one of them stayed green through a release in which * `createApp` never passed one — turning Piggy off in the admin UI did nothing * at all in production. Only a request through the composed app proves the * stored setting is consulted, so this one goes through `createApp`. */ test('createApp wires the stored toggle into the chat routes', async () => { const piggy = await healthServer(); try { const store = { piggyEnabled: false }; const config = loadConfig({ NODE_ENV: 'development', DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig', PIGGY_ENABLED: 'true', PIGGY_INTERNAL_URL: piggy.url, PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters', }); // Null provider is the development path: no token, principal comes from the // first user in the table. What is under test is the toggle, not the auth. const app = createApp(config, stubDatabase(store), null); assert.equal(config.PIGGY_ENABLED, true); assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: false, canUse: false, }); store.piggyEnabled = true; assert.deepEqual(await (await app.request('/api/piggy/status')).json(), { enabled: true, canUse: true, }); } finally { await piggy.close(); } }); /** * The read guard is mounted before every feature route in `createApp`, and the * chat POST now has a row in that table. This proves the composed app refuses * the turn before the relay is even reached — the relay's own capability check * is the one that can see the context, and this is the floor beneath it. */ test('createApp governs the chat POST with the read guard as well', async () => { const piggy = await healthServer(); try { const config = loadConfig({ NODE_ENV: 'development', DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig', PIGGY_ENABLED: 'true', PIGGY_INTERNAL_URL: piggy.url, PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters', }); // On no team, so no read capability at all — the case the guard exists for. const app = createApp(config, stubDatabase({ piggyEnabled: true }, []), null); const response = await app.request('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Show me the book.' }), }); assert.equal(response.status, 403); } finally { await piggy.close(); } });