[object Object]

← back to Norma Platform

test+fix(cycle5): write-path smoke (216 mutations) + Playwright E2E (3 tiers) + fixes surfaced

959da8b23928d27591ce69b4855fc5acb1a092f7 · 2026-08-06 12:05:01 -0700 · Steve

WRITE-PATH (tests/api-write-smoke.mjs, npm run test:write): enumerates all 216
mutating (route,verb) pairs; asserts every one gates unauth (4xx, never 2xx/500) and
does not 500 on an empty body. Empty bodies keep it side-effect-free; external/AI/send
families are probed unauth-only (never fired). Resolves a real org id so tenant-path
inserts satisfy FKs (the null-org blind spot Cody flagged, applied to writes).

Fixes it surfaced (all reversible, verified on :7411):
- lib/body.ts readJson(): safe body parse ({} on empty, typed error on malformed).
  Applied to 5 routes that crashed on empty body via unguarded request.json()
  (outreach-pipeline, settings/news-watch-people, social/competitors, social/posts,
  pulse/tracked).
- webhooks/pulse-topic: not-configured 500 -> 503. contacts/import: wrong content-type
  500 -> 415. donations/insights: removed org_id filter (table has no org_id column) +
  readJson. community: guarded the unauth proxy to :7400 (non-JSON/upstream-fail -> 502,
  not a 500 crash). sessions: FK violation (23503) -> 409, not 500.

E2E (playwright.config.ts + tests/e2e/, npm run test:e2e): chromium installed + working
in this env. 8 specs — admin/staff/pulse login + land + no console errors; pulse-tier
lands on /pulse; wrong creds surface an error; public /pulse,/pulse/petitions,/pulse/about
render with no login wall. Caught a real public-surface bug: /pulse fetched the auth-gated
/api/petitions/topics which 307s to HTML /login -> JSON parse crash; fixed with
redirect:'manual' so the existing .ok guard degrades gracefully.

Suites: api-smoke 195 / write-smoke 216 / regression 20 / e2e 8 — all green.

Files touched

Diff

commit 959da8b23928d27591ce69b4855fc5acb1a092f7
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:05:01 2026 -0700

    test+fix(cycle5): write-path smoke (216 mutations) + Playwright E2E (3 tiers) + fixes surfaced
    
    WRITE-PATH (tests/api-write-smoke.mjs, npm run test:write): enumerates all 216
    mutating (route,verb) pairs; asserts every one gates unauth (4xx, never 2xx/500) and
    does not 500 on an empty body. Empty bodies keep it side-effect-free; external/AI/send
    families are probed unauth-only (never fired). Resolves a real org id so tenant-path
    inserts satisfy FKs (the null-org blind spot Cody flagged, applied to writes).
    
    Fixes it surfaced (all reversible, verified on :7411):
    - lib/body.ts readJson(): safe body parse ({} on empty, typed error on malformed).
      Applied to 5 routes that crashed on empty body via unguarded request.json()
      (outreach-pipeline, settings/news-watch-people, social/competitors, social/posts,
      pulse/tracked).
    - webhooks/pulse-topic: not-configured 500 -> 503. contacts/import: wrong content-type
      500 -> 415. donations/insights: removed org_id filter (table has no org_id column) +
      readJson. community: guarded the unauth proxy to :7400 (non-JSON/upstream-fail -> 502,
      not a 500 crash). sessions: FK violation (23503) -> 409, not 500.
    
    E2E (playwright.config.ts + tests/e2e/, npm run test:e2e): chromium installed + working
    in this env. 8 specs — admin/staff/pulse login + land + no console errors; pulse-tier
    lands on /pulse; wrong creds surface an error; public /pulse,/pulse/petitions,/pulse/about
    render with no login wall. Caught a real public-surface bug: /pulse fetched the auth-gated
    /api/petitions/topics which 307s to HTML /login -> JSON parse crash; fixed with
    redirect:'manual' so the existing .ok guard degrades gracefully.
    
    Suites: api-smoke 195 / write-smoke 216 / regression 20 / e2e 8 — all green.
