mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
356 lines
23 KiB
Markdown
356 lines
23 KiB
Markdown
# HR + Finance Playwright e2e — Phase 0 map and plan
|
||
|
||
> Phase 0 deliverable for `docs/prompts/hr-finance-e2e-prompt.md`. **Approved by the owner
|
||
> 2026-08-24.**
|
||
>
|
||
> Reconstructed after the 2026-08-24 data-destruction incident
|
||
> (`SECURITY-INCIDENT-2026-08-24.md`); the original was lost with the working tree. Content is
|
||
> unchanged from the approved version.
|
||
|
||
---
|
||
|
||
## 1. The database decision
|
||
|
||
**Recommendation: use `smart_office_e2e`.** It already exists on this machine — a schema clone of
|
||
`smart_office_prod` (verified):
|
||
|
||
| | `smart_office_prod` | `smart_office_e2e` |
|
||
|---|---|---|
|
||
| Size | 1483 MB | 1484 MB |
|
||
| `iam.users` | 34,637 | 34,639 |
|
||
| `iam.employees` | 2,431 | 2,433 |
|
||
| `iam.positions` | 1,043 | 1,043 |
|
||
| Schemas | `iam`, `freight`, `passenger`, `edr_payment`, `audit`, `hr`, `finance` | same six |
|
||
| `hr` tables | 38 | 38 |
|
||
| `finance` tables | 22 | 22 |
|
||
|
||
This answers the prompt's own suggested option ("clone `smart_office_prod` into a
|
||
`smart_office_e2e` database and run migrations + HR/Finance seeds there"). Its origin was traced to
|
||
an earlier session at 2026-08-24 01:22, which also left a hand-made `e2e_hr_employee` role behind
|
||
(since retired — see §1.1).
|
||
|
||
### 1.1 Seed HR and Finance into it — with the scoped seeder, not the vendor one
|
||
|
||
Neither module's IAM rows existed in either database (`iam.application` had no `hr` or `finance`
|
||
row; 0 of 9 roles). **Do not** run `pnpm run seed:hr`: it re-seeds IAM's entire baseline and aborts
|
||
the whole transaction on an id conflict with a pre-existing row —
|
||
|
||
```
|
||
"people_operation" is id 9f9c0c2e-… in the database but the seed assigns it id 7bbf4e76-…
|
||
```
|
||
|
||
— which reproduces identically on any database restored from a dump taken at a different
|
||
`@tria-plc/iamapi-common` version. That refusal is correct (it protects rows another product owns),
|
||
but it means HR's and Finance's own rows can never be written that way.
|
||
|
||
Use `scripts/seed-module-permissions.cjs` instead, which writes only the module's own rows by the
|
||
stable ids its registry declares:
|
||
|
||
```bash
|
||
DB_NAME=smart_office_e2e DB_PASSWORD=… node scripts/seed-module-permissions.cjs hr
|
||
DB_NAME=smart_office_e2e DB_PASSWORD=… node scripts/seed-module-permissions.cjs finance
|
||
```
|
||
|
||
It carries a guard that has already fired for real: a key that exists under a *different* id stops
|
||
the write rather than silently diverging. That caught five HR permission keys duplicated by the
|
||
earlier session's hand-made `e2e_hr_employee` role. Those five rows and that role were retired in
|
||
favour of the product's own `employee_self_service`, which is a strict superset (10 self-service
|
||
keys vs 5).
|
||
|
||
### 1.2 Create every fixture inside the Active railway org
|
||
|
||
13 of 15 `iam.organizations` rows are `status='Debarred'`, and IAM's login SQL joins `o.status = $2`
|
||
when building the session's `employee` array — so an employee in a Debarred org gets an **empty
|
||
employee array and no organization context**, and every HR/Finance write plus most reads then 403
|
||
with "no organization context".
|
||
|
||
**This is correct data, not a bug** — the counts make that clear:
|
||
|
||
| org | status | employees | positions |
|
||
|---|---|---|---|
|
||
| Ethio Djibouti Standard Gauge Railway Share Company | Active | 2,416 | 1,004 |
|
||
| TRIA | Active | 1 | 0 |
|
||
| EDR | Debarred | 7 | 8 |
|
||
| Nifas Silk-Lafto Sub City | Debarred | 2 | 0 |
|
||
| EDR Freight | Debarred | 1 | 10 |
|
||
| 10 further Addis Ababa sub-cities + "Endode EDR" | Debarred | 0 | 0 |
|
||
|
||
**2,417 of 2,433 employees are in Active orgs.** The Debarred rows are Addis Ababa sub-cities
|
||
inherited from the Smart Office lineage (this database is a `smart_office` replica — those were
|
||
never EDR tenants) plus rows named after **applications** rather than legal entities. Note
|
||
`iam.application` separately and correctly holds `edr_freight_app` / `edr_passenger_app` — "EDR
|
||
Freight" and "EDR" as *organization* rows are artifacts, and near-empty ones.
|
||
|
||
The practical rule: **seed fixtures exclusively inside the Active railway org**, which has all the
|
||
real structure (2,416 employees, 1,004 positions, 7-level position hierarchy) a test could need. No
|
||
org status needs changing, in prod or in the e2e clone. `seed-personas.cjs` asserts the org is
|
||
Active and refuses to run otherwise.
|
||
|
||
### 1.3 Running the suite against it
|
||
|
||
Point both APIs at `smart_office_e2e` **for the test run only**, via environment rather than by
|
||
editing `.env` — verified working: `DB_NAME`, `PORT` and `CORS_ORIGINS` all override the `.env`
|
||
file, and Vite picks up `PORT` and `VITE_*` from the process environment too.
|
||
|
||
Use separate ports so a run never collides with the dev stack a human is using (the same isolation
|
||
the freight/passenger suites get from their own databases):
|
||
|
||
| | dev | e2e |
|
||
|---|---|---|
|
||
| hr-api | 3005 | **3105** |
|
||
| finance-api | 3004 | **3104** |
|
||
| hr-web | 5185 | **5285** |
|
||
| finance-web | 5186 | **5286** |
|
||
|
||
`CORS_ORIGINS` must be set on each API to its e2e web origin, or login dies at the preflight with a
|
||
"Network Error" that says nothing about CORS.
|
||
|
||
**Never point the running dev instances at the e2e database by accident** — a stray `nest start
|
||
--watch` found bound to `smart_office_e2e` cost an hour of misdiagnosis, because every write
|
||
appeared to succeed and then could not be found.
|
||
|
||
### 1.4 Teardown and hermeticity
|
||
|
||
Journals, payroll runs and posted ledger entries are deliberately append-only by product design, so
|
||
"run twice, DB unchanged" cannot mean "delete what got posted". The realistic story:
|
||
|
||
- Fixtures live under the Active org, never touching the 2,415 real employees' data.
|
||
- Each spec drives its fixture to a terminal, assertable state, or leaves a **named, findable
|
||
row** (`E2E-` prefix) rather than deleting — matching the passenger suite's convention of
|
||
asserting against real rows, not a wiped table.
|
||
- The reset mechanism is periodically re-cloning `smart_office_e2e` from `smart_office_prod`, not a
|
||
per-run DELETE. Cadence to be decided.
|
||
|
||
---
|
||
|
||
## 2. Route × permission × audience inventory
|
||
|
||
Read from `App.tsx` in both apps, cross-referenced against each app's permission-role matrix
|
||
(`hr-permissions.registry.ts`, 5 roles; `finance-permissions.registry.ts`, 4 roles). `viewOwn`-gated
|
||
rows are additionally reachable by everyone via `SELF_SERVICE_KEYS` (HR) / `READ_ONLY_KEYS`
|
||
(Finance).
|
||
|
||
### edr-hr-web (26 routes)
|
||
|
||
| Path | Gate | Reachable by |
|
||
|---|---|---|
|
||
| `/`, `/me` | none | any authenticated employee |
|
||
| `/employees` | `view:employee_profile` | hr_manager, hr_officer, payroll_admin, recruitment_officer |
|
||
| `/employees/new` | `create:employee_profile` | hr_manager, hr_officer |
|
||
| `/employees/:id` | `view:employee_profile` | as `/employees` |
|
||
| `/employees/:id/edit` | `update:employee_profile` | hr_manager, hr_officer |
|
||
| `/organisation`, `/departments`, `/job-titles`, `/job-positions` | `view:hr_org_structure` | hr_manager, hr_officer, payroll_admin, recruitment_officer |
|
||
| `/reports` | `view:hr_report` | hr_manager, hr_officer, payroll_admin |
|
||
| `/appraisals` | `submit_self:appraisal` | everyone (self-service) |
|
||
| `/appraisals/reviews` | `submit_manager:appraisal` | hr_manager |
|
||
| `/appraisals/cycles` | `manage:appraisal_cycle` | hr_manager |
|
||
| `/recruitment`, `/recruitment/:id` | `view:application` | hr_manager, recruitment_officer |
|
||
| `/payroll` | `view_all:payslip` | hr_manager, payroll_admin |
|
||
| `/payroll/settings` | `manage:salary_rule` | hr_manager, payroll_admin |
|
||
| `/payslips` | `view_own:payslip` | everyone |
|
||
| `/attendance` | `view_own:attendance` | everyone |
|
||
| `/attendance/approvals` | `approve:regularization` — confirmed NOT the same trap as leave; both roles hold this key | hr_manager, hr_officer |
|
||
| `/attendance/register` | `view_all:attendance` | hr_manager, hr_officer |
|
||
| `/attendance/settings` | `view_own:attendance` (read; write gated in-page on `manage:work_schedule`) | everyone (read) |
|
||
| `/leave` | `view_own:leave_request` | everyone |
|
||
| `/leave/approvals` | **fixed 2026-08-24** — was `approve_l1` solo (ungrantable), now `[approve_l1, approve_l2]` | any line manager (dynamic) + hr_manager (static L2) |
|
||
| `/leave/register` | `view_all:leave_request` | hr_manager, hr_officer |
|
||
| `/leave/settings`, `/leave/types`, `/leave/holidays` | `view_own:leave_request` (read) | everyone (read) |
|
||
| `/forbidden` | none | anyone denied elsewhere |
|
||
|
||
### finance-web (14 routes)
|
||
|
||
| Path | Gate | Reachable by |
|
||
|---|---|---|
|
||
| `/`, `/forbidden` | none | any authenticated staff |
|
||
| `/accounts` | `view:gl_account` | all 4 roles |
|
||
| `/journals`, `/journals/:id` | `view:journal_entry` | all 4 roles |
|
||
| `/journals/new` | `create:journal_entry` | finance_manager, accountant |
|
||
| `/periods` | `view:fiscal_period` | all 4 roles |
|
||
| `/receivables`, `/revenue-mappings` | `view:receivable` | all 4 roles |
|
||
| `/payables`, `/payroll` | `view:payable` | all 4 roles |
|
||
| `/budgets`, `/cost-centers` | `view:budget` | finance_manager, accountant, finance_auditor (**not** cashier) |
|
||
| `/assets` | `view:fixed_asset` | all 4 roles |
|
||
| `/reports` | `view:finance_report` | all 4 roles |
|
||
| `/cutover` | `view:fiscal_period` (reuses `period.view`, not a new key) | all 4 read; only finance_manager can act |
|
||
|
||
Every write action is gated **in-page**, not at the route level — `finance_auditor` and `cashier`
|
||
can open most screens but most buttons on them should be disabled or absent. That belongs in the
|
||
scenario matrix (§5) and needs its own `data-testid`s (§4).
|
||
|
||
### Verified persona boundaries
|
||
|
||
Driven against the running services rather than inferred from the matrix:
|
||
|
||
| persona | 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 |
|
||
|
||
| persona | 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 |
|
||
|
||
("past guard" = 400 from DTO validation on a deliberately empty body, i.e. the permission check
|
||
was passed.)
|
||
|
||
---
|
||
|
||
## 3. Network contract per flow
|
||
|
||
Paths come from the controllers (`@Controller()` base + verb literals). `(verify)` means the path is
|
||
confirmed but the guard should be re-read at test-writing time.
|
||
|
||
### HR
|
||
|
||
| Flow | Method + path | Key fields | Notes |
|
||
|---|---|---|---|
|
||
| Leave — quote | `GET /leave-requests/quote` | day count, balance after | drives the request-modal preview |
|
||
| Leave — submit | `POST /leave-requests` | `id`, `status: SUBMITTED`, `chargedDays` | day count frozen at submission |
|
||
| Leave — awaiting-me | `GET /leave-requests/awaiting-me` | `{items, total}` | **gate fixed**: `[approve_l1, approve_l2]` |
|
||
| Leave — approve/reject | `PATCH /leave-requests/:id/approve`, `/:id/reject` | `status`, `decidedByEmployeeId` | deducts in the same transaction — assert response AND `hr.leave_ledger_entries` |
|
||
| Leave — cancel | `PATCH /leave-requests/:id/cancel` | reversal entry | REVERSAL row, capped at one per entry by a unique index |
|
||
| Leave balance | via `/leave-balances` | balance = `SUM(days)` | **never stored** — the DB assertion must SUM the ledger |
|
||
| Attendance — clock | `POST /attendance/check-in`, `/check-out` | `work_date` (DATE) | night shift stays on the day it began |
|
||
| Attendance — regularize | `POST /attendance-regularizations` → `PATCH /:id/approve` | prior values preserved | applies **on approval** |
|
||
| Overtime | `GET /overtime/rates` + request/approve `(verify)` | category, multiplier | category derived, multiplier **frozen on the claim** |
|
||
| Payroll — lifecycle | `POST /payroll-runs` → `/:id/calculate` → `PATCH /:id/approve` → `/:id/mark-paid` | `status` | recalculation refused after APPROVED; one run per period |
|
||
| Payroll — payslips | `GET /payroll-runs/my-payslips`, `/payslips/:id` | own + approved only | |
|
||
| Appraisal — cycle open | `POST /appraisal/cycles/:id/open` | criteria copied | copy independent of later template edits |
|
||
| Appraisal — score | `POST /appraisal/:id/manager` | `finalScore` | manager's score only, never averaged |
|
||
| Recruitment — hire | `POST /recruitment/offers/:id/hire` | IAM user + employee + profile + salary | re-hiring the same offer → 409 |
|
||
| Reports — leave liability | `GET /reports/leave-liability` | valued days | only types with `max_carry_over_days > 0` |
|
||
| Org — hire | `POST /org/positions/:id/hire` | employee, `status='pending'` | `pending` is normal (2,424 of 2,433 real employees are) — only **org** status gates login |
|
||
| Job positions | `GET /job-positions` | `{items, total}` | **fixed**: 500'd for any non-super-admin (`hr.job_positions` has no org column; must join `iam.positions`) |
|
||
|
||
### Finance
|
||
|
||
| Flow | Method + path | Notes |
|
||
|---|---|---|
|
||
| GL — post | `POST /journals` → `POST /journals/:id/post` | balances or refused; refused outside a fiscal period; enforced in service **and** a DB constraint |
|
||
| GL — reverse | `POST /journals/:id/reverse` | links both ways, requires a reason |
|
||
| AR — ingest | broker consumer (`payment.#`), not HTTP | idempotent under redelivery; needs `PAYMENT_RABBITMQ_URL` (off by default) |
|
||
| AP — bills | `POST /payables/bills` → `/:id/approve` → `/:id/payments` | approve = `approve:supplier_bill` (finance_manager only); pay = `record:supplier_payment` (accountant + cashier) |
|
||
| AP — payroll→GL | `POST /payables/payroll/runs/:id/post` | depends on an HR payroll run existing first |
|
||
| Budgeting | `POST /budgeting/budgets/:id/approve` | budget vs actual vs committed |
|
||
| Assets — depreciation | `POST /assets/depreciation/run` | counts from `depreciation_entries` **plus** `opening_periods_charged`, in both the run and the schedule |
|
||
| Assets — dispose | `POST /assets/:id/dispose` | opening figures may not combine with `fundingAccountId` |
|
||
| Reports | `GET /reports/trial-balance`, `/balance-sheet` | debits = credits; assets = liabilities + equity + unclosed result |
|
||
| Cutover — readiness | `GET /cutover/readiness` | 5 checks; **suspense check counts posted lines only** |
|
||
| Cutover — import | `POST /cutover/opening-balances` | DRAFT entry; 3900 plug **computed, never accepted from the caller** |
|
||
| Cutover — set date | `PUT /cutover` | a payment dated before cutover must be **SKIPPED**, recorded in `inbound_events` |
|
||
|
||
---
|
||
|
||
## 4. `data-testid` prerequisite checklist
|
||
|
||
**Current state:** only nav-level testids exist (`nav-item-*`, `nav-group-*` from the redesign).
|
||
**Zero on any feature page.** This is the blocker that stalled the passenger effort; treat it as a
|
||
blocker, not a footnote. Note `SpotlightSearchProps` rejects `data-testid` — address the palette
|
||
input by placeholder.
|
||
|
||
**HR (`apps/edr-hr-web/src/features/**`)**
|
||
- `leave/RequestModal.tsx`: `leave-request-type`, `leave-request-dates`, `leave-request-submit`
|
||
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`, `leave-reject-btn-<id>`
|
||
- `attendance/*`: `attendance-clock-in`, `attendance-clock-out`, `attendance-regularization-row-<id>`
|
||
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, `payroll-calculate-btn`, `payroll-approve-btn`, `payroll-mark-paid-btn`
|
||
- `appraisal/*`: `appraisal-criterion-<key>`, `appraisal-weight-total`, `appraisal-submit-self`, `appraisal-submit-manager`
|
||
- `recruitment/ApplicationDrawer.tsx`: `recruitment-stage-select`, `recruitment-hire-btn`
|
||
- `reports/ReportsPage.tsx`: `report-row-<key>`, `report-export-btn`
|
||
|
||
**Finance (`apps/finance-web/src/features/**`)**
|
||
- `journals/NewJournalPage.tsx`: `journal-line-row-<n>`, `journal-balance-indicator`, `journal-post-btn`
|
||
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn-<id>`, `bill-record-payment-btn-<id>`
|
||
- `budgeting/BudgetsPage.tsx`: `budget-row-<id>`, `budget-approve-btn`
|
||
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn`, `dispose-btn-<id>`
|
||
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`, `cutover-import-paste`, `cutover-import-preview`
|
||
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`, `report-balance-sheet-total`
|
||
|
||
A starting inventory from reading route components, not exhaustive — whoever writes each spec adds
|
||
what their scenario needs in the same pass.
|
||
|
||
---
|
||
|
||
## 5. Scenario matrix
|
||
|
||
**DOM** = UI assertion, **NET** = network, **DB** = database.
|
||
|
||
| id | module | audience | proves | DOM | NET | DB | fixture |
|
||
|---|---|---|---|---|---|---|---|
|
||
| HR-01 | leave | employee | submitting freezes the day count | request in "My leave" | `POST /leave-requests` 201, `chargedDays` matches quote | `charged_days` unchanged after a later holiday-calendar edit | employee with an entitlement |
|
||
| HR-02 | leave | line manager | L1 approval deducts atomically | balance updates | `PATCH /:id/approve` 200 | one DEDUCTION row in the same statement window as the status flip | the real manager/report position pair |
|
||
| HR-03 | leave | hr_manager | **L2 reaches the page and can decide** (regression pin) | `/leave/approvals` renders, not `/forbidden` | `GET /awaiting-me` 200 not 403 | — | hr-manager persona |
|
||
| HR-04 | leave | employee | cancel reverses, not deletes | both entries in ledger drawer | `PATCH /:id/cancel` 200 | REVERSAL row added; original untouched; unique index holds | an approved request |
|
||
| HR-05 | attendance | employee | night shift stays on its start date | one day marked | check-in 22:00, check-out after midnight | `work_date` = check-in date | — |
|
||
| HR-06 | payroll | payroll_admin | recalculation refused after approval | error on retry | `POST /:id/calculate` → non-2xx | status unchanged | an APPROVED run |
|
||
| HR-07 | payroll | employee | self-service is scoped | own, approved-only | `GET /my-payslips` | — | two employees, one run |
|
||
| HR-08 | appraisal | hr_manager | manager score is final | shown = manager's, not an average | `POST /:id/manager` | `final_score` = manager's raw score | template weights = 100 |
|
||
| HR-09 | recruitment | recruitment_officer | re-hiring an offer is refused | error on 2nd click | 2nd `POST /offers/:id/hire` → 409 | FILLED count unchanged | one vacancy, one offer |
|
||
| HR-10 | job-positions | hr_manager | **org-filtered list no longer 500s** (regression pin) | table renders | `GET /job-positions` 200 | — | any non-super-admin |
|
||
| HR-11 | reports | hr_manager | liability values only carry-over types | excludes sick/bereavement/paternity | `GET /reports/leave-liability` | cross-check filtered `SUM` | both balance kinds |
|
||
| HR-12 | gating | **hr-employee** | a gated screen is refused by direct URL | lands on `/forbidden` | 403 if forced | — | ✅ unblocked — persona verified 403 on 4 of 5 endpoints |
|
||
| FIN-01 | GL | accountant | unbalanced entry refused | error on submit | `POST /journals` mismatched → non-2xx | no row written | — |
|
||
| FIN-02 | GL | finance_manager | posting outside a period refused | error on post | `POST /:id/post` → non-2xx | stays DRAFT | journal outside any open period |
|
||
| FIN-03 | AP | finance_manager vs cashier | separation of duties | approve visible only to manager; pay only to accountant/cashier | matching 403s if forced | — | one bill, two personas |
|
||
| FIN-04 | assets | accountant | a migrated asset still depreciates | non-zero charge in schedule | `POST /assets/depreciation/run` | entry row added; `periodsCharged` past the opening count | asset with opening figures |
|
||
| FIN-05 | cutover | finance_manager | suspense counts posted lines only | panel matches DB state | `GET /cutover/readiness` | DRAFT-only batch reads PENDING, not FAIL | unposted opening import |
|
||
| FIN-06 | cutover | finance_manager | pre-cutover payment skipped, not double-posted | — | broker message before cutover → no journal | `inbound_events` SKIPPED; ledger unchanged | needs `PAYMENT_RABBITMQ_URL` |
|
||
| FIN-07 | reports | finance_auditor | trial balance balances | totals equal | `GET /reports/trial-balance` | `SUM(debit) = SUM(credit)` | any posted period |
|
||
| FIN-08 | gating | **cashier** | a manage-only screen is refused | — | 403 | — | ✅ unblocked — cashier verified 403 on create-journal and budgets |
|
||
|
||
---
|
||
|
||
## 6. Harness plan
|
||
|
||
- `e2e-hr-finance/playwright.config.ts` — one project per audience (8 personas + super admin).
|
||
- `webServer`: boot-or-reuse both API+web pairs on the e2e ports (§1.3) against `smart_office_e2e`.
|
||
- **`storageState` per role**: mint programmatically per role (`POST /api/v1/auth/login`) for
|
||
speed, plus **one persona through the real login UI** — the passenger suite's precedent — so the
|
||
actual `<LoginPage>` form stays covered.
|
||
- **Fixtures**: draw on the Active org's existing hierarchy rather than building an IAM tree from
|
||
nothing. This is the advantage `smart_office_e2e` has over the passenger suite's 5544 database,
|
||
and the reason Phase 0 did not recommend a fresh Prisma-seeded DB. New rows get an `E2E-` prefix.
|
||
- **Serialization**: serialize specs sharing a payroll run or journal period; parallelize
|
||
independent reads.
|
||
- **Run script**: `e2e-hr-finance/run.sh` — infra → build → seed (module permissions + personas) →
|
||
boot → mint storageStates → run → open report.
|
||
- **CI**: no pipeline exists for either app (no `Jenkinsfile.HR`/`Jenkinsfile.Finance`). Out of
|
||
scope for Phase 0; flagged rather than invented.
|
||
|
||
---
|
||
|
||
## 7. Bugs found while preparing this
|
||
|
||
The `hr.demo` recreation doubled as the first real non-super-admin browser pass either app has had.
|
||
Both were invisible to every prior pass because those ran as super admin, which bypasses the checks
|
||
involved.
|
||
|
||
1. **`/leave/approvals` was unreachable by any role** — gated on `can:approve_l1:leave_request`, a
|
||
key the seed deliberately grants to no role, position, or position-type (zero rows in both
|
||
`iam.position_permissions` and `iam.position_type_permissions`, system-wide). Fixed in the nav
|
||
gate, route gate, badge hook, and the three backend routes to accept `approve_l1` **or**
|
||
`approve_l2` — matching what `LeaveRequestsService.assertCanDecide` already assumed. Verified:
|
||
403 → 200 for an L2-only persona.
|
||
2. **`GET /job-positions` 500'd for any non-super-admin** — `findPage` referenced
|
||
`position.organization_id`, a column `hr.job_positions` has never had (it deliberately stores no
|
||
copy of IAM's org/unit). Fixed by joining `iam.positions` the way the file's own
|
||
`headcountTotals` already did correctly. Verified: 500 → 200.
|
||
|
||
HR-03 and HR-10 exist to pin these.
|
||
|
||
---
|
||
|
||
## Open items before Phase 1 writes specs
|
||
|
||
1. Land the `data-testid` pass (§4), one reviewable commit per app.
|
||
2. Decide the re-clone cadence for `smart_office_e2e` (§1.4).
|
||
3. **Separately, not an e2e concern**: `iam.organizations` holds rows named after applications
|
||
("EDR Freight", "EDR") rather than legal entities, alongside 10 Addis Ababa sub-cities from the
|
||
Smart Office lineage. ~10 employees between them, all Debarred. A cleanup decision for whoever
|
||
owns the tenant list — flagged, not acted on.
|