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

@@ -1,29 +1,34 @@
/**
* Auth/authorization gaps (matrix Suite J), proven via route guard metadata — no boot needed.
* J1 🔴 The exchange-rate controller's write routes (PUT upsert, PATCH update) carry NO guard,
* so USD/ETB/DJF rates — which every international fare multiplies by — can be rewritten by
* an unauthenticated caller. Only DELETE is guarded (@PassengerAdmin). fare-engine/currency.controller.ts:25,32,42
* Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed.
*
* C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so
* they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization)
* — unlike DELETE, which is admin-gated. Net effect (verified live in
* e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated
* user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42
*
* NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global
* APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated
* FX write" reading was a false positive corrected by the live BC-11 test.
*/
import "reflect-metadata";
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
// Nest stores @UseGuards under the "__guards__" metadata key on the route handler.
const GUARDS_METADATA = "__guards__";
function guardsOn(handler: unknown): unknown[] {
return (Reflect.getMetadata(GUARDS_METADATA, handler as object) as unknown[]) ?? [];
}
describe("Auth gaps (Suite J)", () => {
it("J1 🔴 PUT upsert exchange-rate has NO guard (unauthenticated FX write)", () => {
it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
});
it("J1 🔴 PATCH update exchange-rate has NO guard (unauthenticated FX write)", () => {
it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
});
it("J1 control: DELETE exchange-rate IS guarded — proving the omission on writes is not global", () => {
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,122 @@
/**
* C-9-UI 🔴 Authenticated POST /bookings is broken: the controller overrides passengerId with the
* JWT user id (`bookings.controller.ts:528-532`, "never trust the request body"), but the service
* only resolves an iamUserId → Passenger when it is NON-UUID (`bookings.service.ts:773`). Real IAM
* ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`),
* so `booking.create` uses the iamUserId directly as passengerId → foreign-key violation.
*
* This reproduces the controller's behavior by calling BookingsService.create with passengerId set to
* a UUID iamUserId (not the Passenger.id), exactly as the authed controller does. It also shows the
* CONTROL: passing the real Passenger.id succeeds — proving the resolution gap, not a fixture problem.
*/
import { BookingsService } from "../src/modules/bookings/bookings.service";
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
let seq = 0;
async function buildBookableGraph(prisma: any, passengerId: string, iamUserId: string) {
const passenger = await prisma.passenger.create({ data: { id: passengerId, iamUserId } });
const train = await prisma.train.create({ data: { number: `AB-${++seq}`, name: "T" } });
const schedule = await 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,
},
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
],
});
const coach = await prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `AB-${seq}` } });
const seat = await prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" } });
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, expiresAt: new Date(Date.now() + 3_600_000) },
});
return { schedule, seat, hold };
}
function dtoFor(passengerId: string, schedule: any, hold: any, seat: any) {
return {
passengerId, // the controller passes req.user.id here (the iamUserId)
scheduleId: schedule.id,
holdId: hold.id,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
bookingType: "ONE_WAY",
passengers: [
{
seatId: seat.id,
passengerName: "Auth User",
dateOfBirth: new Date("1990-01-01"),
idDocumentType: "PASSPORT",
passportNumber: "P1",
passportCountry: "ET",
nationality: "Ethiopian",
seatFareMinor: 30_000,
},
],
};
}
describe("Authenticated booking passengerId resolution (regression)", () => {
const prisma = getTestPrisma();
let bookings: BookingsService;
beforeAll(() => {
bookings = new BookingsService(
prisma as any,
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
asyncStub(), // seatsService
{ emit: () => true } as any,
asyncStub(), // verifaydaService (PASSPORT skips)
asyncStub(), // currencyService (ETB skips)
asyncStub(), // fareEngine (FareRule short-circuits)
asyncStub(), // auditService
);
});
beforeEach(async () => {
await truncateAllPassenger(prisma);
await seedCore(prisma);
});
afterAll(async () => {
await disconnectTestPrisma();
});
it("🔴 create() with a UUID iamUserId (as the authed controller passes) FAILS the passenger FK", async () => {
const passengerId = "aaaaaaaa-0000-4000-8000-000000000001"; // real Passenger.id
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000002"; // UUID iamUserId ≠ Passenger.id
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
// The controller calls service.create({ ...dto, passengerId: req.user.id }) — i.e. the iamUserId.
await expect(
bookings.create(dtoFor(iamUserId, schedule, hold, seat) as any),
).rejects.toThrow(); // Prisma P2003 on Booking_passengerId_fkey
expect(await prisma.booking.count()).toBe(0);
});
it("control: create() with the real Passenger.id succeeds — proving the gap is the id, not the fixture", async () => {
const passengerId = "aaaaaaaa-0000-4000-8000-000000000003";
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000004";
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
const booking: any = await bookings.create(dtoFor(passengerId, schedule, hold, seat) as any);
expect(booking.id).toBeTruthy();
expect(booking.passengerId).toBe(passengerId);
});
});