---
 .gitignore                                  |   3 +
 app/api/community/route.ts                  |   9 +-
 app/api/contacts/import/route.ts            |  10 ++
 app/api/donations/insights/[id]/route.ts    |  22 +---
 app/api/outreach-pipeline/route.ts          |   7 +-
 app/api/pulse/tracked/route.ts              |   5 +-
 app/api/sessions/route.ts                   |   7 ++
 app/api/settings/news-watch-people/route.ts |   7 +-
 app/api/social/competitors/route.ts         |   7 +-
 app/api/social/posts/route.ts               |   7 +-
 app/api/webhooks/pulse-topic/route.ts       |   2 +-
 app/pulse/page.tsx                          |   6 +-
 lib/body.ts                                 |  25 +++++
 package.json                                |   3 +-
 playwright.config.ts                        |  23 ++++
 tests/api-write-smoke.mjs                   | 165 ++++++++++++++++++++++++++++
 tests/e2e/auth.spec.ts                      |  61 ++++++++++
 tests/e2e/pulse.spec.ts                     |  25 +++++
 tsconfig.json                               |   4 +-
 19 files changed, 363 insertions(+), 35 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7dabd2d..329ff09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,6 @@ docs/sessions/NORMA-WAKEUP-*.md
 
 # agent runtime cache (dashboard data source)
 agents/*/data/
+test-results/
+playwright-report/
+/tests/e2e/.auth/
diff --git a/app/api/community/route.ts b/app/api/community/route.ts
index 115f6cd..7f0cc53 100644
--- a/app/api/community/route.ts
+++ b/app/api/community/route.ts
@@ -152,7 +152,14 @@ export async function POST(request: NextRequest) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ sources: ['reddit', 'reddit_hot'] }),
     });
-    const data = await res.json();
+    // The upstream ingest may be down or answer non-JSON (auth page, 5xx). A failed
+    // dependency is a 502 (bad gateway), not a 500 crash — and never let res.json()
+    // throw unguarded on a non-JSON body.
+    const raw = await res.text();
+    let data;
+    try { data = raw ? JSON.parse(raw) : {}; }
+    catch { return NextResponse.json({ error: 'Upstream ingest returned a non-JSON response' }, { status: 502 }); }
+    if (!res.ok) return NextResponse.json({ error: 'Upstream ingest failed', status: res.status, data }, { status: 502 });
     return NextResponse.json(data);
   } catch (err) {
     console.error('[community] POST error:', (err as Error).message);
diff --git a/app/api/contacts/import/route.ts b/app/api/contacts/import/route.ts
index fd4958f..5e2d23f 100644
--- a/app/api/contacts/import/route.ts
+++ b/app/api/contacts/import/route.ts
@@ -17,6 +17,16 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
 
+  // This endpoint takes a multipart/form-data file upload; a wrong content-type is
+  // a client error (415), not a server crash — guard before formData() throws.
+  const ctype = request.headers.get('content-type') || '';
+  if (!/multipart\/form-data|application\/x-www-form-urlencoded/.test(ctype)) {
+    return NextResponse.json(
+      { error: 'Send the CSV as multipart/form-data with a "file" field.' },
+      { status: 415 },
+    );
+  }
+
   try {
     const formData = await request.formData();
     const file = formData.get('file') as File | null;
diff --git a/app/api/donations/insights/[id]/route.ts b/app/api/donations/insights/[id]/route.ts
index 1b2789e..ffb9bc9 100644
--- a/app/api/donations/insights/[id]/route.ts
+++ b/app/api/donations/insights/[id]/route.ts
@@ -1,8 +1,8 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
-import { getOrgId } from '@/lib/orgId';
 import { auditLog } from '@/lib/audit';
+import { readJson } from '@/lib/body';
 
 type RouteContext = { params: Promise<{ id: string }> };
 
@@ -17,7 +17,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'insight_type', 'title', 'description', 'data_points',
       'confidence', 'is_actionable', 'is_dismissed',
@@ -45,14 +45,9 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
       return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
     }
 
-    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
+    // donation_insights is not org-scoped (no org_id column) — filter by id only.
     values.push(id);
-    let sql = `UPDATE donation_insights SET ${setClauses.join(', ')} WHERE id = $${paramIndex}`;
-    if (orgId) {
-      values.push(orgId);
-      sql += ` AND org_id = $${paramIndex + 1}`;
-    }
-    sql += ' RETURNING *';
+    const sql = `UPDATE donation_insights SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`;
     const result = await query(sql, values);
 
     if (result.rowCount === 0) {
@@ -89,14 +84,9 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
+    // donation_insights is not org-scoped (no org_id column) — delete by id only.
     const delParams: unknown[] = [id];
-    let delSql = `DELETE FROM donation_insights WHERE id = $1`;
-    if (orgId) {
-      delParams.push(orgId);
-      delSql += ` AND org_id = $2`;
-    }
-    delSql += ' RETURNING id, insight_type, title';
+    const delSql = `DELETE FROM donation_insights WHERE id = $1 RETURNING id, insight_type, title`;
     const result = await query(delSql, delParams);
 
     if (result.rowCount === 0) {
diff --git a/app/api/outreach-pipeline/route.ts b/app/api/outreach-pipeline/route.ts
index 9a7e107..a4eabb8 100644
--- a/app/api/outreach-pipeline/route.ts
+++ b/app/api/outreach-pipeline/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -95,7 +96,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       target_type, office_name, official_name, staff_contacts,
       district, state, city, level, party, committees, issue_areas,
@@ -156,7 +157,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, ...fields } = body;
 
     if (!id) {
@@ -219,7 +220,7 @@ export async function DELETE(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id } = body;
     if (!id) {
       return NextResponse.json({ error: 'id is required' }, { status: 400 });
diff --git a/app/api/pulse/tracked/route.ts b/app/api/pulse/tracked/route.ts
index ba93bb2..83cd48a 100644
--- a/app/api/pulse/tracked/route.ts
+++ b/app/api/pulse/tracked/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import type { AuthSession } from '@/lib/auth';
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
   const session = result as AuthSession;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const petitionId = body.petition_id;
 
     if (!petitionId || typeof petitionId !== 'string') {
@@ -146,7 +147,7 @@ export async function DELETE(request: NextRequest) {
   const session = result as AuthSession;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const petitionId = body.petition_id;
 
     if (!petitionId || typeof petitionId !== 'string') {
diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts
index e8754b7..8e94137 100644
--- a/app/api/sessions/route.ts
+++ b/app/api/sessions/route.ts
@@ -145,6 +145,13 @@ export async function POST(request: NextRequest) {
   } catch (err) {
     await client.query('ROLLBACK');
     console.error('[api/sessions] POST error:', (err as Error).message);
+    // A bad/mismatched org scope violates the org_id FK — that's a client error
+    // (invalid org reference), not a server crash. NOTE: sessions.org_id references
+    // nonprofit_accounts(id) while the rest of the app scopes by organizations(id) —
+    // a data-model inconsistency flagged for follow-up (see hardening ledger).
+    if ((err as { code?: string }).code === '23503') {
+      return NextResponse.json({ error: 'Invalid or mismatched org reference for this session' }, { status: 409 });
+    }
     return NextResponse.json({ error: 'Failed to create session' }, { status: 500 });
   } finally {
     client.release();
diff --git a/app/api/settings/news-watch-people/route.ts b/app/api/settings/news-watch-people/route.ts
index 696d5a8..faabe29 100644
--- a/app/api/settings/news-watch-people/route.ts
+++ b/app/api/settings/news-watch-people/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { query } from '@/lib/db';
 import { getOrgId } from '@/lib/orgId';
@@ -42,7 +43,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
-  const body = await request.json();
+  const body = await readJson(request);
 
   const personName = (body.person_name || '').trim();
   if (!personName) {
@@ -77,7 +78,7 @@ export async function PATCH(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { id, is_active } = body;
 
   if (typeof id !== 'number' || typeof is_active !== 'boolean') {
@@ -108,7 +109,7 @@ export async function DELETE(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { id } = body;
 
   if (typeof id !== 'number') {
diff --git a/app/api/social/competitors/route.ts b/app/api/social/competitors/route.ts
index 3edd282..5b27dcf 100644
--- a/app/api/social/competitors/route.ts
+++ b/app/api/social/competitors/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -54,7 +55,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const {
     platform,
     handle,
@@ -104,7 +105,7 @@ export async function PATCH(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { id, ...fields } = body;
 
   if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 });
@@ -151,7 +152,7 @@ export async function DELETE(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const { id } = await request.json();
+  const { id } = await readJson(request);
   if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 });
 
   const result = await query(
diff --git a/app/api/social/posts/route.ts b/app/api/social/posts/route.ts
index 55fa2dc..3dca855 100644
--- a/app/api/social/posts/route.ts
+++ b/app/api/social/posts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -51,7 +52,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const {
     platforms = [],
     post_type = 'post',
@@ -103,7 +104,7 @@ export async function PATCH(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const { id, body: postBody, status, hashtags, link_url } = await request.json();
+  const { id, body: postBody, status, hashtags, link_url } = await readJson(request);
   if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
 
   const sets: string[] = ['updated_at = NOW()'];
@@ -132,7 +133,7 @@ export async function DELETE(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const { id } = await request.json();
+  const { id } = await readJson(request);
   if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
 
   await query(`DELETE FROM social_posts WHERE id = $1 AND status = 'draft'`, [id]);
diff --git a/app/api/webhooks/pulse-topic/route.ts b/app/api/webhooks/pulse-topic/route.ts
index 4fff251..6977c28 100644
--- a/app/api/webhooks/pulse-topic/route.ts
+++ b/app/api/webhooks/pulse-topic/route.ts
@@ -27,7 +27,7 @@ function getGeminiUrl(): string {
  */
 export async function POST(request: NextRequest) {
   if (!WEBHOOK_SECRET) {
-    return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 });
+    return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 });
   }
 
   const secret = request.headers.get('x-pulse-webhook-secret');
