From 5167a2ae1888d23c54b6aec0d98a0f1e698b8e9e Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 28 May 2026 20:36:58 +0200 Subject: [PATCH] test+fix(stammbaum): capture script refuses default creds and non-localhost (#361) @Nora + @Tobias on PR #693: defaulting CAPTURE_EMAIL/PASSWORD to documented admin creds and BACKEND_URL to localhost:8080 means an env-var slip silently auth's against staging/prod. Make both explicit: refuse to run unless CAPTURE_EMAIL and CAPTURE_PASSWORD are set, and unless BACKEND_URL hostname is localhost / 127.0.0.1 / ::1. Co-Authored-By: Claude Opus 4.7 --- frontend/scripts/capture-network-fixture.mjs | 41 +++++++++++- .../capture-network-fixture.guards.test.ts | 67 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/person/genealogy/__fixtures__/capture-network-fixture.guards.test.ts diff --git a/frontend/scripts/capture-network-fixture.mjs b/frontend/scripts/capture-network-fixture.mjs index 95fd9504..eb91ca18 100644 --- a/frontend/scripts/capture-network-fixture.mjs +++ b/frontend/scripts/capture-network-fixture.mjs @@ -12,8 +12,45 @@ import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080'; -const EMAIL = process.env.CAPTURE_EMAIL ?? 'admin@familyarchive.local'; -const PASSWORD = process.env.CAPTURE_PASSWORD ?? 'admin123'; +const EMAIL = process.env.CAPTURE_EMAIL; +const PASSWORD = process.env.CAPTURE_PASSWORD; + +// Preflight guards: this script writes the canonical Stammbaum fixture from +// a *running* backend with admin-shaped credentials. Two slips would be +// silent disasters — running with default creds against staging/prod, or +// running with a typo'd BACKEND_URL that happens to resolve. Refuse both +// before sending a single byte. +preflight(); + +function preflight() { + const failures = []; + if (!EMAIL) { + failures.push('CAPTURE_EMAIL must be set explicitly (no default).'); + } + if (!PASSWORD) { + failures.push('CAPTURE_PASSWORD must be set explicitly (no default).'); + } + if (!isLocalhost(BACKEND_URL)) { + failures.push( + `BACKEND_URL must point at localhost / 127.0.0.1 (got: ${BACKEND_URL}). ` + + 'This script is local-only.' + ); + } + if (failures.length > 0) { + console.error('Preflight failed:'); + for (const f of failures) console.error(` - ${f}`); + process.exit(2); + } +} + +function isLocalhost(url) { + try { + const host = new URL(url).hostname; + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; + } catch { + return false; + } +} const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`; diff --git a/frontend/src/lib/person/genealogy/__fixtures__/capture-network-fixture.guards.test.ts b/frontend/src/lib/person/genealogy/__fixtures__/capture-network-fixture.guards.test.ts new file mode 100644 index 00000000..5bf3be72 --- /dev/null +++ b/frontend/src/lib/person/genealogy/__fixtures__/capture-network-fixture.guards.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +// The capture script is a local-only utility (never invoked from CI) but its +// failure modes are load-bearing for the canonical fixture lifecycle: a slip +// in env vars must not silently authenticate to a non-local backend or +// overwrite the canonical snapshot with a vacuous response. These guards are +// the public contract. + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = resolve(HERE, '../../../../../scripts/capture-network-fixture.mjs'); + +function runScript(env: Record) { + return spawnSync(process.execPath, [SCRIPT_PATH], { + env: { PATH: process.env.PATH, ...env } as NodeJS.ProcessEnv, + encoding: 'utf8', + timeout: 5_000 + }); +} + +describe('capture-network-fixture.mjs — preflight guards', () => { + it('refuses_to_run_without_explicit_CAPTURE_EMAIL', () => { + const result = runScript({ + CAPTURE_PASSWORD: 'pw', + BACKEND_URL: 'http://localhost:8080' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/CAPTURE_EMAIL/); + }); + + it('refuses_to_run_without_explicit_CAPTURE_PASSWORD', () => { + const result = runScript({ + CAPTURE_EMAIL: 'someone@example.test', + BACKEND_URL: 'http://localhost:8080' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/CAPTURE_PASSWORD/); + }); + + it('refuses_to_run_when_BACKEND_URL_is_not_localhost', () => { + const result = runScript({ + CAPTURE_EMAIL: 'someone@example.test', + CAPTURE_PASSWORD: 'pw', + BACKEND_URL: 'https://staging.example.com' + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/BACKEND_URL/); + expect(result.stderr).toMatch(/localhost|127\.0\.0\.1/); + }); + + it('accepts_127_0_0_1_as_BACKEND_URL', () => { + // With creds + a localhost backend the preflight passes; the fetch + // then fails (no server) — but the exit message must NOT mention the + // preflight guards, proving they let the run through. + const result = runScript({ + CAPTURE_EMAIL: 'someone@example.test', + CAPTURE_PASSWORD: 'pw', + BACKEND_URL: 'http://127.0.0.1:65500' + }); + expect(result.stderr).not.toMatch(/BACKEND_URL.*localhost/); + expect(result.stderr).not.toMatch(/CAPTURE_EMAIL/); + expect(result.stderr).not.toMatch(/CAPTURE_PASSWORD/); + }); +});