mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
457 lines
18 KiB
JavaScript
457 lines
18 KiB
JavaScript
#!/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);
|
|
});
|