mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
208 lines
9.4 KiB
JavaScript
208 lines
9.4 KiB
JavaScript
#!/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();
|