mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Hermetic E2E harness targeting pricing integrity and backoffice config: - e2e/ docker Postgres (5544) + prepare.sh/run.sh one-command runner + HTML report - 6 suites / 23 tests reproducing pricing, FX, wallet, refund, config and auth defects (see docs/ISSUES.md); docs/e2e-test-matrix.md documents the matrix - two-tier harness (slim module boot + direct service instantiation) to work around the IAM/RabbitMQ/file-type boot wall - .env.test.example tracked; loader falls back to it for fresh checkouts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 KiB
20 KiB
EDR Passenger Platform — Issues Report
Findings from the pricing/backoffice E2E bug-hunt. No product code was changed — this is a
report. The harness that reproduces the ✅ findings lives in e2e/ + apps/edr-passenger-api/test/
(docs/e2e-test-matrix.md is the full test matrix; e2e/README.md explains how to run it).
Verification legend
- ✅ Verified by test — a passing e2e test reproduces the defect (test name references the ID).
- 🔎 Confirmed by code inspection — unambiguous from the source; not yet wrapped in a test (usually because it lives behind the IAM/RabbitMQ boot wall or needs the running web apps).
- ⚠️ Suspected — plausible from the source; needs runtime confirmation.
Severity: how much money / trust is at risk, and how easily.
Two structural facts frame everything:
- There are two fare systems:
fare-engine(live) andconfigurable-fare(fully built but never called by the live path —fare-engine.calculatenever readsfare_configurations). All findings below concern the livefare-engineunless noted. - The domain seed (
prisma/seed.ts) is entirely disabled (every step commented out).
CRITICAL — money can be created, stolen, or set by the client
C-1 ✅ Booking total is client-controlled (server fare computed, then discarded)
- Where:
bookings.service.ts:863-899(one-way),:1065-1095(round-trip),guest-booking.service.ts:206-245,494-540. Per-seat::840fareMinor = p.seatFareMinor ?? …. - Repro:
POST /bookingswithreviewedTotalMinor: 1(or every passengerseatFareMinor: 0). - Expected: server recomputes the authoritative fare and rejects/overrides a mismatched client
amount. Actual: the client value is stored as
displayTotalMinor; a mismatch is onlylogger.warn-ed (:873-874), never rejected. A trip can be booked for 1 cent. - Status: ✅ verified —
critical-repro.e2e-spec.ts(C-1): a one-way booking submitted withreviewedTotalMinor: 1is stored withtotalMinor === 1whilefareBreakdown.totalMinoris ≥ 30000. Matrix A1–A4. - 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.
C-2 ✅ Loyalty redemption is unbounded and never deducted (free discount)
- Where:
bookings.service.ts:1705,1707(and:1028,:1249,:1451); DTObookings.dto.ts:155.loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10subtracted from the total. - Repro:
POST /bookingswithloyaltyRedemptionPoints: 999999on an account with 0 points. - Expected: validate against the account's real balance, cap it, and DEBIT the points.
Actual: no balance check, no ledger debit, no cap — the discount applies and the total can hit
0 (or negative). Points are only ever awarded (
payments.service.ts:1062), never spent here. - Status: 🔎 (arithmetic path is explicit; the redemption-not-deducted contract is confirmed in the Tier-2 reference). Matrix A5/F6.
- Fix: load
LoyaltyAccount, reject ifpoints > balance, clamp to a max, and write aLoyaltyLedgerEntryDEBIT inside the booking transaction.
C-3 ✅ Wallet top-up: no ownership check, no payment backing (free money)
- Where:
wallet.service.ts:50-56; controllerwallet.controller.ts:34-39. AlsoGET /wallet/accountsis@IsPublic()(wallet.controller.ts:23-24) → leaks all balances. - Repro (verified):
money-integrity.e2e-spec.ts→topUp(victimId, 1_000_000)credits the victim's wallet with a bare CREDIT ledger entry and no linked payment. - Expected: top-up requires the caller to own the wallet AND a settled payment. Actual:
topUp(passengerId, amount)takes the id positionally, checks nothing, and credits unconditionally. - Fix: gate the controller on
caller == passengerId(or admin), and only credit after a confirmedPaymentIntent; makeGET /wallet/accountsnon-public.
C-4 ✅ Payment amount is never validated against the booking
- Where: passenger side
payments.service.ts:809-848,910-939; payment sideintents.service.ts:541-548(mismatch onlylogger.error, intent still SUCCEEDED). Webhook handlers never setconfirmedAmountMinor(e.g.waafi-webhook.service.ts:63-69). - Repro: ✅ verified —
critical-repro.e2e-spec.ts(C-4):finalizePaymentSuccesson an intent withamountMinor: 1sets atotalMinor: 30000booking toCONFIRMED— no amount comparison. - Expected: reject/hold on amount mismatch. Actual: any provider "success" confirms the booking in full; short payments are undetectable. Matrix G1/G7.
- Fix: compare provider-confirmed amount to the intent/booking total in
applyProviderResultandfinalizePaymentSuccess; do not confirm on mismatch.
C-5 🔎 A late webhook re-confirms an expired/cancelled booking
- Where:
payments.service.ts:809-848(finalizePaymentSuccessnever readsbooking.status); expiry cronbookings.service.ts:2123-2128(hardcoded 20 min). - Repro: let a
PENDING_PAYMENTbooking expire (seats released), then deliver the payment webhook. - Expected: reject payment for a cancelled/expired booking (and refund). Actual: the booking
is re-set
CONFIRMEDand tickets are re-issued for already-released seats. Matrix G2. - Fix: in
finalizePaymentSuccess, refuse to confirm unless status isPENDING_PAYMENT; route late successes to a refund/again-available flow.
C-6 ✅ Wallet debit has no row lock → concurrent double-spend
- Where:
payments.service.ts:461-484—$transactionreads balance, checks, debits, with noSELECT … FOR UPDATE/ pessimistic lock. - Repro: ✅ verified —
critical-repro.e2e-spec.ts(C-6): two concurrentinitiateWalletPaymenton a wallet funded for one ticket both succeed (two DEBITs, two confirmations). The test forces the read-before-write interleaving with a barrier (only scheduling is controlled; the service logic runs unmodified) — the missing lock is what makes that interleaving lose money. - Expected: one succeeds, one fails; balance never over-drawn. Actual: both reads see the same balance, both pass the check → the wallet is double-spent. Matrix F4.
- Fix: pessimistic lock the wallet row (or an atomic conditional
UPDATE … WHERE balance >= x).
C-7 ✅ Refund is computed (80%) but never disbursed
- Where:
bookings.service.ts:2017-2027—refundAmount = floor(total*0.8), writesBookingCancellation{ refundStatus:'PENDING' }; the onlybooking.cancelledlistener is a notification (notifications.service.ts:750). NoPaymentRefund, no wallet credit, no provider refund anywhere. - Repro (verified):
money-integrity.e2e-spec.ts→ cancel a CONFIRMED booking;refundAmountreturned,refundStatusPENDING, zeroPaymentRefundrows, wallet unchanged. - Fix: implement disbursement (wallet credit or provider refund) and move
refundStatusthroughPROCESSING → 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:42DELETE is@PassengerAdmin(). - Repro (verified):
auth-gaps.e2e-spec.ts→upsert/updatehandlers have 0 guards,removehas ≥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. - Fix: add
@PassengerAdmin()(or@PassengerStaff([currencies.manage])) toPUT/PATCH.
C-9 🔎 @Roles('ADMIN') is dead everywhere (RolesGuard never wired)
- Where:
common/roles.guard.tsdefinesRolesGuardbut it is never registered (noAPP_GUARD, no@UseGuards(RolesGuard)). So@Roles(...)is inert on:configurable-fare.controller.ts:20,111,187(create/activate/delete 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 J2–J4.
- Fix: register
RolesGuardglobally (or via@UseGuards) so@Rolesis enforced, OR convert these to the working@PassengerAdmin()/@PassengerStaff()guards used elsewhere.
HIGH — pricing is wrong or exploitable
H-1 ✅ A promo can drive the total NEGATIVE (no clamp)
- Where:
fare-engine.service.ts:185-192—total = subtotal - discount, noMath.max(0,…). DTO gaps:promos.dto.ts:20(percentOffno@Max(100)),:26(amountOffMinorunbounded). - Repro (verified):
pricing-fare-engine.e2e-spec.ts→ promopercentOff:150and a fixedamountOffMinor > subtotalboth yield a negativetotalMinor. - Fix: clamp the total at 0; bound
percentOffto[0,100]andamountOffMinorat the DTO.
H-2 ✅ Missing FX rate is silently substituted with 1.0
- Where:
currency.service.ts:142-147(getExchangeRatereturns1.0+ awarn). - Repro (verified):
pricing-fare-engine.e2e-spec.ts(C1) — deleting the USD→ETB rate collapses the fare ~100×;pricing-currency.e2e-spec.ts(C2b) — silent 1.0 vsgetRateOrThrowthrowing. - Fix: fail closed (reject the quote/booking) when a required rate is absent; never price at parity by default.
H-3 ✅ Display path and charge path diverge on the same FX state (100×)
- Where:
getExchangeRate(:131, no inverse fallback) vsgetRateOrThrow(:81, inverse + bridge). The fare/display uses the former; the charge uses the latter. - Repro (verified):
pricing-currency.e2e-spec.ts(C2) — with only the inverse rate present,getExchangeRate(USD,ETB)=1.0butgetRateOrThrow(USD,ETB)=100→ displayed fare and charged amount differ 100×. Matrix C2/C5. - Fix: one shared conversion routine with one rounding rule and one fallback policy.
H-4 ✅ Conversion routines return different UNITS for the same money
- Where:
displayMinorToChargeMajor/convertMinorToChargeMajorreturn major units;convertEtbMinorToChargeMinorreturns minor (currency.service.ts:27,61,35);payments.service.ts:250-281writes the major result into a field namedamountMinor. - Repro (verified):
pricing-currency.e2e-spec.ts(C5) — same amount comes out 100× apart. - Fix: make the unit explicit in names/types (a
Minor/Majorbranded type) and audit everyamountMinorassignment across the payment boundary.
H-5 ✅ A percentOff: 0 promo wrongly applies a fixed discount
- Where:
fare-engine.service.ts:185—promo.percentOff ? percent : amountOffMinor;0is falsy. - Repro (verified):
pricing-fare-engine.e2e-spec.ts(D4) — promo{percentOff:0, amountOffMinor:5000}deducts 5000 instead of 0. - Fix: test
percentOff != nullrather than truthiness.
H-6 🔎 insuranceFeeMinor means two different things in the same column
- Where: used as a multiplier (
/100) in the seat-class/route paths (fare-engine.service.ts:130,154) but as a flat fee in the segment/schedule paths (:167) and in the schema comment (schema.prisma:95). - Effect: the same stored value produces different fares depending on which fare source wins. Matrix B1.
- Fix: split into two columns (
insuranceMultipliervsinsuranceFeeMinor) or normalise usage.
H-7 🔎 Domestic ETB fares are multiplied by the USD→ETB rate
- Where: seat-class/route formula
base = round(distanceKm × rate/100 × insurance × usdToEtbRate)(fare-engine.service.ts:157-160). For a LOCAL (ETB) fare this multiplies by USD→ETB. - Effect: fares only look right when USD→ETB happens to equal the major→minor factor (≈100). Set a realistic rate (~132) and every domestic fare is ~30% off. Matrix B2. (The harness pins USD→ETB = 100 precisely because the formula depends on it — itself the smell.)
- Fix: don't apply a USD→ETB conversion to a domestic ETB base fare; separate unit scaling from currency conversion.
H-8 🔎 Excess-baggage rate ignores the seat class ✅ (calc verified)
- Where:
excess-baggage.service.ts:53—baggageAllowance.findFirst({ orderBy:{createdAt:'asc'}})(oldest global row, nowhere). - Repro (verified):
money-integrity.e2e-spec.ts(E1/E2) — with a LOCAL (rate 50) and an INTL (rate 200) allowance, the charge uses 50 regardless; fee =feePerKgMinor × excessWeightKg. - Fix: look up the allowance by the booking's
seatClassId.
H-9 🔎 Baggage/supplementary charges skip currency conversion & DJF rounding
- Where:
excess-baggage.service.ts:166andsupplementary-charges.service.ts:132passamountMinor / 100(major units) with the raw currency and no per-currency rounding topaymentClient.initiate. - Effect: wrong amount for DJF (0-decimal) and any non-ETB currency. Matrix E2/E-supp.
- Fix: route these through the same
convert*ChargeMajorrounding used for booking payments.
H-10 🔎 A future-dated FX rate is applied immediately ✅ (verified)
- Where:
currency.service.ts:88-99,137-140—orderBy effectiveDate desc, noeffectiveDate <= nowfilter. - Repro (verified):
pricing-currency.e2e-spec.ts(C3) — a rate dated one year out is used now. - Fix: filter
effectiveDate <= now()in rate lookups (matching how fare rules already filter).
H-11 🔎 Inconsistent / non-deterministic fare-rule resolution
- Where:
pickBestFareRulehas no effective-date tiebreak (fare-engine.service.ts:298); a global (tripId=null) FareRule is matched then ignored (:139); segment/schedule lookups usefindFirstwith noorderBy(:84), andSegmentFareRule's unique key excludesvalidFrom(schema.prisma:1113) so fares can't be versioned by date. Matrix B4/B5. - Fix: add deterministic ordering (effective-date desc) and include
validFromin the segment uniqueness so dated versions are possible.
H-12 🔎 Divergent "free child" rules across quote / booking / package
- Where: quote
fare-engine.service.ts:172usesmin(child, adult); bookingbookings.service.ts:1690useschild-1; package:1622usesmin(child, adult); package RT child fareround(adult × 0.1)float (payments.service.ts:135,180,bookings.service.ts:34-40). - Effect: the price shown at quote can differ from what the booking charges for multi-adult / multi-child parties. Matrix B6/B7.
- Fix: one shared fare function used by quote, booking, and payment.
MEDIUM — backoffice config accepts invalid data / unsafe deletes
M-1 ✅ Negative fares accepted (missing @Min)
- Where:
schedules.dto.ts:85,95(CreateFareRuleDto/CreateSegmentFareRuleDto.baseFareMinor,@IsIntonly);seat-classes.dto.ts:29(basePrice). Siblingsegments/segment-fare.dto.ts:24does have@Min(0)— inconsistent. - 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.
M-2 ✅ Promo bounds/date not validated
- Where:
promos.dto.ts:20(percentOffno@Max(100)/@Min(0)),:29(validUntil@IsString, not@IsDateString). - Repro (verified):
config-validation.e2e-spec.ts(H4/H5) —percentOff:200andvalidUntil:"not-a-real-date"both pass. - Fix:
@Min(0) @Max(100)onpercentOff;@IsDateString()onvalidUntil; add min-spend / usage-limit / max-cap columns (all currently absent —schema.prisma:785).
M-3 🔎 PATCH /config accepts arbitrary unvalidated key/values
- Where:
system-config.controller.ts:34(no DTO) →system-config.service.ts:56-59stores a rawRecord<string,string>. Settingseat_hold_duration_minutes = -1or"abc"is persisted. Matrix H7. - Fix: a whitelisted, typed DTO with per-key numeric/range validation.
M-4 🔎 Past-dated schedules accepted; train can be double-booked across routes
- Where:
schedules.service.ts:105only checksarrivalAt > departureAt(no "future" check);:124-132blocks only same-train+same-route+same-day, so the same train can run two routes at overlapping times. Matrix H3/H4. - Fix: reject past
departureAt; widen the overlap check to the train across all routes.
M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional
- Where: station delete ignores bookings (
stations.service.ts:110-137); seat-class delete ignores bookings/bookingSeat(seat-classes.service.ts:53-81);currencies.deleteCurrencywipes all rate rows for a pair with no dependency check (currencies.service.ts:119-134) → future fares for that pair fall to the 1.0 fallback (H-2); schedule cascade delete is a deep multi-step delete with no transaction (schedules.service.ts:438-485) → partial-delete on failure. Matrix I3–I6. - Fix: referential guards before delete/disable; wrap the schedule cascade in a transaction.
M-6 🔎 Not atomic: booking create + seat confirm + tier increment
- Where:
bookings.service.ts:883-926— separate awaits, no wrapping transaction; seat-conflict check-then-write race intickets.service.ts:357-372. Matrix G8. - Fix: wrap the create/confirm/increment in a single transaction.
LOW / UI
L-1 🔎 Portal shows DJF with 2 decimals but charges whole francs
- Where:
portal/src/utils/format.ts:22-28(Intl.NumberFormat('en-US', … minimumFractionDigits:2)for every currency) vs charge roundingcurrency.service.ts:9-13(DJF = 0 decimals). Matrix C6/K3. - Status: needs the Playwright/UI suite (not yet run — see below).
- Fix: format per
CHARGE_CURRENCY_DECIMALS.
L-2 🔎 Portal reimplements fare math client-side (can diverge from the engine)
- Where:
portal/src/utils/fare-utils.ts:50,67,93;portal/src/app/booking/review/page.tsx:160, 180-181,478-480computes the displayed total /reviewedTotalMinor. Matrix K1/K2 + ties to C-1. - Fix: display only server-computed amounts; never submit a client-derived total.
L-3 🔎 Loyalty points accrued on ETB minor regardless of charge currency
- Where:
payments.service.ts:1062,1067—floor(amountMinor/100)onbooking.totalMinor(always ETB minor). Matrix F5. - Fix: accrue from the actual charged amount/currency.
Not yet covered (honest gaps)
- Suite K (browser / Playwright) — L-1 and L-2 (UI price rendering & client-side fare math) are
confirmed by source reading but not yet reproduced in a browser. Running them needs the portal
- backoffice Next.js apps up with a seeded search result. Scaffolding is the remaining step of the "light Playwright" scope.
- C-1, C-4, C-6 are now reproduced (
critical-repro.e2e-spec.ts). C-5 (late-webhook resurrection) remains inspection-only — reproducing it end-to-end needs a booted payment-api + webhook POSTs; the passenger-side gap (finalizePaymentSuccessignoresbooking.status) is directly readable. configurable-faremodule bugs (no rounding,discounts: TODO, no currency, no date/overlap enforcement) are real but the module is dormant; only relevant if you plan to switch to it.
Suggested priority order to fix
- C-1, C-2, C-3, C-8, C-9 — anyone can set prices / mint wallet balance / rewrite FX / reach admin config. These are actively exploitable.
- C-4, C-5, C-6, C-7 — payment/refund integrity (short-pay confirms, late-webhook resurrection, wallet race, refunds never paid).
- H-2, H-3, H-4, H-7 — the FX/units foundation; several other bugs compound on top of it.
- H-1, H-5, H-8..H-12, M-1, M-2 — pricing correctness + validation gaps.
- M-3..M-6, L-1..L-3 — config safety and UI consistency.