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);
});

View 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);
});

View 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); });