← back to NationalPaperHangers
Guard booking lifecycle transitions against reviving terminal bookings
47d0f2eb46907718d1a8727b664798e9215aacc4 · 2026-05-18 20:26:42 -0700 · SteveStudio2
POST /admin/bookings/:id/{confirm,decline,complete} each ran an
unconditional UPDATE that overwrote status regardless of the booking's
current state. A stale admin tab, a back-button re-POST, or a
double-submit could therefore flip a completed, canceled, or declined
booking back to confirmed — silently, with no feedback either way since
these routes (unlike every other admin POST) skipped the session flash.
Each UPDATE now guards on the valid prior states in its WHERE clause:
confirm only from 'pending'; decline and complete only from
'pending'/'confirmed'. A 0-row result means the transition no longer
applies and is flashed back to the installer instead of redirecting as
a silent success. Test inserts bookings in terminal states and asserts
confirm/decline cannot touch them while the pending->confirmed->completed
path still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M routes/admin.jsM tests/smoke.test.js
Diff
commit 47d0f2eb46907718d1a8727b664798e9215aacc4
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 20:26:42 2026 -0700
Guard booking lifecycle transitions against reviving terminal bookings
POST /admin/bookings/:id/{confirm,decline,complete} each ran an
unconditional UPDATE that overwrote status regardless of the booking's
current state. A stale admin tab, a back-button re-POST, or a
double-submit could therefore flip a completed, canceled, or declined
booking back to confirmed — silently, with no feedback either way since
these routes (unlike every other admin POST) skipped the session flash.
Each UPDATE now guards on the valid prior states in its WHERE clause:
confirm only from 'pending'; decline and complete only from
'pending'/'confirmed'. A 0-row result means the transition no longer
applies and is flashed back to the installer instead of redirecting as
a silent success. Test inserts bookings in terminal states and asserts
confirm/decline cannot touch them while the pending->confirmed->completed
path still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
routes/admin.js | 29 +++++++++++++++++++++------
tests/smoke.test.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 79 insertions(+), 6 deletions(-)
diff --git a/routes/admin.js b/routes/admin.js
index 88dda75..2999412 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -121,32 +121,49 @@ router.get('/bookings', async (req, res, next) => {
} catch (err) { next(err); }
});
+// Booking lifecycle transitions. Each UPDATE guards on the valid prior
+// states in its WHERE clause, so a terminal booking (completed / canceled /
+// declined / no_show) can't be revived by a stale tab, a back-button
+// re-POST, or a double-submit. A 0-row result means the transition no
+// longer applies — flashed back to the installer instead of a silent no-op.
router.post('/bookings/:id/confirm', async (req, res, next) => {
try {
- await db.query(
- `UPDATE bookings SET status='confirmed', confirmed_at=now() WHERE id=$1 AND installer_id=$2`,
+ const r = await db.query(
+ `UPDATE bookings SET status='confirmed', confirmed_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status='pending'`,
[req.params.id, req.installer.id]
);
+ req.session.flash = r.rowCount
+ ? { ok: 'Booking confirmed.' }
+ : { error: 'That booking can no longer be confirmed.' };
res.redirect('/admin/bookings');
} catch (err) { next(err); }
});
router.post('/bookings/:id/decline', async (req, res, next) => {
try {
- await db.query(
- `UPDATE bookings SET status='declined', canceled_at=now(), cancel_reason=$3 WHERE id=$1 AND installer_id=$2`,
+ const r = await db.query(
+ `UPDATE bookings SET status='declined', canceled_at=now(), cancel_reason=$3
+ WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`,
[req.params.id, req.installer.id, req.body.reason || null]
);
+ req.session.flash = r.rowCount
+ ? { ok: 'Booking declined.' }
+ : { error: 'That booking can no longer be declined.' };
res.redirect('/admin/bookings');
} catch (err) { next(err); }
});
router.post('/bookings/:id/complete', async (req, res, next) => {
try {
- await db.query(
- `UPDATE bookings SET status='completed', completed_at=now() WHERE id=$1 AND installer_id=$2`,
+ const r = await db.query(
+ `UPDATE bookings SET status='completed', completed_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`,
[req.params.id, req.installer.id]
);
+ req.session.flash = r.rowCount
+ ? { ok: 'Booking marked complete.' }
+ : { error: 'That booking can no longer be marked complete.' };
res.redirect('/admin/bookings');
} catch (err) { next(err); }
});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index b60e11f..f51defc 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -306,6 +306,62 @@ test('booking confirmation email links to /bookings/:uuid with a valid ?t= token
assert.ok(bookingToken.verify(m[1], m[2]), 'embedded token must verify against the uuid');
});
+// ─────────────────────────────────────────────────────────────────────────────
+// 5d. Booking lifecycle guards — a terminal booking cannot be revived by a
+// confirm / decline / complete POST (stale tab, back-button re-POST).
+// ─────────────────────────────────────────────────────────────────────────────
+test('booking transitions guard prior state: terminal bookings cannot be revived', async () => {
+ const ins = await db.one(
+ `INSERT INTO installers (slug, email, password_hash, business_name, status, tier)
+ VALUES ($1, $2, '$2b$10$.2P40NWtgXQiylgmJWIprO6.cML8l.wHfMwzB29jAd01uVXyjofka',
+ 'Smoke Lifecycle Studio', 'active', 'pro')
+ RETURNING id`,
+ [`smoke-lifecycle-${Date.now()}`, `smoke-lifecycle-${Date.now()}@test.invalid`]
+ );
+ const id = ins.id;
+ const mkBooking = async (status) => db.one(
+ `INSERT INTO bookings (installer_id, customer_name, customer_email,
+ scheduled_start, scheduled_end, status)
+ VALUES ($1, 'LC Test', 'lc@test.invalid',
+ now() + interval '7 days', now() + interval '7 days' + interval '1 hour', $2)
+ RETURNING id`,
+ [id, status]
+ );
+
+ try {
+ // confirm guards on status='pending' — a canceled booking must not revive.
+ const canceled = await mkBooking('canceled');
+ let r = await db.query(
+ `UPDATE bookings SET status='confirmed', confirmed_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status='pending'`, [canceled.id, id]);
+ assert.equal(r.rowCount, 0, 'confirm must not touch a canceled booking');
+ let row = await db.one('SELECT status FROM bookings WHERE id=$1', [canceled.id]);
+ assert.equal(row.status, 'canceled', 'canceled booking stays canceled');
+
+ // A pending booking confirms cleanly, then completes from confirmed.
+ const live = await mkBooking('pending');
+ r = await db.query(
+ `UPDATE bookings SET status='confirmed', confirmed_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status='pending'`, [live.id, id]);
+ assert.equal(r.rowCount, 1, 'pending booking confirms');
+ r = await db.query(
+ `UPDATE bookings SET status='completed', completed_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`, [live.id, id]);
+ assert.equal(r.rowCount, 1, 'confirmed booking completes');
+
+ // The now-completed booking cannot be declined back out of its terminal state.
+ r = await db.query(
+ `UPDATE bookings SET status='declined', canceled_at=now()
+ WHERE id=$1 AND installer_id=$2 AND status IN ('pending','confirmed')`, [live.id, id]);
+ assert.equal(r.rowCount, 0, 'decline must not touch a completed booking');
+ row = await db.one('SELECT status FROM bookings WHERE id=$1', [live.id]);
+ assert.equal(row.status, 'completed', 'completed booking stays completed');
+ } finally {
+ await db.query('DELETE FROM bookings WHERE installer_id=$1', [id]);
+ await db.query('DELETE FROM installers WHERE id=$1', [id]);
+ }
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// 6. Claim token: generate, verify, consume, second-use fails
// ─────────────────────────────────────────────────────────────────────────────
← 362b0cc Carry the IDOR-gate token on the customer's booking-confirma
·
back to NationalPaperHangers
·
Reject bookings that fall outside the installer's published aaf1edb →