diff --git a/app/pulse/page.tsx b/app/pulse/page.tsx
index e424721..0ebc643 100644
--- a/app/pulse/page.tsx
+++ b/app/pulse/page.tsx
@@ -415,7 +415,11 @@ export default function PulseLandingPage() {
       try {
         const [petRes, topicRes] = await Promise.all([
           fetch('/api/pulse/petitions?limit=12&sort=featured'),
-          fetch('/api/petitions/topics?limit=12&source=pulse&org=false'),
+          // NOTE: /api/petitions/topics is auth-gated — for the public (unauth) Pulse
+          // landing it 307s to /login (HTML). `redirect: 'manual'` keeps res.ok false
+          // so the guard below degrades gracefully instead of choking on HTML in
+          // .json(). (Follow-up: give the public landing a public topics source.)
+          fetch('/api/petitions/topics?limit=12&source=pulse&org=false', { redirect: 'manual' }),
         ]);
 
         if (petRes.ok) {
diff --git a/lib/body.ts b/lib/body.ts
new file mode 100644
index 0000000..18ab14d
--- /dev/null
+++ b/lib/body.ts
@@ -0,0 +1,25 @@
+/**
+ * Safe request-body parsing.
+ *
+ * `request.json()` THROWS on an empty or malformed body ("Unexpected end of JSON
+ * input"), which — when unguarded — surfaces to the client as an unhandled 500.
+ * A missing/garbage body is a client error, so callers should get a 400, not a
+ * crash. `readJson` returns `{}` for an empty body and throws a typed
+ * `BodyParseError` for malformed JSON so route handlers can map it to a 400.
+ */
+export class BodyParseError extends Error {
+  constructor(message = 'Invalid or missing JSON body') {
+    super(message);
+    this.name = 'BodyParseError';
+  }
+}
+
+export async function readJson<T = Record<string, unknown>>(request: Request): Promise<T> {
+  const raw = await request.text();
+  if (!raw || raw.trim() === '') return {} as T;
+  try {
+    return JSON.parse(raw) as T;
+  } catch {
+    throw new BodyParseError();
+  }
+}
diff --git a/package.json b/package.json
index 738eae2..9660eb9 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
     "test:instance": "bash scripts/test-instance.sh",
     "test:api": "node tests/api-smoke.mjs",
     "test:smoke": "node tests/api-smoke.mjs --smoke",
-    "test:write": "node tests/api-write-smoke.mjs"
+    "test:write": "node tests/api-write-smoke.mjs",
+    "test:e2e": "playwright test"
   },
   "dependencies": {
     "@codemirror/lang-html": "^6.4.11",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..b468d78
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Norma E2E — runs against the isolated TEST instance (:7411 vs sdcc_test), never
+ * live :7400. Start it first:  bash scripts/test-instance.sh
+ * Then:  npm run test:e2e
+ */
+export default defineConfig({
+  testDir: './tests/e2e',
+  timeout: 30_000,
+  expect: { timeout: 10_000 },
+  fullyParallel: false,
+  workers: 1,
+  retries: process.env.CI ? 1 : 0,
+  reporter: [['list']],
+  use: {
+    baseURL: process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411',
+    headless: true,
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+  },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/tests/api-write-smoke.mjs b/tests/api-write-smoke.mjs
new file mode 100644
index 0000000..e689d43
--- /dev/null
+++ b/tests/api-write-smoke.mjs
@@ -0,0 +1,165 @@
+#!/usr/bin/env node
+/**
+ * Norma WRITE-PATH smoke suite — the mutating-route counterpart to api-smoke.mjs.
+ * Enumerates every POST/PUT/PATCH/DELETE handler from app/api/**\/route.ts and
+ * checks two safety properties without ever creating data or firing a real
+ * send/ingest:
+ *
+ *   1. UNAUTH GATE — every mutation, called with NO auth + an empty body, must
+ *      answer 4xx (401/403 gated, or 400 validation). A 2xx = an unauthenticated
+ *      write succeeded (hard fail). A 5xx = a crash (hard fail). The empty body is
+ *      the safety mechanism: auth/validation rejects before any handler side-effect,
+ *      so even hazard send-routes (gmail/send, drafts/[id]/send-test, …) are safe to
+ *      probe — nothing is ever sent or written.
+ *   2. GRACEFUL INPUT — for SAFE CRUD routes only (AI/generate/send/gmail/cron/
+ *      ingest/external families excluded), an admin call with an empty body must not
+ *      500 — it should validate to a 4xx, not crash. Excluded families are never
+ *      fired so no metered model call or outbound fetch happens.
+ *
+ * Run against the isolated test instance (NEVER live :7400):
+ *   bash scripts/test-instance.sh      # :7411 vs sdcc_test, integrations sinked
+ *   npm run test:write
+ *
+ * Requires the seeded admin (pw TestPass123!). Override with NORMA_TEST_URL.
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath as f2p } from 'node:url';
+import pg from 'pg';
+
+const BASE = process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411';
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+const TEST_DB = process.env.NORMA_TEST_DB || 'postgresql://127.0.0.1:5432/sdcc_test';
+const CONCURRENCY = 6;
+const NIL_UUID = '00000000-0000-0000-0000-000000000000';
+const SEG = { '[id]': NIL_UUID, '[type]': 'org', '[slug]': 'smoke', '[zip]': '90210' };
+const MUT = ['POST', 'PUT', 'PATCH', 'DELETE'];
+
+// Routes whose handler does external work (send / fetch / model / cron) — probed
+// UNAUTH (safe, rejected before side-effects) but NOT fired as admin, so the suite
+// never sends an email, hits a paid model, or makes an outbound API call.
+const NO_AUTHED_FIRE =
+  /\/(send|send-test|gmail|slack|cron|ingest|community|generate|auto-generate|rewrite|score|discover|enrich|orchestrate|dispatch|digest|compute|student-finder|suggest|oauth|sync|draft-reply|data-explorer|chat|ai-chat)(\/|$)|\/ai\//;
+
+// Resolve a REAL org id so org-scoped INSERTs satisfy their org_id FK and the
+// tenant path is exercised faithfully (a nonexistent org id triggers FK-violation
+// 500s that mask real behavior — the null-org blind spot Cody flagged for reads).
+async function resolveOrgId() {
+  try {
+    const c = new pg.Client({ connectionString: TEST_DB });
+    await c.connect();
+    const r = await c.query('SELECT id FROM organizations LIMIT 1');
+    await c.end();
+    return r.rows[0]?.id || NIL_UUID;
+  } catch { return NIL_UUID; }
+}
+
+const ROOT = join(dirname(f2p(import.meta.url)), '..');
+const API_DIR = join(ROOT, 'app', 'api');
+
+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 = MUT.filter((v) =>
+        new RegExp(`export\\s+(async\\s+)?function\\s+${v}\\b`).test(src) ||
+        new RegExp(`export\\s+const\\s+${v}\\b`).test(src));
+      if (!verbs.length) continue;
+      const url = '/api' + rel.replace(/\[[^\]]+\]/g, (m) => SEG[m] ?? NIL_UUID);
+      for (const verb of verbs) out.push({ url, verb });
+    }
+  }
+  return out;
+}
+
+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 };
+}
+async function call(path, verb, cookie, extra = {}) {
+  const headers = { 'Content-Type': 'application/json', ...extra };
+  if (cookie) headers.Cookie = `norma-auth=${cookie}`;
+  // empty body for verbs that carry one — this is what keeps the probe side-effect-free
+  const body = verb === 'DELETE' ? undefined : '{}';
+  try {
+    const res = await fetch(`${BASE}${path}`, { method: verb, headers, body, redirect: 'manual' });
+    return res.status;
+  } catch (e) { return `ERR:${e.code || e.message}`; }
+}
+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 routes = walk(API_DIR);
+  console.log(`\n=== Norma write-path smoke — ${BASE} ===`);
+  console.log(`${routes.length} mutating (route,verb) pair(s) across POST/PUT/PATCH/DELETE\n`);
+
+  const admin = await login('admin');
+  if (admin.status !== 200 || !admin.cookie) {
+    console.error(`\x1b[31mFATAL\x1b[0m admin login failed (status ${admin.status}) — is :7411 up? Aborting.`);
+    process.exit(2);
+  }
+  const orgId = await resolveOrgId();
+  console.log(`admin org scope: ${orgId}${orgId === NIL_UUID ? ' (no real org found — FK inserts may 500)' : ''}\n`);
+  const ADMIN_ORG = { 'x-org-id': orgId };
+
+  const crashes = [];      // 500 (unhandled crash) under any auth = hard fail
+  const unauthWrites = []; // 2xx with NO auth = an unauthenticated mutation succeeded = hard fail
+  const errored = [];      // network errors
+  // Intentionally-public mutations (safe no-op / not session-gated by design).
+  const PUBLIC_OK = /\/auth\/logout$/;
+  // 500 is an unhandled crash. 502/503/504 are dependency-unavailable states — on the
+  // sinked test instance the external integrations are deliberately unreachable, so
+  // those are EXPECTED graceful degradations, not crashes.
+  const isCrash = (s) => s === 500;
+
+  await pool(routes, async (r) => {
+    const label = `${r.verb} ${r.url}`;
+    // 1. unauth gate — must be 4xx (never 2xx unless public-by-design, never 500)
+    const anon = await call(r.url, r.verb, null);
+    if (typeof anon === 'string') errored.push(`${label} (anon) ${anon}`);
+    else if (isCrash(anon)) crashes.push(`${label} → ${anon} (anon)`);
+    else if (anon >= 200 && anon < 300 && !PUBLIC_OK.test(r.url)) unauthWrites.push(`${label} → ${anon} (UNAUTH write succeeded)`);
+    // 2. graceful-input — admin empty body, safe CRUD routes only, must not 500
+    if (!NO_AUTHED_FIRE.test(r.url)) {
+      const asAdmin = await call(r.url, r.verb, admin.cookie, ADMIN_ORG);
+      if (typeof asAdmin === 'string') errored.push(`${label} (admin) ${asAdmin}`);
+      else if (isCrash(asAdmin)) crashes.push(`${label} → ${asAdmin} (admin, empty body)`);
+    }
+  });
+
+  const fired = routes.filter((r) => !NO_AUTHED_FIRE.test(r.url)).length;
+  console.log(`probed ${routes.length} unauth · fired ${fired} as admin (${routes.length - fired} external/AI/send routes probed unauth-only)\n`);
+  if (errored.length) {
+    console.log(`\x1b[33mNETWORK ERRORS\x1b[0m (instance flapping? re-run):`);
+    errored.forEach((e) => console.log(`    · ${e}`));
+    console.log('');
+  }
+  if (crashes.length) {
+    console.log(`\x1b[31mFAIL\x1b[0m — ${crashes.length} mutation(s) crashed (5xx):`);
+    crashes.forEach((c) => console.log(`    ✗ ${c}`));
+  }
+  if (unauthWrites.length) {
+    console.log(`\x1b[31mFAIL\x1b[0m — ${unauthWrites.length} mutation(s) succeeded WITHOUT auth:`);
+    unauthWrites.forEach((u) => console.log(`    ✗ ${u}`));
+  }
+  const passed = crashes.length === 0 && unauthWrites.length === 0;
+  if (passed) console.log(`\x1b[32mPASS\x1b[0m — every mutation gates unauth (4xx) + no crash on empty body.`);
+  console.log(`\n${passed ? '\x1b[32m✔ write-smoke green' : '\x1b[31m✘ write-smoke red'}\x1b[0m  (${routes.length} pairs, ${crashes.length} crashes, ${unauthWrites.length} unauth-writes)\n`);
+  process.exit(passed ? 0 : 1);
+}
+
+main().catch((e) => { console.error('write-smoke harness error:', e); process.exit(2); });
diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts
new file mode 100644
index 0000000..9c7d517
--- /dev/null
+++ b/tests/e2e/auth.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * Cross-tier login E2E. Proves each seeded tier (admin/staff/pulse) can log in via
+ * the standard username/password form and land on a non-login page that renders
+ * without real console errors — the browser-level counterpart to the API suites.
+ * Requires the 3 seeded users on the :7411 test instance (pw TestPass123!).
+ */
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+
+// Benign noise to ignore (missing favicon, analytics beacons, resource 404s).
+const BENIGN = /favicon|analytics|gtag|net::ERR|Failed to load resource|manifest/i;
+
+function trackErrors(page: Page): string[] {
+  const errors: string[] = [];
+  page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', (e) => errors.push(String(e)));
+  return errors;
+}
+
+async function login(page: Page, username: string, password = PW) {
+  await page.goto('/login');
+  await page.getByPlaceholder('Username or email').fill(username);
+  await page.getByPlaceholder('Password').fill(password);
+  await page.getByPlaceholder('Password').press('Enter');
+}
+
+const TIERS = [
+  { user: 'admin', role: 'admin' },
+  { user: 'teststaff', role: 'staff' },
+  { user: 'testpulse', role: 'pulse' },
+];
+
+for (const t of TIERS) {
+  test(`${t.role} logs in, leaves /login, no console errors`, async ({ page }) => {
+    const errors = trackErrors(page);
+    await login(page, t.user);
+    await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
+    expect(page.url(), 'should have navigated off /login').not.toContain('/login');
+    await expect(page.locator('body')).toBeVisible();
+    const real = errors.filter((e) => !BENIGN.test(e));
+    expect(real, `unexpected console errors: ${real.join(' | ')}`).toHaveLength(0);
+  });
+}
+
+test('pulse tier lands on the pulse surface', async ({ page }) => {
+  await login(page, 'testpulse');
+  await page.waitForURL(/\/pulse/, { timeout: 15_000 });
+  expect(page.url()).toContain('/pulse');
+});
+
+test('wrong credentials stay on /login and surface an error', async ({ page }) => {
+  // Use a throwaway username so this never rate-limits (or is rate-limited by) the
+  // real seeded users' per-(ip,username) brute-force limiter.
+  await login(page, 'nobody-e2e-xyz', 'definitely-wrong-pw');
+  await page.waitForTimeout(1500);
+  expect(page.url()).toContain('/login');
+  // The visible error banner (not Next's empty __next-route-announcer__ live region,
+  // which also has role="alert").
+  await expect(page.getByText(/invalid credentials|login failed|incorrect/i)).toBeVisible();
+});
diff --git a/tests/e2e/pulse.spec.ts b/tests/e2e/pulse.spec.ts
new file mode 100644
index 0000000..608b117
--- /dev/null
+++ b/tests/e2e/pulse.spec.ts
@@ -0,0 +1,25 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * Public Pulse surface — the end-user tier is reachable WITHOUT auth. Assert the
+ * public pages render (no login wall, no real console errors).
+ */
+const BENIGN = /favicon|analytics|gtag|net::ERR|Failed to load resource|manifest/i;
+
+function trackErrors(page: Page): string[] {
+  const errors: string[] = [];
+  page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', (e) => errors.push(String(e)));
+  return errors;
+}
+
+for (const path of ['/pulse', '/pulse/petitions', '/pulse/about']) {
+  test(`public ${path} renders without a login wall or console errors`, async ({ page }) => {
+    const errors = trackErrors(page);
+    await page.goto(path);
+    expect(page.url(), 'public pulse page should not redirect to /login').not.toContain('/login');
+    await expect(page.locator('body')).toBeVisible();
+    const real = errors.filter((e) => !BENIGN.test(e));
+    expect(real, `unexpected console errors on ${path}: ${real.join(' | ')}`).toHaveLength(0);
+  });
+}
diff --git a/tsconfig.json b/tsconfig.json
index 941b2f1..5b68979 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,6 +38,8 @@
     ".next/dev/dev/types/**/*.ts"
   ],
   "exclude": [
-    "node_modules"
+    "node_modules",
+    "tests/e2e",
+    "playwright.config.ts"
   ]
 }

← 9ba7940 auto-data-snapshot: 2026-08-06T10:52:46 (2 data files) — pac  ·  back to Norma Platform  ·  fix(cycle5 Cody-gate): truly-empty-body probe + readJson acr d26f6c6 →