Files
pig/apps/api/test/calendar.test.ts
T
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

324 lines
11 KiB
TypeScript

/**
* Tests for the calendar boundary.
*
* The projection itself is exercised against a real Postgres by the seeded
* demo book; what is pinned here are the decisions that would otherwise fail
* silently — a mistyped `kinds` filter that looks like a quiet quarter, an
* authorization gate that mistakes authentication for permission, and the
* relationship checks that the nullable foreign keys cannot enforce
* themselves.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { Database } from '@pig/db';
import { AuthError, type Principal } from '../src/lib/auth';
import { MutationError, executeMutation } from '../src/lib/mutation';
import {
calendarReadAllowed,
createEntryMutationDefinition,
deleteEntryMutationDefinition,
entriesQuerySchema,
parseKinds,
querySchema,
requireCalendarWrite,
} from '../src/routes/calendar';
function principal(overrides: Partial<Principal> = {}): Principal {
return {
userId: '10000000-0000-4000-8000-000000000001',
email: 'seller@example.com',
name: 'Seller',
isPlatformAdmin: false,
teams: [{ team: 'demand', role: 'member' }],
via: 'jwt',
scopes: ['read', 'write'],
...overrides,
};
}
describe('calendar read boundary', () => {
it('requires an explicit read scope rather than treating a token as permission', () => {
assert.equal(calendarReadAllowed([]), false);
assert.equal(calendarReadAllowed(['write']), false);
assert.equal(calendarReadAllowed(['read']), true);
});
});
describe('calendar write boundary', () => {
it('accepts either pipeline, because a dated item belongs to whoever runs the motion', () => {
assert.doesNotThrow(() =>
requireCalendarWrite(principal({ teams: [{ team: 'demand', role: 'member' }] })),
);
assert.doesNotThrow(() =>
requireCalendarWrite(principal({ teams: [{ team: 'supply', role: 'member' }] })),
);
});
it('refuses a read-only credential even when its owner has the role', () => {
// The credential's scope caps the person's authority; an agent key issued
// for reading must not be able to write because a human somewhere may.
assert.throws(
() => requireCalendarWrite(principal({ scopes: ['read'] })),
(error: unknown) =>
error instanceof AuthError && error.code === 'insufficient_scope',
);
});
it('refuses a member of neither pipeline', () => {
assert.throws(
() => requireCalendarWrite(principal({ teams: [{ team: 'research', role: 'admin' }] })),
(error: unknown) =>
error instanceof AuthError && error.code === 'insufficient_permission',
);
});
});
describe('kinds filter', () => {
it('rejects an unknown kind rather than returning nothing', () => {
// A typo that silently filters everything out is indistinguishable from a
// genuinely empty quarter, which is the worst possible failure for a view
// whose whole job is to show what is coming.
assert.throws(
() => parseKinds('renewal'),
(error: unknown) => error instanceof MutationError && error.code === 'invalid_kinds',
);
assert.throws(() => parseKinds('obligation_due,expected_clos'), MutationError);
});
it('treats absent and empty as no filter at all', () => {
assert.equal(parseKinds(undefined), undefined);
assert.equal(parseKinds(''), undefined);
assert.equal(parseKinds(' , '), undefined);
});
it('accepts a spaced list of known kinds', () => {
assert.deepEqual(parseKinds('obligation_due, renewal_notice'), [
'obligation_due',
'renewal_notice',
]);
});
});
describe('calendar query validation', () => {
it('rejects a malformed account id on both reads, not just one', () => {
// Fed straight into `eq()` on a uuid column, `not-a-uuid` came back as a
// 500 from Postgres 22P02. The two endpoints take the identical parameter
// and must answer it identically.
assert.equal(querySchema.safeParse({ accountId: 'not-a-uuid' }).success, false);
assert.equal(entriesQuerySchema.safeParse({ accountId: 'not-a-uuid' }).success, false);
assert.equal(
entriesQuerySchema.safeParse({ accountId: '30000000-0000-4000-8000-000000000003' })
.success,
true,
);
assert.equal(entriesQuerySchema.safeParse({}).success, true);
});
it('rejects a time zone the runtime cannot use rather than silently answering in UTC', () => {
// The cache in @pig/core is keyed on this string, so an unvalidated one is
// both a wrong answer and a way to make a long-lived process grow.
assert.equal(querySchema.safeParse({ timezone: 'Mars/Olympus' }).success, false);
assert.equal(querySchema.safeParse({ timezone: 'Europe/London' }).success, true);
});
});
/** A transaction stub that records the order of writes, as in capacity-writes. */
function recordingDb(rows: {
select?: unknown[];
insertReturns?: unknown[];
deleteReturns?: unknown[];
}) {
const events: string[] = [];
const tx = {
select: () => ({
from: () => ({
where: () => ({
limit: async () => {
events.push('select');
return rows.select ?? [];
},
}),
}),
}),
insert: () => ({
// A thenable rather than a promise: the audit write is awaited directly
// while the entity write goes through `.returning()`, and constructing a
// real promise here would record the audit write that never happened.
values: (values: Record<string, unknown>) => {
const record = () => events.push('subject' in values ? 'activity' : 'insert');
return {
then: (resolve: (value: unknown) => unknown) => {
record();
return Promise.resolve().then(() => resolve(undefined));
},
returning: async () => {
record();
return rows.insertReturns ?? [];
},
};
},
}),
delete: () => ({
where: () => ({
returning: async () => {
events.push('delete');
return rows.deleteReturns ?? [];
},
}),
}),
};
const db = {
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
events.push('begin');
const result = await work(tx);
events.push('commit');
return result;
},
} as unknown as Database;
return { db, events };
}
describe('calendar entry mutation', () => {
const entry = {
id: '20000000-0000-4000-8000-000000000002',
title: 'Q business review',
kind: 'qbr' as const,
accountId: null,
demandDealId: null,
supplyDealId: null,
startsAt: new Date('2026-09-03T14:00:00.000Z'),
endsAt: new Date('2026-09-03T15:30:00.000Z'),
completedAt: null,
};
it('writes the entry and its audit event inside one transaction', async () => {
const { db, events } = recordingDb({ insertReturns: [entry] });
const created = await executeMutation(
db,
principal(),
async () => ({
title: 'Q business review',
kind: 'qbr',
startsAt: '2026-09-03T14:00:00.000Z',
endsAt: '2026-09-03T15:30:00.000Z',
}),
createEntryMutationDefinition(),
);
assert.equal(created.id, entry.id);
assert.deepEqual(events, ['begin', 'insert', 'activity', 'commit']);
});
it('defaults the owner to the author, because unassigned work is work nobody does', async () => {
let written: Record<string, unknown> | undefined;
const capturing = {
transaction: async (work: (transaction: unknown) => Promise<unknown>) =>
work({
insert: () => ({
values: (values: Record<string, unknown>) => {
written ??= values;
return Object.assign(Promise.resolve(), {
returning: async () => [entry],
});
},
}),
}),
} as unknown as Database;
await executeMutation(
capturing,
principal(),
async () => ({ title: 'Reminder', startsAt: '2026-09-03T14:00:00.000Z' }),
createEntryMutationDefinition(),
);
assert.equal(written?.ownerUserId, '10000000-0000-4000-8000-000000000001');
assert.equal(written?.createdByUserId, '10000000-0000-4000-8000-000000000001');
});
it('refuses a window that ends before it starts', async () => {
const { db } = recordingDb({ insertReturns: [entry] });
await assert.rejects(
executeMutation(
db,
principal(),
async () => ({
title: 'Backwards',
startsAt: '2026-09-03T16:00:00.000Z',
endsAt: '2026-09-03T14:00:00.000Z',
}),
createEntryMutationDefinition(),
),
(error: unknown) => error instanceof MutationError && error.code === 'invalid_window',
);
});
it('refuses a deal that belongs to a different account', async () => {
// Nothing in the schema can catch this: both columns are independently
// nullable foreign keys, so the disagreement is only visible here.
const { db } = recordingDb({
select: [{ accountId: '90000000-0000-4000-8000-000000000009' }],
});
await assert.rejects(
executeMutation(
db,
principal(),
async () => ({
title: 'Mismatch',
startsAt: '2026-09-03T14:00:00.000Z',
accountId: '30000000-0000-4000-8000-000000000003',
demandDealId: '40000000-0000-4000-8000-000000000004',
}),
createEntryMutationDefinition(),
),
(error: unknown) =>
error instanceof MutationError && error.code === 'relationship_mismatch',
);
});
it('reports a stale owner as 404, the way every other reference here does', async () => {
// The column is a foreign key with no check in front of it, so assigning
// to a user who has been removed produced a 500 from the constraint. It is
// an ordinary client mistake and deserves the ordinary answer.
const { db } = recordingDb({ select: [], insertReturns: [entry] });
await assert.rejects(
executeMutation(
db,
principal(),
async () => ({
title: 'Handover',
startsAt: '2026-09-03T14:00:00.000Z',
ownerUserId: '50000000-0000-4000-8000-000000000005',
}),
createEntryMutationDefinition(),
),
(error: unknown) => error instanceof MutationError && error.status === 404,
);
});
it('does not re-read the author when it defaults the owner to them', async () => {
// The request already proved that user exists; a lookup per create to
// confirm it would be a query bought with nothing.
const { db, events } = recordingDb({ insertReturns: [entry] });
await executeMutation(
db,
principal(),
async () => ({ title: 'Reminder', startsAt: '2026-09-03T14:00:00.000Z' }),
createEntryMutationDefinition(),
);
assert.deepEqual(events, ['begin', 'insert', 'activity', 'commit']);
});
it('reports a missing entry as 404 rather than a silent no-op delete', async () => {
const { db } = recordingDb({ deleteReturns: [] });
await assert.rejects(
executeMutation(
db,
principal(),
async () => ({}),
deleteEntryMutationDefinition(),
{ id: '20000000-0000-4000-8000-000000000002' },
),
(error: unknown) =>
error instanceof MutationError && error.status === 404,
);
});
});