/** * That reads are governed, and that the guard actually runs. * * Two separate risks. The policy could be wrong — a research contractor let * near supplier cost — and that is what the first suite checks. Or the policy * could be right and never execute, because Hono runs matched handlers in * registration order and a guard mounted after its handler is inert. That * second failure produces no error, no warning and a 200, which is exactly the * shape of the bug being fixed, so it is checked separately and explicitly. */ import { strict as assert } from 'node:assert'; import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import type { Team, TeamRole } from '@pig/core'; import { Hono } from 'hono'; import { AuthError, type Principal } from '../src/lib/auth'; import { apiError, type ApiEnv } from '../src/lib/mutation'; import { createReadGuardRoutes, READ_RULES } from '../src/routes/read-guards'; import { principal as makePrincipal } from './helpers/principal'; /** The app's own error mapping, reproduced so a 403 here means a 403 there. */ function guardedApp(principal: Principal, mountGuardsFirst = true) { const app = new Hono(); app.use('*', async (context, next) => { context.set('principal', principal); await next(); }); const handlers = new Hono(); for (const rule of READ_RULES) handlers.on(rule.method, rule.path, (c) => c.json({ ok: true })); if (mountGuardsFirst) { app.route('/', createReadGuardRoutes()); app.route('/', handlers); } else { app.route('/', handlers); app.route('/', createReadGuardRoutes()); } app.onError((error, c) => error instanceof AuthError ? c.json(apiError(error.code, error.message), error.status) : c.json({ error: 'Internal error' }, 500), ); return app; } function on(team: Team, role: TeamRole): Principal { return makePrincipal({ teams: [{ team, role }] }); } async function statusFor(principal: Principal, rule: (typeof READ_RULES)[number]) { const path = rule.path.replace(':id', '00000000-0000-4000-8000-000000000001'); const response = await guardedApp(principal).request(path, { method: rule.method, ...(rule.method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}), }); return response.status; } describe('read policy', () => { it('denies every governed read to someone on no team', async () => { const stranger = makePrincipal({ teams: [] }); for (const rule of READ_RULES) { assert.equal(await statusFor(stranger, rule), 403, `${rule.method} ${rule.path}`); } }); it('admits every governed read to a platform admin', async () => { const admin = makePrincipal({ isPlatformAdmin: true, teams: [] }); for (const rule of READ_RULES) { assert.equal(await statusFor(admin, rule), 200, `${rule.method} ${rule.path}`); } }); /** * The case the audit named: a research contractor and a demand rep seeing * supplier cost economics identically. They must now differ, and only on the * economics rules — research still reads the book. */ it('splits research off the economics rules and nothing else', async () => { const researcher = on('research', 'lead'); for (const rule of READ_RULES) { const expected = rule.capability === 'economics:read' ? 403 : 200; assert.equal(await statusFor(researcher, rule), expected, `${rule.method} ${rule.path}`); } }); it('gives a viewer the book and the roster but not the cost side', async () => { const viewer = on('demand', 'viewer'); for (const rule of READ_RULES) { const expected = rule.capability === 'economics:read' ? 403 : 200; assert.equal(await statusFor(viewer, rule), expected, `${rule.method} ${rule.path}`); } }); it('admits a commercial member to everything, cost included', async () => { const seller = on('demand', 'member'); for (const rule of READ_RULES) { assert.equal(await statusFor(seller, rule), 200, `${rule.method} ${rule.path}`); } }); it('refuses a write-only credential even where the person qualifies', async () => { const writeOnly = makePrincipal({ via: 'api_key', scopes: ['write'] }); const response = await guardedApp(writeOnly).request('/api/capacity/margin'); assert.equal(response.status, 403); assert.equal(((await response.json()) as { code: string }).code, 'insufficient_scope'); }); }); describe('the guard has to be mounted before the handler', () => { it('runs when registered first', async () => { const response = await guardedApp(on('research', 'lead'), true).request('/api/capacity/margin'); assert.equal(response.status, 403); }); /** * Not a test of desired behaviour — a test of the trap. If this ever starts * returning 403, Hono's dispatch order changed and the warning comment in * read-guards.ts can be deleted. Until then, the mount position in * `createApp` is load-bearing and this records why. */ it('is silently inert when registered after', async () => { const response = await guardedApp(on('research', 'lead'), false).request('/api/capacity/margin'); assert.equal(response.status, 200); }); }); /** * Nothing stops a future GET being added without a row in READ_RULES, so this * reads the routing source and insists that every `/api` GET is either * governed or listed below with a reason. It is a coarse regex over source * text and that is deliberate: a cleverer check would need the app running, * and a check that is hard to run is a check that gets deleted. */ describe('no read escapes the table', () => { /** Reads whose own handler authorises them, or which must stay open. */ const DELIBERATELY_UNGOVERNED: Readonly> = { '/api/health': 'Liveness, for load balancers. Unauthenticated by design.', '/api/config': 'Public front-end configuration; contains no secret.', '/api/me': 'Your own identity. Gating it would hide the reason you are gated.', '/api/me/profile': 'Your own profile row.', '/api/api-keys': 'Guarded by requireApiKeyManagement, which also bars API keys.', '/api/admin/settings': 'settings:admin, enforced in admin-settings.ts.', '/api/admin/invites': 'settings:admin, enforced in admin-settings.ts.', '/api/admin/members': 'settings:admin, enforced in admin-settings.ts.', '/api/admin/integrations': 'settings:admin, enforced in integration-settings.ts.', '/api/piggy/status': 'Whether the assistant is switched on; carries no book data.', '/api/imports/config': 'data:import, enforced by the router middleware.', '/api/imports/google/status': 'integration:connect, enforced by the router middleware.', '/api/imports/google/files': 'data:import, enforced by the router middleware.', '/api/imports/google/spreadsheets/:id/sheets': 'data:import, enforced by the router middleware.', '/api/imports/notion/status': 'integration:connect, enforced by the router middleware.', '/api/imports/notion/connections/:id/data-sources': 'integration:connect, ditto.', '/api/integrations/hubspot/oauth/callback': 'OAuth redirect; verifies its own state.', '/api/integrations/hubspot/connections': 'settings:admin, enforced in hubspot.ts.', '/api/integrations/slack/channel-links': 'Channel wiring, not book data.', '/api/integrations/buzz/channel-links': 'Channel wiring, not book data.', '/api/calendar': 'Owned by the calendar track; gated in calendar.ts.', '/api/calendar/entries': 'Owned by the calendar track; gated in calendar.ts.', // Landed while this table was being written and carries its own access // code rather than a capability. Listed so the check stays green, not // because the arrangement has been reviewed — the learn track owns it. '/api/learn': 'Owned by the learn track; gated by its own access code.', '/api/learn/access-code': 'Owned by the learn track; gated by its own access code.', }; it('has a row, or a stated reason, for every GET', () => { const root = join(import.meta.dirname, '..', 'src'); const files = [ join(root, 'app.ts'), ...readdirSync(join(root, 'routes')) .filter((name) => name.endsWith('.ts')) .map((name) => join(root, 'routes', name)), ]; const governed = new Set(READ_RULES.filter((rule) => rule.method === 'GET').map((r) => r.path)); const found = new Set(); for (const file of files) { const source = readFileSync(file, 'utf8'); for (const match of source.matchAll(/\.get\(\s*'(\/api\/[^']*)'/g)) found.add(match[1]!); } const ungoverned = [...found].filter( (path) => !governed.has(path) && !(path in DELIBERATELY_UNGOVERNED), ); assert.deepEqual( ungoverned, [], `these reads are ungoverned — add a READ_RULES row or a stated reason:\n${ungoverned.join('\n')}`, ); }); });