View File

@@ -0,0 +1,89 @@
/**
* Seeds a passenger IAM user + session directly (no OTP flow) and mints an access token the portal
* accepts. The API JwtGuard verifies the JWT signature (JWT_ACCESS_TOKEN_SECRET) and looks up the
* session by its `id` claim; the portal then calls /auth/profile which needs a Passenger row linked
* by iamUserId. Returns { token, profile } for the Playwright passenger storageState.
*
* Run standalone to validate: `npx ts-node test/fixtures/seed-passenger-session.ts` (prints the
* token and the /auth/profile status via the running API on :4000).
*/
import { PrismaClient } from "@prisma/client";
import { SignJWT } from "jose";
import { UI_IDS } from "./seed-ui";
export const PASSENGER_USER_ID = "11111111-0000-4000-8000-000000000001";
export const PASSENGER_SESSION_ID = "11111111-0000-4000-8000-000000000002";
const EMAIL = "test.passenger@edr.local";
const USERNAME = "test_passenger";
export async function seedPassengerSession(prisma: PrismaClient): Promise<{ token: string }> {
const secret = process.env.JWT_ACCESS_TOKEN_SECRET;
if (!secret) throw new Error("JWT_ACCESS_TOKEN_SECRET is required to mint the passenger token");
const userInfo = {
id: PASSENGER_USER_ID,
name: { en: "Test Passenger" },
email: EMAIL,
roles: [] as unknown[],
status: "accepted",
employee: [] as unknown[],
userType: "individual",
username: USERNAME,
permissions: [] as unknown[],
};
const expiry = new Date(Date.now() + 7 * 86400_000);
// iam.users (delete-then-insert so re-seeding is idempotent).
await prisma.$executeRawUnsafe(`DELETE FROM iam.sessions WHERE id = $1::uuid`, PASSENGER_SESSION_ID);
await prisma.$executeRawUnsafe(`DELETE FROM iam.users WHERE id = $1::uuid`, PASSENGER_USER_ID);
await prisma.$executeRawUnsafe(
`INSERT INTO iam.users (created_at, id, name, username, email, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by)
VALUES (now(), $1::uuid, $2::jsonb, $3, $4, 'individual', 'accepted', true, true, true, 'SYSTEM')`,
PASSENGER_USER_ID,
JSON.stringify(userInfo.name),
USERNAME,
EMAIL,
);
await prisma.$executeRawUnsafe(
`INSERT INTO iam.sessions (created_at, id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
VALUES (now(), $1::uuid, $2, 'e2e', $3::jsonb, $4, 0, 'ACTIVE', $5::uuid)`,
PASSENGER_SESSION_ID,
EMAIL,
JSON.stringify(userInfo),
expiry,
PASSENGER_USER_ID,
);
// Link the seeded Passenger row to this IAM user so /auth/profile resolves.
await prisma.passenger.update({
where: { id: UI_IDS.passenger },
data: { iamUserId: PASSENGER_USER_ID },
});
const token = await new SignJWT({ id: PASSENGER_SESSION_ID })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(new TextEncoder().encode(secret));
return { token };
}
if (require.main === module) {
(async () => {
const prisma = new PrismaClient();
try {
const { token } = await seedPassengerSession(prisma);
const api = process.env.API_URL ?? "http://localhost:4000";
const res = await fetch(`${api}/auth/profile`, {
headers: { Authorization: `Bearer ${token}` },
});
// eslint-disable-next-line no-console
console.log(`[passenger-session] /auth/profile -> HTTP ${res.status}`);
// eslint-disable-next-line no-console
console.log((await res.text()).slice(0, 400));
} finally {
await prisma.$disconnect();
}
})();
}

