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>
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
/**
|
|
* Executable reproducers for the highest-severity findings that were previously inspection-only.
|
|
* All Tier-2 (direct instantiation, real Prisma + stubbed collaborators).
|
|
*
|
|
* C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1
|
|
* while the server fare engine computed ~30000.
|
|
* C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for
|
|
* 1 minor confirms a 30000 booking.
|
|
* C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one
|
|
* ticket pays for two.
|
|
*/
|
|
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
|
import { PaymentsService } from "../src/modules/payments/payments.service";
|
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
|
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
|
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
|
|
|
function asyncStub(): any {
|
|
return new Proxy({}, { get: () => async () => undefined });
|
|
}
|
|
|
|
/**
|
|
* Wraps a PrismaClient so that inside `$transaction(cb)`, every `walletAccount.update` waits until
|
|
* BOTH concurrent transactions have finished their `walletAccount.findUnique` (balance read). This
|
|
* deterministically forces the exact interleaving a real multi-request system permits, exposing the
|
|
* service's unlocked check-then-act (no SELECT … FOR UPDATE). Only scheduling is controlled — the
|
|
* service's own logic runs unmodified.
|
|
*/
|
|
function makeRaceWrappedPrisma(real: any, parties: number) {
|
|
let arrived = 0;
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((r) => (release = r));
|
|
const signalRead = () => {
|
|
if (++arrived >= parties) release();
|
|
};
|
|
|
|
return new Proxy(real, {
|
|
get(target, prop, receiver) {
|
|
if (prop === "$transaction") {
|
|
return (cb: (tx: any) => unknown, opts?: unknown) =>
|
|
target.$transaction((tx: any) => {
|
|
const wrappedTx = new Proxy(tx, {
|
|
get(t, p) {
|
|
if (p === "walletAccount") {
|
|
return {
|
|
findUnique: async (args: unknown) => {
|
|
const res = await t.walletAccount.findUnique(args);
|
|
signalRead();
|
|
return res;
|
|
},
|
|
update: async (args: unknown) => {
|
|
await gate; // hold the write until both reads are done
|
|
return t.walletAccount.update(args);
|
|
},
|
|
};
|
|
}
|
|
return t[p];
|
|
},
|
|
});
|
|
return cb(wrappedTx);
|
|
}, opts);
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
}
|
|
|
|
let seq = 0;
|
|
async function makeSchedule(prisma: any) {
|
|
const train = await prisma.train.create({ data: { number: `CR-${++seq}`, name: "T" } });
|
|
return prisma.trainSchedule.create({
|
|
data: {
|
|
trainId: train.id,
|
|
routeId: IDS.route,
|
|
originStationId: IDS.stationA,
|
|
destinationStationId: IDS.stationB,
|
|
departureAt: new Date(Date.now() + 86_400_000),
|
|
arrivalAt: new Date(Date.now() + 90_000_000),
|
|
durationMinutes: 60,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("Critical reproducers (Tier-2)", () => {
|
|
const prisma = getTestPrisma();
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllPassenger(prisma);
|
|
await seedCore(prisma);
|
|
});
|
|
afterAll(async () => {
|
|
await disconnectTestPrisma();
|
|
});
|
|
|
|
// ── C-1 ──────────────────────────────────────────────────────────────────
|
|
it("C-1 🔴 booking stores client reviewedTotalMinor=1 while the fare engine computed ~30000", async () => {
|
|
const passenger = await prisma.passenger.create({ data: {} });
|
|
const schedule = await makeSchedule(prisma);
|
|
// Stop times so origin/dest resolve on the schedule.
|
|
await prisma.tripStopTime.createMany({
|
|
data: [
|
|
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
|
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
|
],
|
|
});
|
|
// Coach + seat for the passenger to occupy.
|
|
const coach = await prisma.coach.create({
|
|
data: { coachTypeId: IDS.coachType, number: `C-${seq}` },
|
|
});
|
|
const seat = await prisma.seat.create({
|
|
data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" },
|
|
});
|
|
// A real server fare source (tripId match → highest priority): 30000 minor.
|
|
await prisma.fareRule.create({
|
|
data: {
|
|
tripId: schedule.id,
|
|
seatClassId: IDS.seatClassLocal,
|
|
baseFareMinor: 30_000,
|
|
currency: "ETB",
|
|
validFrom: new Date("2020-01-01"),
|
|
},
|
|
});
|
|
const hold = await prisma.seatHold.create({
|
|
data: {
|
|
scheduleId: schedule.id,
|
|
seatIds: [seat.id],
|
|
passengerId: passenger.id,
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|
},
|
|
});
|
|
|
|
const bookings = new BookingsService(
|
|
prisma as any,
|
|
asyncStub(), // dataSource
|
|
asyncStub(), // seatsService (confirmSeats no-op)
|
|
{ emit: () => true } as any, // eventEmitter
|
|
asyncStub(), // verifaydaService (PASSPORT path skips it anyway)
|
|
asyncStub(), // currencyService (ETB path skips it)
|
|
asyncStub(), // fareEngine (FareRule short-circuits before this)
|
|
asyncStub(), // auditService
|
|
);
|
|
|
|
const dto = {
|
|
passengerId: passenger.id,
|
|
scheduleId: schedule.id,
|
|
holdId: hold.id,
|
|
originStationId: IDS.stationA,
|
|
destinationStationId: IDS.stationB,
|
|
seatClassId: IDS.seatClassLocal,
|
|
bookingType: "ONE_WAY",
|
|
reviewedTotalMinor: 1, // the forged client total
|
|
passengers: [
|
|
{
|
|
seatId: seat.id,
|
|
passengerName: "Mallory Adult",
|
|
dateOfBirth: new Date("1990-01-01"),
|
|
idDocumentType: "PASSPORT",
|
|
passportNumber: "P123",
|
|
passportCountry: "ET",
|
|
nationality: "Ethiopian",
|
|
// NOTE: no seatFareMinor → not "allFaresProvided" → reviewedTotalMinor is trusted
|
|
},
|
|
],
|
|
};
|
|
|
|
const result: any = await (bookings as any).createOneWayBooking(dto);
|
|
|
|
// The server engine computed the real fare…
|
|
expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000);
|
|
// …but the booking was stored at the client's forged 1 minor.
|
|
expect(result.totalMinor).toBe(1);
|
|
const stored = await prisma.booking.findUnique({ where: { id: result.id } });
|
|
expect(stored?.totalMinor).toBe(1);
|
|
});
|
|
|
|
// ── C-4 ──────────────────────────────────────────────────────────────────
|
|
it("C-4 🔴 finalizePaymentSuccess confirms a 30000 booking from an intent of 1 (no amount check)", async () => {
|
|
const passenger = await prisma.passenger.create({ data: {} });
|
|
const schedule = await makeSchedule(prisma);
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
bookingRef: "PAY-0001",
|
|
passengerId: passenger.id,
|
|
scheduleId: schedule.id,
|
|
totalMinor: 30_000,
|
|
status: "PENDING_PAYMENT",
|
|
},
|
|
});
|
|
const intent = await prisma.paymentIntent.create({
|
|
data: {
|
|
bookingId: booking.id,
|
|
amountMinor: 1, // wildly short payment
|
|
method: "WALLET",
|
|
status: "PROCESSING",
|
|
},
|
|
});
|
|
|
|
const payments = new PaymentsService(
|
|
prisma as any,
|
|
{ confirmSeats: async () => undefined } as any,
|
|
{ generate: async () => undefined } as any, // must not throw (re-thrown otherwise)
|
|
{ emit: () => true } as any,
|
|
asyncStub(), // paymentClient
|
|
asyncStub(), // currencyService (not used on this path)
|
|
asyncStub(), // auditService
|
|
);
|
|
|
|
await payments.finalizePaymentSuccess({ intentId: intent.id });
|
|
|
|
const after = await prisma.booking.findUnique({ where: { id: booking.id } });
|
|
// Confirmed despite intent.amountMinor (1) ≠ booking.totalMinor (30000).
|
|
expect(after?.status).toBe("CONFIRMED");
|
|
});
|
|
|
|
// ── C-6 ──────────────────────────────────────────────────────────────────
|
|
it("C-6 🔴 two concurrent WALLET payments double-spend a single-ticket balance", async () => {
|
|
const passenger = await prisma.passenger.create({ data: {} });
|
|
const schedule = await makeSchedule(prisma);
|
|
// Wallet funded for exactly ONE ticket.
|
|
await prisma.walletAccount.create({
|
|
data: { passengerId: passenger.id, balanceMinor: 30_000 },
|
|
});
|
|
const mkBooking = (ref: string) =>
|
|
prisma.booking.create({
|
|
data: {
|
|
bookingRef: ref,
|
|
passengerId: passenger.id,
|
|
scheduleId: schedule.id,
|
|
totalMinor: 30_000,
|
|
status: "PENDING_PAYMENT",
|
|
},
|
|
});
|
|
const b1 = await mkBooking("W-0001");
|
|
const b2 = await mkBooking("W-0002");
|
|
|
|
// Race-wrapped prisma forces both balance reads to complete before either debit writes.
|
|
const racePrisma = makeRaceWrappedPrisma(prisma, 2);
|
|
const payments = new PaymentsService(
|
|
racePrisma as any,
|
|
{ confirmSeats: async () => undefined } as any,
|
|
{ generate: async () => undefined } as any,
|
|
{ emit: () => true } as any,
|
|
asyncStub(),
|
|
asyncStub(),
|
|
asyncStub(),
|
|
);
|
|
|
|
const [bk1, bk2] = await Promise.all([
|
|
prisma.booking.findUnique({ where: { id: b1.id }, include: { seats: true } }),
|
|
prisma.booking.findUnique({ where: { id: b2.id }, include: { seats: true } }),
|
|
]);
|
|
|
|
const [r1, r2] = await Promise.allSettled([
|
|
(payments as any).initiateWalletPayment(bk1),
|
|
(payments as any).initiateWalletPayment(bk2),
|
|
]);
|
|
|
|
const succeeded = await prisma.paymentIntent.count({
|
|
where: { bookingId: { in: [b1.id, b2.id] }, status: { in: ["SUCCEEDED", "PROCESSING"] } },
|
|
});
|
|
const debits = await prisma.walletLedgerEntry.count({ where: { type: "DEBIT" } });
|
|
const wallet = await prisma.walletAccount.findUnique({
|
|
where: { passengerId: passenger.id },
|
|
});
|
|
|
|
// Double-spend signature: two successful debits from a one-ticket balance, or a negative
|
|
// balance. A correctly-locked wallet allows exactly one.
|
|
const totalDebited = debits * 30_000;
|
|
const doubleSpent =
|
|
(succeeded === 2 && totalDebited > 30_000) || (wallet?.balanceMinor ?? 0) < 0;
|
|
expect(doubleSpent).toBe(true);
|
|
expect([r1.status, r2.status]).toEqual(["fulfilled", "fulfilled"]);
|
|
});
|
|
});
|