13dec6b4b8
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>
232 lines
8.4 KiB
TypeScript
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'],
|
|
);
|
|
});
|
|
|
|
});
|