From 60bd097b702f0b45ad90d66e72472897694bf077 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 03:52:55 +0000 Subject: [PATCH] test: lock in secret redaction from logs with static, mutation and deterministic probes (E00-S04-T03) --- .gitea/workflows/ci.yml | 29 ++ tests/config-log-redaction.test.mjs | 646 ++++++++++++++++++++++++++++ 2 files changed, 675 insertions(+) create mode 100644 tests/config-log-redaction.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7ef9c5e..f8e49a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -197,6 +197,35 @@ jobs: - name: Run config startup error test suite run: node --test tests/config-startup-error.test.mjs + # E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs + # gate every PR — the suite locks in automatic secret redaction from logs + # (packages/config's redactConfig/redactText + the server's redacting + # logger: every log line is scrubbed of the config's secret values) with + # mutation probes, and the deterministic probes execute the issue's test + # plan ("log configuration and confirm secret values are redacted"): + # booting the committed server logs its resolved configuration with the + # secret values replaced by [REDACTED], and no secret value appears in the + # log output. The job installs the frozen workspace and builds the config + # and database-postgres packages because the probes boot the committed + # server which imports them. + config-log-redaction: + name: Secret redaction from logs (E00-S04-T03) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run config log redaction test suite + run: node --test tests/config-log-redaction.test.mjs + # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate # every PR — the suite locks in the TypeBox/Ajv configuration schema # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + diff --git a/tests/config-log-redaction.test.mjs b/tests/config-log-redaction.test.mjs new file mode 100644 index 0000000..60aa1c6 --- /dev/null +++ b/tests/config-log-redaction.test.mjs @@ -0,0 +1,646 @@ +/** + * Config log redaction test — locks in the [E00-S04-T03] guarantee that + * secrets automatically redact from logs: the app's log output contains no + * secret values. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "secrets automatically redact from logs" → `packages/config` exposes the + * redaction layer (`redactConfig` — a config value with every secret + * replaced by `[REDACTED]`, for logging the resolved configuration — and + * `redactText` — scrubbing free-form log text of the config's secret + * values), and the committed server writes ALL of its log output through + * the redacting logger (`createLogger` in the server entrypoint, seeded + * with the validated config). Locked in statically (mutation probes prove + * non-vacuity: renaming the exports, dropping the split/join scrub, + * unmasking the databaseUrl password, or reintroducing a bare + * `console.log`/`console.error` all fail) and behaviorally by the + * deterministic probes. + * - "log output contains no secret values" → the deterministic probes + * execute the issue's test plan ("log configuration and confirm secret + * values are redacted") against the committed code: the compiled + * `@personal-blog/config` boundary redacts the admin-session secret and + * the password embedded in a `DATABASE_URL` connection string, and + * booting the committed server logs its resolved configuration with + * every secret value replaced by `[REDACTED]` — the booted server's + * stdout/stderr contain no secret value. + * + * Run: `node --test tests/config-log-redaction.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched. + * The deterministic probes boot the committed server, which imports + * `@personal-blog/config` and `@personal-blog/database-postgres` — build those + * packages first, exactly as the CI job does.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer as createNetServer } from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed redaction layer, package boundary and server entrypoint under test. */ +const REDACT_SRC = 'packages/config/src/redact.ts'; +const INDEX_SRC = 'packages/config/src/index.ts'; +const SERVER_SRC = 'apps/server/src/index.ts'; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the log-redaction criterion on every PR. */ +const CI_JOB = 'config-log-redaction'; + +/** A distinctive >= 32-char admin-session secret the probes must never leak. */ +const SECRET = 'redact-me-0123456789abcdefghijklmnopqrstuv'; + +/** A connection string whose password the probes must never leak. */ +const DATABASE_URL = 'postgres://redact-user:redact-password@db:5432/redact-db'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Static assertions on the committed sources +// --------------------------------------------------------------------------- + +/** + * Asserts the config package exposes the redaction layer: `redactConfig` + * (a config value with every secret replaced by `[REDACTED]` — the secret + * fields by name and the `databaseUrl` password masked in place) and + * `redactText` (free-form log text scrubbed of the config's secret values). + * Fails fast on a deviation; the mutation probes below prove the assertions + * are non-vacuous. + */ +function assertRedactionSource(src) { + // The placeholder and the schema-derived secret field names. + assert.match( + src, + /export const REDACTED = '\[REDACTED\]'/, + 'the redaction module must export the [REDACTED] placeholder', + ); + assert.match( + src, + /export const SECRET_FIELD_NAMES/, + 'the redaction module must export the secret field names (SECRET_FIELD_NAMES)', + ); + assert.match( + src, + /SECRET_FIELD_NAMES: readonly string\[\] = \['sessionSecret'\]/, + "the secret field names must name the schema's secret field (sessionSecret, E00-S04-T01)", + ); + + // redactConfig — a config copy with every secret replaced by the placeholder. + assert.match( + src, + /export function redactConfig\(config: Config\): Config/, + 'the redaction module must export redactConfig (a redacted copy of a config value, for logging the resolved configuration)', + ); + assert.match( + src, + /SECRET_FIELD_NAMES\.includes\(key\)/, + 'redactConfig must replace the secret fields by name (SECRET_FIELD_NAMES)', + ); + assert.match( + src, + /redacted\[key\] = REDACTED/, + 'redactConfig must replace secret field values with the [REDACTED] placeholder', + ); + assert.match( + src, + /redactDatabaseUrl\(value\)/, + 'redactConfig must mask the databaseUrl password (redactDatabaseUrl)', + ); + assert.match( + src, + /:\$\{REDACTED\}@/, + 'redactDatabaseUrl must mask the password in place (scheme://user:[REDACTED]@host)', + ); + + // redactText — free-form log text scrubbed of the config's secret values. + assert.match( + src, + /export function redactText\(text: string, config: Config\): string/, + 'the redaction module must export redactText (scrub free-form log text of the config\'s secret values)', + ); + assert.match( + src, + /\.split\(value\)\.join\(REDACTED\)/, + 'redactText must replace every occurrence of a secret value with the [REDACTED] placeholder', + ); +} + +/** + * Asserts the package boundary re-exports the redaction layer. + */ +function assertBoundary(src) { + assert.match( + src, + /export \{ REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText \} from '\.\/redact\.js'/, + 'the package boundary must re-export the redaction layer (REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText)', + ); +} + +/** + * Asserts the committed server logs through a redacting logger only: it + * imports the redaction entry points from `@personal-blog/config`, defines + * `createLogger(config)` which scrubs every joined log line with the + * validated config's secret values before writing it to stdout/stderr, and + * logs its resolved configuration at startup via `redactConfig` (the issue's + * test plan: "log configuration and confirm secret values are redacted"). A + * bare `console.log`/`console.error` would bypass the redaction and is + * rejected. + */ +function assertServerSource(src) { + // The redacting logger — every log line passes through the config + // package's scrubber before it reaches stdout/stderr. + assert.match( + src, + /import \{ assertValidConfig \} from '@personal-blog\/config'/, + 'the server must import the startup validation entry point from the config package', + ); + assert.match( + src, + /import \{ redactConfig, redactText, type Config \} from '@personal-blog\/config'/, + 'the server must import the redaction entry points (redactConfig, redactText) and the Config type from the config package', + ); + assert.match( + src, + /function createLogger\(config: Config\): ServerLogger/, + 'the server must define the redacting logger (createLogger, seeded with the validated configuration)', + ); + assert.match( + src, + /redactText\(args\.map\(serialize\)\.join\(' '\), config\)/, + 'every log line must pass through redactText (the config package\'s scrubber) before it is written', + ); + assert.match( + src, + /write\(process\.stdout, args\)/, + 'log lines must be written to stdout', + ); + assert.match( + src, + /write\(process\.stderr, args\)/, + 'error lines must be written to stderr', + ); + + // The wiring — the server keeps its validated config, creates the logger + // with it and logs the resolved configuration redacted. + assert.match( + src, + /const config = assertValidConfig\(\{/, + 'the server must keep its validated configuration (const config = assertValidConfig(...))', + ); + assert.match( + src, + /const logger = createLogger\(config\)/, + 'the server must create the redacting logger seeded with its validated configuration', + ); + assert.match( + src, + /resolved configuration/, + 'the server must log its resolved configuration at startup (the issue\'s test plan: "log configuration and confirm secret values are redacted")', + ); + assert.match( + src, + /redactConfig\(config\)/, + 'the configuration log must be redacted (redactConfig) so secret values never reach the log output', + ); + assert.doesNotMatch( + src, + /console\.(log|error)\(/, + 'the server must not write log output with bare console.log/console.error (they would bypass the redaction)', + ); + // The startup validation runs before the logger is created, so a missing + // required setting still fails fast (E00-S04-T02) before any log output. + const validationIndex = src.indexOf('assertValidConfig({'); + const loggerIndex = src.indexOf('createLogger(config)'); + assert.ok( + validationIndex !== -1 && loggerIndex !== -1 && validationIndex < loggerIndex, + 'the startup validation must run before the logger is created (a missing required setting is still a startup error)', + ); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the config package exposes the secret redaction layer (redactConfig + redactText)', () => { + assert.ok(existsSync(path.join(REPO_ROOT, REDACT_SRC)), `committed ${REDACT_SRC} must exist`); + assertRedactionSource(read(REDACT_SRC)); +}); + +test('the package boundary re-exports the redaction layer', () => { + assertBoundary(read(INDEX_SRC)); +}); + +test('the server logs through the redacting logger and logs its resolved configuration redacted', () => { + assertServerSource(read(SERVER_SRC)); +}); + +test('the config-log-redaction criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/config-log-redaction.test.mjs`), + `CI must run the config-log-redaction suite (job "${CI_JOB}") on every PR`, + ); + assert.ok( + workflow.includes( + 'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build', + ), + 'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)', + ); +}); + +// --------------------------------------------------------------------------- +// Mutation probes — the static assertions are non-vacuous +// --------------------------------------------------------------------------- + +test('renaming the redactText export fails the redaction assertion (mutation probe)', () => { + const src = read(REDACT_SRC); + const renamed = src.replace('export function redactText(', 'export function redactTextX('); + assert.notEqual(renamed, src, 'the mutation must actually rename the redactText export'); + assert.throws(() => assertRedactionSource(renamed), /must export redactText/); +}); + +test('changing the [REDACTED] placeholder fails the redaction assertion (mutation probe)', () => { + const src = read(REDACT_SRC); + const noPlaceholder = src.replace("export const REDACTED = '[REDACTED]';", "export const REDACTED = '***';"); + assert.notEqual(noPlaceholder, src, 'the mutation must actually change the placeholder'); + assert.throws(() => assertRedactionSource(noPlaceholder), /\[REDACTED\] placeholder/); +}); + +test('replacing every occurrence instead of scrubbing the whole value fails the redaction assertion (mutation probe)', () => { + const src = read(REDACT_SRC); + const partial = src.replace('.split(value).join(REDACTED)', '.replace(value, REDACTED)'); + assert.notEqual(partial, src, 'the mutation must actually change the scrubbing'); + assert.throws(() => assertRedactionSource(partial), /every occurrence/); +}); + +test('unmasking the databaseUrl password fails the redaction assertion (mutation probe)', () => { + const src = read(REDACT_SRC); + const unmasked = src.replace('redactDatabaseUrl(value)', 'value'); + assert.notEqual(unmasked, src, 'the mutation must actually drop the databaseUrl password masking'); + assert.throws(() => assertRedactionSource(unmasked), /must mask the databaseUrl password/); +}); + +test('dropping a redaction export from the package boundary fails the boundary assertion (mutation probe)', () => { + const src = read(INDEX_SRC); + const dropped = src.replace('REDACTED, SECRET_FIELD_NAMES, redactConfig, redactText', 'REDACTED, SECRET_FIELD_NAMES, redactConfig'); + assert.notEqual(dropped, src, 'the mutation must actually drop the redactText export'); + assert.throws(() => assertBoundary(dropped), /must re-export the redaction layer/); +}); + +test('renaming createLogger fails the logger assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const renamed = src.replace('function createLogger(', 'function createLoggerX('); + assert.notEqual(renamed, src, 'the mutation must actually rename the createLogger function'); + assert.throws(() => assertServerSource(renamed), /must define the redacting logger/); +}); + +test('writing a log line without redacting it fails the logger assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const unredacted = src.replace("redactText(args.map(serialize).join(' '), config)", "args.map(serialize).join(' ')"); + assert.notEqual(unredacted, src, 'the mutation must actually drop the redactText pass-through'); + assert.throws(() => assertServerSource(unredacted), /must pass through redactText/); +}); + +test('reintroducing a bare console.log/console.error in the server fails the wiring assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const bareConsole = src.replaceAll('logger.log(', 'console.log(').replaceAll('logger.error(', 'console.error('); + assert.notEqual(bareConsole, src, 'the mutation must actually replace the logger calls with bare console calls'); + assert.throws(() => assertServerSource(bareConsole), /console\.(log|error)/); +}); + +test('dropping the resolved-configuration log fails the wiring assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const noConfigLog = src.replace( + "logger.log('[config] resolved configuration:', JSON.stringify(redactConfig(config)));", + '', + ); + assert.notEqual(noConfigLog, src, 'the mutation must actually drop the resolved-configuration log'); + assert.throws(() => assertServerSource(noConfigLog), /resolved configuration/); +}); + +// --------------------------------------------------------------------------- +// Deterministic behavioral probe — the compiled @personal-blog/config boundary +// --------------------------------------------------------------------------- + +/** + * How the current Node executes TypeScript sources: `default` (>= 23.6, type + * stripping on by default), `strip-types-flag` (>= 22.6 via + * `--experimental-strip-types`) or `null` (cannot run .ts at all). The + * workspace pins engines.node to 24.x, where type stripping is stable. + */ +function tsExecMode() { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major > 23 || (major === 23 && minor >= 6)) return 'default'; + if (major === 22 && minor >= 6) return 'strip-types-flag'; + return null; +} + +/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */ +const TS_STRIPPING = tsExecMode() !== null; + +/** The compiled config package boundary the probes import (built by the CI job first). */ +const CONFIG_DIST = existsSync(path.join(REPO_ROOT, 'packages/config', 'dist', 'index.js')); +/** The compiled database-postgres package the booted server also imports. */ +const DATABASE_POSTGRES_DIST = existsSync( + path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'), +); + +/** Why the boot probes may be skipped on a clean clone without a build step. */ +const BUILD_HINT = + 'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)'; + +/** + * The boundary probe source: exercises the compiled `@personal-blog/config` + * redaction boundary (`redactConfig` + `redactText`) exactly as the server's + * logger consumes it — the admin-session secret is replaced by `[REDACTED]`, + * the `databaseUrl` password is masked in place (and an unparseable + * `databaseUrl` is replaced wholesale), free-form text is scrubbed of the + * secret values, and non-secret text passes through unchanged. Written to a + * temp file inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve + * through the package's own dependency links, then removed. + */ +const PROBE_SOURCE = ` +import { REDACTED, redactConfig, redactText } from './dist/index.js'; + +const SECRET = '${SECRET}'; +const URL = '${DATABASE_URL}'; + +const result = { + placeholder: REDACTED, + redactedSecret: redactConfig({ sessionSecret: SECRET }).sessionSecret, + maskedUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: URL }).databaseUrl, + unparseableUrl: redactConfig({ sessionSecret: SECRET, databaseUrl: 'not a url' }).databaseUrl, + nonSecretKept: redactConfig({ sessionSecret: SECRET, host: '0.0.0.0', port: 3000 }), + scrubText: redactText('connecting with ' + SECRET + ' now', { sessionSecret: SECRET }), + scrubUrl: redactText('failed at ' + URL, { sessionSecret: SECRET, databaseUrl: URL }), + untouched: redactText('no secrets here', { sessionSecret: SECRET }), +}; + +console.log('CONFIG_REDACTION_PROBE_RESULT ' + JSON.stringify(result)); +`; + +test('the compiled boundary redacts secret values from config and text (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => { + const probeFile = path.join(REPO_ROOT, 'packages/config', `.config-redaction-probe-${process.pid}.mjs`); + try { + writeFileSync(probeFile, PROBE_SOURCE); + const run = spawnSync(process.execPath, [path.basename(probeFile)], { + cwd: path.join(REPO_ROOT, 'packages/config'), + encoding: 'utf8', + timeout: 60_000, + }); + assert.equal( + run.status, + 0, + `the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`, + ); + const match = run.stdout.match(/CONFIG_REDACTION_PROBE_RESULT (\{.*\})/); + assert.ok(match, `the probe must print CONFIG_REDACTION_PROBE_RESULT:\n${run.stdout.trim()}`); + const result = JSON.parse(match[1]); + + // The placeholder and the redacted admin-session secret. + assert.equal(result.placeholder, '[REDACTED]', 'REDACTED must be the [REDACTED] placeholder'); + assert.equal( + result.redactedSecret, + '[REDACTED]', + 'redactConfig must replace the admin-session secret with [REDACTED]', + ); + + // The databaseUrl password is masked in place; an unparseable databaseUrl + // is replaced wholesale (its password cannot be isolated). + assert.equal( + result.maskedUrl, + 'postgres://redact-user:[REDACTED]@db:5432/redact-db', + `redactConfig must mask the databaseUrl password in place (got: ${JSON.stringify(result.maskedUrl)})`, + ); + assert.equal( + result.unparseableUrl, + '[REDACTED]', + 'redactConfig must replace an unparseable databaseUrl wholesale (its password cannot be isolated)', + ); + + // Non-secret fields pass through unchanged. + assert.equal(result.nonSecretKept.host, '0.0.0.0', 'non-secret fields must pass through unchanged'); + assert.equal(result.nonSecretKept.port, 3000, 'non-secret fields must pass through unchanged'); + assert.equal(result.nonSecretKept.sessionSecret, '[REDACTED]', 'the secret field must still be redacted'); + + // Free-form text is scrubbed of the config's secret values. + assert.equal( + result.scrubText, + 'connecting with [REDACTED] now', + `redactText must scrub the admin-session secret from free text (got: ${JSON.stringify(result.scrubText)})`, + ); + assert.ok( + !result.scrubText.includes(SECRET), + 'redactText output must not contain the admin-session secret value', + ); + assert.equal( + result.scrubUrl, + 'failed at postgres://redact-user:[REDACTED]@db:5432/redact-db', + `redactText must scrub the database password from free text (got: ${JSON.stringify(result.scrubUrl)})`, + ); + assert.ok( + !result.scrubUrl.includes('redact-password'), + 'redactText output must not contain the database password', + ); + assert.equal(result.untouched, 'no secrets here', 'redactText must leave non-secret text unchanged'); + } finally { + rmSync(probeFile, { force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Server-boot probes — the issue's test plan executed against the real +// committed server: "log configuration and confirm secret values are +// redacted" +// --------------------------------------------------------------------------- + +/** Reserves an ephemeral TCP port, then releases it for the child to bind. */ +function reservePort() { + return new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} + +/** + * Boots the committed server source on `port` with the given env overrides. + * An inherited `DATABASE_URL` (the no-database path must be deterministic) + * and `EPPP_SESSION_SECRET` (the secret must be deterministic) are stripped + * unless explicitly provided. Returns `{ child, stdout, stderr }` with + * closures for the captured output. + */ +function bootServer(port, envOverrides = {}) { + const args = + tsExecMode() === 'strip-types-flag' + ? ['--experimental-strip-types', SERVER_SRC] + : [SERVER_SRC]; + const env = { ...process.env, PORT: String(port) }; + delete env.DATABASE_URL; + delete env.EPPP_SESSION_SECRET; + Object.assign(env, envOverrides); + const child = spawn(process.execPath, args, { + cwd: REPO_ROOT, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + return { child, stdout: () => stdout, stderr: () => stderr }; +} + +/** + * Polls `GET /health` until the server answers with ANY status, the child + * exits, or the deadline passes. Returns the fetch Response. + */ +async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) { + const deadline = Date.now() + deadlineMs; + let lastError = ''; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`, + ); + } + try { + return await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(1_000), + }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + await delay(100); + } + } + throw new Error( + `GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`, + ); +} + +/** Waits until the captured log output matches `pattern` (or the deadline passes). */ +async function waitForLog(readOutput, pattern, deadlineMs = 5_000) { + const deadline = Date.now() + deadlineMs; + while (Date.now() < deadline) { + if (pattern.test(readOutput())) return true; + await delay(100); + } + return false; +} + +/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */ +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([once(child, 'exit'), delay(2_000)]); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); +} + +test('booting the server logs the resolved configuration with secret values redacted (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { + // The issue's test plan: "log configuration and confirm secret values are + // redacted". The committed server logs its resolved configuration at + // startup through the redacting logger — the admin-session secret must + // appear as [REDACTED], and the secret value must not appear in the log + // output at all. + const port = await reservePort(); + const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET }); // DATABASE_URL stripped + try { + const response = await waitForAnswer(port, child, stderr); + assert.equal( + response.status, + 200, + `the server must boot to GET /health 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`, + ); + // The resolved configuration is logged, with the secret redacted. + assert.match( + stdout(), + /\[config\] resolved configuration:/, + `the server must log its resolved configuration at startup (got: ${stdout().trim()})`, + ); + assert.match( + stdout(), + /"sessionSecret":"\[REDACTED\]"/, + `the resolved-configuration log must show the admin-session secret redacted (got: ${stdout().trim()})`, + ); + // No secret value in the log output (the acceptance criterion). + assert.ok( + !(stdout() + stderr()).includes(SECRET), + `the log output must not contain the admin-session secret value (got: ${stdout().trim()} ${stderr().trim()})`, + ); + } finally { + await stopChild(child); + } +}); + +test('the log output contains no database password when a DATABASE_URL is configured (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { + // With a DATABASE_URL whose password must never leak, the startup + // migration run cannot complete (nothing listens on the dead port), so the + // app stays not-ready — and the log output (the resolved-configuration log + // AND the migration-failure log) must contain the masked URL, never the + // password and never the raw connection string. + const port = await reservePort(); + const deadPort = await reservePort(); // reserved then released: nothing listens + const databaseUrl = `postgres://redact-user:redact-password@127.0.0.1:${deadPort}/redact-db`; + const { child, stdout, stderr } = bootServer(port, { + EPPP_SESSION_SECRET: SECRET, + DATABASE_URL: databaseUrl, + }); + try { + const response = await waitForAnswer(port, child, stderr); + assert.equal( + response.status, + 503, + `the app must stay not-ready while the migration run cannot complete (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`, + ); + // The resolved-configuration log masks the databaseUrl password in place. + assert.match( + stdout(), + new RegExp(`postgres://redact-user:${'\\[REDACTED\\]'}@127\\.0\\.0\\.1:`), + `the resolved-configuration log must show the databaseUrl password masked in place (got: ${stdout().trim()})`, + ); + // Wait for the migration-failure log (also written through the redacting logger). + assert.ok( + await waitForLog(() => stdout() + stderr(), /startup migration run failed; app stays not-ready/), + `the app must log the failed startup migration run (got: ${stdout().trim()} ${stderr().trim()})`, + ); + const output = stdout() + stderr(); + assert.ok( + !output.includes('redact-password'), + `the log output must not contain the database password (got: ${output.trim()})`, + ); + assert.ok( + !output.includes('postgres://redact-user:redact-password@'), + `the log output must not contain the raw connection string with its password (got: ${output.trim()})`, + ); + assert.ok( + !output.includes(SECRET), + `the log output must not contain the admin-session secret value (got: ${output.trim()})`, + ); + } finally { + await stopChild(child); + } +});