Files
pig/apps/api/test/http-auth.test.ts
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:02:48 -07:00

232 lines
8.4 KiB
TypeScript

/**
* 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<string, unknown> = {
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<unknown>) => 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'],
);
});
});