test: lock in secret redaction from logs with static, mutation and deterministic probes (E00-S04-T03)

This commit is contained in:
implementer
2026-08-30 03:52:55 +00:00
parent 6458013306
commit 60bd097b70
2 changed files with 675 additions and 0 deletions
+29
View File
@@ -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 +
+646
View File
@@ -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);
}
});