#!/usr/bin/env node /** * Double-booking reproduction harness — drives the real HTTP API only. * * No direct database access, no row edits: every seat here is claimed the same way a * passenger's browser claims it (POST /seats/hold, POST /bookings/guest). If a scenario * reports FAIL, two bookings hold the same seat on the same schedule and the API let it * happen through its own public endpoints. * * node scripts/repro-double-booking.cjs \ * --schedule --origin --destination [--scenario S3] [--concurrency 8] * * The three UUIDs come straight off any booking-detail response (data.schedule.id, * data.schedule.origin.id, data.schedule.destination.id). Pick a FUTURE departure — holds * are refused inside the check-in cutoff. * * Bookings land in PENDING_PAYMENT and are never paid, so no JourneySegment or ticket is * produced. Every bookingRef created is printed at the end for cleanup. */ const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, ''); // ── args ────────────────────────────────────────────────────────────────────── const argv = process.argv.slice(2); const arg = (name, fallback) => { const i = argv.indexOf(`--${name}`); return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback; }; const SCHEDULE_ID = arg('schedule'); const ORIGIN_ID = arg('origin'); const DESTINATION_ID = arg('destination'); const SEAT_CLASS_ID = arg('seat-class-id'); const ONLY = arg('scenario'); const CONCURRENCY = Number(arg('concurrency', 8)); if (!SCHEDULE_ID || !ORIGIN_ID || !DESTINATION_ID) { console.error('Usage: --schedule --origin --destination '); process.exit(1); } const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/.test(BASE); if (!isLocal && process.env.ALLOW_REMOTE !== '1') { console.error(`Refusing to run against ${BASE} — this CREATES REAL BOOKINGS.`); console.error('Set ALLOW_REMOTE=1 only if this is a staging/dev API you are willing to dirty.'); process.exit(1); } // ── http ────────────────────────────────────────────────────────────────────── let callCount = 0; async function api(method, path, body) { callCount++; const res = await fetch(`${BASE}${path}`, { method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 300) }; } // A global interceptor wraps every payload as { success, data, timestamp } — unwrap it // so callers see the resource itself, not the envelope. const data = json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json; return { ok: res.ok, status: res.status, json, data }; } const uuid = () => crypto.randomUUID(); const msg = (r) => r.json?.message ?? r.json?.error ?? JSON.stringify(r.json).slice(0, 160); // ── building blocks ─────────────────────────────────────────────────────────── function holdBody(seatIds, direction, passengerId) { return { scheduleId: SCHEDULE_ID, originStationId: ORIGIN_ID, destinationStationId: DESTINATION_ID, ...(direction ? { journeyDirection: direction } : {}), passengers: seatIds.map((seatId) => ({ passengerId: passengerId ?? uuid(), seatId })), }; } /** * POST /seats/hold — public, exactly what the seat map calls. Pass `passengerId` to make * two holds look like the same traveller, which is what the OUTBOUND/RETURN exemption * legitimately covers. */ async function hold(seatIds, direction, passengerId) { const r = await api('POST', '/seats/hold', holdBody(seatIds, direction, passengerId)); return { ...r, holdId: r.data?.id ?? r.data?.holdId }; } // One traveller may not hold two unpaid bookings on the same train (see // assertIdentitiesNotAlreadyBooked). Every run therefore needs fresh identities, or the // leftovers from the previous run reject this one for the wrong reason. const RUN = Date.now().toString(36).slice(-5).toUpperCase(); let personCounter = 0; function passenger(seatId, extra = {}) { personCounter++; return { seatId, passengerName: `Repro ${RUN} ${personCounter}`, dateOfBirth: '1990-05-15', idDocumentType: 'PASSPORT', passportNumber: `RP${RUN}${String(personCounter).padStart(3, '0')}`, passportCountry: 'Djibouti', nationality: 'Other', phone: `+2519${String(10000000 + personCounter).slice(0, 8)}`, ...extra, }; } /** * POST /bookings/guest — public, no auth. `holdId` and the seat ids in `passengers` * are sent independently, which is the whole point of scenarios 1 and 2. */ async function guestBook({ holdId, seatIds, seatClassId }) { const r = await api('POST', '/bookings/guest', { bookingType: 'ONE_WAY', scheduleId: SCHEDULE_ID, holdId, originStationId: ORIGIN_ID, destinationStationId: DESTINATION_ID, seatClassId, skipIdentityVerification: true, passengers: seatIds.map((s) => passenger(s)), }); const ref = r.data?.bookingRef ?? r.data?.booking?.bookingRef; return { ...r, bookingRef: ref }; } // ── ledger: who ended up on which seat ──────────────────────────────────────── const claims = []; // { seatId, seatNumber, bookingRef, scenario } const created = []; // every bookingRef we made, for cleanup function record(scenario, seat, bookingRef) { claims.push({ seatId: seat.id, seatNumber: seat.seatNumber, bookingRef, scenario }); created.push(bookingRef); } // ── seat pool ───────────────────────────────────────────────────────────────── async function loadSeats() { const q = `originStationId=${ORIGIN_ID}&destinationStationId=${DESTINATION_ID}`; const r = await api('GET', `/seats/seatmap/${SCHEDULE_ID}?${q}`); if (!r.ok) throw new Error(`seatmap failed (${r.status}): ${msg(r)}`); const pool = []; for (const coach of r.data?.coaches ?? []) { for (const s of coach.seats ?? []) { if (String(s.status).toUpperCase() !== 'AVAILABLE') continue; pool.push({ id: s.id, seatNumber: s.seatNumber, coach: coach.coachNumber, coachClass: coach.seatClass, }); } } return pool; } async function resolveSeatClassId(seat) { if (SEAT_CLASS_ID) return SEAT_CLASS_ID; const r = await api('GET', '/seat-classes'); const list = Array.isArray(r.data) ? r.data : (r.data?.items ?? []); const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); const hit = list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0]; if (!hit) throw new Error('No seat classes returned — pass --seat-class-id explicitly.'); return hit.id; } // ── scenarios ───────────────────────────────────────────────────────────────── // Each returns { verdict: 'FAIL' | 'PASS' | 'INCONCLUSIVE', detail }. // S* are attacks: FAIL means the double booking reproduced. // P* are positive controls: FAIL means a legitimate booking got blocked, which is just // as much a regression — a guard that refuses everything is not a fix. const scenarios = { /** P1 — the ordinary happy path: hold a seat, book that same seat. Must succeed. */ async P1(pool, seatClassId) { const seat = pool.shift(); const h = await hold([seat.id]); if (!h.holdId) return { verdict: 'FAIL', detail: `could not hold seat ${seat.seatNumber}: ${msg(h)}` }; const b = await guestBook({ holdId: h.holdId, seatIds: [seat.id], seatClassId }); if (!b.bookingRef) { return { verdict: 'FAIL', detail: `held seat ${seat.seatNumber} but booking was refused (${b.status}): ${msg(b)}` }; } record('P1', seat, b.bookingRef); return { verdict: 'PASS', detail: `seat ${seat.seatNumber} held and booked normally — ${b.bookingRef}` }; }, /** * P2 — one traveller, both legs of a turnaround round trip on the same seat. This is * what the OUTBOUND/RETURN exemption is for, so it must keep working after the * exemption is narrowed to the requester's own holds. */ async P2(pool) { const seat = pool.shift(); const traveller = uuid(); const outbound = await hold([seat.id], 'OUTBOUND', traveller); if (!outbound.holdId) { return { verdict: 'FAIL', detail: `outbound hold refused on seat ${seat.seatNumber}: ${msg(outbound)}` }; } const ret = await hold([seat.id], 'RETURN', traveller); if (!ret.holdId) { return { verdict: 'FAIL', detail: `same traveller was blocked from holding their own return leg on seat ${seat.seatNumber}: ${msg(ret)}`, }; } return { verdict: 'PASS', detail: `one traveller held seat ${seat.seatNumber} on both legs` }; }, /** * S1 — Hold laundering. Hold seat A, then guest-book seat B while presenting A's holdId. * bookings.service.ts runs validateSeatIdsAgainstHold here; guest-booking.service.ts * does not, so the seat you pay for need never be the seat you held. */ async S1(pool, seatClassId) { const [decoy, target] = [pool.shift(), pool.shift()]; const h = await hold([decoy.id]); if (!h.holdId) return { verdict: 'INCONCLUSIVE', detail: `hold on decoy failed: ${msg(h)}` }; const b = await guestBook({ holdId: h.holdId, seatIds: [target.id], seatClassId }); if (!b.bookingRef) return { verdict: 'PASS', detail: `rejected (${b.status}): ${msg(b)}` }; record('S1', target, b.bookingRef); return { verdict: 'FAIL', detail: `held seat ${decoy.seatNumber} but booked seat ${target.seatNumber} — ${b.bookingRef}`, }; }, /** * S2 — Steal a live hold. Someone else holds the seat; we present an unrelated hold * and book their seat anyway. This is the production shape: a seat that another * passenger is mid-checkout on gets sold underneath them. */ async S2(pool, seatClassId) { const [victimSeat, mySeat] = [pool.shift(), pool.shift()]; const victimHold = await hold([victimSeat.id]); if (!victimHold.holdId) return { verdict: 'INCONCLUSIVE', detail: `victim hold failed: ${msg(victimHold)}` }; const myHold = await hold([mySeat.id]); if (!myHold.holdId) return { verdict: 'INCONCLUSIVE', detail: `attacker hold failed: ${msg(myHold)}` }; const b = await guestBook({ holdId: myHold.holdId, seatIds: [victimSeat.id], seatClassId }); if (!b.bookingRef) return { verdict: 'PASS', detail: `rejected (${b.status}): ${msg(b)}` }; record('S2', victimSeat, b.bookingRef); return { verdict: 'FAIL', detail: `seat ${victimSeat.seatNumber} was under a live hold, booked anyway — ${b.bookingRef}`, }; }, /** * S3 — Concurrent guest bookings, same seat. N independent "browsers" each hold their * own throwaway seat, then all fire at the same instant for one shared seat. Closest * analogue to peak-hour traffic. More than one bookingRef means the seat was sold twice. */ async S3(pool, seatClassId) { const target = pool.shift(); const decoys = pool.splice(0, CONCURRENCY); if (decoys.length < CONCURRENCY) return { verdict: 'INCONCLUSIVE', detail: 'not enough free seats' }; const holds = await Promise.all(decoys.map((d) => hold([d.id]))); const usable = holds.filter((h) => h.holdId); if (usable.length < 2) return { verdict: 'INCONCLUSIVE', detail: 'fewer than 2 holds succeeded' }; const results = await Promise.all( usable.map((h) => guestBook({ holdId: h.holdId, seatIds: [target.id], seatClassId })), ); const won = results.filter((r) => r.bookingRef); won.forEach((r) => record('S3', target, r.bookingRef)); if (won.length <= 1) { return { verdict: 'PASS', detail: `${won.length}/${usable.length} succeeded on seat ${target.seatNumber}`, }; } return { verdict: 'FAIL', detail: `seat ${target.seatNumber} sold ${won.length}x concurrently — ${won.map((r) => r.bookingRef).join(', ')}`, }; }, /** * S4 — Concurrent holds, same seat. Tests the hold transaction itself, upstream of any * booking. If two holds coexist on one seat, every downstream check is already poisoned. */ async S4(pool) { const target = pool.shift(); const results = await Promise.all(Array.from({ length: CONCURRENCY }, () => hold([target.id]))); const won = results.filter((r) => r.holdId); if (won.length <= 1) { return { verdict: 'PASS', detail: `${won.length}/${CONCURRENCY} holds granted on seat ${target.seatNumber}`, }; } return { verdict: 'FAIL', detail: `seat ${target.seatNumber} held ${won.length}x simultaneously — holdIds ${won.map((r) => r.holdId).join(', ')}`, }; }, /** * S5 — Direction bypass. checkDirectionConflict (journey-direction.utils.ts:13) returns * false for OUTBOUND vs RETURN so a round trip can reuse a seat across its own legs. * The rule is per-seat, not per-booking, so two *different* passengers can straddle it. */ async S5(pool, seatClassId) { const target = pool.shift(); const outbound = await hold([target.id], 'OUTBOUND'); const ret = await hold([target.id], 'RETURN'); if (!outbound.holdId || !ret.holdId) { return { verdict: 'PASS', detail: `second direction refused: ${msg(outbound.holdId ? ret : outbound)}`, }; } const a = await guestBook({ holdId: outbound.holdId, seatIds: [target.id], seatClassId }); const b = await guestBook({ holdId: ret.holdId, seatIds: [target.id], seatClassId }); const won = [a, b].filter((r) => r.bookingRef); won.forEach((r) => record('S5', target, r.bookingRef)); if (won.length <= 1) { return { verdict: 'INCONCLUSIVE', detail: `both directions held seat ${target.seatNumber} at once, but only ${won.length} booking stuck`, }; } return { verdict: 'FAIL', detail: `seat ${target.seatNumber} sold as OUTBOUND and RETURN — ${won.map((r) => r.bookingRef).join(', ')}`, }; }, /** * S6 — Hold expiry gap. A booking sits in PENDING_PAYMENT; its hold lapses; no * JourneySegment exists yet because those are only written on payment success * (payments.service.ts:2163). The seat reads as free to everyone until the first * booking pays. Needs --wait-hold-expiry, since it must outlive SEAT_HOLD_DURATION_MINUTES. */ async S6(pool, seatClassId) { const waitMin = Number(arg('wait-hold-expiry', '0')); if (!waitMin) { return { verdict: 'SKIPPED', detail: 'pass --wait-hold-expiry SEAT_HOLD_DURATION_MINUTES>', }; } const target = pool.shift(); const first = await hold([target.id]); if (!first.holdId) return { verdict: 'INCONCLUSIVE', detail: `hold failed: ${msg(first)}` }; const b1 = await guestBook({ holdId: first.holdId, seatIds: [target.id], seatClassId }); if (!b1.bookingRef) return { verdict: 'INCONCLUSIVE', detail: `first booking failed: ${msg(b1)}` }; record('S6', target, b1.bookingRef); console.log(` waiting ${waitMin} min for the hold to lapse (booking stays PENDING_PAYMENT)…`); await new Promise((r) => setTimeout(r, waitMin * 60 * 1000)); const second = await hold([target.id]); if (!second.holdId) return { verdict: 'PASS', detail: `re-hold refused after expiry: ${msg(second)}` }; const b2 = await guestBook({ holdId: second.holdId, seatIds: [target.id], seatClassId }); if (!b2.bookingRef) return { verdict: 'PASS', detail: `second booking refused: ${msg(b2)}` }; record('S6', target, b2.bookingRef); return { verdict: 'FAIL', detail: `seat ${target.seatNumber} rebooked after hold lapsed — ${b1.bookingRef} and ${b2.bookingRef}`, }; }, }; // ── runner ──────────────────────────────────────────────────────────────────── (async () => { console.log(`API ${BASE}`); console.log(`Schedule ${SCHEDULE_ID}`); console.log(`Route ${ORIGIN_ID} -> ${DESTINATION_ID}\n`); const pool = await loadSeats(); console.log(`${pool.length} seats reported AVAILABLE.\n`); if (pool.length < CONCURRENCY + 6) { console.error( `Need at least ${CONCURRENCY + 6} free seats; lower --concurrency or pick an emptier departure.`, ); process.exit(1); } const seatClassId = await resolveSeatClassId(pool[0]); const names = ONLY ? [ONLY] : Object.keys(scenarios); const summary = []; for (const name of names) { const fn = scenarios[name]; if (!fn) { console.error(`Unknown scenario ${name}`); continue; } process.stdout.write(`${name} … `); let out; try { out = await fn(pool, seatClassId); } catch (e) { out = { verdict: 'ERROR', detail: e.message }; } console.log(`${out.verdict}\n ${out.detail}`); summary.push({ name, ...out }); } // Collision ledger — built only from what the API handed back to us. const bySeat = new Map(); for (const c of claims) { if (!bySeat.has(c.seatId)) bySeat.set(c.seatId, []); bySeat.get(c.seatId).push(c); } const collisions = [...bySeat.values()].filter((g) => g.length > 1); console.log(`\n─────── ${callCount} API calls, ${created.length} bookings created ───────`); for (const s of summary) console.log(` ${s.verdict.padEnd(13)} ${s.name}`); if (collisions.length) { console.log('\nDOUBLE-BOOKED SEATS:'); for (const g of collisions) { console.log(` seat ${g[0].seatNumber} (${g[0].seatId})`); for (const c of g) console.log(` ${c.bookingRef} [${c.scenario}]`); } } else { console.log('\nNo seat was claimed by more than one booking.'); } if (created.length) { console.log('\nCreated (PENDING_PAYMENT, unpaid — cancel these):'); console.log(` ${created.join(' ')}`); } process.exit(collisions.length ? 2 : 0); })().catch((e) => { console.error(e); process.exit(1); });