fix: ( bookings ) prevent double booking by claiming seats under a row lock

This commit is contained in:
Abubeker Yasin
2026-09-08 10:05:47 +03:00
parent 70b1f75158
commit 888bf20072
10 changed files with 1946 additions and 187 deletions

View File

@@ -0,0 +1,456 @@
#!/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 <uuid> --origin <uuid> --destination <uuid> [--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 <uuid> --origin <uuid> --destination <uuid>');
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 <minutes, > 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);
});