mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Muluhabt ERP modules
This commit is contained in:
59
e2e-hr-finance/fixtures/db.ts
Normal file
59
e2e-hr-finance/fixtures/db.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* `pg` is not a dependency of this directory and pnpm's strict layout means a
|
||||
* bare `import "pg"` does not resolve here. Borrow it from the app that already
|
||||
* owns a Postgres client rather than adding a duplicate declaration of the same
|
||||
* driver to the workspace root.
|
||||
*/
|
||||
const requireFrom = createRequire(
|
||||
path.join(__dirname, "..", "..", "apps", "edr-hr-api", "package.json"),
|
||||
);
|
||||
const { Client } = requireFrom("pg") as typeof import("pg");
|
||||
|
||||
/**
|
||||
* Direct database reads for assertions.
|
||||
*
|
||||
* The suite's doctrine (docs/hr-finance-ui-e2e-matrix.md) is that every scenario
|
||||
* checks the DOM, the network response AND the database row, and fails if the
|
||||
* three disagree. A UI that renders the right number from a stale cache, or an
|
||||
* API that answers 200 without writing, both pass a DOM-only test.
|
||||
*
|
||||
* READ-ONLY by intent. Fixtures are created through the product's own endpoints
|
||||
* or the seed scripts, never here — a test that sets up state with raw SQL stops
|
||||
* proving the code path that normally creates it.
|
||||
*/
|
||||
const config = {
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: Number(process.env.DB_PORT ?? 5432),
|
||||
user: process.env.DB_USER ?? "postgres",
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME ?? "smart_office_e2e",
|
||||
};
|
||||
|
||||
export async function query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
if (config.database === "smart_office_prod") {
|
||||
// The suite must never read prod as if it were the fixture database: an
|
||||
// assertion that passes there is meaningless and the mistake is silent.
|
||||
throw new Error("Refusing to run e2e assertions against smart_office_prod");
|
||||
}
|
||||
const client = new Client(config);
|
||||
await client.connect();
|
||||
try {
|
||||
const { rows } = await client.query(sql, params);
|
||||
return rows as T[];
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for the common "one row, one column" assertion. */
|
||||
export async function scalar<T = string>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<Record<string, T>>(sql, params);
|
||||
if (!rows.length) return null;
|
||||
return Object.values(rows[0])[0] ?? null;
|
||||
}
|
||||
174
e2e-hr-finance/fixtures/personas.ts
Normal file
174
e2e-hr-finance/fixtures/personas.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The audiences the suite drives, and the fixture data they hang off.
|
||||
*
|
||||
* Every persona lives in the **Active** railway organization. That is not a
|
||||
* style choice: IAM's login SQL joins `o.status = $2` when it builds a session's
|
||||
* `employee` array, so a persona in a Debarred org logs in fine but arrives with
|
||||
* NO organization context, and then 403s on nearly every HR/Finance call with a
|
||||
* message that looks nothing like the real cause. 13 of 15 orgs in this database
|
||||
* are Debarred (Addis Ababa sub-cities inherited from the Smart Office lineage,
|
||||
* plus rows named after applications rather than legal entities) — they hold ~10
|
||||
* employees between them. The railway org holds 2,416 and the whole 7-level
|
||||
* position hierarchy. See docs/hr-finance-ui-e2e-matrix.md §1.2.
|
||||
*/
|
||||
|
||||
/** Ethio Djibouti Standard Gauge Railway Share Company — status Active. */
|
||||
export const ORG_ID = "8abeba98-502e-4025-b048-c352dcfd198d";
|
||||
export const UNIT_ID = "5251ba4d-1dfe-4480-b3d9-da96bd409f72";
|
||||
|
||||
/**
|
||||
* A real parent/child position pair, used for the leave-approval scenarios.
|
||||
* Leave L1 approval is deliberately NOT a role grant — the first approver is
|
||||
* resolved through the IAM position hierarchy at request time — so a manager and
|
||||
* a direct report have to be genuinely related in that tree for the approval
|
||||
* scenarios to mean anything. Picking two unrelated positions would make the
|
||||
* test pass for the wrong reason.
|
||||
*/
|
||||
export const MANAGER_POSITION_ID = "6e1b52e0-7c9f-41cf-a277-b738b398b531"; // Team Leader, Online Booking System
|
||||
export const REPORT_POSITION_ID = "3ceca5d2-4468-4185-aca2-72723bc6eb01"; // Officer, Operation Data Management
|
||||
|
||||
/** A position unrelated to the pair above, for personas whose reporting line is irrelevant. */
|
||||
export const STAFF_POSITION_ID = "c5fbfcb5-d4d2-4e0b-8f0c-961f94216c82"; // Administrative Assistant I
|
||||
|
||||
export type Persona = {
|
||||
/** Playwright project name and storageState filename. */
|
||||
key: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
nameEn: string;
|
||||
nameAm: string;
|
||||
/** IAM role key granted in ORG_ID. */
|
||||
roleKey: string;
|
||||
positionId: string;
|
||||
/** Which app this persona signs into. */
|
||||
app: "hr" | "finance";
|
||||
};
|
||||
|
||||
/**
|
||||
* One password for every fixture persona. These accounts exist only in
|
||||
* `smart_office_e2e`, a disposable clone; the value is checked in deliberately
|
||||
* so a run is reproducible without a secret store.
|
||||
*/
|
||||
export const E2E_PASSWORD = "E2ePersona@2026";
|
||||
|
||||
export const PERSONAS: Persona[] = [
|
||||
{
|
||||
key: "hr-manager",
|
||||
username: "e2e_hr_manager",
|
||||
email: "e2e.hr.manager@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E HR Manager",
|
||||
nameAm: "ኢ2ኢ የሰው ሀብት ሥራ አስኪያጅ",
|
||||
roleKey: "hr_manager",
|
||||
positionId: MANAGER_POSITION_ID,
|
||||
app: "hr",
|
||||
},
|
||||
{
|
||||
key: "hr-employee",
|
||||
username: "e2e_hr_employee",
|
||||
email: "e2e.hr.employee@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E HR Employee",
|
||||
nameAm: "ኢ2ኢ ሠራተኛ",
|
||||
// The narrowest HR role — self-service only. This is the persona the
|
||||
// negative gating scenarios need: it must be REFUSED the manage screens.
|
||||
roleKey: "employee_self_service",
|
||||
positionId: REPORT_POSITION_ID,
|
||||
app: "hr",
|
||||
},
|
||||
{
|
||||
key: "hr-payroll-admin",
|
||||
username: "e2e_payroll_admin",
|
||||
email: "e2e.payroll.admin@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Payroll Administrator",
|
||||
nameAm: "ኢ2ኢ የደመወዝ አስተዳዳሪ",
|
||||
roleKey: "payroll_admin",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "hr",
|
||||
},
|
||||
{
|
||||
key: "hr-recruitment-officer",
|
||||
username: "e2e_recruiter",
|
||||
email: "e2e.recruiter@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Recruitment Officer",
|
||||
nameAm: "ኢ2ኢ የቅጥር ኦፊሰር",
|
||||
roleKey: "recruitment_officer",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "hr",
|
||||
},
|
||||
{
|
||||
key: "finance-manager",
|
||||
username: "e2e_finance_manager",
|
||||
email: "e2e.finance.manager@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Finance Manager",
|
||||
nameAm: "ኢ2ኢ የፋይናንስ ሥራ አስኪያጅ",
|
||||
roleKey: "finance_manager",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "finance",
|
||||
},
|
||||
{
|
||||
key: "finance-accountant",
|
||||
username: "e2e_accountant",
|
||||
email: "e2e.accountant@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Accountant",
|
||||
nameAm: "ኢ2ኢ ሒሳብ ሠራተኛ",
|
||||
roleKey: "accountant",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "finance",
|
||||
},
|
||||
{
|
||||
key: "finance-cashier",
|
||||
username: "e2e_cashier",
|
||||
email: "e2e.cashier@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Cashier",
|
||||
nameAm: "ኢ2ኢ ገንዘብ ያዥ",
|
||||
// Narrowest Finance role — may record a payment but must NOT approve a bill.
|
||||
roleKey: "cashier",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "finance",
|
||||
},
|
||||
{
|
||||
key: "finance-auditor",
|
||||
username: "e2e_auditor",
|
||||
email: "e2e.auditor@edr.local",
|
||||
password: E2E_PASSWORD,
|
||||
nameEn: "E2E Finance Auditor",
|
||||
nameAm: "ኢ2ኢ የሒሳብ ተቆጣጣሪ",
|
||||
roleKey: "finance_auditor",
|
||||
positionId: STAFF_POSITION_ID,
|
||||
app: "finance",
|
||||
},
|
||||
];
|
||||
|
||||
export const personaByKey = (key: string): Persona => {
|
||||
const found = PERSONAS.find((p) => p.key === key);
|
||||
if (!found) {
|
||||
throw new Error(`No persona "${key}" — known: ${PERSONAS.map((p) => p.key).join(", ")}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verified against the running services on 2026-08-24 — each persona's actual
|
||||
* boundary, not what the role matrix says it should be. Kept here because a
|
||||
* regression in the seed or the role grants shows up as a boundary shift, and
|
||||
* this table is what a failing gating test should be read against.
|
||||
*
|
||||
* HR (`own-leave` / `approvals` / `employees` / `payroll` / `recruitment`):
|
||||
* hr-employee 200 403 403 403 403
|
||||
* hr-manager 200 200 200 200 200
|
||||
* hr-payroll-admin 200 403 200 200 403
|
||||
* hr-recruitment-officer 200 403 200 403 200
|
||||
*
|
||||
* Finance (`accounts` / `journals` / `create-journal` / `budgets` / `cutover`):
|
||||
* finance-manager 200 200 past-guard 200 200
|
||||
* finance-accountant 200 200 past-guard 200 200
|
||||
* finance-cashier 200 200 403 403 200
|
||||
* finance-auditor 200 200 403 200 200
|
||||
*/
|
||||
207
e2e-hr-finance/fixtures/seed-personas.cjs
Normal file
207
e2e-hr-finance/fixtures/seed-personas.cjs
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Creates the suite's fixture personas in `smart_office_e2e`.
|
||||
*
|
||||
* Idempotent: an existing persona is left alone except for its password and role
|
||||
* grant, which are re-asserted so a half-finished earlier run cannot leave an
|
||||
* account that exists but cannot log in.
|
||||
*
|
||||
* WHY THIS WRITES `iam.*` DIRECTLY, unlike everything else in this repo:
|
||||
* the product's own path to a new employee is `POST /org/positions/:id/hire`,
|
||||
* which is the right call for a real hire and is itself covered by a scenario.
|
||||
* But it has no password field — a real hire receives an invite and sets their
|
||||
* own — so a suite that used it would still have to reach into
|
||||
* `iam.user_credentials` afterwards to get a usable login. Given that, doing the
|
||||
* whole thing in one explicit transaction is clearer than half-API/half-SQL, and
|
||||
* keeps fixture setup obviously separate from the behaviour under test.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* DB_NAME=smart_office_e2e DB_PASSWORD=... node e2e-hr-finance/fixtures/seed-personas.cjs
|
||||
*
|
||||
* Prerequisite: scripts/seed-module-permissions.cjs has been run for both `hr`
|
||||
* and `finance` against the same database, or the role lookups below fail loudly.
|
||||
*/
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const HR_API = path.join(ROOT, "apps", "edr-hr-api");
|
||||
// `argon2` is declared by @edr/iam-seed — the package that owns password
|
||||
// hashing for this platform — so resolve it from there rather than adding a
|
||||
// second declaration of the same native dependency.
|
||||
const IAM_SEED = path.join(ROOT, "packages", "iam-seed");
|
||||
|
||||
const { Client } = require(require.resolve("pg", { paths: [HR_API] }));
|
||||
const argon2 = require(require.resolve("argon2", { paths: [IAM_SEED, ROOT] }));
|
||||
|
||||
// personas.ts is TypeScript; the values are plain data, so they are mirrored here
|
||||
// rather than adding a build step for a fixture script. Keep in step with it.
|
||||
const ORG_ID = "8abeba98-502e-4025-b048-c352dcfd198d";
|
||||
const UNIT_ID = "5251ba4d-1dfe-4480-b3d9-da96bd409f72";
|
||||
const MANAGER_POSITION_ID = "6e1b52e0-7c9f-41cf-a277-b738b398b531";
|
||||
const REPORT_POSITION_ID = "3ceca5d2-4468-4185-aca2-72723bc6eb01";
|
||||
const STAFF_POSITION_ID = "c5fbfcb5-d4d2-4e0b-8f0c-961f94216c82";
|
||||
const E2E_PASSWORD = "E2ePersona@2026";
|
||||
|
||||
const PERSONAS = [
|
||||
{ username: "e2e_hr_manager", email: "e2e.hr.manager@edr.local", nameEn: "E2E HR Manager", nameAm: "ኢ2ኢ የሰው ሀብት ሥራ አስኪያጅ", roleKey: "hr_manager", positionId: MANAGER_POSITION_ID },
|
||||
{ username: "e2e_hr_employee", email: "e2e.hr.employee@edr.local", nameEn: "E2E HR Employee", nameAm: "ኢ2ኢ ሠራተኛ", roleKey: "employee_self_service", positionId: REPORT_POSITION_ID },
|
||||
{ username: "e2e_payroll_admin", email: "e2e.payroll.admin@edr.local", nameEn: "E2E Payroll Administrator", nameAm: "ኢ2ኢ የደመወዝ አስተዳዳሪ", roleKey: "payroll_admin", positionId: STAFF_POSITION_ID },
|
||||
{ username: "e2e_recruiter", email: "e2e.recruiter@edr.local", nameEn: "E2E Recruitment Officer", nameAm: "ኢ2ኢ የቅጥር ኦፊሰር", roleKey: "recruitment_officer", positionId: STAFF_POSITION_ID },
|
||||
{ username: "e2e_finance_manager", email: "e2e.finance.manager@edr.local", nameEn: "E2E Finance Manager", nameAm: "ኢ2ኢ የፋይናንስ ሥራ አስኪያጅ", roleKey: "finance_manager", positionId: STAFF_POSITION_ID },
|
||||
{ username: "e2e_accountant", email: "e2e.accountant@edr.local", nameEn: "E2E Accountant", nameAm: "ኢ2ኢ ሒሳብ ሠራተኛ", roleKey: "accountant", positionId: STAFF_POSITION_ID },
|
||||
{ username: "e2e_cashier", email: "e2e.cashier@edr.local", nameEn: "E2E Cashier", nameAm: "ኢ2ኢ ገንዘብ ያዥ", roleKey: "cashier", positionId: STAFF_POSITION_ID },
|
||||
{ username: "e2e_auditor", email: "e2e.auditor@edr.local", nameEn: "E2E Finance Auditor", nameAm: "ኢ2ኢ የሒሳብ ተቆጣጣሪ", roleKey: "finance_auditor", positionId: STAFF_POSITION_ID },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const dbName = process.env.DB_NAME;
|
||||
if (!dbName) {
|
||||
console.error("DB_NAME is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (dbName === "smart_office_prod") {
|
||||
// A guard, not politeness: these are test accounts with a checked-in
|
||||
// password, and prod is a replica of the real production database.
|
||||
console.error("Refusing to write fixture personas into smart_office_prod.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: dbName,
|
||||
});
|
||||
await client.connect();
|
||||
console.log(`personas → ${dbName}`);
|
||||
|
||||
// Hash once: argon2id at these parameters costs ~100ms, and every persona
|
||||
// shares the password.
|
||||
const hash = await argon2.hash(E2E_PASSWORD, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const { rows: orgRows } = await client.query(
|
||||
`SELECT status FROM iam.organizations WHERE id = $1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
if (!orgRows.length) throw new Error(`Organization ${ORG_ID} not found`);
|
||||
if (orgRows[0].status !== "Active") {
|
||||
// Fail loudly rather than create personas that log in but carry no
|
||||
// organization context — the failure mode that cost a day to diagnose.
|
||||
throw new Error(
|
||||
`Organization ${ORG_ID} is "${orgRows[0].status}", not Active — personas created ` +
|
||||
`there would have no organization context and 403 on nearly every call.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const p of PERSONAS) {
|
||||
const { rows: roleRows } = await client.query(
|
||||
`SELECT id FROM iam.roles WHERE key = $1`,
|
||||
[p.roleKey],
|
||||
);
|
||||
if (!roleRows.length) {
|
||||
throw new Error(
|
||||
`Role "${p.roleKey}" is missing — run scripts/seed-module-permissions.cjs first.`,
|
||||
);
|
||||
}
|
||||
const roleId = roleRows[0].id;
|
||||
|
||||
const { rows: existing } = await client.query(
|
||||
`SELECT id FROM iam.users WHERE username = $1`,
|
||||
[p.username],
|
||||
);
|
||||
|
||||
let userId;
|
||||
if (existing.length) {
|
||||
userId = existing[0].id;
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO iam.users (name, email, username, user_type, status, is_active,
|
||||
has_set_password, phone_number, verified_by)
|
||||
VALUES ($1, $2, $3, 'employee', 'accepted', true, true, NULL, 'phone_number')
|
||||
RETURNING id`,
|
||||
[JSON.stringify({ am: p.nameAm, en: p.nameEn }), p.email, p.username],
|
||||
);
|
||||
userId = rows[0].id;
|
||||
}
|
||||
|
||||
// Re-assert the password every run: an account that exists but whose
|
||||
// credential row is missing or stale is worse than one that is absent,
|
||||
// because it fails at login time inside global-setup with a 400 that says
|
||||
// nothing about why.
|
||||
const { rowCount: credUpdated } = await client.query(
|
||||
`UPDATE iam.user_credentials SET password = $2, is_active = true, changed_at = now()
|
||||
WHERE user_id = $1`,
|
||||
[userId, hash],
|
||||
);
|
||||
if (!credUpdated) {
|
||||
await client.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active)
|
||||
VALUES ($1, $2, true)`,
|
||||
[userId, hash],
|
||||
);
|
||||
}
|
||||
|
||||
const { rows: empRows } = await client.query(
|
||||
`SELECT id FROM iam.employees WHERE user_id = $1 AND organization_id = $2`,
|
||||
[userId, ORG_ID],
|
||||
);
|
||||
let employeeId;
|
||||
if (empRows.length) {
|
||||
employeeId = empRows[0].id;
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO iam.employees (organization_id, unit_id, user_id, is_current, status, name)
|
||||
VALUES ($1, $2, $3, true, 'accepted', $4)
|
||||
RETURNING id`,
|
||||
[ORG_ID, UNIT_ID, userId, JSON.stringify({ am: p.nameAm, en: p.nameEn })],
|
||||
);
|
||||
employeeId = rows[0].id;
|
||||
}
|
||||
|
||||
// `status = 'APPROVED'` and `is_current = true` are both load-bearing:
|
||||
// IAM's login SQL filters employee_positions on them, so a position row in
|
||||
// any other state contributes nothing to the session's permission set.
|
||||
const { rows: posRows } = await client.query(
|
||||
`SELECT id FROM iam.employee_positions WHERE employee_id = $1 AND position_id = $2`,
|
||||
[employeeId, p.positionId],
|
||||
);
|
||||
if (!posRows.length) {
|
||||
await client.query(
|
||||
`INSERT INTO iam.employee_positions
|
||||
(employee_id, is_current, start_date, position_id, unit_id, status, is_delegate)
|
||||
VALUES ($1, true, now(), $2, $3, 'APPROVED', false)`,
|
||||
[employeeId, p.positionId, UNIT_ID],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO iam.user_roles (user_id, role_id, organization_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE SET organization_id = EXCLUDED.organization_id`,
|
||||
[userId, roleId, ORG_ID],
|
||||
);
|
||||
|
||||
console.log(` ✓ ${p.username.padEnd(20)} ${p.roleKey}`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(` ${PERSONAS.length} personas ready · password ${E2E_PASSWORD}`);
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error(` ✗ rolled back: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
114
e2e-hr-finance/global-setup.ts
Normal file
114
e2e-hr-finance/global-setup.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { PERSONAS, type Persona } from "./fixtures/personas";
|
||||
|
||||
/**
|
||||
* Mints one storageState per persona before any spec runs.
|
||||
*
|
||||
* Both web apps keep their session in COOKIES (`auth-token`, `refresh-token`,
|
||||
* `auth-user`) rather than localStorage — deliberately the same names
|
||||
* freight-backoffice uses, so the apps share one IAM session. storageState is
|
||||
* therefore cookie-shaped, and `auth-user` must be present: AuthContext seeds
|
||||
* itself from that cookie so a reload does not flash the login screen, and
|
||||
* without it the app renders logged-out for the first paint of every test.
|
||||
*
|
||||
* Most personas are minted programmatically (a direct POST to /auth/login) —
|
||||
* eight real UI logins would add ~30s to every run and prove the same thing
|
||||
* eight times. ONE persona per app goes through the real <LoginPage> form, so
|
||||
* the login UI itself stays covered. That split follows the passenger suite.
|
||||
*
|
||||
* Fixture data (permissions, roles, personas) is seeded by run.sh before this
|
||||
* runs; this file only mints sessions. If a login fails here it is almost
|
||||
* always because the seeders were skipped — the error says so rather than
|
||||
* letting 20 specs fail with a confusing redirect to /login.
|
||||
*/
|
||||
const STORAGE = path.join(__dirname, "fixtures", "storage");
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
const HR_WEB = process.env.HR_WEB_URL ?? "http://localhost:5285";
|
||||
const FINANCE_WEB = process.env.FINANCE_WEB_URL ?? "http://localhost:5286";
|
||||
|
||||
const apiFor = (p: Persona) => (p.app === "hr" ? HR_API : FINANCE_API);
|
||||
const webFor = (p: Persona) => (p.app === "hr" ? HR_WEB : FINANCE_WEB);
|
||||
|
||||
/** The apps read these three; `auth-user` is what AuthContext seeds from. */
|
||||
const cookiesFor = (origin: string, token: string, refreshToken: string, user: unknown) => {
|
||||
const { hostname } = new URL(origin);
|
||||
const base = { domain: hostname, path: "/", expires: -1, httpOnly: false, secure: false, sameSite: "Lax" as const };
|
||||
return [
|
||||
{ ...base, name: "auth-token", value: token },
|
||||
{ ...base, name: "refresh-token", value: refreshToken },
|
||||
{ ...base, name: "auth-user", value: encodeURIComponent(JSON.stringify(user)) },
|
||||
];
|
||||
};
|
||||
|
||||
async function mintViaApi(persona: Persona): Promise<void> {
|
||||
const api = apiFor(persona);
|
||||
const res = await fetch(`${api}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// The login DTO validates `email` — it accepts a username in that field
|
||||
// too, but the field name is `email` and an omitted one 400s with
|
||||
// "email should not be empty".
|
||||
body: JSON.stringify({ email: persona.email, password: persona.password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`[global-setup] login failed for ${persona.key} (${persona.email}): HTTP ${res.status}. ` +
|
||||
`Did run.sh seed the personas? ` +
|
||||
`DB_NAME=smart_office_e2e node e2e-hr-finance/fixtures/seed-personas.cjs`,
|
||||
);
|
||||
}
|
||||
const { token, refreshToken } = (await res.json()) as { token: string; refreshToken: string };
|
||||
|
||||
const meRes = await fetch(`${api}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!meRes.ok) throw new Error(`[global-setup] /me failed for ${persona.key}: HTTP ${meRes.status}`);
|
||||
const me = await meRes.json();
|
||||
|
||||
const state = { cookies: cookiesFor(webFor(persona), token, refreshToken, me), origins: [] };
|
||||
fs.writeFileSync(path.join(STORAGE, `${persona.key}.json`), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/** One per app, through the real form, so <LoginPage> itself is covered. */
|
||||
async function mintViaUi(persona: Persona): Promise<void> {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${webFor(persona)}/login`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Mantine renders the password field with a visibility-toggle BUTTON that
|
||||
// also carries the accessible name "Password", so getByLabel matches two
|
||||
// elements. Address the input by role instead.
|
||||
await page.getByLabel("Email or username").fill(persona.email);
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(persona.password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }),
|
||||
page.getByRole("button", { name: "Sign in" }).click(),
|
||||
]);
|
||||
|
||||
await ctx.storageState({ path: path.join(STORAGE, `${persona.key}.json`) });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(_config: FullConfig) {
|
||||
fs.mkdirSync(STORAGE, { recursive: true });
|
||||
|
||||
const uiMinted = new Set(["hr-manager", "finance-manager"]);
|
||||
|
||||
for (const persona of PERSONAS) {
|
||||
if (uiMinted.has(persona.key)) {
|
||||
await mintViaUi(persona);
|
||||
console.log(`[global-setup] ${persona.key.padEnd(24)} minted via real login UI`);
|
||||
} else {
|
||||
await mintViaApi(persona);
|
||||
console.log(`[global-setup] ${persona.key.padEnd(24)} minted via API`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[global-setup] ${PERSONAS.length} storageStates written to fixtures/storage/`);
|
||||
}
|
||||
156
e2e-hr-finance/playwright.config.ts
Normal file
156
e2e-hr-finance/playwright.config.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { PERSONAS } from "./fixtures/personas";
|
||||
|
||||
/**
|
||||
* Playwright end-to-end suite for the HR and Finance modules.
|
||||
*
|
||||
* Scope and doctrine are set by docs/hr-finance-ui-e2e-matrix.md (Phase 0,
|
||||
* approved 2026-08-24): every scenario asserts the DOM, the network response
|
||||
* AND the database row, and fails if the three disagree.
|
||||
*
|
||||
* ── Isolation ────────────────────────────────────────────────────────────────
|
||||
* Runs against `smart_office_e2e` on its OWN ports, so a run can never disturb
|
||||
* the dev stack a human is using, nor write into `smart_office_prod` (a
|
||||
* production replica). Both are load-bearing:
|
||||
*
|
||||
* | service | dev | e2e |
|
||||
* | hr-api | 3005 | 3105 |
|
||||
* | finance-api | 3004 | 3104 |
|
||||
* | hr-web | 5185 | 5285 |
|
||||
* | finance-web | 5186 | 5286 |
|
||||
*
|
||||
* `DB_NAME`, `PORT` and `CORS_ORIGINS` all override the app's own `.env`
|
||||
* (verified), and Vite reads `PORT`/`VITE_*` from the process environment — so
|
||||
* no config file is edited to run the suite.
|
||||
*
|
||||
* CORS_ORIGINS is not optional: without it the API rejects the browser at the
|
||||
* preflight and login fails with a bare "Network Error" that names nothing.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
const HR_WEB = process.env.HR_WEB_URL ?? "http://localhost:5285";
|
||||
const FINANCE_WEB = process.env.FINANCE_WEB_URL ?? "http://localhost:5286";
|
||||
const DB_NAME = process.env.DB_NAME ?? "smart_office_e2e";
|
||||
|
||||
const STORAGE = path.join(__dirname, "fixtures", "storage");
|
||||
|
||||
const webEnv = {
|
||||
GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy",
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, "specs"),
|
||||
// Shared seeded database, and several scenarios move state that cannot be
|
||||
// moved back (a posted journal, an approved payroll run). Serialize, as the
|
||||
// passenger suite does, so assertions stay deterministic.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
globalSetup: path.join(__dirname, "global-setup.ts"),
|
||||
reporter: [
|
||||
["list"],
|
||||
[
|
||||
"html",
|
||||
{
|
||||
outputFolder: path.join(__dirname, "..", "e2e-hr-finance-report"),
|
||||
open: "never",
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm --filter @edr/hr-api start",
|
||||
url: `${HR_API}/api-docs`,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: true,
|
||||
env: { ...webEnv, PORT: "3105", DB_NAME, CORS_ORIGINS: HR_WEB },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/finance-api start",
|
||||
url: `${FINANCE_API}/api-docs`,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: true,
|
||||
env: { ...webEnv, PORT: "3104", DB_NAME, CORS_ORIGINS: FINANCE_WEB },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/hr-web dev",
|
||||
url: HR_WEB,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
...webEnv,
|
||||
PORT: "5285",
|
||||
VITE_HR_API_URL: HR_API,
|
||||
// hr-api serves POST /api/v1/auth/login itself (it embeds IamModule).
|
||||
// The app's own .env points login at passenger-api :4000 on the belief
|
||||
// that it does not — that belief is wrong, and the suite does not
|
||||
// inherit the coupling.
|
||||
VITE_AUTH_API_URL: HR_API,
|
||||
VITE_AUTH_BASE_PATH: "/api/v1",
|
||||
VITE_CLIENT_APP: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/finance-web dev",
|
||||
url: FINANCE_WEB,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
...webEnv,
|
||||
PORT: "5286",
|
||||
VITE_FINANCE_API_URL: FINANCE_API,
|
||||
VITE_AUTH_API_URL: FINANCE_API,
|
||||
VITE_AUTH_BASE_PATH: "/api/v1",
|
||||
VITE_CLIENT_APP: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
use: {
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 15_000,
|
||||
launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) },
|
||||
},
|
||||
|
||||
/**
|
||||
* ONE PROJECT PER APP, not per persona.
|
||||
*
|
||||
* Per-persona projects were the first design and were wrong: a project's
|
||||
* `testMatch` selects files, so every HR spec ran once under each of the four
|
||||
* HR personas. A gating scenario written for `hr-employee` then also ran as
|
||||
* `hr-manager` and failed — not because the gate was broken, but because it
|
||||
* was asserted against the wrong role. Worse, the inverse would pass silently.
|
||||
*
|
||||
* The persona is a property of the SCENARIO, so each `describe` declares it:
|
||||
*
|
||||
* test.use({ storageState: storageFor("hr-employee") })
|
||||
*
|
||||
* The project only supplies what is genuinely per-app: the baseURL.
|
||||
*/
|
||||
projects: [
|
||||
{
|
||||
name: "hr",
|
||||
testMatch: /specs\/hr\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: HR_WEB },
|
||||
},
|
||||
{
|
||||
name: "finance",
|
||||
testMatch: /specs\/finance\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: FINANCE_WEB },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/** Path to a persona's storageState — every describe block names its own. */
|
||||
export const storageFor = (personaKey: string): string => {
|
||||
if (!PERSONAS.some((p) => p.key === personaKey)) {
|
||||
throw new Error(`Unknown persona "${personaKey}"`);
|
||||
}
|
||||
return path.join(STORAGE, `${personaKey}.json`);
|
||||
};
|
||||
56
e2e-hr-finance/run.sh
Executable file
56
e2e-hr-finance/run.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One command for the HR + Finance e2e suite:
|
||||
# build → seed → (Playwright boots the stack) → run → report
|
||||
#
|
||||
# bash e2e-hr-finance/run.sh # everything
|
||||
# bash e2e-hr-finance/run.sh --headed # watch it
|
||||
# bash e2e-hr-finance/run.sh hr # only specs/hr
|
||||
# SKIP_SEED=1 bash e2e-hr-finance/run.sh # re-run without re-seeding
|
||||
#
|
||||
# Requires DB_PASSWORD for the seeders. Everything else has a default.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export DB_NAME="${DB_NAME:-smart_office_e2e}"
|
||||
export DB_HOST="${DB_HOST:-localhost}"
|
||||
export DB_PORT="${DB_PORT:-5432}"
|
||||
export DB_USER="${DB_USER:-postgres}"
|
||||
|
||||
# A checked-in password reaching the production replica would be a real
|
||||
# incident, not an inconvenience. Refuse early and unmistakably.
|
||||
if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
||||
echo "refusing to run the e2e suite against smart_office_prod" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${DB_PASSWORD:-}" ]]; then
|
||||
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building the apps the suite boots"
|
||||
# Playwright's webServer runs `start` for the APIs (dist/main.js), so they must
|
||||
# be built first — `dev` would work too but recompiles on every run and races
|
||||
# the health check.
|
||||
pnpm turbo build --filter=@edr/hr-api --filter=@edr/finance-api
|
||||
|
||||
if [[ "${SKIP_SEED:-}" != "1" ]]; then
|
||||
echo "==> Seeding module permissions (idempotent)"
|
||||
node scripts/seed-module-permissions.cjs hr
|
||||
node scripts/seed-module-permissions.cjs finance
|
||||
|
||||
echo "==> Seeding fixture personas (idempotent)"
|
||||
node e2e-hr-finance/fixtures/seed-personas.cjs
|
||||
fi
|
||||
|
||||
echo "==> Running Playwright"
|
||||
# global-setup mints one storageState per persona; webServer boots the four
|
||||
# services on their e2e ports (3105/3104/5285/5286) against $DB_NAME.
|
||||
npx playwright test --config e2e-hr-finance/playwright.config.ts "$@"
|
||||
|
||||
echo
|
||||
echo "==> Report: e2e-hr-finance-report/index.html"
|
||||
echo " npx playwright show-report e2e-hr-finance-report"
|
||||
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