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>
324 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|