View File

@@ -0,0 +1,197 @@
/**
* UI E2E seed — extends seed-core with a BOOKABLE trip + payment methods + promos so the portal
* search/booking flow and the backoffice config flow have real data to drive. Runnable standalone
* (`ts-node test/fixtures/seed-ui.ts`) or importable (`seedUi(prisma)`) from the Playwright
* global-setup. Reads DATABASE_URL from the environment (the UI stack points at the 5544 test DB).
*
* Searchability: a schedule shows in POST /search when it is SCHEDULED, not package-only, has a
* future departure on the searched date, operational coaches with AVAILABLE seats, and a resolvable
* fare (seat-class distance formula using the seeded route-stop distances + USD→ETB rate).
*/
import { PrismaClient } from "@prisma/client";
import { IDS, resetAndSeedCore } from "./seed-core";
export const UI_IDS = {
train: "00000000-0000-4000-8000-000000000100",
schedule: "00000000-0000-4000-8000-000000000101",
coach: "00000000-0000-4000-8000-000000000102",
// Passenger.id is set EQUAL to the IAM user id. The bookings controller overrides passengerId
// with the JWT user id (req.user.id), and the service only resolves iamUserId→passenger when it
// is NON-UUID — since IAM ids are UUIDs, it uses the id directly, so Passenger.id must equal it.
passenger: "11111111-0000-4000-8000-000000000001",
promoValid: "PROMO10",
promoExpired: "EXPIRED50",
// Return leg C→A on the same calendar date, for ROUND_TRIP scenarios (UA-6). The route stops are
// symmetric in distance (A=0, C=250) so the reverse leg prices identically to the outbound.
returnSchedule: "00000000-0000-4000-8000-000000000201",
returnCoach: "00000000-0000-4000-8000-000000000202",
} as const;
/** Days-from-now the sample trip departs (tests search on this calendar date, Addis TZ). */
export const DEPART_IN_DAYS = 2;
export function sampleDepartAt(): Date {
const d = new Date();
d.setUTCDate(d.getUTCDate() + DEPART_IN_DAYS);
d.setUTCHours(6, 0, 0, 0); // 06:00Z ~ 09:00 Addis — safely same calendar day either TZ
return d;
}
/** The date string a test passes to POST /search for the sample trip (YYYY-MM-DD). */
export function sampleDepartDate(): string {
return sampleDepartAt().toISOString().slice(0, 10);
}
export async function seedUi(prisma: PrismaClient): Promise<void> {
await resetAndSeedCore(prisma);
// Give the two seed-core seat classes the exact names the portal review flow maps by.
await prisma.seatClass.update({
where: { id: IDS.seatClassLocal },
data: { name: "Economy Regular" },
});
await prisma.seatClass.update({
where: { id: IDS.seatClassIntl },
data: { name: "Economy Regular Intl" },
});
// Enabled payment methods — the portal pay page renders ONLY enabled PaymentMethod rows.
await prisma.paymentMethod.createMany({
data: [
{ type: "WALLET", displayName: "Wallet", currency: "ETB", enabled: true, isDefault: true, sortOrder: 0 },
{ type: "TELEBIRR", displayName: "telebirr", currency: "ETB", enabled: true, sortOrder: 1 },
],
});
const departAt = sampleDepartAt();
const arriveAt = new Date(departAt.getTime() + 4 * 3600_000);
await prisma.train.create({
data: { id: UI_IDS.train, number: "UI-100", name: "UI Test Express" },
});
await prisma.trainSchedule.create({
data: {
id: UI_IDS.schedule,
trainId: UI_IDS.train,
routeId: IDS.route,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
departureAt: departAt,
arrivalAt: arriveAt,
durationMinutes: 240,
status: "SCHEDULED",
stopsCount: 3,
isPackageOnly: false,
},
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationA, sequence: 1, plannedDepartureAt: departAt, status: "OPEN" },
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(departAt.getTime() + 2 * 3600_000), status: "OPEN" },
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationC, sequence: 3, plannedArrivalAt: arriveAt, status: "OPEN" },
],
});
// Enough seats that a full suite run (many bookings share one seeded DB, seats are not released
// between specs) never exhausts availability: 12 rows × 4 cols = 48 seats.
const SEAT_ROWS = 12;
await prisma.coach.create({
data: { id: UI_IDS.coach, coachTypeId: IDS.coachType, number: "UI-C1", capacity: SEAT_ROWS * 4, sequence: 1, status: "ACTIVE" },
});
await prisma.coachAssignment.create({
data: { scheduleId: UI_IDS.schedule, coachId: UI_IDS.coach, positionNumber: 1, isOperational: true },
});
await prisma.seat.createMany({ data: buildSeats(UI_IDS.coach, SEAT_ROWS) });
// Logged-in passenger with a funded wallet + loyalty (used by the portal storageState + WALLET pay).
await prisma.passenger.create({ data: { id: UI_IDS.passenger } });
await prisma.walletAccount.create({
data: { passengerId: UI_IDS.passenger, balanceMinor: 100_000_000 },
});
await prisma.loyaltyAccount.create({
data: { passengerId: UI_IDS.passenger, pointsBalance: 500 },
});
// Promotions (schema field names, NOT the backoffice UI names). Unique exact codes.
await prisma.promotion.createMany({
data: [
{ title: "10% off", code: UI_IDS.promoValid, percentOff: 10, validUntil: new Date(Date.now() + 30 * 86400_000), active: true },
{ title: "Expired", code: UI_IDS.promoExpired, percentOff: 50, validUntil: new Date(Date.now() - 86400_000), active: true },
],
});
// One baggage allowance (for excess-baggage flows later).
await prisma.baggageAllowance.create({
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
});
// ── Return leg (C→A) for ROUND_TRIP (UA-6) ──────────────────────────────────
// Same train, same day, departs after the outbound arrives. Distances are symmetric
// (A=0km … C=250km) so the reverse leg prices the same as the outbound.
const returnDepart = new Date(departAt.getTime() + 8 * 3600_000); // 8h after outbound departs
const returnArrive = new Date(returnDepart.getTime() + 4 * 3600_000);
await prisma.trainSchedule.create({
data: {
id: UI_IDS.returnSchedule,
trainId: UI_IDS.train,
routeId: IDS.route,
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
departureAt: returnDepart,
arrivalAt: returnArrive,
durationMinutes: 240,
status: "SCHEDULED",
stopsCount: 3,
isPackageOnly: false,
},
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: returnDepart, status: "OPEN" },
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(returnDepart.getTime() + 2 * 3600_000), status: "OPEN" },
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationA, sequence: 3, plannedArrivalAt: returnArrive, status: "OPEN" },
],
});
await prisma.coach.create({
data: { id: UI_IDS.returnCoach, coachTypeId: IDS.coachType, number: "UI-C2", capacity: 48, sequence: 1, status: "ACTIVE" },
});
await prisma.coachAssignment.create({
data: { scheduleId: UI_IDS.returnSchedule, coachId: UI_IDS.returnCoach, positionNumber: 1, isOperational: true },
});
await prisma.seat.createMany({ data: buildSeats(UI_IDS.returnCoach, 12) });
}
/** Build `rows × 4` seats (cols AD) for a coach. */
function buildSeats(coachId: string, rows: number) {
const cols = ["A", "B", "C", "D"];
const seats: Array<{ coachId: string; seatNumber: string; row: number; col: string; isWindow: boolean; isAisle: boolean }> = [];
for (let row = 1; row <= rows; row++) {
for (const col of cols) {
seats.push({
coachId,
seatNumber: `${row}${col}`,
row,
col,
isWindow: col === "A" || col === "D",
isAisle: col === "B" || col === "C",
});
}
}
return seats;
}
// Standalone runner
if (require.main === module) {
(async () => {
const prisma = new PrismaClient();
try {
await seedUi(prisma);
// eslint-disable-next-line no-console
console.log(`[seed-ui] done. Sample trip ${IDS.stationA}${IDS.stationC} on ${sampleDepartDate()} (schedule ${UI_IDS.schedule}).`);
} finally {
await prisma.$disconnect();
}
})();
}