mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
177 lines
7.2 KiB
TypeScript
177 lines
7.2 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { personaByKey } from "../../fixtures/personas";
|
|
import { query } from "../../fixtures/db";
|
|
|
|
/**
|
|
* FIN-01, FIN-03, FIN-07, FIN-08 — the Finance gates and two ledger invariants
|
|
* that need no fixture data to prove.
|
|
*
|
|
* Finance gates writes IN-PAGE rather than at the route level: `cashier` and
|
|
* `finance_auditor` can open most screens, but most buttons on them must be
|
|
* absent or refuse. Route-level assertions alone would miss that entirely.
|
|
*/
|
|
|
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
|
|
|
const tokenFor = async (request: any, key: string): Promise<string> => {
|
|
const persona = personaByKey(key);
|
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
|
data: { email: persona.email, password: persona.password },
|
|
});
|
|
return (await res.json()).token;
|
|
};
|
|
|
|
test.describe("FIN-08 · a cashier is refused what it must not do", () => {
|
|
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
|
|
|
/**
|
|
* `cashier` is the narrowest Finance role: READ_ONLY_KEYS plus receivable
|
|
* view/record-receipt and payable view/record-payment. It must NOT be able to
|
|
* create a journal (that is the accountant's and manager's key) or see
|
|
* budgets at all. Verified boundary (2026-08-24): accounts 200, journals 200,
|
|
* create-journal 403, budgets 403, cutover 200.
|
|
*/
|
|
test("cannot create a journal entry", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-cashier");
|
|
const res = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
data: {},
|
|
});
|
|
|
|
// 403 = stopped by the guard. A 400 would mean it passed the permission
|
|
// check and only failed DTO validation — which is the bug this pins.
|
|
expect(res.status(), "cashier must not hold can:create:journal_entry").toBe(403);
|
|
});
|
|
|
|
test("cannot read budgets", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-cashier");
|
|
const res = await request.get(`${FINANCE_API}/api/v1/budgeting/budgets?limit=1`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status()).toBe(403);
|
|
});
|
|
|
|
test("CAN still read the ledger it needs to do its job", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-cashier");
|
|
for (const url of [
|
|
`${FINANCE_API}/api/v1/accounts?limit=1`,
|
|
`${FINANCE_API}/api/v1/journals?limit=1`,
|
|
]) {
|
|
const res = await request.get(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
expect(res.status(), `${url} should be readable by a cashier`).toBe(200);
|
|
}
|
|
});
|
|
|
|
test("the budgets screen is refused by direct URL", async ({ page }) => {
|
|
await page.goto("/budgets", { waitUntil: "networkidle" });
|
|
expect(page.url()).toContain("/forbidden");
|
|
});
|
|
});
|
|
|
|
test.describe("FIN-03 · separation of duties, approve vs pay", () => {
|
|
/**
|
|
* Structural, and must not be collapsed into one role later:
|
|
* POST /payables/bills/:id/approve → can:approve:supplier_bill (finance_manager only)
|
|
* POST /payables/bills/:id/payments → can:record:supplier_payment (accountant + cashier)
|
|
*
|
|
* So the person who approves a bill is never the person who pays it. Asserted
|
|
* against a non-existent bill id on purpose: the guard runs before the row is
|
|
* looked up, so 403-vs-404 cleanly separates "refused" from "allowed through".
|
|
*/
|
|
const NO_SUCH_BILL = "00000000-0000-4000-8000-000000000000";
|
|
|
|
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
|
|
|
test("a cashier may pay but may not approve", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-cashier");
|
|
const headers = { Authorization: `Bearer ${token}` };
|
|
|
|
const approve = await request.post(
|
|
`${FINANCE_API}/api/v1/payables/bills/${NO_SUCH_BILL}/approve`,
|
|
{ headers, data: {} },
|
|
);
|
|
expect(approve.status(), "cashier must NOT hold approve:supplier_bill").toBe(403);
|
|
|
|
const pay = await request.post(
|
|
`${FINANCE_API}/api/v1/payables/bills/${NO_SUCH_BILL}/payments`,
|
|
{ headers, data: {} },
|
|
);
|
|
expect(
|
|
pay.status(),
|
|
"cashier DOES hold record:supplier_payment — expect anything but 403",
|
|
).not.toBe(403);
|
|
});
|
|
|
|
test("a finance manager may approve but may not pay", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-manager");
|
|
const headers = { Authorization: `Bearer ${token}` };
|
|
|
|
const approve = await request.post(
|
|
`${FINANCE_API}/api/v1/payables/bills/${NO_SUCH_BILL}/approve`,
|
|
{ headers, data: {} },
|
|
);
|
|
expect(approve.status(), "finance_manager holds approve:supplier_bill").not.toBe(403);
|
|
|
|
const pay = await request.post(
|
|
`${FINANCE_API}/api/v1/payables/bills/${NO_SUCH_BILL}/payments`,
|
|
{ headers, data: {} },
|
|
);
|
|
expect(
|
|
pay.status(),
|
|
"finance_manager must NOT hold record:supplier_payment — that is the separation",
|
|
).toBe(403);
|
|
});
|
|
});
|
|
|
|
test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
|
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-accountant.json` });
|
|
|
|
test("FIN-01 · an unbalanced journal entry is refused and writes nothing", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-accountant");
|
|
const before = await query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM finance.journal_entries`,
|
|
);
|
|
|
|
const res = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
data: {
|
|
entryDate: "2026-01-15",
|
|
description: "E2E-FIN-01 deliberately unbalanced",
|
|
lines: [
|
|
{ accountCode: "1000", debit: 100, credit: 0 },
|
|
{ accountCode: "4000", debit: 0, credit: 50 },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(res.status(), "an entry that does not balance must be refused").toBeGreaterThanOrEqual(400);
|
|
|
|
const after = await query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM finance.journal_entries`,
|
|
);
|
|
// The DOM/network half is not enough: a service could answer 400 having
|
|
// already written the header row. The invariant is that nothing lands.
|
|
expect(after[0].count, "a refused entry must leave no row behind").toBe(before[0].count);
|
|
});
|
|
|
|
test("FIN-07 · the trial balance balances, computed independently", async ({ request }) => {
|
|
const token = await tokenFor(request, "finance-accountant");
|
|
const res = await request.get(`${FINANCE_API}/api/v1/reports/trial-balance`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status()).toBe(200);
|
|
|
|
// Assert against the ledger itself rather than trusting the report to check
|
|
// its own arithmetic — the report is the thing under test.
|
|
const rows = await query<{ debit: string; credit: string }>(
|
|
`SELECT COALESCE(SUM(l.debit), 0)::text AS debit,
|
|
COALESCE(SUM(l.credit), 0)::text AS credit
|
|
FROM finance.journal_lines l
|
|
JOIN finance.journal_entries e ON e.id = l.journal_entry_id
|
|
WHERE e.status = 'POSTED'`,
|
|
);
|
|
expect(rows[0].debit, "posted debits must equal posted credits").toBe(rows[0].credit);
|
|
});
|
|
});
|