← back to Costa Rica

test/admin-gate-fail-closed.test.js

93 lines

'use strict';
// Fail-CLOSED guard for the admin-only curation surfaces when the site-wide
// basic-auth gate is unconfigured (TK-10346, Cody audit, Steve-approved option B —
// see ~/.claude/yolo-queue/pending-approval/2026-09-24-TK-10346-costa-admin-auth-
// coupling-DECISION.md). Before this fix, unsetting BASIC_AUTH_USER/PASS (as
// docs/GO-LIVE.md §8 plans, to open the public directory) also silently opened
// /api/admin (host-claim approvals + traveler PII), /api/build (ops dashboard),
// and /api/logo-agent. This is the RED case: gate unconfigured -> those surfaces
// must refuse (503), not serve.
//
// Pin BASIC_AUTH_USER/PASS to EMPTY strings BEFORE requiring server.js — dotenv's
// config() does not override a key that already exists in process.env (even ''),
// so this wins over the real .env (which has real creds for local dev) and
// deterministically reproduces the "site is OPEN" boot path server.js warns about.
process.env.BASIC_AUTH_USER = '';
process.env.BASIC_AUTH_PASS = '';

const { test, before, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');

const app = require('../server'); // exported app, does NOT listen on import

let server, base;
before(async () => {
  await new Promise(r => { server = app.listen(0, r); });
  base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
  server && server.close();
  try { await app.locals.pool.end(); } catch { /* already closed */ }
});

function get(path) {
  return new Promise((resolve, reject) => {
    http.get(base + path, res => {
      let b = '';
      res.on('data', c => b += c);
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
    }).on('error', reject);
  });
}

test('GET /api/admin/stats -> 503 admin_gate_unconfigured (JSON) when the site gate is unset', async () => {
  const r = await get('/api/admin/stats');
  assert.equal(r.status, 503);
  assert.match(r.headers['content-type'] || '', /application\/json/);
  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
});

test('GET /api/admin/bookings -> 503 admin_gate_unconfigured (no DB read of traveler PII)', async () => {
  const r = await get('/api/admin/bookings');
  assert.equal(r.status, 503);
  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
});

test('GET /admin (HTML page) -> 503 plain text when the site gate is unset', async () => {
  const r = await get('/admin');
  assert.equal(r.status, 503);
  assert.match(r.headers['content-type'] || '', /text\/plain/);
});

test('GET /api/build/status -> 503 admin_gate_unconfigured (ops dashboard API)', async () => {
  const r = await get('/api/build/status');
  assert.equal(r.status, 503);
  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
});

test('GET /build (HTML page) -> 503 plain text when the site gate is unset', async () => {
  const r = await get('/build');
  assert.equal(r.status, 503);
  assert.match(r.headers['content-type'] || '', /text\/plain/);
});

test('GET /api/logo-agent/* -> 503 admin_gate_unconfigured', async () => {
  const r = await get('/api/logo-agent/state');
  assert.equal(r.status, 503);
  assert.deepEqual(JSON.parse(r.body), { error: 'admin_gate_unconfigured' });
});

test('GET /logo-agent (HTML page) -> 503 plain text when the site gate is unset', async () => {
  const r = await get('/logo-agent');
  assert.equal(r.status, 503);
  assert.match(r.headers['content-type'] || '', /text\/plain/);
});

test('the PUBLIC directory is unaffected by the admin guard (still open, not 503)', async () => {
  const health = await get('/health');
  assert.equal(health.status, 200, '/health is not an admin surface -> unchanged');
  const map = await get('/api/map');
  assert.notEqual(map.status, 503, '/api/map (public directory) must not inherit the admin fail-closed guard');
});