Files
edr-platform/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts

216 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier-2 money-integrity suite — services behind the IAM/RabbitMQ wall, instantiated directly with
* a real Prisma (test DB) + stubbed collaborators. Confirms critical findings:
* F1/F2 🔴 WalletService.topUp credits any passenger's wallet with no ownership check and no
* payment backing (free money).
* G4/G5 🔴 BookingsService.cancel computes an 80% refund but NEVER disburses it — no PaymentRefund,
* no wallet credit; the cancellation sits at refundStatus PENDING forever.
* E1/E2 🔴 ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the
* booking's seat class, and computes fee = feePerKgMinor × excessWeightKg.
*/
import { WalletService } from "../src/modules/wallet/wallet.service";
import { BookingsService } from "../src/modules/bookings/bookings.service";
import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service";
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
function asyncStub(): any {
return new Proxy(
{},
{ get: () => async () => undefined },
);
}
describe("Money integrity (Tier-2 direct instantiation)", () => {
const prisma = getTestPrisma();
beforeEach(async () => {
await truncateAllPassenger(prisma);
await seedCore(prisma);
});
afterAll(async () => {
await disconnectTestPrisma();
});
// ── F1 / F2 ────────────────────────────────────────────────────────────────
it("F1/F2 🔴 topUp credits another passenger's wallet — no ownership check, no payment backing", async () => {
const victim = await prisma.passenger.create({ data: {} });
await prisma.walletAccount.create({
data: { passengerId: victim.id, balanceMinor: 0 },
});
const wallet = new WalletService(prisma as any);
// An attacker-controlled call: just pass the victim's id. Nothing checks caller identity,
// and no PaymentIntent/settlement backs the credit.
await wallet.topUp(victim.id, 1_000_000, "free money");
const after = await prisma.walletAccount.findUnique({
where: { passengerId: victim.id },
});
expect(after?.balanceMinor).toBe(1_000_000);
// The only ledger entry is a bare CREDIT — no linked payment.
const ledger = await prisma.walletLedgerEntry.findMany({
where: { walletId: after!.id },
});
expect(ledger).toHaveLength(1);
expect(ledger[0].type).toBe("CREDIT");
expect(ledger[0].relatedBookingId ?? null).toBeNull();
});
// ── G4 / G5 ────────────────────────────────────────────────────────────────
it("G4/G5 🔴 cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => {
const passenger = await prisma.passenger.create({ data: {} });
// Give the passenger a wallet so we can prove NO refund lands in it.
const w = await prisma.walletAccount.create({
data: { passengerId: passenger.id, balanceMinor: 0 },
});
const schedule = await makeSchedule(prisma, passenger.id);
const booking = await prisma.booking.create({
data: {
bookingRef: "CXL-0001",
passengerId: passenger.id,
scheduleId: schedule.id,
totalMinor: 30_000,
displayCurrency: "ETB",
status: "CONFIRMED",
},
});
const bookings = new BookingsService(
prisma as any,
asyncStub(), // dataSource
asyncStub(), // seatsService
asyncStub(), // ticketsService — constructor gained this param since this test was written
{ emit: () => true } as any, // eventEmitter
asyncStub(), // verifaydaService
asyncStub(), // currencyService
asyncStub(), // fareEngine
asyncStub(), // auditService
);
const result: any = await bookings.cancel(booking.bookingRef, "test");
// Refund is COMPUTED as 80%:
expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00
// …but recorded only as PENDING, and never actually paid out:
const cancellation = await prisma.bookingCancellation.findFirst({
where: { bookingId: booking.id },
});
expect(cancellation?.refundStatus).toBe("PENDING");
// No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited.
const refundCount = await prisma.paymentRefund.count();
expect(refundCount).toBe(0);
const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } });
expect(walletAfter?.balanceMinor).toBe(0);
});
it("accepts a booking reference when logging an excess baggage charge", async () => {
const passenger = await prisma.passenger.create({ data: {} });
const schedule = await makeSchedule(prisma, passenger.id);
const booking = await prisma.booking.create({
data: {
bookingRef: "BAG-REF-001",
passengerId: passenger.id,
scheduleId: schedule.id,
totalMinor: 30_000,
status: "CONFIRMED",
},
});
await prisma.baggageAllowance.create({
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 80 },
});
const service = new ExcessBaggageService(
prisma as any,
asyncStub(), // auditService
asyncStub(), // currencyService
asyncStub(), // paymentClient
asyncStub(), // notifications
asyncStub(), // smsClient
asyncStub(), // emailClient
);
const charge: any = await service.logCharge({
bookingReference: booking.bookingRef,
excessWeightKg: 2,
collectCash: true,
} as any);
expect(charge.bookingId).toBe(booking.id);
expect(charge.feePerKgMinor).toBe(80);
expect(charge.totalMinor).toBe(160);
});
// ── E1 / E2 ────────────────────────────────────────────────────────────────
it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => {
const passenger = await prisma.passenger.create({ data: {} });
const schedule = await makeSchedule(prisma, passenger.id);
const booking = await prisma.booking.create({
data: {
bookingRef: "BAG-0001",
passengerId: passenger.id,
scheduleId: schedule.id,
totalMinor: 30_000,
status: "CONFIRMED",
},
});
// Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win
// for an intl booking — but logCharge ignores seat class and takes the oldest row.
await prisma.baggageAllowance.create({
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
});
await prisma.baggageAllowance.create({
data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 },
});
const service = new ExcessBaggageService(
prisma as any,
asyncStub(), // auditService
asyncStub(), // currencyService
asyncStub(), // paymentClient
asyncStub(), // notifications
asyncStub(), // smsClient
asyncStub(), // emailClient
);
const charge: any = await service.logCharge({
bookingId: booking.id,
excessWeightKg: 10,
collectCash: true,
} as any);
// Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 × 10.
expect(charge.feePerKgMinor).toBe(50);
expect(charge.totalMinor).toBe(50 * 10);
});
});
let trainSeq = 0;
/** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */
async function makeSchedule(prisma: any, _passengerId: string) {
const train = await prisma.train.create({
data: { number: `T-${++trainSeq}`, name: "Test Train" },
});
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,
},
});
}