mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
Muluhabt ERP modules
This commit is contained in:
176
e2e-hr-finance/specs/finance/gating.spec.ts
Normal file
176
e2e-hr-finance/specs/finance/gating.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
173
e2e-hr-finance/specs/hr/gating.spec.ts
Normal file
173
e2e-hr-finance/specs/hr/gating.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
|
||||
/**
|
||||
* HR-03, HR-10, HR-12 — permission gating, and the two regressions found by the
|
||||
* first non-super-admin browser pass (2026-08-24).
|
||||
*
|
||||
* Every earlier pass over these apps ran as `superadmin@tria.com`, which
|
||||
* short-circuits `hasHrPermission` before any key is examined. That is exactly
|
||||
* why both bugs below survived a full seven-slice UI review: as super admin the
|
||||
* screens work. These scenarios therefore run ONLY as narrow personas, and the
|
||||
* suite has no super-admin project by design.
|
||||
*/
|
||||
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
||||
|
||||
/**
|
||||
* The regression: nav gate, route gate, badge hook and three backend routes
|
||||
* all required `can:approve_l1:leave_request` — a key the seed deliberately
|
||||
* grants to NO role, position or position-type (verified: zero rows in both
|
||||
* `iam.position_permissions` and `iam.position_type_permissions`,
|
||||
* system-wide). L1 is resolved dynamically from the IAM position hierarchy at
|
||||
* request time; the only *grantable* approval key is L2. So the screen was
|
||||
* unreachable by every real user, and only super admin could see it work.
|
||||
*/
|
||||
test("the screen renders instead of redirecting to /forbidden", async ({ page }) => {
|
||||
await page.goto("/leave/approvals", { waitUntil: "networkidle" });
|
||||
|
||||
expect(page.url()).not.toContain("/forbidden");
|
||||
await expect(page.locator("body")).not.toContainText("Not permitted");
|
||||
});
|
||||
|
||||
test("GET /leave-requests/awaiting-me answers 200, not 403", async ({ request }) => {
|
||||
const persona = personaByKey("hr-manager");
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await login.json();
|
||||
|
||||
const res = await request.get(`${HR_API}/api/v1/leave-requests/awaiting-me?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(res.status(), "an hr_manager holds approve_l2 and must reach this queue").toBe(200);
|
||||
// Paginated, so `total` is the count to read — never the array length, which
|
||||
// the endpoint caps.
|
||||
expect(await res.json()).toHaveProperty("total");
|
||||
});
|
||||
|
||||
test("no role, position or position-type grants approve_l1 — L2 is the only grantable key", async () => {
|
||||
const rows = await query<{ source: string; count: string }>(
|
||||
`SELECT 'role' AS source, count(*)::text FROM iam.role_permissions rp
|
||||
JOIN iam.permissions p ON p.id = rp.permission_id
|
||||
WHERE p.key = 'can:approve_l1:leave_request'
|
||||
UNION ALL
|
||||
SELECT 'position', count(*)::text FROM iam.position_permissions pp
|
||||
JOIN iam.permissions p ON p.id = pp.permission_id
|
||||
WHERE p.key = 'can:approve_l1:leave_request'
|
||||
UNION ALL
|
||||
SELECT 'position_type', count(*)::text FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions p ON p.id = ptp.permission_id
|
||||
WHERE p.key = 'can:approve_l1:leave_request'`,
|
||||
);
|
||||
|
||||
// This is the fact the fix rests on. If a future seed DOES grant L1
|
||||
// statically, this fails loudly — and the widened gate should be revisited
|
||||
// rather than silently left as the only thing making the screen reachable.
|
||||
for (const row of rows) {
|
||||
expect(Number(row.count), `${row.source} grants approve_l1`).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("HR-10 · the org-filtered job positions list", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
||||
|
||||
/**
|
||||
* The regression: `JobPositionsRepository.findPage` filtered on
|
||||
* `position.organization_id`, a column `hr.job_positions` has never had — it
|
||||
* deliberately stores no copy of IAM's org/unit (see the entity's own doc
|
||||
* comment). Super admin passes `organizationId = null`, which skips the
|
||||
* branch entirely, so the 500 only ever appeared for a real user.
|
||||
*/
|
||||
test("returns 200 for a non-super-admin, not 500", async ({ request }) => {
|
||||
const persona = personaByKey("hr-manager");
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await login.json();
|
||||
|
||||
const res = await request.get(`${HR_API}/api/v1/job-positions?page=1&limit=25`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(
|
||||
res.status(),
|
||||
"500 here means the org filter is referencing a column hr.job_positions does not have",
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("the page renders a table rather than an error boundary", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
|
||||
await page.goto("/job-positions", { waitUntil: "networkidle" });
|
||||
|
||||
expect(page.url()).not.toContain("/forbidden");
|
||||
expect(errors, "no page errors or 5xx responses").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("HR-12 · a self-service employee is refused the manage screens", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-employee.json` });
|
||||
|
||||
/**
|
||||
* `employee_self_service` is the narrowest HR role — the ten SELF_SERVICE_KEYS
|
||||
* and nothing else. Verified boundary (2026-08-24): own-leave 200, and 403 on
|
||||
* approvals, employees, payroll and recruitment.
|
||||
*
|
||||
* The negative case is the point. A suite that only ever runs as a broad role
|
||||
* proves the screens work, never that the gates hold.
|
||||
*/
|
||||
const denied = ["/employees", "/payroll", "/recruitment", "/leave/approvals"];
|
||||
|
||||
for (const route of denied) {
|
||||
test(`direct URL ${route} lands on /forbidden`, async ({ page }) => {
|
||||
await page.goto(route, { waitUntil: "networkidle" });
|
||||
|
||||
// The route guard redirects rather than rendering an empty screen, so the
|
||||
// user is told why instead of seeing a page that silently does nothing.
|
||||
expect(page.url(), `${route} should be refused for employee_self_service`).toContain(
|
||||
"/forbidden",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("own leave IS reachable — the gate is narrow, not broken", async ({ page }) => {
|
||||
await page.goto("/leave", { waitUntil: "networkidle" });
|
||||
expect(page.url()).not.toContain("/forbidden");
|
||||
});
|
||||
|
||||
test("the API refuses the same endpoints, not just the UI", async ({ request }) => {
|
||||
const persona = personaByKey("hr-employee");
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await login.json();
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// The UI gate is a convenience; the API is the real one. Assert both, or a
|
||||
// future refactor could drop the server check and the suite would still pass.
|
||||
for (const url of [
|
||||
`${HR_API}/api/v1/employee-profiles?limit=1`,
|
||||
`${HR_API}/api/v1/payroll-runs?limit=1`,
|
||||
`${HR_API}/api/v1/recruitment/openings?limit=1`,
|
||||
`${HR_API}/api/v1/leave-requests/awaiting-me?limit=1`,
|
||||
]) {
|
||||
const res = await request.get(url, { headers: auth });
|
||||
expect(res.status(), `${url} must be refused server-side`).toBe(403);
|
||||
}
|
||||
|
||||
const own = await request.get(`${HR_API}/api/v1/leave-requests/mine?limit=1`, { headers: auth });
|
||||
expect(own.status(), "self-service must still work").toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user