[object Object]

← back to Norma

test(api-smoke): durable fs-enumerated GET sweep over all 271 routes (test:api/test:smoke)

8bbee18afbd49219d64731baec8f4f242619dacf · 2026-08-06 10:30:57 -0700 · Steve

Enumerates every app/api/**/route.ts at runtime (auto-covers new routes), hits
each GET as {anon,admin} against :7411, hard-fails on any 500, and emits a
visibility census of GET routes that answer 200 unauthenticated. Uses a valid-
format nil UUID for [id] segments so healthy routes 404 rather than pg-cast 500.

Files touched

Diff

commit 8bbee18afbd49219d64731baec8f4f242619dacf
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 10:30:57 2026 -0700

    test(api-smoke): durable fs-enumerated GET sweep over all 271 routes (test:api/test:smoke)
    
    Enumerates every app/api/**/route.ts at runtime (auto-covers new routes), hits
    each GET as {anon,admin} against :7411, hard-fails on any 500, and emits a
    visibility census of GET routes that answer 200 unauthenticated. Uses a valid-
    format nil UUID for [id] segments so healthy routes 404 rather than pg-cast 500.
---
 tests/api-smoke.mjs | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 151 insertions(+)

diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs
new file mode 100644
index 0000000..fae4173
--- /dev/null
+++ b/tests/api-smoke.mjs
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+/**
+ * Norma API smoke suite — the plan's "Node/curl API smoke suite, all ~290 routes"
+ * deliverable (locked decision, 2026-08-05 campaign). Complements the narrower
+ * tests/security-regression.mjs: this sweep enumerates EVERY route from the
+ * filesystem (app/api/**\/route.ts) so it auto-covers new routes, and proves the
+ * whole GET surface answers without a 500 (crash) under three conditions:
+ * unauthenticated, admin, and pulse. It also emits a VISIBILITY CENSUS — which
+ * GET routes answer 200 while unauthenticated — as an audit surface.
+ *
+ * GREEN when: zero unexpected 500s across the GET sweep. (Auth-gating on MUTATING
+ * routes is asserted by the guard-enforcement work in cycles 1–2 + the regression
+ * suite; this file is crash-coverage + a visibility audit, not a guard re-test.)
+ *
+ * Run against the isolated test instance (NEVER live :7400):
+ *   bash scripts/test-instance.sh    # starts :7411 vs sdcc_test (separate shell)
+ *   npm run test:api                 # full sweep (this file)
+ *   npm run test:smoke               # fast critical subset (--smoke)
+ *
+ * Requires the 3 seeded users (admin/teststaff/testpulse, pw TestPass123!).
+ * Override target with NORMA_TEST_URL.
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath as f2p } from 'node:url';
+
+const BASE = process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411';
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+const SMOKE = process.argv.includes('--smoke');
+const CONCURRENCY = 8;
+
+// Repo root = two levels up from tests/api-smoke.mjs
+const ROOT = join(dirname(f2p(import.meta.url)), '..');
+const API_DIR = join(ROOT, 'app', 'api');
+
+// Placeholders for dynamic route segments. A smoke test proves routes work on
+// WELL-FORMED requests, so [id] is a valid-format (all-zero) UUID that is
+// guaranteed non-existent → a healthy route answers 404, not 500. (Norma's id
+// columns are uuid; a garbage id like 'smoke-1' would fail the pg cast (22P02)
+// and surface as 500 — that malformed-input hardening is tracked separately, not
+// as a smoke failure.) These ids are non-existent, so no GET mutates anything.
+const NIL_UUID = '00000000-0000-0000-0000-000000000000';
+const SEG = { '[id]': NIL_UUID, '[type]': 'org', '[slug]': 'smoke', '[zip]': '90210' };
+
+// ── Enumerate every route.ts, its URL, and its exported verbs ──────────────────
+function walk(dir, rel = '') {
+  const out = [];
+  for (const name of readdirSync(dir)) {
+    const abs = join(dir, name);
+    if (statSync(abs).isDirectory()) out.push(...walk(abs, `${rel}/${name}`));
+    else if (name === 'route.ts') {
+      const src = readFileSync(abs, 'utf8');
+      const verbs = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].filter((v) =>
+        new RegExp(`export\\s+(async\\s+)?function\\s+${v}\\b`).test(src) ||
+        new RegExp(`export\\s+const\\s+${v}\\b`).test(src));
+      // rel like /admin/impersonate — map each dynamic [seg] to a placeholder.
+      // Unknown [seg] defaults to a valid-format UUID (Norma ids are uuid), so an
+      // unmapped id segment yields a clean 404 rather than a pg-cast 500 that would
+      // masquerade as a route crash.
+      const url = '/api' + rel.replace(/\[[^\]]+\]/g, (m) => SEG[m] ?? NIL_UUID);
+      out.push({ url, verbs, dynamic: /\[/.test(rel) });
+    }
+  }
+  return out;
+}
+
+// ── HTTP helpers (mirror the regression suite) ────────────────────────────────
+async function login(username, password = PW) {
+  const res = await fetch(`${BASE}/api/auth/login`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password }), redirect: 'manual',
+  });
+  const cookie = (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
+  return { status: res.status, cookie, role: (await res.json().catch(() => ({}))).role };
+}
+const jar = (c) => (c ? { Cookie: `norma-auth=${c}` } : {});
+async function hit(path, cookie) {
+  try {
+    const res = await fetch(`${BASE}${path}`, { headers: jar(cookie), redirect: 'manual' });
+    return res.status;
+  } catch (e) { return `ERR:${e.code || e.message}`; }
+}
+
+// Run tasks with bounded concurrency so the sweep is quick but not a thundering herd.
+async function pool(items, worker) {
+  const results = new Array(items.length);
+  let i = 0;
+  await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+    while (i < items.length) { const idx = i++; results[idx] = await worker(items[idx], idx); }
+  }));
+  return results;
+}
+
+async function main() {
+  const all = walk(API_DIR);
+  const gettable = all.filter((r) => r.verbs.includes('GET'));
+  const mutateOnly = all.filter((r) => !r.verbs.includes('GET'));
+  console.log(`\n=== Norma API smoke — ${BASE} ===`);
+  console.log(`routes: ${all.length} total · ${gettable.length} GET-able · ${mutateOnly.length} mutate-only (skipped in GET sweep)\n`);
+
+  const admin = await login('admin');
+  const pulse = await login('testpulse');
+  if (admin.status !== 200 || !admin.cookie) {
+    console.error(`\x1b[31mFATAL\x1b[0m admin login failed (status ${admin.status}) — is :7411 up with seeded users? Aborting.`);
+    process.exit(2);
+  }
+
+  // In --smoke mode, cover a curated critical slice for CI speed.
+  const critical = new Set(['/api/v1/health', '/api/auth/session', '/api/drafts', '/api/petitions',
+    '/api/contacts', '/api/grants', '/api/news', '/api/pulse/profile', '/api/registry', '/api/audit']);
+  const targets = SMOKE ? gettable.filter((r) => critical.has(r.url)) : gettable;
+
+  const crashes = [];       // any 500 under any auth = hard fail
+  const publicGets = [];    // 200 while unauthenticated = visibility audit surface
+  const errored = [];       // network errors (instance flapping)
+
+  await pool(targets, async (r) => {
+    const anon = await hit(r.url, null);
+    const asAdmin = await hit(r.url, admin.cookie);
+    for (const [who, code] of [['anon', anon], ['admin', asAdmin]]) {
+      if (code === 500) crashes.push(`${r.url} → 500 (${who})`);
+      if (typeof code === 'string' && code.startsWith('ERR')) errored.push(`${r.url} (${who}) ${code}`);
+    }
+    if (anon === 200) publicGets.push(r.url);
+  });
+
+  // ── Report ──────────────────────────────────────────────────────────────────
+  console.log(`swept ${targets.length} GET route(s) × {anon, admin}\n`);
+  if (publicGets.length) {
+    console.log(`\x1b[33mVISIBILITY CENSUS\x1b[0m — ${publicGets.length} GET route(s) answer 200 unauthenticated (audit, not a fail):`);
+    publicGets.sort().forEach((u) => console.log(`    · ${u}`));
+    console.log('');
+  }
+  if (errored.length) {
+    console.log(`\x1b[33mNETWORK ERRORS\x1b[0m (instance flapping? re-run):`);
+    errored.forEach((e) => console.log(`    · ${e}`));
+    console.log('');
+  }
+
+  const passed = crashes.length === 0;
+  if (passed) {
+    console.log(`\x1b[32mPASS\x1b[0m — 0 unexpected 500s across ${targets.length} GET routes.`);
+  } else {
+    console.log(`\x1b[31mFAIL\x1b[0m — ${crashes.length} route(s) returned 500 (crash):`);
+    crashes.forEach((c) => console.log(`    ✗ ${c}`));
+  }
+  console.log(`\n${passed ? '\x1b[32m✔ api-smoke green' : '\x1b[31m✘ api-smoke red'}\x1b[0m  (${targets.length} swept, ${publicGets.length} public, ${crashes.length} crashes)\n`);
+  process.exit(passed ? 0 : 1);
+}
+
+main().catch((e) => { console.error('api-smoke harness error:', e); process.exit(2); });

← f4c1615 auto-data-snapshot: 2026-08-06T10:21:57 (1 data files) — pac  ·  back to Norma  ·  fix(api): 4 GET-500s surfaced by api-smoke (onboard/library eb853c3 →