Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

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

View 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
*/

View 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();