/** * The first tests that go through `createApp()`. * * Every other test in this directory calls a mutation definition, or a helper, * directly. That checks the rule and skips the wiring — and the wiring is where * this codebase has actually been wrong: a guard mounted after its handler * never runs, an AuthError thrown inside a mounted sub-app has to reach the * parent's `onError` to become a 403 rather than a 500, and a route added to * the public allowlist by mistake is invisible to a unit test. `grep createApp * apps/api/test` used to return nothing. * * So these assert on status codes and error envelopes over real HTTP, and * nothing else. They are deliberately cheap: no Postgres, a fake that answers * only the handful of queries authentication and the read guard reach. */ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { Team, TeamRole } from '@pig/core'; import type { Database } from '@pig/db'; import { apiKeys, teamMemberships, users } from '@pig/db'; import { createApp } from '../src/app'; import type { AuthProvider } from '../src/lib/auth-provider'; import { hashApiKey } from '../src/lib/auth'; import { loadConfig, type Config } from '../src/lib/config'; const USER_ID = '00000000-0000-4000-8000-0000000000aa'; const SUBJECT = 'auth-subject-1'; const API_KEY = 'pig_test_key_value'; interface Fixture { /** Absent means a verified token with no PIG profile — the `needs_profile` case. */ user?: { id: string; email: string; name: string; authSubject: string; deactivatedAt: Date | null; isPlatformAdmin: boolean }; memberships?: { team: Team; role: TeamRole }[]; apiKey?: { scopes: string[] }; } /** * Answers by table identity rather than by call order, because the order in * which `loadPrincipal` and a handler query is an implementation detail and a * fake that depends on it fails for the wrong reason later. */ function fixtureDatabase(fixture: Fixture): Database { const userRows = fixture.user ? [fixture.user] : []; const membershipRows = fixture.memberships ?? []; const keyRows = fixture.apiKey ? [{ id: 'key-1', userId: USER_ID, keyHash: hashApiKey(API_KEY), scopes: fixture.apiKey.scopes, revokedAt: null, expiresAt: null, }] : []; function rowsFor(table: unknown): unknown[] { if (table === users) return userRows; if (table === teamMemberships) return membershipRows; if (table === apiKeys) return keyRows; return []; } function chain(rows: unknown[]) { const self: Record = { leftJoin: () => self, innerJoin: () => self, where: () => self, orderBy: () => self, limit: async () => rows, then: (resolve: (value: unknown[]) => unknown) => resolve(rows), }; return self; } return { select: () => ({ from: (table: unknown) => { // `/api/team` joins users to memberships and expects the flattened // shape, which the users fixture already carries enough of. if (table === users) { return chain(userRows.map((row) => ({ ...row, team: membershipRows[0]?.team ?? null, role: membershipRows[0]?.role ?? null }))); } return chain(rowsFor(table)); }, }), update: () => ({ set: () => ({ where: async () => undefined }) }), transaction: async (work: (tx: unknown) => Promise) => work({}), } as unknown as Database; } const provider: AuthProvider = { name: 'test', async verifyAccessToken(token: string) { if (token !== 'good-token') throw new Error('bad token'); return { subject: SUBJECT, email: 'seller@example.com' }; }, }; function config(): Config { // A real `loadConfig`, not a literal: the production guards live in it, and a // hand-rolled Config object would let this suite pass under a configuration // the server would refuse to start on. return loadConfig({ NODE_ENV: 'test', DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig-not-connected', PIG_PUBLIC_URL: 'http://localhost:8920', PIG_ADMIN_EMAILS: '', } as NodeJS.ProcessEnv); } function member(team: Team, role: TeamRole): Fixture { return { user: { id: USER_ID, email: 'seller@example.com', name: 'Seller', authSubject: SUBJECT, deactivatedAt: null, isPlatformAdmin: false, }, memberships: [{ team, role }], }; } function request(fixture: Fixture, path: string, init: RequestInit = {}) { return createApp(config(), fixtureDatabase(fixture), provider).request(path, init); } const bearer = (token: string) => ({ headers: { authorization: `Bearer ${token}` } }); async function envelope(response: Response) { return (await response.json()) as { code?: string; error?: string }; } describe('authentication over HTTP', () => { it('answers 401 no_token when nothing is presented', async () => { const response = await request(member('demand', 'member'), '/api/dashboard'); assert.equal(response.status, 401); assert.equal((await envelope(response)).code, 'no_token'); }); it('answers 401 invalid_token without saying which knob to turn', async () => { const response = await request(member('demand', 'member'), '/api/dashboard', bearer('rubbish')); assert.equal(response.status, 401); assert.equal((await envelope(response)).code, 'invalid_token'); }); /** * The distinction the whole auth file exists for: the identity provider is * shared with another application, so a verified token proves an account * somewhere, not membership here. */ it('answers 403 needs_profile for a verified token with no PIG user', async () => { const response = await request({}, '/api/team', bearer('good-token')); assert.equal(response.status, 403); assert.equal((await envelope(response)).code, 'needs_profile'); }); it('answers 403 deactivated rather than pretending the account is unknown', async () => { const fixture = member('demand', 'member'); fixture.user!.deactivatedAt = new Date('2026-01-01T00:00:00Z'); const response = await request(fixture, '/api/team', bearer('good-token')); assert.equal(response.status, 403); assert.equal((await envelope(response)).code, 'deactivated'); }); it('leaves health and config reachable without a token', async () => { for (const path of ['/api/health', '/api/config']) { const response = await request({}, path); assert.equal(response.status, 200, path); } }); }); describe('credential scope over HTTP', () => { it('refuses a write from a read-only API key', async () => { const fixture = { ...member('demand', 'admin'), apiKey: { scopes: ['read'] } }; const response = await request(fixture, '/api/contracts', { method: 'POST', headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json' }, body: JSON.stringify({ accountId: USER_ID, type: 'msa', side: 'demand', title: 'MSA' }), }); // Scope, not permission: this person IS a demand admin. The credential // they are acting through is what lacks the authority, and saying so is // the difference between "ask your administrator" and "use another key". assert.equal(response.status, 403); assert.equal((await envelope(response)).code, 'insufficient_scope'); }); it('admits a read from the same read-only key', async () => { const fixture = { ...member('demand', 'admin'), apiKey: { scopes: ['read'] } }; const response = await request(fixture, '/api/me', { headers: { authorization: `Bearer ${API_KEY}` }, }); assert.equal(response.status, 200); }); }); describe('capability over HTTP', () => { it('refuses a write from a viewer', async () => { const response = await request(member('demand', 'viewer'), '/api/contracts', { method: 'POST', headers: { authorization: 'Bearer good-token', 'content-type': 'application/json' }, body: JSON.stringify({ accountId: USER_ID, type: 'msa', side: 'demand', title: 'MSA' }), }); assert.equal(response.status, 403); assert.equal((await envelope(response)).code, 'insufficient_permission'); }); it('reports a viewer\'s grants on /api/me as reads only', async () => { const response = await request(member('demand', 'viewer'), '/api/me', bearer('good-token')); const body = (await response.json()) as { permissions: { capability: string }[] }; assert.equal(response.status, 200); assert.deepEqual( body.permissions.map((grant) => grant.capability), ['book:read', 'team:read'], ); }); });