mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
214 lines
8.2 KiB
TypeScript
214 lines
8.2 KiB
TypeScript
/**
|
||
* 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(),
|
||
asyncStub(),
|
||
asyncStub(),
|
||
asyncStub(),
|
||
asyncStub(),
|
||
);
|
||
|
||
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(), // 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,
|
||
},
|
||
});
|
||
}
|