[object Object]

← back to Norma

test: add green security-regression suite (18 assertions) + wire npm test / npm run test:instance — asserts every 2026-08-05 campaign fix holds

89bc532d31856e0cbf6d385e3e669841399ea151 · 2026-08-05 14:08:16 -0700 · Steve

Files touched

Diff

commit 89bc532d31856e0cbf6d385e3e669841399ea151
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 14:08:16 2026 -0700

    test: add green security-regression suite (18 assertions) + wire npm test / npm run test:instance — asserts every 2026-08-05 campaign fix holds
---
 package.json                  |  4 +-
 tests/security-regression.mjs | 98 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 101 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index a169a3c..6cc274f 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,9 @@
     "dev": "next dev -p 7400",
     "build": "next build",
     "start": "next start -p 7400",
-    "lint": "eslint"
+    "lint": "eslint",
+    "test": "node tests/security-regression.mjs",
+    "test:instance": "bash scripts/test-instance.sh"
   },
   "dependencies": {
     "@codemirror/lang-html": "^6.4.11",
diff --git a/tests/security-regression.mjs b/tests/security-regression.mjs
new file mode 100644
index 0000000..abf37b5
--- /dev/null
+++ b/tests/security-regression.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+/**
+ * Norma security regression suite — asserts every fix from the 2026-08-05
+ * campaign HOLDS. Unlike the cluster-*.mjs campaign artifacts (which encode the
+ * original adversarial findings), this suite is GREEN when the app is healthy.
+ *
+ * Run against the isolated test instance:
+ *   bash scripts/test-instance.sh   # starts :7411 vs sdcc_test (separate shell)
+ *   npm test                         # runs this file
+ *
+ * Requires the 3 seeded test users (admin/teststaff/testpulse, pw TestPass123!).
+ * Override the target with NORMA_TEST_URL. NEVER point this at live :7400.
+ */
+const BASE = process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411';
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+
+let pass = 0, fail = 0;
+const fails = [];
+function ok(cond, name, detail = '') {
+  if (cond) { pass++; console.log(`  \x1b[32mPASS\x1b[0m ${name}`); }
+  else { fail++; fails.push(name); console.log(`  \x1b[31mFAIL\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
+}
+
+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, body: await res.json().catch(() => ({})) };
+}
+const jar = (c) => (c ? { Cookie: `norma-auth=${c}` } : {});
+async function get(path, cookie) {
+  return fetch(`${BASE}${path}`, { headers: jar(cookie), redirect: 'manual' });
+}
+
+async function main() {
+  console.log(`\n=== Norma security regression — ${BASE} ===\n`);
+
+  // --- Auth basics ---
+  const admin = await login('admin');
+  const staff = await login('teststaff');
+  const pulse = await login('testpulse');
+  ok(admin.status === 200 && admin.body.role === 'admin' && admin.cookie, 'admin login → 200 + role admin + cookie');
+  ok(staff.status === 200 && staff.body.role === 'staff', 'staff login → 200 + role staff');
+  ok(pulse.status === 200 && pulse.body.role === 'pulse', 'pulse login → 200 + role pulse');
+  ok((await login('admin', 'wrong-pw-xyz')).status === 401, 'wrong password → 401');
+  const nonJson = await fetch(`${BASE}/api/auth/login`, { method: 'POST', body: 'username=x&password=y' });
+  ok(nonJson.status === 400, 'non-JSON body → 400 (not 500)');
+
+  // --- Std-login-only (OAuth removed) ---
+  const loginHtml = await (await fetch(`${BASE}/login`)).text();
+  ok(!/Continue with Google|Continue with Email/.test(loginHtml), 'login page shows no OAuth buttons');
+
+  // --- FIX: social routes require admin/staff (were pulse-open) ---
+  for (const r of ['/api/social/posts/pending', '/api/social/analytics/dashboard', '/api/social/bulk-schedule']) {
+    ok((await get(r, pulse.cookie)).status === 403, `social ${r} → 403 for pulse`);
+  }
+  ok((await get('/api/social/posts/pending', admin.cookie)).status === 200, 'social pending → 200 for admin');
+
+  // --- FIX: impersonate DELETE rejects a bogus/forged imp-by token ---
+  const impRes = await fetch(`${BASE}/api/admin/impersonate`, {
+    method: 'DELETE', headers: { Cookie: `norma-auth=${staff.cookie}; norma-imp-by=bogus.deadbeef` }, redirect: 'manual',
+  });
+  ok(impRes.status === 400, 'impersonate DELETE with bogus imp-by → 400 (no escalation)');
+
+  // --- FIX: registry HMAC-verifies (admin 200, pulse 403; not 403-for-all) ---
+  ok((await get('/api/registry', admin.cookie)).status === 200, 'registry → 200 for admin');
+  ok((await get('/api/registry', pulse.cookie)).status === 403, 'registry → 403 for pulse');
+
+  // --- FIX: /api/users admin-only (was staff-readable) ---
+  ok((await get('/api/users', staff.cookie)).status === 403, '/api/users → 403 for staff');
+  ok((await get('/api/users', admin.cookie)).status === 200, '/api/users → 200 for admin');
+
+  // --- FIX: petition-sign is rate-limited (public flood → 429) ---
+  const uuid = '11111111-1111-1111-1111-111111111111';
+  let got429 = false;
+  for (let i = 0; i < 25; i++) {
+    const r = await fetch(`${BASE}/api/pulse/petitions/${uuid}/sign`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x', email: 'x@x.co' }),
+    });
+    if (r.status === 429) { got429 = true; break; }
+  }
+  ok(got429, 'petition-sign flood → 429 (rate limited)');
+
+  // --- FIX: rate-limiter scoped per (ip,username) — one account's fails don't lock others ---
+  // (run LAST — it trips a lock on a throwaway username)
+  for (let i = 0; i < 12; i++) await login('lockbait-user', 'wrong');
+  const lockbait = await login('lockbait-user', 'wrong');
+  const collateral = await login('teststaff'); // different username, same client
+  ok(lockbait.status === 429, 'brute-forced username → 429 (locked)');
+  ok(collateral.status === 200, 'DIFFERENT username still logs in (no tenant-wide lockout)');
+
+  console.log(`\n=== ${pass} passed, ${fail} failed ===`);
+  if (fail) { console.log('FAILURES: ' + fails.join('; ')); process.exit(1); }
+  process.exit(0);
+}
+main().catch((e) => { console.error('runner error:', e.message); process.exit(2); });

← e390f1a security(cycle-2): pin retired gemini-2.0-flash -> 2.5-flash  ·  back to Norma  ·  security(yoloforever c1): rate-limit public petition CREATE b98a5b6 →