/// /** * Behavioral coverage for 0185_hrm_employment_change_requests — governed * HRM employment change REQUEST storage (proposals, never mutations). * * What this suite pins: * - rows are born drafts with a storage-computed canonical digest (the * caller-supplied digest is ignored, never trusted); * - digest canonicalization is semantic (key order / whitespace do not * move the digest); * - draft edits advance request_revision by exactly one and recompute the * digest; touch-only updates hold the revision; * - submit is atomic (stamps + run + reason, with a committing positive * control) and freezes identity, payload, expected revision, revision, * and submission stamps; * - the transition machine (draft -> pending_approval -> approved / * rejected / withdrawn, approved -> applied; draft -> withdrawn without * fabricated submission) with terminal states terminal; * - decision snapshots bind under two-valued presence-plus-typeof pins * (revision by integral value: 1.0 approves, 1.5 and "1" refuse) and * freeze once written; * - employment scope is composite (org_id, employment_id) — a valid * employment id from another org is refused; * - the flow run anchor is scope-bound (same org, governed subject * kind/id), stamped at submit, retained, never re-pointed; * - submitted history cannot be deleted; pure drafts can; * - RLS restricts by tenant identity under a proven-restricted role, with * positive controls each way; * - application evidence is all-or-nothing with approved -> applied and * proves same org + same employment + exact canonical revision. * * ROLLBACK CONTAINMENT. Every test body runs inside `isolated()`: one * transaction that always rolls back, so this suite commits ZERO rows to * its own table. That is load-bearing, not tidy: the no_delete trigger * honors no bypass GUC (by coordinator order there is no production * trigger bypass), while suite teardown (dropScratchOrgReporting) deletes * org-owned rows under openbooks.amend/sandbox_wipe/bypass_rls. A * committed submitted row would make the file-level `after()` hook throw. * Shared fixture rows (scratch org, users, two employments) stay committed * and leave through the standard teardown path like every other suite. * * SAVEPOINT DISCIPLINE. A refused statement aborts its transaction: every * later command on the same transaction fails with 25P02 until rollback, * which would mask every assertion after the first refusal in a test. * Every expected refusal therefore goes through `refuses()`, which wraps * the single statement in its own savepoint and asserts the genuine guard * message — never the 25P02 echo of an earlier abort. * * FIXTURES (final 0184 shape): worker_employments * inserts (id, org_id, worker_party_id, employer_subsidiary_id, revision) * — every other column is nullable or defaulted, and UNIQUE(org_id, id) * backs the composite FK. employment_changes inserts (id, org_id, * employment_id, revision, change_kind 'created', prior_snapshot '{}', * reason, recorded_by): recorded_source defaults to 'user', * recorded_source_ref stays NULL, closed_versions defaults to '[]', and * change_txid is trigger-stamped so it is OMITTED, never supplied. * Party/subsidiary legs reuse real scratch-org rows (customerId, * subsidiaryId) so the fixtures hold whether or not 0184 declares those * FKs. Canonical mutation, approval execution, and application auth are * other slices — this suite asserts storage refuses, never that services * allow or authenticate. * * Like every DB-backed suite it self-skips without OPENBOOKS_DB_URL. Run * on the coordinated dedicated DB at handoff — never the schema-worker * DB concurrently, never production. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test, { after } from "node:test"; import { sql, type SQL } from "drizzle-orm"; /** Drizzle wraps driver errors, hiding the PostgreSQL message in `cause`. */ function pgMessage(error: unknown): string { const cause = (error as { cause?: unknown }).cause; return `${String(error)}\n${cause === undefined ? "" : String(cause)}`; } const DB = Boolean(process.env.OPENBOOKS_DB_URL); type EngineDb = typeof import("@openbooks/engine/platform/db"); type Tx = Parameters[0]>[0]; type Harness = { db: EngineDb["db"]; /** Current executor: the shared db, or the test's rollback transaction. */ run: EngineDb["db"] | Tx; orgId: string; actorId: string; employmentId: string; secondEmploymentId: string; cleanupOrgs: string[]; }; // One harness per top-level test. The pooled fixture lifecycle resets and // releases a leased scratch org at every top-level test boundary, so a // harness memoized across tests would keep ids for rows that no longer // exist (the first run of this file failed every test after the first with // "Key is not present in table worker_employments"). Each test therefore // leases its own org and drops it in its own after-hook; the suite-level // hook only drains anything a test failed to register. const harnesses: Harness[] = []; after(async () => { const { dropScratchOrgReporting } = await import("@openbooks/engine/testing/fixtures"); for (const h of harnesses.splice(0)) for (const id of h.cleanupOrgs.splice(0)) await dropScratchOrgReporting(id); }); type TestAfter = { after?: (fn: () => Promise) => void }; async function ctx(t?: unknown): Promise { let harness: Harness; { const [{ db }, { createScratchOrg, createScratchUser }] = await Promise.all([ import("@openbooks/engine/platform/db"), import("@openbooks/engine/testing/fixtures"), ]); const org = await createScratchOrg(); // Inspect (never assume) every seed this suite joins to: the submission // actor must be a real user row, and the party/subsidiary legs must be // real seeded rows — random UUIDs would trip 0184 FKs before any guard // under test is reached, turning every refusal into a fixture error. assert.ok(org.customerId, "scratch org must seed a party for the worker leg"); assert.ok(org.vendorId, "scratch org must seed a second party for the worker leg"); assert.ok(org.subsidiaryId, "scratch org must seed a subsidiary for the employer leg"); // A scratch org seeds no users: the submission actor is created here as a // real user row (role assignment included) rather than assumed. const actorId = await createScratchUser(org.orgId, "HRM request actor", "hrm_request_actor"); const users = ( await db.execute<{ id: string }>(sql` select id from users where id = ${actorId} and org_id = ${org.orgId}`) ).rows; assert.ok(users[0], "submission actor must be a real user row in the scratch org"); const employmentId = randomUUID(); const secondEmploymentId = randomUUID(); for (const [id, party] of [ [employmentId, org.customerId], [secondEmploymentId, org.vendorId], ] as const) { await db.execute(sql` insert into worker_employments (id, org_id, worker_party_id, employer_subsidiary_id, revision) values (${id}, ${org.orgId}, ${party}, ${org.subsidiaryId}, 1)`); } harness = { db, run: db, orgId: org.orgId, actorId: users[0]!.id, employmentId, secondEmploymentId, cleanupOrgs: [org.orgId], }; } harnesses.push(harness); const hook = (t as TestAfter | undefined)?.after; if (typeof hook === "function") { hook.call(t, async () => { const { dropScratchOrgReporting } = await import("@openbooks/engine/testing/fixtures"); for (const id of harness.cleanupOrgs.splice(0)) await dropScratchOrgReporting(id); const at = harnesses.indexOf(harness); if (at >= 0) harnesses.splice(at, 1); }); } return harness; } /** Sentinel: thrown to roll the test transaction back after assertions. */ class IsolatedRollback extends Error {} /** * Run `work` on a transaction that always rolls back, restoring the shared * executor afterwards. Tests run sequentially in this file, so the * temporary `h.run` swap cannot leak across tests. Real refusals propagate; * only the sentinel is swallowed. */ async function isolated(h: Harness, work: () => Promise): Promise { try { await h.db.transaction(async (tx) => { const prev = h.run; h.run = tx; try { await work(); } finally { h.run = prev; } throw new IsolatedRollback(); }); } catch (error) { if (error instanceof IsolatedRollback) return; throw error; } } /** * Assert one statement is refused with the guard's own message. The * statement runs inside its own savepoint: without it the refusal would * abort the test transaction and every later assertion would see only * 25P02 (in_failed_sql_transaction), masking the behavior under test. A * statement that succeeds is itself the failure. */ async function refuses(h: Harness, stmt: SQL, re: RegExp, msg?: string): Promise { await h.run.execute(sql`savepoint expect_refusal`); try { await h.run.execute(stmt); } catch (error: unknown) { await h.run.execute(sql`rollback to savepoint expect_refusal`); await h.run.execute(sql`release savepoint expect_refusal`); assert.match(pgMessage(error), re, msg); return; } await h.run.execute(sql`release savepoint expect_refusal`); assert.fail(`expected refusal did not fire${msg ? `: ${msg}` : ""}`); } const PAYLOAD = (n: number) => ({ kind: "transfer", department: "ledger", level: n }); async function insertDraft(h: Harness): Promise { const id = randomUUID(); await h.run.execute(sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, created_by) values (${id}, ${h.orgId}, ${h.employmentId}, 1, ${JSON.stringify(PAYLOAD(1))}::jsonb, ${"0".repeat(64)}, 'v1', ${h.actorId})`); return id; } async function readRequest(h: Harness, id: string) { const rows = ( await h.run.execute<{ status: string; request_revision: number; payload_digest: string; submitted_at: string | null; flow_run_id: string | null; decision_snapshot: Record | null; }>(sql` select status, request_revision, payload_digest, submitted_at::text as submitted_at, flow_run_id::text as flow_run_id, decision_snapshot from hrm_employment_change_requests where id = ${id}`) ).rows; return rows[0]!; } async function canonicalDigest(h: Harness, payload: unknown): Promise { const rows = ( await h.run.execute<{ digest: string }>(sql` select encode(digest(convert_to(${JSON.stringify(payload)}::jsonb::text, 'UTF8'), 'sha256'), 'hex') as digest`) ).rows; return rows[0]!.digest; } async function makeFlowRun(h: Harness, requestId: string, orgId?: string): Promise { const flowId = randomUUID(); await h.run.execute(sql` insert into flows (id, org_id, subject_kind, graph) values (${flowId}, ${orgId ?? h.orgId}, 'hrm_employment_change_request', '{}'::jsonb)`); const runId = randomUUID(); await h.run.execute(sql` insert into flow_runs (id, org_id, flow_id, subject_kind, subject_id, trigger) values (${runId}, ${orgId ?? h.orgId}, ${flowId}, 'hrm_employment_change_request', ${requestId}, 'on_submit')`); return runId; } function snapshotFor(h: { digest: string; runId: string; expectedRevision?: number; }): Record { return { payload_digest: h.digest, payload_schema_version: "v1", expected_employment_revision: h.expectedRevision ?? 1, flow_run_id: h.runId, gates: [{ gate_id: randomUUID(), decided_by: randomUUID(), outcome: "approved" }], }; } async function insertCanonicalChange( h: Harness, employmentId: string, revision: number, ): Promise { const id = randomUUID(); await h.run.execute(sql` insert into employment_changes (id, org_id, employment_id, revision, change_kind, prior_snapshot, reason, recorded_by) values (${id}, ${h.orgId}, ${employmentId}, ${revision}, 'created', '{}'::jsonb, 'governed application evidence', ${h.actorId})`); return id; } test( "draft insert gets storage-computed canonical digest, never the caller value", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); const row = await readRequest(h, id); assert.equal(row.status, "draft"); assert.equal(row.request_revision, 1); assert.equal(row.payload_digest, await canonicalDigest(h, PAYLOAD(1))); assert.notEqual(row.payload_digest, "0".repeat(64)); }); }, ); test( "digest is semantic: key order and whitespace do not move it", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const a = await insertDraft(h); const reordered = JSON.stringify({ level: 1, kind: "transfer", department: " ledger ".trim(), }); const idB = randomUUID(); await h.run.execute(sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, created_by) values (${idB}, ${h.orgId}, ${h.employmentId}, 1, ${reordered}::jsonb, ${"0".repeat(64)}, 'v1', ${h.actorId})`); const rowA = await readRequest(h, a); const rowB = await readRequest(h, idB); assert.equal(rowA.payload_digest, rowB.payload_digest); }); }, ); test( "draft edit bumps request_revision by exactly one and recomputes digest", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); await h.run.execute(sql` update hrm_employment_change_requests set payload = ${JSON.stringify(PAYLOAD(2))}::jsonb, request_revision = 2, updated_by = ${h.actorId} where id = ${id}`); const row = await readRequest(h, id); assert.equal(row.request_revision, 2); assert.equal(row.payload_digest, await canonicalDigest(h, PAYLOAD(2))); await refuses( h, sql` update hrm_employment_change_requests set payload = ${JSON.stringify(PAYLOAD(3))}::jsonb, request_revision = 4 where id = ${id}`, /exactly one/, "skipped revision must be refused", ); }); }, ); test( "forged inserts are refused: no born-submitted, stamped, decided, or applied rows", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { await refuses( h, sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, status) values (${randomUUID()}, ${h.orgId}, ${h.employmentId}, 1, ${JSON.stringify(PAYLOAD(1))}::jsonb, ${"0".repeat(64)}, 'v1', 'pending_approval')`, /inserted as draft/, ); await refuses( h, sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, submitted_by, submitted_at) values (${randomUUID()}, ${h.orgId}, ${h.employmentId}, 1, ${JSON.stringify(PAYLOAD(1))}::jsonb, ${"0".repeat(64)}, 'v1', ${h.actorId}, now())`, /unsubmitted/, ); }); }, ); test( "submit freezes payload, revision, identity, and submission stamps", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'department transfer', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const digest = (await readRequest(h, id)).payload_digest; await refuses( h, sql` update hrm_employment_change_requests set payload = ${JSON.stringify(PAYLOAD(9))}::jsonb where id = ${id}`, /freeze on submit/, "post-submit payload edit must be refused", ); await refuses( h, sql` update hrm_employment_change_requests set request_revision = 2 where id = ${id}`, /freezes on submit/, ); await refuses( h, sql` update hrm_employment_change_requests set submitted_by = ${randomUUID()} where id = ${id}`, /immutable once set/, ); await refuses( h, sql` update hrm_employment_change_requests set employment_id = ${randomUUID()} where id = ${id}`, /immutable/, "identity is frozen from insert", ); const still = await readRequest(h, id); assert.equal(still.payload_digest, digest); }); }, ); test( "illegal transitions are refused; draft may withdraw without fabricated submission", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const direct = await insertDraft(h); await refuses( h, sql`update hrm_employment_change_requests set status = 'approved' where id = ${direct}`, /must submit/, "draft -> approved must be refused", ); const draftWithdrawal = await insertDraft(h); await h.run.execute( sql`update hrm_employment_change_requests set status = 'withdrawn' where id = ${draftWithdrawal}`, ); const withdrawn = await readRequest(h, draftWithdrawal); assert.equal(withdrawn.status, "withdrawn"); assert.equal(withdrawn.submitted_at, null); assert.equal(withdrawn.flow_run_id, null); await refuses( h, sql`update hrm_employment_change_requests set status = 'pending_approval' where id = ${draftWithdrawal}`, /terminal/, "withdrawn must never resurrect", ); const pending = await insertDraft(h); const runId = await makeFlowRun(h, pending); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${pending}`); await refuses( h, sql`update hrm_employment_change_requests set status = 'applied' where id = ${pending}`, /resolves to approved, rejected, or withdrawn/, "pending -> applied must be refused", ); }); }, ); test( "decision snapshot exists exactly on decided rows, binds digests, freezes", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const digest = (await readRequest(h, id)).payload_digest; await refuses( h, sql`update hrm_employment_change_requests set status = 'approved' where id = ${id}`, /snapshot/i, "approve without snapshot must be refused", ); await refuses( h, sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify({ ...snapshotFor({ digest: "f".repeat(64), runId }), gates: [] })}::jsonb where id = ${id}`, /snapshot_binding/, "snapshot bound to another digest must be refused", ); await h.run.execute(sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify(snapshotFor({ digest, runId }))}::jsonb where id = ${id}`); await refuses( h, sql` update hrm_employment_change_requests set decision_snapshot = ${JSON.stringify(snapshotFor({ digest, runId }))}::jsonb where id = ${id}`, /immutable once written/, "snapshot must freeze once written", ); }); }, ); test( "employment scope is composite: a valid employment id from another org is refused", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); const { createScratchOrg } = await import("@openbooks/engine/testing/fixtures"); const other = await createScratchOrg(); h.cleanupOrgs.push(other.orgId); await isolated(h, async () => { const foreignEmployment = randomUUID(); await h.run.execute(sql` insert into worker_employments (id, org_id, worker_party_id, employer_subsidiary_id, revision) values (${foreignEmployment}, ${other.orgId}, ${other.customerId}, ${other.subsidiaryId}, 1)`); await refuses( h, sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version) values (${randomUUID()}, ${h.orgId}, ${foreignEmployment}, 1, ${JSON.stringify(PAYLOAD(1))}::jsonb, ${"0".repeat(64)}, 'v1')`, /employment_fkey|foreign key/i, "cross-org employment binding must be refused", ); }); }, ); test( "flow run anchor is scope-bound, retained, never re-pointed", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); const { createScratchOrg } = await import("@openbooks/engine/testing/fixtures"); const other = await createScratchOrg(); h.cleanupOrgs.push(other.orgId); await isolated(h, async () => { const id = await insertDraft(h); const foreignRun = await makeFlowRun(h, randomUUID(), other.orgId); await refuses( h, sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${foreignRun} where id = ${id}`, /another organization/, "foreign-org run must be refused", ); const wrongKindFlow = randomUUID(); await h.run.execute(sql` insert into flows (id, org_id, subject_kind, graph) values (${wrongKindFlow}, ${h.orgId}, 'allocation_run', '{}'::jsonb)`); const wrongKindRun = randomUUID(); await h.run.execute(sql` insert into flow_runs (id, org_id, flow_id, subject_kind, subject_id, trigger) values (${wrongKindRun}, ${h.orgId}, ${wrongKindFlow}, 'allocation_run', ${randomUUID()}, 'on_submit')`); await refuses( h, sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${wrongKindRun} where id = ${id}`, /not opened for this request/, "wrong-kind run must be refused", ); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const otherRun = await makeFlowRun(h, randomUUID()); await refuses( h, sql`update hrm_employment_change_requests set flow_run_id = ${otherRun} where id = ${id}`, /never re-pointed|retained/, "run re-pointing must be refused", ); const row = await readRequest(h, id); assert.equal(row.flow_run_id, runId); }); }, ); test( "submitted history cannot be deleted; pure drafts can", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const draft = await insertDraft(h); await h.run.execute(sql`delete from hrm_employment_change_requests where id = ${draft}`); const submitted = await insertDraft(h); const runId = await makeFlowRun(h, submitted); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${submitted}`); await refuses( h, sql`delete from hrm_employment_change_requests where id = ${submitted}`, /retained as history/, ); }); }, ); test( "submit demands atomic evidence: bare flip refused, complete submit accepted", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); await refuses( h, sql`update hrm_employment_change_requests set status = 'pending_approval' where id = ${id}`, /atomically/, "draft -> pending_approval without submission evidence must be refused", ); const refused = await readRequest(h, id); assert.equal(refused.status, "draft"); // Positive control: the same transition with stamps, run, and reason // commits — the refusal above is about missing evidence, not the flip. const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'department transfer', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const submitted = await readRequest(h, id); assert.equal(submitted.status, "pending_approval"); assert.equal(submitted.submitted_at === null, false); assert.equal(submitted.flow_run_id, runId); }); }, ); test("row id and created_at freeze from insert", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); await refuses( h, sql`update hrm_employment_change_requests set id = ${randomUUID()} where id = ${id}`, /immutable/, "row id must freeze", ); await refuses( h, sql`update hrm_employment_change_requests set created_at = now() - interval '1 day' where id = ${id}`, /immutable/, "created_at must freeze", ); }); }); test( "snapshot binding is two-valued: presence plus typeof pins refuse null, missing, wrong-type, and mismatch", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const digest = (await readRequest(h, id)).payload_digest; const good = snapshotFor({ digest, runId }); const withoutDigest = { ...good }; delete withoutDigest.payload_digest; const withoutGates = { ...good }; delete withoutGates.gates; // Every variant below must evaluate the binding CHECK to FALSE (never // UNKNOWN): a missing key fails `?`, a present JSON null fails its // typeof pin, a wrong scalar type fails its typeof pin, and a wrong // value fails the equality against two non-null sides. const variants: Array<[string, Record]> = [ ["json-null digest", { ...good, payload_digest: null }], ["missing digest key", withoutDigest], ["missing gates key", withoutGates], ["gates not array", { ...good, gates: { gate_id: randomUUID() } }], ["string revision", { ...good, expected_employment_revision: "1" }], ["non-integral revision", { ...good, expected_employment_revision: 1.5 }], ["wrong revision", { ...good, expected_employment_revision: 2 }], ["wrong flow run", { ...good, flow_run_id: randomUUID() }], ["json-null flow run", { ...good, flow_run_id: null }], ]; for (const [name, snapshot] of variants) { await refuses( h, sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify(snapshot)}::jsonb where id = ${id}`, /snapshot_binding/, `${name} must be refused by the binding CHECK`, ); } const still = await readRequest(h, id); assert.equal(still.status, "pending_approval"); assert.equal(still.decision_snapshot, null); // Revision binds by INTEGRAL VALUE, not representation: JSON 1.0 denotes // the same counter as JSON 1 and must approve. JSON.stringify drops the // ".0", so the .0 form is spliced into the raw text deliberately. const floatForm = JSON.stringify(good).replace( '"expected_employment_revision":1,', '"expected_employment_revision":1.0,', ); assert.ok(floatForm.includes("1.0"), "float revision form must be present in the probe JSON"); await h.run.execute(sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${floatForm}::jsonb where id = ${id}`); const approved = await readRequest(h, id); assert.equal(approved.status, "approved"); }); }, ); test( "history FKs are restrictive except org cascade", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const rows = ( await h.run.execute<{ conname: string; confdeltype: string }>(sql` select c.conname, c.confdeltype from pg_constraint c join pg_class t on t.oid = c.conrelid where t.relname = 'hrm_employment_change_requests' and c.contype = 'f'`) ).rows; const byName = new Map(rows.map((r) => [r.conname, r.confdeltype])); assert.equal(byName.get("hrm_employment_change_requests_org_id_fkey"), "c"); for (const name of [ "hrm_employment_change_requests_employment_fkey", "hrm_employment_change_requests_flow_run_fkey", "hrm_employment_change_requests_applied_change_fkey", "hrm_employment_change_requests_submitted_by_fkey", "hrm_employment_change_requests_applied_by_fkey", "hrm_employment_change_requests_created_by_fkey", "hrm_employment_change_requests_updated_by_fkey", ]) { assert.ok( byName.get(name) === "r" || byName.get(name) === "a", `${name} must be restrictive (RESTRICT/NO ACTION), got ${byName.get(name)}`, ); } }); }, ); test( "RLS restricts by identity under a proven-restricted role", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); const { createScratchOrg } = await import("@openbooks/engine/testing/fixtures"); const other = await createScratchOrg(); h.cleanupOrgs.push(other.orgId); await isolated(h, async () => { // Fixture rows are seeded as the owner (the harness login): the // restricted role below receives only a test-local SELECT grant, so it // can prove isolation without ever writing. const otherEmployment = randomUUID(); await h.run.execute(sql` insert into worker_employments (id, org_id, worker_party_id, employer_subsidiary_id, revision) values (${otherEmployment}, ${other.orgId}, ${other.customerId}, ${other.subsidiaryId}, 1)`); // A scratch org seeds no users; the second org's actor is written on the // rollback-contained executor so it vanishes with the rest of the test. const otherActor = randomUUID(); await h.run.execute(sql` insert into users (id, org_id, email, name, password_hash, is_active) values (${otherActor}, ${other.orgId}, ${`u-${otherActor.slice(0, 8)}@scratch.test`}, 'HRM other-org actor', 'x', true)`); const otherUsers = ( await h.run.execute<{ id: string }>(sql` select id from users where id = ${otherActor} and org_id = ${other.orgId}`) ).rows; assert.ok(otherUsers[0], "second org actor must be a real user row"); const ownId = await insertDraft(h); const otherId = randomUUID(); await h.run.execute(sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, created_by) values (${otherId}, ${other.orgId}, ${otherEmployment}, 1, ${JSON.stringify(PAYLOAD(1))}::jsonb, ${"0".repeat(64)}, 'v1', ${otherUsers[0]!.id})`); // Owner-side precondition: the bootstrap-provided restricted role must // exist and be genuinely constrained. Fail here with the role named // instead of misreading counts later. Never CREATE ROLE here — global // role creation is outside this slice. const role = ( await h.run.execute<{ r: string; s: boolean; b: boolean }>(sql` select rolname as r, rolsuper as s, rolbypassrls as b from pg_roles where rolname = 'openbooks_app'`) ).rows[0]; assert.ok(role, "bootstrap must provide the restricted openbooks_app role"); assert.equal(role.s, false, "openbooks_app must not be a superuser"); assert.equal(role.b, false, "openbooks_app must not have BYPASSRLS"); // Test-local read grant as owner: the new table carries no standing // grant to the restricted role, so without this every count below would // be 0 from missing privilege — a vacuous pass. Rolls back with the // isolated transaction. await h.run.execute(sql`grant select on hrm_employment_change_requests to openbooks_app`); // Fixture setup as owner ends here: the assertions below run AS the // restricted role with bypass OFF under an explicit tenant, in a // savepoint with transaction-local GUCs so no scope leaks to siblings. // Each side carries a positive control — invisibility is proved to be // identity-based, never an empty table or a missing grant. await h.run.transaction(async (tx) => { await tx.execute(sql`set local role openbooks_app`); try { // Load-bearing proof AFTER the switch: a superuser or BYPASSRLS // role skips policies entirely (FORCE included), which would make // the invisibility assertions below vacuous. Asserting before the // switch proved only the owner login, never the asserting role. const who = ( await tx.execute<{ u: string; s: boolean; b: boolean }>(sql` select current_user as u, (select rolsuper from pg_roles where rolname = current_user) as s, (select rolbypassrls from pg_roles where rolname = current_user) as b`) ).rows[0]!; assert.equal(who.u, "openbooks_app", "RLS proof must run as the restricted role"); assert.equal(who.s, false, `RLS proof needs a non-superuser role, got ${who.u}`); assert.equal(who.b, false, `RLS proof needs a role without BYPASSRLS, got ${who.u}`); const scopedCount = async (orgId: string, id: string): Promise => { await tx.execute(sql`select set_config('app.bypass_rls', 'off', true)`); await tx.execute(sql`select set_config('app.current_org', ${orgId}, true)`); const rows = ( await tx.execute<{ n: number }>(sql` select count(*)::int as n from hrm_employment_change_requests where id = ${id}`) ).rows; return rows[0]!.n; }; assert.equal(await scopedCount(h.orgId, ownId), 1, "own request visible in own scope"); assert.equal( await scopedCount(h.orgId, otherId), 0, "foreign request hidden in own scope", ); assert.equal( await scopedCount(other.orgId, otherId), 1, "foreign request visible in its own scope", ); assert.equal( await scopedCount(other.orgId, ownId), 0, "own request hidden in foreign scope", ); } finally { // Restore the owner session role before the savepoint releases, so // outer owner cleanup never runs as the restricted role — even when // an assertion above throws. The outer isolated() rollback is the // final backstop. await tx.execute(sql`reset role`); } }); }); }, ); test( "application is all-or-nothing, linked, and single-fire", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { const id = await insertDraft(h); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const digest = (await readRequest(h, id)).payload_digest; await h.run.execute(sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify(snapshotFor({ digest, runId }))}::jsonb where id = ${id}`); const changeId = await insertCanonicalChange(h, h.employmentId, 2); await h.run.execute(sql` update hrm_employment_change_requests set status = 'applied', applied_at = now(), applied_by = ${h.actorId}, applied_employment_revision = 2, applied_employment_change_id = ${changeId} where id = ${id}`); await refuses( h, sql` update hrm_employment_change_requests set applied_employment_revision = 3 where id = ${id}`, /duplicate/, "second application evidence must be refused", ); await refuses( h, sql`update hrm_employment_change_requests set status = 'approved' where id = ${id}`, /terminal/, "applied must never leave its terminal state", ); const bogus = await insertDraft(h); const bogusRun = await makeFlowRun(h, bogus); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${bogusRun} where id = ${bogus}`); const bogusDigest = (await readRequest(h, bogus)).payload_digest; await h.run.execute(sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify(snapshotFor({ digest: bogusDigest, runId: bogusRun }))}::jsonb where id = ${bogus}`); await refuses( h, sql` update hrm_employment_change_requests set status = 'applied', applied_at = now(), applied_by = ${h.actorId}, applied_employment_revision = 2, applied_employment_change_id = ${randomUUID()} where id = ${bogus}`, /does not exist/, "application against a nonexistent canonical change must be refused", ); }); }, ); test( "application proves same org, same employment, and exact revision", { skip: !DB, timeout: 120_000 }, async (t) => { const h = await ctx(t); await isolated(h, async () => { async function approvedRequest(): Promise<{ id: string; runId: string; digest: string }> { const id = await insertDraft(h); const runId = await makeFlowRun(h, id); await h.run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'r', submitted_by = ${h.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); const digest = (await readRequest(h, id)).payload_digest; await h.run.execute(sql` update hrm_employment_change_requests set status = 'approved', decision_snapshot = ${JSON.stringify(snapshotFor({ digest, runId }))}::jsonb where id = ${id}`); return { id, runId, digest }; } const otherEmploymentChange = await insertCanonicalChange(h, h.secondEmploymentId, 2); const wrongRevisionChange = await insertCanonicalChange(h, h.employmentId, 99); const first = await approvedRequest(); await refuses( h, sql` update hrm_employment_change_requests set status = 'applied', applied_at = now(), applied_by = ${h.actorId}, applied_employment_revision = 2, applied_employment_change_id = ${otherEmploymentChange} where id = ${first.id}`, /another employment/, "application against another employment's change must be refused", ); const second = await approvedRequest(); await refuses( h, sql` update hrm_employment_change_requests set status = 'applied', applied_at = now(), applied_by = ${h.actorId}, applied_employment_revision = 2, applied_employment_change_id = ${wrongRevisionChange} where id = ${second.id}`, /not the applied revision/, "application with a mismatched canonical revision must be refused", ); const rightChange = await insertCanonicalChange(h, h.employmentId, 2); const third = await approvedRequest(); await h.run.execute(sql` update hrm_employment_change_requests set status = 'applied', applied_at = now(), applied_by = ${h.actorId}, applied_employment_revision = 2, applied_employment_change_id = ${rightChange} where id = ${third.id}`); const applied = await readRequest(h, third.id); assert.equal(applied.status, "applied"); }); }, );