diff --git a/tests/app-readiness.test.mjs b/tests/app-readiness.test.mjs index fccde91..f173b68 100644 --- a/tests/app-readiness.test.mjs +++ b/tests/app-readiness.test.mjs @@ -157,7 +157,7 @@ function assertReadinessGate(src) { * after migrations finish. */ function assertReadyAfterRun(src) { - const dbUrlIndex = src.indexOf('const databaseUrl = process.env.DATABASE_URL'); + const dbUrlIndex = src.indexOf('const databaseUrl = config.databaseUrl'); assert.ok(dbUrlIndex !== -1, 'the server must read DATABASE_URL for the startup migration path'); const elseStart = src.indexOf('} else {', dbUrlIndex); assert.ok(elseStart !== -1, 'the DATABASE_URL-configured startup path must exist (else branch)'); @@ -191,7 +191,7 @@ function assertReadyAfterRun(src) { * non-container path and the E00-S02-T03 health endpoint working). */ function assertNoDatabaseUrlPath(src) { - const startup = src.slice(src.indexOf('const databaseUrl = process.env.DATABASE_URL')); + const startup = src.slice(src.indexOf('const databaseUrl = config.databaseUrl')); assert.match( startup, /if \(databaseUrl === undefined\) \{/, diff --git a/tests/config-log-redaction.test.mjs b/tests/config-log-redaction.test.mjs index 60aa1c6..ffc49e3 100644 --- a/tests/config-log-redaction.test.mjs +++ b/tests/config-log-redaction.test.mjs @@ -159,8 +159,8 @@ function assertServerSource(src) { // 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', + /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, + 'the server must import the environment adapter (loadConfigFromEnv) from the config package', ); assert.match( src, @@ -188,12 +188,13 @@ function assertServerSource(src) { '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. + // The wiring — the server keeps its validated config (loaded through the + // environment adapter, E00-S04-T04), 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(...))', + /const config = loadConfigFromEnv\(\);/, + 'the server must keep its validated configuration (const config = loadConfigFromEnv(), E00-S04-T04)', ); assert.match( src, @@ -215,13 +216,19 @@ function assertServerSource(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({'); + assert.doesNotMatch( + src, + /process\.env\.[A-Z_]+/, + 'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)', + ); + // The startup configuration loads through the adapter (which validates it) + // before the logger is created, so a missing required setting still fails + // fast (E00-S04-T02) before any log output. + const validationIndex = src.indexOf('loadConfigFromEnv('); 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)', + 'the startup configuration must load (and validate) before the logger is created (a missing required setting is still a startup error)', ); } diff --git a/tests/config-startup-error.test.mjs b/tests/config-startup-error.test.mjs index 69a831c..03d6d15 100644 --- a/tests/config-startup-error.test.mjs +++ b/tests/config-startup-error.test.mjs @@ -6,17 +6,20 @@ * - "missing required setting gives a field-specific startup error" → the * `packages/config` package exposes the startup validation entry point * (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema) - * and the committed `apps/server/src/index.ts` calls it before the server + * and the environment adapter (`loadConfigFromEnv`, E00-S04-T04 — the + * config package's single owner of `process.env` reads) validates the + * mapped environment through it; the committed `apps/server/src/index.ts` + * loads its startup configuration through the adapter before the server * binds, so a deployment missing a required setting (the admin-session * secret `EPPP_SESSION_SECRET` — the schema's required field, * Security-and-Operations §32/§26) fails fast at startup instead of * booting with an invalid configuration. Locked in statically (mutation - * probes prove non-vacuity: dropping the startup validation call, - * moving it after the bind, or dropping the compose/Dockerfile support - * all fail) and behaviorally by the deterministic probes (the issue's - * test plan: "start with a missing required field and confirm the error - * names it" — booting the committed server without `EPPP_SESSION_SECRET` - * exits non-zero with the error naming the missing field). + * probes prove non-vacuity: dropping the adapter call, moving it after + * the bind, or dropping the compose/Dockerfile support all fail) and + * behaviorally by the deterministic probes (the issue's test plan: "start + * with a missing required field and confirm the error names it" — booting + * the committed server without `EPPP_SESSION_SECRET` exits non-zero with + * the error naming the missing field). * - "the error names the missing field" → a missing required setting throws * `MissingRequiredSettingError` whose message and `missingField` name the * missing field (e.g. `"missing required setting: sessionSecret"`); other @@ -149,33 +152,35 @@ function assertStartupErrorSource(src) { } /** - * Asserts the committed server validates the required settings at startup: - * it imports `assertValidConfig` from `@personal-blog/config` and calls it - * with the parsed startup configuration (including the required - * `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required - * setting is a startup error, never a silently-booted invalid configuration. + * Asserts the committed server loads its startup configuration through the + * environment adapter (E00-S04-T04) before it binds: it imports + * `loadConfigFromEnv` from `@personal-blog/config` and calls it (the adapter + * validates the mapped environment with `assertValidConfig`, including the + * required `EPPP_SESSION_SECRET`) BEFORE `server.listen` — so a missing + * required setting is a startup error, never a silently-booted invalid + * configuration, and the server itself never reads `process.env` directly. */ function assertServerStartupValidation(src) { assert.match( src, - /import \{ assertValidConfig \} from '@personal-blog\/config'/, - 'the server must import the startup validation entry point from the config package', + /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, + 'the server must import the environment adapter (loadConfigFromEnv) from the config package', ); assert.match( src, - /assertValidConfig\(\{/, - 'the server must call assertValidConfig with its startup configuration', + /const config = loadConfigFromEnv\(\);/, + 'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())', ); - assert.match( + assert.doesNotMatch( src, - /sessionSecret: process\.env\.EPPP_SESSION_SECRET/, - 'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation', + /process\.env\.[A-Z_]+/, + 'the server must not read process.env directly (all settings flow through the config adapter, E00-S04-T04)', ); - const callIndex = src.indexOf('assertValidConfig({'); + const callIndex = src.indexOf('loadConfigFromEnv('); const listenIndex = src.indexOf('server.listen('); assert.ok( callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex, - 'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error', + 'the startup configuration must load through the adapter before the server binds (server.listen) so a missing required setting is a startup error', ); } @@ -274,25 +279,35 @@ test('the config-startup-error criterion is enforced in CI', () => { // Mutation probes — the static assertions are non-vacuous // --------------------------------------------------------------------------- -test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => { +test('dropping the adapter call fails the server wiring assertion (mutation probe)', () => { const src = read(SERVER_SRC); - const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n'); - assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call'); - assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/); + const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();'); + assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring'); + assert.throws(() => assertServerStartupValidation(withoutCall), /must load its startup configuration/); }); -test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => { +test('moving the adapter call after the server binds fails the order assertion (mutation probe)', () => { const src = read(SERVER_SRC); const moved = src - .replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '') + .replace('const config = loadConfigFromEnv();\n', '') .replace( - 'server.listen(PORT, () => {', - 'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });', + 'server.listen(config.port, () => {', + 'server.listen(config.port, () => {\n const config = loadConfigFromEnv();', ); - assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind'); + assert.notEqual(moved, src, 'the mutation must actually move the adapter call after the bind'); assert.throws(() => assertServerStartupValidation(moved), /before the server binds/); }); +test('the server reading process.env directly fails the no-direct-read assertion (mutation probe)', () => { + const src = read(SERVER_SRC); + const directRead = src.replace( + 'const config = loadConfigFromEnv();', + 'const config = loadConfigFromEnv();\nconst PORT = process.env.PORT;', + ); + assert.notEqual(directRead, src, 'the mutation must actually add a direct process.env read'); + assert.throws(() => assertServerStartupValidation(directRead), /must not read process\.env/); +}); + test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => { const src = read(STARTUP_SRC); const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting'); diff --git a/tests/health-endpoint.test.mjs b/tests/health-endpoint.test.mjs index 27c4f59..f8d04ba 100644 --- a/tests/health-endpoint.test.mjs +++ b/tests/health-endpoint.test.mjs @@ -73,8 +73,8 @@ function assertHealthEndpointSource(src) { ); assert.match( src, - /3000/, - 'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)', + /server\.listen\(config\.port/, + 'the server must bind the port from the validated configuration (config.port, E00-S04-T04)', ); } @@ -189,6 +189,19 @@ test('the endpoint reports a healthy application (committed health payload is {" ); }); +test('the application port default (3000) lives in the config adapter (E00-S04-T04)', () => { + // The server binds `config.port` (asserted above); the port default (3000, + // matching the Dockerfile EXPOSE / compose :3000) and the PORT override + // live in the config package's environment adapter — the single owner of + // process.env reads. + const envSrc = read('packages/config/src/env.ts'); + assert.match( + envSrc, + /3000/, + 'the config adapter must default the application port to 3000 (Dockerfile EXPOSE / compose :3000)', + ); +}); + test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => { if (!tsExecMode()) { t.skip(