← back to Costa Rica
costa-rica: add routes/admin.js coverage (was zero) + characterize the unenforced claim-approval gap — TK-10346
e57398fb83207812910f25638e307f6c825093c4 · 2026-09-23 19:55:06 -0700 · Steve
Cycle 9 audit of routes/admin.js (previously zero test coverage).
admin-routes.test.js (+7): baseline coverage for all 4 admin endpoints —
GET /stats, GET /claims (default pending + status=all), POST /claims/:placeId/:hostId
(bad-decision 400 before any write, approve 200 + parameterized args, 404 on no row),
GET /bookings.
host-listing-approval-gap.test.js (+2): CHARACTERIZATION of a real authorization
finding surfaced by the audit — the admin approve/reject workflow writes
place_hosts.claim_status, but the point that actually grants a host the money
(routes/app.js POST /host/listings -> place_booking.host_id -> every booking's
payout recipient) gates on claim ROW EXISTENCE, not claim_status='approved'. So a
'pending' (or 'rejected') claim can still list. Exclusivity IS enforced (first-come
via the place_booking 409 guard); approval is not. The test asserts the structural
fact (the guard SQL has no claim_status filter) so any deliberate future enforcement
flips it visibly. Whether approval SHOULD gate listing is a customer-facing
onboarding decision -> drafted to pending-approval, not changed here.
No source change — coverage + characterization only. Suite 139 -> 148.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
A test/admin-routes.test.jsA test/host-listing-approval-gap.test.js
Diff
commit e57398fb83207812910f25638e307f6c825093c4
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 19:55:06 2026 -0700
costa-rica: add routes/admin.js coverage (was zero) + characterize the unenforced claim-approval gap — TK-10346
Cycle 9 audit of routes/admin.js (previously zero test coverage).
admin-routes.test.js (+7): baseline coverage for all 4 admin endpoints —
GET /stats, GET /claims (default pending + status=all), POST /claims/:placeId/:hostId
(bad-decision 400 before any write, approve 200 + parameterized args, 404 on no row),
GET /bookings.
host-listing-approval-gap.test.js (+2): CHARACTERIZATION of a real authorization
finding surfaced by the audit — the admin approve/reject workflow writes
place_hosts.claim_status, but the point that actually grants a host the money
(routes/app.js POST /host/listings -> place_booking.host_id -> every booking's
payout recipient) gates on claim ROW EXISTENCE, not claim_status='approved'. So a
'pending' (or 'rejected') claim can still list. Exclusivity IS enforced (first-come
via the place_booking 409 guard); approval is not. The test asserts the structural
fact (the guard SQL has no claim_status filter) so any deliberate future enforcement
flips it visibly. Whether approval SHOULD gate listing is a customer-facing
onboarding decision -> drafted to pending-approval, not changed here.
No source change — coverage + characterization only. Suite 139 -> 148.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
test/admin-routes.test.js | 95 ++++++++++++++++++++++++++++++++++
test/host-listing-approval-gap.test.js | 86 ++++++++++++++++++++++++++++++
2 files changed, 181 insertions(+)
diff --git a/test/admin-routes.test.js b/test/admin-routes.test.js
new file mode 100644
index 0000000..8521d32
--- /dev/null
+++ b/test/admin-routes.test.js
@@ -0,0 +1,95 @@
+'use strict';
+// Baseline coverage for routes/admin.js (previously ZERO tests). The admin API sits
+// BEHIND the site basic-auth gate (mounted after it in server.js), so the router
+// itself has no auth middleware — we mount it bare, as it runs in prod behind the gate.
+// No real DB: pool.query is a queue mock.
+//
+// Endpoints: GET /stats, GET /claims, POST /claims/:placeId/:hostId, GET /bookings.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const db = require('../lib/db');
+const adminRouter = require('../routes/admin');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+
+let server, base;
+before(async () => {
+ db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
+ const app = express();
+ app.use(express.json());
+ app.use('/api/admin', adminRouter);
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; server && server.close(); });
+
+function reset(resp) { responses = resp.slice(); calls = []; }
+const lastArgs = () => calls.length ? calls[calls.length - 1].args : null;
+
+function req(method, path, body) {
+ const data = body != null ? JSON.stringify(body) : null;
+ return new Promise((resolve, reject) => {
+ const r = http.request(base + path, { method, headers: {
+ ...(data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}) } },
+ res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+ r.on('error', reject); if (data) r.write(data); r.end();
+ });
+}
+
+test('GET /stats returns the marketplace KPI object', async () => {
+ reset([{ rows: [{ bookings: 12, confirmed: 5, gmv_minor: 400000, revenue_minor: 40000, pending_claims: 2, bookable: 8, hosts: 3 }] }]);
+ const r = await req('GET', '/api/admin/stats');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.ok, true);
+ assert.equal(r.json.stats.bookings, 12);
+ assert.equal(r.json.stats.pending_claims, 2);
+});
+
+test('GET /claims defaults to pending and returns the claim rows', async () => {
+ reset([{ rows: [{ place_id: 5, host_id: 9, claim_status: 'pending', place_name: 'Casa', legal_name: 'H', email: 'h@x.c' }] }]);
+ const r = await req('GET', '/api/admin/claims');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.claims.length, 1);
+ assert.equal(lastArgs()[0], 'pending', 'default status filter is pending');
+});
+
+test('GET /claims?status=all passes the "all" sentinel (no status filter)', async () => {
+ reset([{ rows: [] }]);
+ const r = await req('GET', '/api/admin/claims?status=all');
+ assert.equal(r.status, 200);
+ assert.equal(lastArgs()[0], 'all', 'status=all is forwarded so the WHERE short-circuits');
+});
+
+test('POST /claims/:placeId/:hostId rejects a bad decision with 400 BEFORE any DB write', async () => {
+ reset([]); // no query should run
+ const r = await req('POST', '/api/admin/claims/5/9', { decision: 'maybe' });
+ assert.equal(r.status, 400);
+ assert.equal(calls.length, 0, 'an invalid decision never reaches the UPDATE');
+});
+
+test('POST /claims/:placeId/:hostId approves a claim -> 200 + updated row', async () => {
+ reset([{ rows: [{ place_id: 5, host_id: 9, claim_status: 'approved' }] }]);
+ const r = await req('POST', '/api/admin/claims/5/9', { decision: 'approved' });
+ assert.equal(r.status, 200);
+ assert.equal(r.json.claim.claim_status, 'approved');
+ assert.deepEqual(lastArgs(), ['approved', '5', '9'], 'UPDATE parameterized with decision + route params (no injection)');
+});
+
+test('POST /claims/:placeId/:hostId on a nonexistent claim -> 404', async () => {
+ reset([{ rows: [] }]); // UPDATE matched nothing
+ const r = await req('POST', '/api/admin/claims/999/999', { decision: 'rejected' });
+ assert.equal(r.status, 404);
+});
+
+test('GET /bookings returns the oversight rows', async () => {
+ reset([{ rows: [{ code: 'CR-1', status: 'confirmed', total: 40000, place_name: 'Casa', traveler_email: 't@x.c' }] }]);
+ const r = await req('GET', '/api/admin/bookings');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.bookings[0].code, 'CR-1');
+});
diff --git a/test/host-listing-approval-gap.test.js b/test/host-listing-approval-gap.test.js
new file mode 100644
index 0000000..996a3c0
--- /dev/null
+++ b/test/host-listing-approval-gap.test.js
@@ -0,0 +1,86 @@
+'use strict';
+// CHARACTERIZATION test (documents CURRENT behavior, does NOT bless it) — TK-10346.
+//
+// FINDING (cycle 9 audit): the admin claim-approval workflow (routes/admin.js:
+// POST /claims/:placeId/:hostId writes place_hosts.claim_status pending->approved/
+// rejected) is NOT enforced at the point that actually grants a host the money:
+// routes/app.js POST /host/listings gates on claim ROW EXISTENCE, not on
+// claim_status='approved'. Its guard query is:
+// SELECT (SELECT 1 FROM place_hosts WHERE place_id=$1 AND host_id=$2) AS claimed, ...
+// -> `claimed` is truthy for a 'pending' or even 'rejected' claim, so a host can
+// list a place (become place_booking.host_id, the payout recipient for every
+// booking on it) WITHOUT admin approval. Exclusivity IS enforced (first-come, via
+// the place_booking 409 guard), but approval is not.
+//
+// Whether approval SHOULD gate listing is a customer-facing onboarding decision
+// for Steve (enforcing it in an unattended system with no active approver would
+// block ALL host self-listing) — drafted to pending-approval. These tests pin the
+// current behavior so any deliberate change is visible; if Steve enforces
+// approval, they flip (that flip is the signal, not a regression).
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const { signToken } = require('../lib/auth');
+const db = require('../lib/db');
+const { router } = require('../routes/app');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+
+let server, base;
+before(async () => {
+ db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
+ const app = express();
+ app.use(express.json());
+ app.use('/api/app', router);
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; server && server.close(); });
+
+function reset(resp) { responses = resp.slice(); calls = []; }
+
+function post(path, body, token) {
+ const data = JSON.stringify(body);
+ return new Promise((resolve, reject) => {
+ const r = http.request(base + path, { method: 'POST', headers: {
+ 'content-type': 'application/json', 'content-length': Buffer.byteLength(data),
+ ...(token ? { authorization: 'Bearer ' + token } : {}) } },
+ res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+ r.on('error', reject); r.end(data);
+ });
+}
+
+test('GAP: /host/listings guard query does NOT filter place_hosts by claim_status (approval is not consulted)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ reset([
+ { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }, // requireHost
+ { rows: [{ id: 5 }] }, // SELECT place by slug
+ { rows: [{ claimed: 1, current_host: null }] }, // guard: claimed=1 (a claim row exists, ANY status)
+ { rows: [{ place_id: 5, host_id: 9, is_active: true }] }, // INSERT place_booking RETURNING *
+ ]);
+ const r = await post('/api/app/host/listings', { place_slug: 'casa', base_price: 12000 }, token);
+ assert.equal(r.status, 200, 'a host with ANY claim row can list — approval is not required today');
+ // The sharp, structural evidence: the guard's `claimed` sub-select checks only
+ // row existence. If a future change adds an approval gate, it must add a
+ // claim_status filter here — at which point this assertion flips deliberately.
+ const guard = calls.find(c => /AS claimed/.test(c.sql));
+ assert.ok(guard, 'the ownership guard query ran');
+ assert.equal(/claim_status|approved/.test(guard.sql), false,
+ 'CURRENT STATE: the guard does NOT consult claim_status — admin approval is unenforced on the money path (see pending-approval memo)');
+});
+
+test('GAP: a host with NO claim row is still correctly blocked (403) — the existence check itself works', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ reset([
+ { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }, // requireHost
+ { rows: [{ id: 5 }] }, // SELECT place
+ { rows: [{ claimed: null, current_host: null }] }, // guard: no claim row at all
+ ]);
+ const r = await post('/api/app/host/listings', { place_slug: 'casa', base_price: 12000 }, token);
+ assert.equal(r.status, 403, 'no claim row -> 403 (the gap is specifically pending/rejected passing, not the absence check)');
+});
← 2a75e80 cycle 8 docs: YOLO_NOTES ledger — fetchT clone() hardening,
·
back to Costa Rica
·
costa-rica: fix TOCTOU silent-overwrite race in /host/listin 002feea →