← back to Norma Platform
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 Platform
·
fix(api): 4 GET-500s surfaced by api-smoke (onboard/library eb853c3 →