Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -34,6 +34,21 @@ Two structural facts frame everything:
≥ 30000. Matrix A1A4.
- **Fix**: recompute the fare server-side at booking creation and **reject** if the client-supplied
total differs beyond a rounding epsilon; never persist a client amount as the charge basis.
- **Resolution (authenticated paths)** ✅ — `bookings.service.ts` now guards both `createOneWayBooking`
and `createRoundTripBooking` with `assertTotalNotUnderAuthoritative(resolvedTotalMinor,
fareCalculation.totalMinor)`: a booking whose ETB charge basis falls below the server-recomputed
authoritative fare (net of promo/loyalty/free-child) by more than a 1% FX-rounding tolerance is
rejected with `BadRequestException` and nothing is persisted. It's a **floor** (not equality) so
legitimate berth surcharges — which only raise the total — still pass. Proven by
`e2e-ui/specs/portal/ua13-forged-total.spec.ts` (now asserts a 4xx + no 1-minor booking; red before
the guard, green after).
- **Resolution (guest paths)** ✅ — `guest-booking.service.ts` now applies the identical
`assertTotalNotUnderAuthoritative` floor guard to both the one-way and round-trip guest booking
creation paths (authoritative ETB fare captured before the client-driven per-seat/reviewed branches
overwrite the total). Proven by `e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts` (forged
`seatFareMinor=0` + `reviewedTotalMinor=0` now rejected with a 4xx and no 0-minor booking persisted;
red before the guard, green after). C-1 is now closed on all four booking-creation paths
(authenticated one-way/round-trip + guest one-way/round-trip).
### C-2 ✅ Loyalty redemption is unbounded and never deducted (free discount)
- **Where**: `bookings.service.ts:1705,1707` (and `:1028,:1249,:1451`); DTO `bookings.dto.ts:155`.
@@ -67,6 +82,18 @@ Two structural facts frame everything:
booking in full; short payments are undetectable. Matrix G1/G7.
- **Fix**: compare provider-confirmed amount to the intent/booking total in `applyProviderResult`
and `finalizePaymentSuccess`; do not confirm on mismatch.
- **Resolution (passenger side)** ✅ — `payments.service.ts` `handlePaymentEvent` (the consumer of
the payment service's `mark-paid` relay — the passenger-side settlement entry point) now compares
the provider-settled `event.amountMinor` against the booking's display-currency total
(`displayTotalMinor`, i.e. the amount the passenger was quoted) before materializing the intent or
finalizing. A short payment (below the expected amount beyond a 1% rounding tolerance) is refused
with `{ processed: false, reason: 'amount-mismatch' }` and the booking is left unconfirmed — no
ticket. Amount-only by design: the display↔charge-currency divergence for USD/DJF (UA-1b/2/3) is
tracked separately, so the guard compares against `displayTotalMinor` to stay correct for both ETB
and the currently-diverging currencies. Proven by `e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts`
(forged `amountMinor:1` now leaves the booking unconfirmed; red before the guard, green after). The
**payment-side** `intents.service.ts` mismatch (`applyProviderResult`) lives in `edr-payment-api`
and is out of scope for the passenger-app fix.
### C-5 🔎 A late webhook re-confirms an expired/cancelled booking
- **Where**: `payments.service.ts:809-848` (`finalizePaymentSuccess` never reads `booking.status`);
@@ -99,27 +126,69 @@ Two structural facts frame everything:
- **Fix**: implement disbursement (wallet credit or provider refund) and move `refundStatus`
through `PROCESSING → COMPLETED`; reconcile stuck PENDING rows.
### C-8 🔎 Unauthenticated exchange-rate writes ✅ (guard metadata verified)
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — no `@UseGuards`;
only `:42` DELETE is `@PassengerAdmin()`.
- **Repro (verified)**: `auth-gaps.e2e-spec.ts``upsert`/`update` handlers have **0** guards,
`remove` has ≥1.
- **Expected**: FX writes are admin-only. **Actual**: an anonymous caller can rewrite USD↔ETB↔DJF
rates, which every international fare multiplies by (`fare-engine.service.ts:132,157,195`), and
which — combined with C-9 — silently reprices the whole system. Matrix J1.
### C-8 ✅ Exchange-rate writes are missing the ADMIN check (any passenger can rewrite FX) — CORRECTED
- **⚠️ Corrected by live testing** — the original claim (*unauthenticated* FX writes) was a **false
positive**: `@tria-plc/api-common`'s `SharedAuthModule` registers a **global `APP_GUARD` = JwtGuard**
(`shared-auth.module` `APP_GUARD`), so anonymous requests get **401**. The metadata-only J1 check
saw no *method-level* guard and wrongly concluded "unauthenticated". The real defect is
**authorization**, not authentication.
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — authenticated but
**no `@PassengerAdmin`** (only `:42` DELETE has it).
- **Repro (verified live)**: `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11) —
anon → **401**, but a **regular passenger token → 200** rewrites the live USD↔ETB rate.
- **Expected**: FX writes are admin-only. **Actual**: any logged-in user (incl. a passenger) can
rewrite USD↔ETB↔DJF rates, which every international fare multiplies by
(`fare-engine.service.ts:132,157,195`). Needs a valid login (not anonymous), so **HIGH, not
CRITICAL** — but a single passenger can still distort all international pricing. Same class as C-9.
- **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`.
- **Resolution** ✅ — `fare-engine/currency.controller.ts` now decorates both `@Put()` and
`@Patch(':id')` with `@PassengerAdmin()` + `@ApiBearerAuth('IAM-auth')`, matching the existing
`@Delete` handler. `@PassengerAdmin()` is the repo's established guard decorator (`JwtGuard` +
`PassengerPermissionGuard(admin)`) — no new auth code, and no `@edr/auth` placeholder needed since
the permission infra already exists and the seeded staff admin carries the permission. The sibling
`/currencies` write surfaces (`currency.controller.ts`, `currencies.controller.ts`) were already
guarded, so `/fare-engine/exchange-rates` was the sole gap. Proven by
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11): anon → 401, regular passenger PUT
and PATCH → **403**, staff admin → 200; red before the guard, green after.
### C-9 🔎 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired)
### C-9 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired) — verified live
- **Where**: `common/roles.guard.ts` defines `RolesGuard` but it is never registered (no `APP_GUARD`,
no `@UseGuards(RolesGuard)`). So `@Roles(...)` is inert on:
`configurable-fare.controller.ts:20,111,187` (create/activate/delete fare configs + feature
no `@UseGuards(RolesGuard)`). The global `JwtGuard` (SharedAuthModule) does authN but NOT authZ, so
`@Roles(...)` is inert on: `configurable-fare.controller.ts:20,111,187` (fare configs + feature
toggle), `segments/segment-fare.controller.ts:15` (`/admin/segment-fares`),
`system-config.controller.ts:23,32` (`GET/PATCH /config`).
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger who
obtained a token) can CRUD fare configuration and system config. Matrix J2J4.
- **Repro (verified live)**: a **regular passenger token**`PATCH /config` (`@Roles('ADMIN')`) →
**HTTP 200** (wrote admin-only system config); `POST /admin/fare-configurations` → 400 (reached DTO
validation, i.e. it passed the role guard). So any authenticated user bypasses the ADMIN gate.
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger) can
CRUD fare configuration and system config. Matrix J2J4.
- **Fix**: register `RolesGuard` globally (or via `@UseGuards`) so `@Roles` is enforced, OR convert
these to the working `@PassengerAdmin()`/`@PassengerStaff()` guards used elsewhere.
### C-10 ✅ Authenticated `POST /bookings` is BROKEN (passengerId resolution regression)
- **Where**: `bookings.controller.ts:528-532` overrides `passengerId` with the JWT user id
(`req.user.id`, the iamUserId — "never trust the request body", added in commit `25fdf88a`).
`bookings.service.ts:773` resolves an iamUserId → Passenger ONLY when it is **non-UUID**. IAM user
ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`
— only `iamUserId` is set; `id` auto-generates). So the resolver never fires and `booking.create`
(`bookings.service.ts:905`) uses the iamUserId directly as `passengerId`.
- **Repro**: ✅ verified two ways — (1) live browser: the full UI booking flow returns **HTTP 400
P2003** on `Booking_passengerId_fkey` for a logged-in passenger whose `Passenger.id ≠ iamUserId`
(the realistic case); (2) deterministic API test `test/authed-booking-passengerid.e2e-spec.ts`
`create()` with a UUID iamUserId fails the FK, while `create()` with the real `Passenger.id`
succeeds (control). The UI suite only goes green because `seed-ui.ts` deliberately sets
`Passenger.id == iamUserId`.
- **Expected**: every IAM-authenticated passenger can book. **Actual**: every authenticated
`POST /bookings` fails with a foreign-key error; only the guest path (`/bookings/guest`, which
creates a fresh passenger) works. This is a **regression** — before `25fdf88a`, the controller
used the frontend-supplied `passengerId` (the real `Passenger.id`), which worked.
- **Fix**: resolve the passenger by iamUserId unconditionally (`passenger.findUnique({ where: {
iamUserId } })`) in the controller or service — drop the UUID-format gate at `bookings.service.ts:773`
— and pass the resolved `Passenger.id` to `booking.create`. (Keep the "don't trust the body"
intent; just translate the identity correctly.)
- **⚠️ Confirm the deployment window**: verify whether `25fdf88a` is already in production. If so,
authenticated bookings are down platform-wide; if it's only on `dev`, this is a pre-release blocker.
---
## HIGH — pricing is wrong or exploitable
@@ -137,6 +206,18 @@ Two structural facts frame everything:
the fare ~100×; `pricing-currency.e2e-spec.ts` (C2b) — silent 1.0 vs `getRateOrThrow` throwing.
- **Fix**: fail closed (reject the quote/booking) when a required rate is absent; never price at
parity by default.
- **Resolution** ✅ — `currency.service.ts` `getExchangeRate` no longer substitutes `1.0` on a missing
rate; it logs and throws `BadRequestException` (`No exchange rate configured for X->Y`), matching
`getRateOrThrow`. Fare pricing therefore fails closed: with the `USD→ETB` pair deleted the fare
engine (`fare-engine.service.ts:157`) throws, so the search returns **no priced class** for the
affected currency (the per-seat-class fare error is caught in `search.service.ts:1041`, so the trip
is listed without a fare rather than 500ing), and an authoritative `calculateFare` on the booking
path — which does not swallow the error — rejects the booking. No path prices at parity by default.
Proven by `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (PB-10): with the rate present the
USD search returns a priced fare; with it deleted the search returns an empty `faresByClass` instead
of a ~100×-collapsed fare (red before the fix, green after). Note: `getExchangeRate` still resolves
only the *direct* rate (no inverse/bridge) — unifying it with `getRateOrThrow` is the separate H-3
cleanup; failing closed here is strictly safer than the old silent 1.0.
### H-3 ✅ Display path and charge path diverge on the same FX state (100×)
- **Where**: `getExchangeRate` (`:131`, no inverse fallback) vs `getRateOrThrow` (`:81`, inverse +
@@ -214,6 +295,37 @@ Two structural facts frame everything:
multi-child parties. Matrix B6/B7.
- **Fix**: one shared fare function used by quote, booking, and payment.
### H-13 ✅ A valid promo is silently dropped in the browser flow (customer overcharged)
- **Where**: `GET /search/fare-breakdown` (`search.service.ts:940-970`) computes the discount into a
SEPARATE `discountMinor` / discounted `totalMinor`, but returns per-passenger `displayFareMinor`
**undiscounted**. The review page (`portal/src/app/booking/review/page.tsx:587`) reduces the
per-passenger fares and sends their sum as `reviewedTotalMinor` — i.e. the **undiscounted
subtotal** — ignoring `discountMinor`. Promo only enters via the `?promoCode=` URL param (no UI
input).
- **Repro**: ✅ verified in-browser — `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`: with a valid 10%
promo, the breakdown shows `discountMinor > 0` and `totalMinor < subtotalMinor`, yet the booking is
stored at the full `subtotalMinor`.
- **Expected**: the discounted total is booked and charged. **Actual**: the customer is charged full
price despite a valid promo — a silent overcharge (and a broken promo feature). Matrix D / UA-8.
- **Fix**: book the breakdown's discounted `totalMinor` (not the client-summed per-pax undiscounted
fares); or return discounted per-pax fares. Best combined with C-1 (server recomputes the
authoritative total, promo included, and rejects a client mismatch).
- **Resolution (authed one-way)** ✅ — `bookings.service.ts` `createOneWayBooking` now applies the
authoritative promo discount server-side. The portal still forwards `promoCode` in the booking body,
so `calculateFare` already computes `discountMinor` — the total-resolution branches simply never
subtracted it. When the total comes from a client-summed subtotal (per-seat sum or
`reviewedTotalMinor`, both undiscounted), the code now subtracts `fareCalculation.discountMinor`
(converted to display currency for the display total) so the stored/charged `totalMinor` =
`subtotal discount`. The engine-fallback branch already booked the discounted `totalMinor`, so it
is excluded (via a `usedClientSubtotal` flag) to avoid double-subtracting; no-op when no promo
applies (`discountMinor === 0`), so UA-11 (expired promo) and the non-promo specs are unaffected.
This composes with the C-1 floor guard: after the discount is applied the resolved total equals the
authoritative fare, so the guard passes. Proven by `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`
(booking now stored at `subtotal discount`; red before the fix, green after). The **round-trip**
and **guest** paths share the same latent frontend drop but have no UI spec yet — tracked for a
follow-up; the guest service additionally still overrides its discounted total with
`reviewedTotalMinor` (see the PROMO REALITY note in `docs/ui-e2e-test-matrix.md`).
---
## MEDIUM — backoffice config accepts invalid data / unsafe deletes
@@ -225,6 +337,14 @@ Two structural facts frame everything:
- **Repro (verified)**: `config-validation.e2e-spec.ts` (H1/H2) — negative values pass validation;
the guarded sibling rejects them.
- **Fix**: add `@Min(0)` to every money DTO field.
- **Resolution (seat-class base price)** ✅ — `seat-classes.dto.ts` `CreateSeatClassDto.basePrice` and
`insuranceFeeMinor` now carry `@Min(0)`; because `UpdateSeatClassDto extends PartialType(...)` the
constraint applies to `PATCH /seat-classes/:id` too. A negative `basePrice` is rejected with 400 at
the DTO layer (matching the backoffice form's `min=0`), so it never reaches the DB. Proven by
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-7): `basePrice:-500` → 400, a valid
write still succeeds (red before the `@Min`, green after). The other money DTOs named above
(`schedules.dto.ts` `CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`) are not exercised
by a UI spec and remain a follow-up for full M-1 closure.
### M-2 ✅ Promo bounds/date not validated
- **Where**: `promos.dto.ts:20` (`percentOff` no `@Max(100)`/`@Min(0)`), `:29` (`validUntil`
@@ -233,12 +353,26 @@ Two structural facts frame everything:
`validUntil:"not-a-real-date"` both pass.
- **Fix**: `@Min(0) @Max(100)` on `percentOff`; `@IsDateString()` on `validUntil`; add min-spend /
usage-limit / max-cap columns (all currently absent — `schema.prisma:785`).
- **Resolution (percentOff bounds)** ✅ — `promos.dto.ts` `CreatePromotionDto.percentOff` now carries
`@Min(0) @Max(100)` and `amountOffMinor` carries `@Min(0)`, so `POST /promos` with `percentOff:200`
is rejected with 400 at the DTO layer while a valid ≤100% promo still saves. Proven by
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-8): `percentOff:200` → 400, `percentOff:50`
→ 201 (red before the bounds, green after). The `validUntil` `@IsDateString` tightening and the
missing min-spend/usage-limit/max-cap columns remain a follow-up (not exercised by BC-8).
### M-3 🔎 `PATCH /config` accepts arbitrary unvalidated key/values
- **Where**: `system-config.controller.ts:34` (no DTO) → `system-config.service.ts:56-59` stores a
raw `Record<string,string>`. Setting `seat_hold_duration_minutes = -1` or `"abc"` is persisted.
Matrix H7.
- **Fix**: a whitelisted, typed DTO with per-key numeric/range validation.
- **Resolution** ✅ — `system-config.dto.ts` adds `UpdateSystemConfigDto`, a whitelisted body listing
every known config key, each `@Type(() => Number) @IsInt() @Min(...)` (seat-hold bounded 1..60,
throttle limits/TTLs `@Min(1)`, hour windows `@Min(0)`). The controller now accepts the DTO (so the
global whitelisting ValidationPipe strips unknown keys and enforces the ranges) and persists the
validated values back as strings. `PATCH /config {seat_hold_duration_minutes:"-1"}` (or `"abc"`) is
rejected with 400; a sane value still stores. Proven by
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-9): `-1` → 400, `15` → stored (red before
the DTO, green after).
### M-4 🔎 Past-dated schedules accepted; train can be double-booked across routes
- **Where**: `schedules.service.ts:105` only checks `arrivalAt > departureAt` (no "future" check);