Files
edr-platform/e2e-hr-finance/specs/hr/gating.spec.ts
2026-08-25 00:11:39 +03:00

174 lines
7.5 KiB
TypeScript

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);
});
});