← back to Costa Rica

test/host-listing-approval-gap.test.js

102 lines

'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('RACE FIX: when the atomic upsert returns no row (a racing non-owner), the route returns 409 (not a false 200)', 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: 1, current_host: null }] },                      // guard passes (race window: reads NULL)
    { rows: [] },                                                        // upsert WHERE rejected -> empty RETURNING
  ]);
  const r = await post('/api/app/host/listings', { place_slug: 'casa', base_price: 12000 }, token);
  assert.equal(r.status, 409, 'an empty RETURNING from the atomic upsert becomes a 409, not a silent success');
  const upsert = calls.find(c => /INSERT INTO place_booking/.test(c.sql));
  assert.ok(upsert && /WHERE place_booking\.host_id = EXCLUDED\.host_id OR place_booking\.host_id IS NULL/.test(upsert.sql),
    'the upsert carries the atomic ownership WHERE clause');
});

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