← 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
M package.jsonM server.jsA test/api/forms.test.mjs
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 →