mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
fix: ( bookings ) prevent double booking by claiming seats under a row lock
This commit is contained in:
456
apps/edr-passenger-api/scripts/repro-double-booking.cjs
Normal file
456
apps/edr-passenger-api/scripts/repro-double-booking.cjs
Normal 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);
|
||||
});
|
||||
236
apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs
Normal file
236
apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Create the three legacy backoffice roles and their users.
|
||||
*
|
||||
* Deliberately a standalone CLI, NOT part of the app lifecycle: nothing here runs on
|
||||
* `onApplicationBootstrap`, so a deploy can decide when accounts appear. Contrast
|
||||
* `EdrPassengerOrgSeeder` / `PassengerStaffUsersSeeder`, which run at boot behind
|
||||
* SEED_EDR_PASSENGER_ORG / SEED_PASSENGER_STAFF.
|
||||
*
|
||||
* pnpm --filter @edr/passenger-api iam:seed-legacy-users
|
||||
* pnpm --filter @edr/passenger-api iam:seed-legacy-users -- --dry-run
|
||||
*
|
||||
* The roles below carry ONLY the permission keys that existed before the granular
|
||||
* create/edit/delete change (47 of them). None of the newer narrow keys are granted.
|
||||
* That is the point: these three model how the app was used before, so running them
|
||||
* against the new guards proves the granular change stayed backwards-compatible.
|
||||
*
|
||||
* Idempotent, and safe to re-run: roles and users are upserted by their natural key,
|
||||
* and each role's permission links are synced to exactly the set declared here —
|
||||
* extras are pruned so the file stays the source of truth.
|
||||
*
|
||||
* Requires DATABASE_* in the environment (`node --env-file=.env` does this).
|
||||
* The password comes from SEED_USER_PASSWORD, falling back to DEFAULT_PASSWORD.
|
||||
* In production one of them MUST be set — there is no built-in default there.
|
||||
*/
|
||||
const { DataSource } = require('typeorm');
|
||||
const { hashPassword } = require('@tria-plc/api-common/utils/argon');
|
||||
|
||||
const APP = 'edr_passenger_app';
|
||||
const ORG_KEY = 'edr';
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
const p = (s) => `${APP}:${s}`;
|
||||
|
||||
/** Every `:view` / `:view_all` key that existed before the granular change. */
|
||||
const LEGACY_VIEW_SUFFIXES = [
|
||||
'agents:view', 'audit:view', 'bookings:view', 'classes:view', 'coaches:view',
|
||||
'currencies:view', 'dashboard:view', 'fraud:view', 'inquiries:view', 'packages:view',
|
||||
'passengers:view', 'payment_methods:view', 'payments:view', 'payments:view_all',
|
||||
'reports:view', 'routes:view', 'schedules:view', 'seats:view', 'stations:view',
|
||||
'tariff_rates:view', 'tickets:view', 'trains:view',
|
||||
];
|
||||
|
||||
/** The rest of the pre-granular registry — 25 non-view keys. */
|
||||
const LEGACY_WRITE_SUFFIXES = [
|
||||
'admin',
|
||||
'agents:manage', 'bookings:cancel', 'bookings:manage', 'bookings:reschedule',
|
||||
'classes:manage', 'coaches:manage', 'currencies:manage', 'fraud:manage',
|
||||
'inquiries:manage', 'notifications:send', 'packages:manage', 'passengers:manage',
|
||||
'payment_methods:manage', 'payments:manage', 'payments:manage_methods',
|
||||
'payments:refund', 'routes:manage', 'schedules:manage', 'seats:manage',
|
||||
'stations:manage', 'tariff_rates:manage', 'tickets:generate', 'tickets:manage',
|
||||
'trains:manage',
|
||||
];
|
||||
|
||||
const LEGACY_VIEW = LEGACY_VIEW_SUFFIXES.map(p);
|
||||
const LEGACY_ALL = [...LEGACY_VIEW_SUFFIXES, ...LEGACY_WRITE_SUFFIXES].map(p);
|
||||
|
||||
/**
|
||||
* Withheld from the chief. `payment_methods:manage` and the legacy alias
|
||||
* `payments:manage_methods` both open POST/PATCH /payments/methods, so excluding only
|
||||
* one would leave the ability intact — both have to go for "no managing payment
|
||||
* methods" to actually hold.
|
||||
*/
|
||||
const CHIEF_EXCLUDED = [
|
||||
p('payment_methods:manage'),
|
||||
p('payments:manage_methods'),
|
||||
p('tickets:generate'),
|
||||
p('admin'),
|
||||
];
|
||||
|
||||
const ROLES = [
|
||||
{
|
||||
key: 'old_ticketofficer',
|
||||
name: { en: 'Ticket Officer (legacy)', am: 'የቲኬት ኦፊሰር' },
|
||||
email: 'old.ticketofficer@edr.local',
|
||||
username: 'old_ticketofficer',
|
||||
permissions: [
|
||||
p('passengers:view'),
|
||||
p('bookings:view'),
|
||||
p('tickets:view'),
|
||||
p('tickets:manage'),
|
||||
p('payments:view'),
|
||||
p('payments:manage'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'old_passengerchief',
|
||||
name: { en: 'Passenger Chief (legacy)', am: 'የተሳፋሪ ኃላፊ' },
|
||||
email: 'old.passengerchief@edr.local',
|
||||
username: 'old_passengerchief',
|
||||
permissions: LEGACY_ALL.filter((k) => !CHIEF_EXCLUDED.includes(k)),
|
||||
},
|
||||
{
|
||||
key: 'old_passengerdirector',
|
||||
name: { en: 'Passenger Director (legacy)', am: 'የተሳፋሪ ዳይሬክተር' },
|
||||
email: 'old.passengerdirector@edr.local',
|
||||
username: 'old_passengerdirector',
|
||||
permissions: LEGACY_VIEW,
|
||||
},
|
||||
];
|
||||
|
||||
function resolvePassword() {
|
||||
const pw = (process.env.SEED_USER_PASSWORD || process.env.DEFAULT_PASSWORD || '').trim();
|
||||
if (pw) return pw;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SEED_USER_PASSWORD (or DEFAULT_PASSWORD) must be set in production');
|
||||
}
|
||||
return '12345678';
|
||||
}
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
database: process.env.DATABASE_NAME,
|
||||
username: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const password = resolvePassword();
|
||||
await ds.initialize();
|
||||
|
||||
// Fail before writing anything if a key is not seeded — a typo here would otherwise
|
||||
// create a role that silently grants less than intended.
|
||||
const wanted = [...new Set(ROLES.flatMap((r) => r.permissions))];
|
||||
const found = await ds.query(
|
||||
`SELECT key FROM iam.permissions WHERE key = ANY($1::text[])`,
|
||||
[wanted],
|
||||
);
|
||||
const missing = wanted.filter((k) => !found.some((f) => f.key === k));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`these permission keys are not in iam.permissions — run the app once with ` +
|
||||
`SEED_EDR_PASSENGER_ORG=true first:\n ${missing.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await ds.query(`SELECT id FROM iam.organizations WHERE key = $1`, [ORG_KEY]);
|
||||
if (!org) throw new Error(`missing_organization:${ORG_KEY}`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('\n=== DRY RUN — nothing written ===');
|
||||
for (const r of ROLES) {
|
||||
console.log(`\n${r.key} (${r.email}) ${r.permissions.length} permissions`);
|
||||
for (const k of [...r.permissions].sort()) console.log(' ', k);
|
||||
}
|
||||
await ds.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
await ds.transaction(async (m) => {
|
||||
for (const r of ROLES) {
|
||||
const [role] = await m.query(
|
||||
`INSERT INTO iam.roles (id, key, name, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), $1, $2::jsonb, now(), now())
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id`,
|
||||
[r.key, JSON.stringify(r.name)],
|
||||
);
|
||||
|
||||
// Sync links to exactly this set: add what is missing, drop what is extra.
|
||||
await m.query(
|
||||
`INSERT INTO iam.role_permissions (id, role_id, permission_id, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1, p.id, now(), now()
|
||||
FROM iam.permissions p
|
||||
WHERE p.key = ANY($2::text[])
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.role_permissions rp
|
||||
WHERE rp.role_id = $1 AND rp.permission_id = p.id)`,
|
||||
[role.id, r.permissions],
|
||||
);
|
||||
// TypeORM's postgres driver returns `[rows, affectedCount]` for a DELETE ... RETURNING,
|
||||
// so the rows are at [0] — reading `.length` off the outer array would report 2 every time.
|
||||
const deleted = await m.query(
|
||||
`DELETE FROM iam.role_permissions rp
|
||||
USING iam.permissions p
|
||||
WHERE rp.permission_id = p.id
|
||||
AND rp.role_id = $1
|
||||
AND NOT (p.key = ANY($2::text[]))
|
||||
RETURNING rp.id`,
|
||||
[role.id, r.permissions],
|
||||
);
|
||||
const prunedCount = (Array.isArray(deleted[0]) ? deleted[0] : deleted).length;
|
||||
|
||||
const [user] = await m.query(
|
||||
`INSERT INTO iam.users (id, name, username, email, user_type, status, is_active,
|
||||
has_set_password, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), $1::jsonb, $2, $3, 'individual', 'accepted', true, true,
|
||||
now(), now())
|
||||
ON CONFLICT (email) DO UPDATE SET updated_at = now()
|
||||
RETURNING id`,
|
||||
[JSON.stringify(r.name), r.username, r.email],
|
||||
);
|
||||
|
||||
// Never overwrite a password that already exists — re-running must not reset a
|
||||
// credential someone has since changed.
|
||||
await m.query(
|
||||
`INSERT INTO iam.user_credentials (id, user_id, password, is_active, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1, $2, true, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM iam.user_credentials
|
||||
WHERE user_id = $1 AND is_active = true)`,
|
||||
[user.id, hashed],
|
||||
);
|
||||
|
||||
await m.query(
|
||||
`INSERT INTO iam.user_roles (id, user_id, role_id, organization_id, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1, $2, $3, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM iam.user_roles
|
||||
WHERE user_id = $1 AND role_id = $2)`,
|
||||
[user.id, role.id, org.id],
|
||||
);
|
||||
|
||||
await m.query(
|
||||
`INSERT INTO iam.employees (id, user_id, organization_id, is_current, name, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1, $2, true, $3::jsonb, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM iam.employees
|
||||
WHERE user_id = $1 AND organization_id = $2 AND is_current = true)`,
|
||||
[user.id, org.id, JSON.stringify(r.name)],
|
||||
);
|
||||
|
||||
console.log(
|
||||
` ${r.key.padEnd(24)} ${String(r.permissions.length).padStart(2)} permissions` +
|
||||
`${prunedCount ? ` (${prunedCount} stale link(s) pruned)` : ''} -> ${r.email}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nDone. Sign in with the email above and the seeded password.');
|
||||
console.log('Re-running is safe; an existing password is never overwritten.\n');
|
||||
await ds.destroy();
|
||||
})().catch((e) => {
|
||||
console.error('[seed-legacy-role-users] FAIL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
359
apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs
Normal file
359
apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Booking concurrency + regression harness.
|
||||
*
|
||||
* Companion to repro-double-booking.cjs: that one proves specific attacks are closed, this
|
||||
* one proves the system stays correct under load and that legitimate bookings are not
|
||||
* rejected as collateral.
|
||||
*
|
||||
* node scripts/stress-booking-concurrency.cjs
|
||||
* --schedule <uuid> --origin <uuid> --destination <uuid>
|
||||
* [--mode ladder|edge|all] [--levels 2,5,10,25,50,100]
|
||||
* [--return-schedule <uuid> --return-origin <uuid> --return-destination <uuid>]
|
||||
*
|
||||
* ladder, per concurrency level N:
|
||||
* hold-contest N browsers click one seat at once -> exactly 1 hold granted
|
||||
* booking-storm N submits of one valid hold at once -> exactly 1 booking created
|
||||
* diff-seats N users book N different seats at once -> all N succeed (no false rejects)
|
||||
*
|
||||
* edge: multi-passenger, all-or-nothing partial availability, overlapping multi-seat
|
||||
* requests, and a contested round trip.
|
||||
*
|
||||
* Everything goes through the public HTTP API — no direct database writes. Every request
|
||||
* body is built up front and released together from one Promise.all, and each line reports
|
||||
* an factor (summed latency / wall time) so the concurrency is provable rather
|
||||
* than assumed.
|
||||
*
|
||||
* Bookings land in PENDING_PAYMENT and are never paid. Every ref created is printed at the
|
||||
* end; cancel them afterwards. Pick a FUTURE departure with plenty of free seats.
|
||||
*/
|
||||
const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, '');
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (n, d) => { const i = argv.indexOf(`--${n}`); return i !== -1 && argv[i + 1] ? argv[i + 1] : d; };
|
||||
|
||||
const SCHED = arg('schedule');
|
||||
const ORIGIN = arg('origin');
|
||||
const DEST = arg('destination');
|
||||
const RET_SCHED = arg('return-schedule');
|
||||
const RET_ORIGIN = arg('return-origin');
|
||||
const RET_DEST = arg('return-destination');
|
||||
const MODE = arg('mode', 'ladder');
|
||||
const LEVELS = arg('levels', '2,5,10,25,50,100').split(',').map(Number);
|
||||
|
||||
// Node's built-in fetch (undici) defaults to an unlimited per-origin connection pool, so
|
||||
// Promise.all really does put every request on the wire at once. Proven per run by
|
||||
// comparing wall time against the summed per-request latency (see in each line).
|
||||
|
||||
const RUN = Date.now().toString(36).slice(-5).toUpperCase();
|
||||
let pc = 0;
|
||||
const uuid = () => crypto.randomUUID();
|
||||
|
||||
async function api(method, path, body) {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
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, 200) }; }
|
||||
const data = json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json;
|
||||
return { ok: res.ok, status: res.status, json, data, ms: Date.now() - t0 };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, json: { message: `NETWORK: ${e.message}` }, data: null, ms: Date.now() - t0 };
|
||||
}
|
||||
}
|
||||
const msg = (r) => String(r.json?.message ?? r.json?.error ?? JSON.stringify(r.json ?? {})).slice(0, 150);
|
||||
|
||||
function person(seatId) {
|
||||
pc++;
|
||||
const tag = `${RUN}${String(pc).padStart(4, '0')}`;
|
||||
return {
|
||||
seatId,
|
||||
passengerName: `Stress ${tag}`,
|
||||
dateOfBirth: '1990-05-15',
|
||||
idDocumentType: 'PASSPORT',
|
||||
passportNumber: `ST${tag}`,
|
||||
passportCountry: 'Djibouti',
|
||||
nationality: 'Other',
|
||||
phone: `+2519${String(40000000 + pc).slice(0, 8)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const holdBody = (seatIds, sched = SCHED, o = ORIGIN, d = DEST, dir, pid) => ({
|
||||
scheduleId: sched, originStationId: o, destinationStationId: d,
|
||||
...(dir ? { journeyDirection: dir } : {}),
|
||||
passengers: seatIds.map((seatId) => ({ passengerId: pid ?? uuid(), seatId })),
|
||||
});
|
||||
async function hold(seatIds, sched, o, d, dir, pid) {
|
||||
const r = await api('POST', '/seats/hold', holdBody(seatIds, sched, o, d, dir, pid));
|
||||
return { ...r, holdId: r.data?.holdId ?? r.data?.id };
|
||||
}
|
||||
|
||||
function oneWayBody(holdId, seatIds, seatClassId) {
|
||||
return {
|
||||
bookingType: 'ONE_WAY', scheduleId: SCHED, holdId,
|
||||
originStationId: ORIGIN, destinationStationId: DEST,
|
||||
seatClassId, skipIdentityVerification: true,
|
||||
passengers: seatIds.map((s) => person(s)),
|
||||
};
|
||||
}
|
||||
function roundTripBody(holdId, returnHoldId, outSeats, retSeats, seatClassId) {
|
||||
return {
|
||||
bookingType: 'ROUND_TRIP', scheduleId: SCHED, holdId,
|
||||
originStationId: ORIGIN, destinationStationId: DEST,
|
||||
returnScheduleId: RET_SCHED, returnHoldId,
|
||||
returnOriginStationId: RET_ORIGIN, returnDestinationStationId: RET_DEST,
|
||||
seatClassId, returnSeatClassId: seatClassId, skipIdentityVerification: true,
|
||||
passengers: outSeats.map((s, i) => ({ ...person(s), returnSeatId: retSeats[i] })),
|
||||
};
|
||||
}
|
||||
async function book(body) {
|
||||
const r = await api('POST', '/bookings/guest', body);
|
||||
return { ...r, bookingRef: r.data?.bookingRef };
|
||||
}
|
||||
|
||||
async function seatPool(sched = SCHED, o = ORIGIN, d = DEST) {
|
||||
const r = await api('GET', `/seats/seatmap/${sched}?originStationId=${o}&destinationStationId=${d}`);
|
||||
if (!r.ok) throw new Error(`seatmap ${r.status}: ${msg(r)}`);
|
||||
const out = [];
|
||||
for (const c of r.data?.coaches ?? [])
|
||||
for (const s of c.seats ?? [])
|
||||
if (String(s.status).toUpperCase() === 'AVAILABLE')
|
||||
out.push({ id: s.id, seatNumber: s.seatNumber, coach: c.coachNumber, coachClass: c.seatClass });
|
||||
return out;
|
||||
}
|
||||
async function seatClassId(seat) {
|
||||
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();
|
||||
return (list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0]).id;
|
||||
}
|
||||
|
||||
/** Fire everything at once and report the arrival spread so "concurrent" is provable. */
|
||||
async function fireAll(tasks) {
|
||||
const t0 = Date.now();
|
||||
const starts = [];
|
||||
const wrapped = tasks.map((fn) => (async () => { starts.push(Date.now() - t0); return fn(); })());
|
||||
const results = await Promise.all(wrapped);
|
||||
const wallMs = Date.now() - t0;
|
||||
const sumMs = results.reduce((a, r) => a + (r.ms || 0), 0);
|
||||
// overlap >> 1 proves the requests were genuinely in flight together rather than queued.
|
||||
return { results, wallMs, releaseSpreadMs: Math.max(...starts) - Math.min(...starts), overlap: (sumMs / Math.max(wallMs, 1)).toFixed(1) };
|
||||
}
|
||||
|
||||
function tally(results) {
|
||||
const t = {};
|
||||
for (const r of results) {
|
||||
const k = r.bookingRef ? 'created' : r.holdId ? 'held' : `http_${r.status}`;
|
||||
t[k] = (t[k] || 0) + 1;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
const errSample = (results) =>
|
||||
[...new Set(results.filter((r) => !r.bookingRef && !r.holdId).map((r) => `${r.status}: ${msg(r)}`))].slice(0, 3);
|
||||
|
||||
const ALL = { created: [], failures: [] };
|
||||
function note(refs) { ALL.created.push(...refs); }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/** N users all click the SAME seat: N simultaneous holds. Exactly 1 must win. */
|
||||
async function holdContest(pool, n) {
|
||||
const seat = pool.shift();
|
||||
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(
|
||||
Array.from({ length: n }, () => () => hold([seat.id])),
|
||||
);
|
||||
const won = results.filter((r) => r.holdId);
|
||||
return {
|
||||
name: `hold-contest n=${n}`,
|
||||
pass: won.length === 1,
|
||||
detail: `seat ${seat.seatNumber}: ${won.length} hold(s) granted ${JSON.stringify(tally(results))} ` +
|
||||
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
|
||||
errs: errSample(results),
|
||||
winner: won[0],
|
||||
seat,
|
||||
};
|
||||
}
|
||||
|
||||
/** The double-submit / retry storm: N simultaneous bookings on ONE valid hold. */
|
||||
async function bookingStorm(seat, holdId, scid, n) {
|
||||
const bodies = Array.from({ length: n }, () => oneWayBody(holdId, [seat.id], scid));
|
||||
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
|
||||
const won = results.filter((r) => r.bookingRef);
|
||||
note(won.map((r) => r.bookingRef));
|
||||
return {
|
||||
name: `booking-storm n=${n}`,
|
||||
pass: won.length === 1,
|
||||
detail: `seat ${seat.seatNumber}: ${won.length} booking(s) ${JSON.stringify(tally(results))} ` +
|
||||
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
|
||||
errs: errSample(results),
|
||||
};
|
||||
}
|
||||
|
||||
/** N users, N DIFFERENT seats, all at once. All N must succeed — no false rejections. */
|
||||
async function differentSeats(pool, scid, n) {
|
||||
const seats = pool.splice(0, n);
|
||||
if (seats.length < n) return { name: `diff-seats n=${n}`, pass: false, detail: 'not enough free seats', errs: [] };
|
||||
const holds = await Promise.all(seats.map((s) => hold([s.id])));
|
||||
const usable = holds.map((h, i) => ({ h, s: seats[i] })).filter((x) => x.h.holdId);
|
||||
const bodies = usable.map((x) => oneWayBody(x.h.holdId, [x.s.id], scid));
|
||||
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
|
||||
const won = results.filter((r) => r.bookingRef);
|
||||
note(won.map((r) => r.bookingRef));
|
||||
return {
|
||||
name: `diff-seats n=${n}`,
|
||||
pass: won.length === usable.length && usable.length === n,
|
||||
detail: `${won.length}/${usable.length} distinct-seat bookings succeeded (holds granted ${usable.length}/${n}) ` +
|
||||
`${JSON.stringify(tally(results))} wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
|
||||
errs: errSample(results),
|
||||
};
|
||||
}
|
||||
|
||||
/** Overlapping multi-seat requests over a small shared pool — deadlock + partial-write probe. */
|
||||
async function overlapping(pool, scid, n, poolSize = 4) {
|
||||
const shared = pool.splice(0, poolSize);
|
||||
const holds = await Promise.all(shared.map((s) => hold([s.id])));
|
||||
const byId = new Map(shared.map((s, i) => [s.id, holds[i]]));
|
||||
const granted = shared.filter((s) => byId.get(s.id).holdId);
|
||||
if (granted.length < 2) return { name: `overlap n=${n}`, pass: false, detail: 'too few holds', errs: [] };
|
||||
|
||||
// Each request asks for 2 seats from the shared pool, in varying order.
|
||||
const bodies = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = granted[i % granted.length];
|
||||
const b = granted[(i + 1 + (i % 2)) % granted.length];
|
||||
if (a.id === b.id) continue;
|
||||
// Present the hold of the FIRST seat; second seat will be rejected as not-in-hold.
|
||||
bodies.push({ body: oneWayBody(byId.get(a.id).holdId, [a.id, b.id], scid), seats: [a, b] });
|
||||
}
|
||||
const { results, wallMs } = await fireAll(bodies.map((x) => () => book(x.body)));
|
||||
const won = results.filter((r) => r.bookingRef);
|
||||
note(won.map((r) => r.bookingRef));
|
||||
return {
|
||||
name: `overlap n=${bodies.length}`,
|
||||
pass: true, // correctness judged by the DB duplicate check, not the count
|
||||
detail: `${won.length} booking(s) from ${bodies.length} overlapping 2-seat requests over ${granted.length} seats ` +
|
||||
`${JSON.stringify(tally(results))} wall=${wallMs}ms`,
|
||||
errs: errSample(results),
|
||||
};
|
||||
}
|
||||
|
||||
/** Multi-passenger booking where one seat is contested — must be all-or-nothing. */
|
||||
async function partialAvailability(pool, scid) {
|
||||
const [a, b, c] = pool.splice(0, 3);
|
||||
const hAll = await hold([a.id, b.id, c.id]);
|
||||
if (!hAll.holdId) return { name: 'partial-availability', pass: false, detail: `3-seat hold failed: ${msg(hAll)}`, errs: [] };
|
||||
|
||||
// Book seat B alone first (legitimately, from the same hold).
|
||||
const first = await book(oneWayBody(hAll.holdId, [b.id], scid));
|
||||
if (!first.bookingRef) return { name: 'partial-availability', pass: false, detail: `setup booking failed: ${msg(first)}`, errs: [] };
|
||||
note([first.bookingRef]);
|
||||
|
||||
// Now try to book all three. B is taken -> the whole request must fail, leaving no row.
|
||||
const second = await book(oneWayBody(hAll.holdId, [a.id, b.id, c.id], scid));
|
||||
if (second.bookingRef) {
|
||||
note([second.bookingRef]);
|
||||
return { name: 'partial-availability', pass: false, detail: `3-seat booking succeeded despite seat ${b.seatNumber} being taken — ${second.bookingRef}`, errs: [] };
|
||||
}
|
||||
return {
|
||||
name: 'partial-availability',
|
||||
pass: true,
|
||||
detail: `seat ${b.seatNumber} taken -> 3-seat request rejected whole (${second.status}: ${msg(second)}); seats ${a.seatNumber},${c.seatNumber} must remain free`,
|
||||
errs: [],
|
||||
freeSeats: [a, c],
|
||||
takenSeat: b,
|
||||
};
|
||||
}
|
||||
|
||||
/** N concurrent ROUND_TRIP bookings contesting one outbound+return seat pair. */
|
||||
async function roundTripContest(n, scid) {
|
||||
if (!RET_SCHED) return { name: `round-trip n=${n}`, pass: true, detail: 'skipped (no --return-schedule)', errs: [] };
|
||||
const outPool = await seatPool(SCHED, ORIGIN, DEST);
|
||||
const retPool = await seatPool(RET_SCHED, RET_ORIGIN, RET_DEST);
|
||||
const outSeat = outPool[0];
|
||||
const retSeat = retPool.find((s) => s.id !== outSeat.id) ?? retPool[0];
|
||||
|
||||
const traveller = uuid();
|
||||
const hOut = await hold([outSeat.id], SCHED, ORIGIN, DEST, 'OUTBOUND', traveller);
|
||||
const hRet = await hold([retSeat.id], RET_SCHED, RET_ORIGIN, RET_DEST, 'RETURN', traveller);
|
||||
if (!hOut.holdId || !hRet.holdId) {
|
||||
return { name: `round-trip n=${n}`, pass: false, detail: `hold failed out=${msg(hOut)} ret=${msg(hRet)}`, errs: [] };
|
||||
}
|
||||
const bodies = Array.from({ length: n }, () =>
|
||||
roundTripBody(hOut.holdId, hRet.holdId, [outSeat.id], [retSeat.id], scid));
|
||||
const { results, wallMs } = await fireAll(bodies.map((b) => () => book(b)));
|
||||
const won = results.filter((r) => r.bookingRef);
|
||||
note(won.map((r) => r.bookingRef));
|
||||
return {
|
||||
name: `round-trip n=${n}`,
|
||||
pass: won.length === 1,
|
||||
detail: `out seat ${outSeat.seatNumber} / ret seat ${retSeat.seatNumber}: ${won.length} booking(s) ` +
|
||||
`${JSON.stringify(tally(results))} wall=${wallMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
|
||||
errs: errSample(results),
|
||||
seats: { outSeat, retSeat },
|
||||
};
|
||||
}
|
||||
|
||||
/** A single multi-passenger, multi-seat booking — the ordinary family booking. */
|
||||
async function multiPassenger(pool, scid, count = 4) {
|
||||
const seats = pool.splice(0, count);
|
||||
const h = await hold(seats.map((s) => s.id));
|
||||
if (!h.holdId) return { name: `multi-passenger x${count}`, pass: false, detail: `hold failed: ${msg(h)}`, errs: [] };
|
||||
const r = await book(oneWayBody(h.holdId, seats.map((s) => s.id), scid));
|
||||
if (r.bookingRef) note([r.bookingRef]);
|
||||
return {
|
||||
name: `multi-passenger x${count}`,
|
||||
pass: !!r.bookingRef,
|
||||
detail: r.bookingRef
|
||||
? `${count} passengers on seats ${seats.map((s) => s.seatNumber).join(',')} -> ${r.bookingRef}`
|
||||
: `rejected (${r.status}): ${msg(r)}`,
|
||||
errs: [],
|
||||
seats,
|
||||
ref: r.bookingRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
console.log(`API ${BASE}`);
|
||||
console.log(`Schedule ${SCHED}`);
|
||||
if (RET_SCHED) console.log(`Return ${RET_SCHED}`);
|
||||
const pool = await seatPool();
|
||||
const scid = await seatClassId(pool[0]);
|
||||
console.log(`${pool.length} AVAILABLE seats, seatClass ${scid}\n`);
|
||||
|
||||
const out = [];
|
||||
const report = (r) => {
|
||||
out.push(r);
|
||||
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`);
|
||||
console.log(` ${r.detail}`);
|
||||
if (r.errs?.length) r.errs.forEach((e) => console.log(` · ${e}`));
|
||||
console.log('');
|
||||
return r;
|
||||
};
|
||||
|
||||
if (MODE === 'ladder' || MODE === 'all') {
|
||||
for (const n of LEVELS) {
|
||||
console.log(`──────── concurrency ${n} ────────`);
|
||||
const hc = report(await holdContest(pool, n));
|
||||
if (hc.winner) report(await bookingStorm(hc.seat, hc.winner.holdId, scid, n));
|
||||
report(await differentSeats(pool, scid, n));
|
||||
}
|
||||
}
|
||||
if (MODE === 'edge' || MODE === 'all') {
|
||||
console.log(`──────── edge cases ────────`);
|
||||
report(await multiPassenger(pool, scid, 4));
|
||||
report(await partialAvailability(pool, scid));
|
||||
report(await overlapping(pool, scid, 12));
|
||||
report(await roundTripContest(10, scid));
|
||||
}
|
||||
|
||||
const fails = out.filter((r) => !r.pass);
|
||||
console.log(`════════ ${out.length} checks, ${fails.length} failed, ${ALL.created.length} bookings created ════════`);
|
||||
fails.forEach((f) => console.log(` FAIL ${f.name}: ${f.detail}`));
|
||||
if (ALL.created.length) {
|
||||
console.log(`\nrefs: ${ALL.created.join(' ')}`);
|
||||
}
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
})().catch((e) => { console.error(e); process.exit(2); });
|
||||
Reference in New Issue
Block a user