[object Object]

← back to Rentv

test(api): lock validation contract on public PII-write endpoints (subscribe/advertise)

83d6d662e7fe0c6d116880b6b5f203d0c07ce219 · 2026-08-08 08:53:27 -0700 · Steve Abrams

The two anonymous input surfaces (POST /api/subscribe, /api/advertise) had zero test
coverage. New test/api/forms.test.mjs locks: email-format rejection (400 + no row),
valid-capture normalization (lowercase/trim), idempotent subscribe (no dup row), and
the free-text length caps (name<=120, interest<=60, company<=160) + required name/company.

To avoid polluting the real PII lists, SUBS/ADV_INQ paths are now env-injectable
(RENTV_SUBS_PATH / RENTV_ADV_INQ_PATH, defaults unchanged — same pattern as
DEALS_REGISTRY_PATH); the suite spawns its own OPEN=1 server on an ephemeral port with
both pointed at a throwaway temp dir. Verified: real subscribers.jsonl untouched, 95/95
green across 2 runs. Not covered: the MAX_SUBS/MAX_ADV 503 ceiling (impractical). Prod
behavior byte-identical when the env vars are unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 83d6d662e7fe0c6d116880b6b5f203d0c07ce219
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 8 08:53:27 2026 -0700

    test(api): lock validation contract on public PII-write endpoints (subscribe/advertise)
    
    The two anonymous input surfaces (POST /api/subscribe, /api/advertise) had zero test
    coverage. New test/api/forms.test.mjs locks: email-format rejection (400 + no row),
    valid-capture normalization (lowercase/trim), idempotent subscribe (no dup row), and
    the free-text length caps (name<=120, interest<=60, company<=160) + required name/company.
    
    To avoid polluting the real PII lists, SUBS/ADV_INQ paths are now env-injectable
    (RENTV_SUBS_PATH / RENTV_ADV_INQ_PATH, defaults unchanged — same pattern as
    DEALS_REGISTRY_PATH); the suite spawns its own OPEN=1 server on an ephemeral port with
    both pointed at a throwaway temp dir. Verified: real subscribers.jsonl untouched, 95/95
    green across 2 runs. Not covered: the MAX_SUBS/MAX_ADV 503 ceiling (impractical). Prod
    behavior byte-identical when the env vars are unset.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package.json            |   2 +-
 server.js               |   6 ++-
 test/api/forms.test.mjs | 121 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 126 insertions(+), 3 deletions(-)

diff --git a/package.json b/package.json
index 30a707d4..24a9ce8b 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "pr:migrate": "node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"",
     "pr:worker": "node src/pr/worker.js",
     "pr:seed:ca": "node src/pr/seed/ca-seed.js",
-    "test": "node --test --test-concurrency=1 test/pr/*.test.js test/deals/*.test.mjs test/smoke/*.test.mjs",
+    "test": "node --test --test-concurrency=1 test/pr/*.test.js test/deals/*.test.mjs test/api/*.test.mjs test/smoke/*.test.mjs",
     "pr:seed:az": "node src/pr/seed/az-seed.js",
     "pr:seed:national": "node src/pr/seed/national-media-seed.js",
     "pr:crawl:media": "node src/pr/tools/daily-media-crawl.js"
diff --git a/server.js b/server.js
index 6fc8c838..34fccf5c 100644
--- a/server.js
+++ b/server.js
@@ -739,7 +739,9 @@ app.post('/api/deals-manage/:id/restore', adminOnly, (req, res) => {
 });
 
 // ── Newsletter capture: append-only local JSONL (NO external send-to-list). ──
-const SUBS = path.join(DATA, 'subscribers.jsonl');
+// Path is env-injectable (default unchanged) so tests can point it at a throwaway
+// file instead of polluting the real PII list — same pattern as DEALS_REGISTRY_PATH.
+const SUBS = process.env.RENTV_SUBS_PATH || path.join(DATA, 'subscribers.jsonl');
 const MAX_SUBS = 50000; // hard ceiling — guards against disk-fill / append abuse
 // Hot set of already-captured emails, loaded once at startup, for idempotent capture.
 const subSet = new Set();
@@ -784,7 +786,7 @@ app.get('/api/subscribers', adminOnly, (_q, res) => {
 // anyone), so it needs no send-gate. The captured inquiries are PII → the read
 // endpoint is admin-only, and the file is a deploy-exclude so a deploy never
 // clobbers prod's accumulated leads (see .deploy.conf RSYNC_EXTRA_EXCLUDES).
-const ADV_INQ = path.join(DATA, 'advertise-inquiries.jsonl');
+const ADV_INQ = process.env.RENTV_ADV_INQ_PATH || path.join(DATA, 'advertise-inquiries.jsonl');
 const MAX_ADV_INQ = 20000; // hard ceiling — guards against disk-fill / append abuse
 let advInqCount = 0;
 try { advInqCount = fs.readFileSync(ADV_INQ, 'utf8').split('\n').filter(Boolean).length; } catch { /* none yet */ }
diff --git a/test/api/forms.test.mjs b/test/api/forms.test.mjs
new file mode 100644
index 00000000..e7dda339
--- /dev/null
+++ b/test/api/forms.test.mjs
@@ -0,0 +1,121 @@
+// Validation-contract regression suite for the two PUBLIC (unauthenticated) PII-write
+// endpoints — POST /api/subscribe and POST /api/advertise. These are the only anonymous
+// input surfaces on the app, so a future edit that loosens email validation, drops the
+// required-field checks, forgets the length caps, or breaks idempotent capture would let
+// junk/oversized rows into the PII lists. Authored by the /yoloforever loop (TK-10366).
+//
+// Isolation: spawns its OWN server on an ephemeral port with OPEN=1 (admin bypass) AND with
+// RENTV_SUBS_PATH / RENTV_ADV_INQ_PATH pointed at a throwaway temp dir, so it never touches
+// the live :9704 instance and never writes a byte to the real subscribers/advertiser lists.
+// Run: `node --test test/api/forms.test.mjs`  (or `npm test`).
+import { test, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const PORT = 9704 + 1500 + Math.floor(Math.random() * 500); // ephemeral, away from live :9704 and the smoke test's band
+const BASE = `http://127.0.0.1:${PORT}`;
+const TMP = mkdtempSync(join(tmpdir(), 'rentv-forms-'));
+const SUBS_PATH = join(TMP, 'subscribers.jsonl');
+const ADV_PATH = join(TMP, 'advertise-inquiries.jsonl');
+let child;
+
+async function post(path, body) {
+  const r = await fetch(BASE + path, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+  const ct = r.headers.get('content-type') || '';
+  return { status: r.status, body: ct.includes('application/json') ? await r.json() : await r.text() };
+}
+const rows = (p) => (existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean).map(JSON.parse) : []);
+
+before(async () => {
+  child = spawn('node', ['server.js'], {
+    cwd: ROOT,
+    env: { ...process.env, PORT: String(PORT), OPEN: '1', RENTV_SUBS_PATH: SUBS_PATH, RENTV_ADV_INQ_PATH: ADV_PATH },
+    stdio: ['ignore', 'ignore', 'ignore'],
+  });
+  try {
+    const deadline = Date.now() + 20_000;
+    for (;;) {
+      try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch { /* not up yet */ }
+      if (Date.now() > deadline) throw new Error('server did not become ready in 20s');
+      await new Promise((r) => setTimeout(r, 200));
+    }
+  } catch (e) {
+    if (child) child.kill('SIGKILL'); // never orphan a node on the ephemeral port if boot times out
+    throw e;
+  }
+});
+
+after(() => {
+  if (child) child.kill('SIGKILL');
+  try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ }
+});
+
+// ── POST /api/subscribe ─────────────────────────────────────────────────────
+test('subscribe: rejects a malformed email with 400 and writes nothing', async () => {
+  for (const bad of ['', 'nope', 'a@b', 'a@b@c.com', 'has space@x.com']) {
+    const r = await post('/api/subscribe', { email: bad });
+    assert.equal(r.status, 400, `"${bad}" should be rejected`);
+    assert.equal(r.body.ok, false);
+  }
+  assert.equal(rows(SUBS_PATH).length, 0, 'no rows persisted for invalid emails');
+});
+
+test('subscribe: accepts a valid email and persists exactly one normalized row', async () => {
+  const r = await post('/api/subscribe', { email: '  Steve@Example.COM ', name: 'Steve', interest: 'multifamily', source: 'unit-test' });
+  assert.equal(r.status, 200);
+  assert.equal(r.body.ok, true);
+  const all = rows(SUBS_PATH);
+  assert.equal(all.length, 1);
+  assert.equal(all[0].email, 'steve@example.com', 'email lowercased + trimmed');
+  assert.equal(all[0].name, 'Steve');
+  assert.ok(all[0].at, 'timestamp stamped');
+});
+
+test('subscribe: is idempotent — a duplicate email succeeds without a second row', async () => {
+  const before = rows(SUBS_PATH).length;
+  const r = await post('/api/subscribe', { email: 'steve@example.com' });
+  assert.equal(r.status, 200);
+  assert.equal(r.body.ok, true);
+  assert.equal(r.body.already, true, 'flagged as already-subscribed');
+  assert.equal(rows(SUBS_PATH).length, before, 'no duplicate row appended');
+});
+
+test('subscribe: caps oversized free-text fields (name<=120, interest<=60)', async () => {
+  const r = await post('/api/subscribe', { email: 'caps@example.com', name: 'N'.repeat(500), interest: 'I'.repeat(500) });
+  assert.equal(r.status, 200);
+  const rec = rows(SUBS_PATH).find((x) => x.email === 'caps@example.com');
+  assert.equal(rec.name.length, 120, 'name sliced to 120');
+  assert.equal(rec.interest.length, 60, 'interest sliced to 60');
+});
+
+// ── POST /api/advertise ─────────────────────────────────────────────────────
+test('advertise: requires name AND company', async () => {
+  assert.equal((await post('/api/advertise', { email: 'a@b.com', company: 'Acme' })).status, 400, 'missing name');
+  assert.equal((await post('/api/advertise', { email: 'a@b.com', name: 'Ann' })).status, 400, 'missing company');
+  assert.equal(rows(ADV_PATH).length, 0, 'nothing persisted when required fields missing');
+});
+
+test('advertise: rejects a malformed work email with 400', async () => {
+  const r = await post('/api/advertise', { name: 'Ann', company: 'Acme', email: 'not-an-email' });
+  assert.equal(r.status, 400);
+  assert.equal(r.body.ok, false);
+});
+
+test('advertise: accepts a complete inquiry and persists one row, caps company<=160', async () => {
+  const r = await post('/api/advertise', { name: 'Ann Buyer', company: 'C'.repeat(500), email: '  ANN@Acme.com ', interest: 'homepage takeover' });
+  assert.equal(r.status, 200);
+  assert.equal(r.body.ok, true);
+  const all = rows(ADV_PATH);
+  assert.equal(all.length, 1);
+  assert.equal(all[0].email, 'ann@acme.com', 'email lowercased + trimmed');
+  assert.equal(all[0].company.length, 160, 'company sliced to 160');
+});

← cd8a9379 chore: gitignore logs/ (runtime cron .out output leaked past  ·  back to Rentv  ·  security(a11y): rel=noopener noreferrer on all target=_blank 6f7c1080 →