/** * Config environment adapter test — locks in the [E00-S04-T04] guarantee * that no module reads `process.env` directly except the configuration * adapter: every setting flows through `packages/config`. * * Acceptance criteria covered (each test fails without the committed state): * - "no module reads `process.env` directly except the configuration * adapter" → `packages/config/src/env.ts` is the workspace's single owner * of `process.env` reads (`loadConfigFromEnv` defaults to `process.env`), * and a comment-stripped static scan of every source file under `apps/`, * `packages/` and `extensions/` (the issue's test plan: "static check * confirms only the adapter reads `process.env`") finds zero * `process.env` references outside the adapter. Mutation probes prove * non-vacuity: injecting a direct `process.env` read into the server (or * into any other module) in a temp copy of the committed tree makes the * scan fail naming the offending file, and a clean copy passes. * - "all settings flow through the adapter" → the committed * `apps/server/src/index.ts` imports `loadConfigFromEnv` from * `@personal-blog/config` and loads its startup configuration through it * (binding `config.port`/`config.host`, taking `config.databaseUrl`, never * reading `process.env` itself); the adapter maps `HOST`/`PORT`/ * `DATABASE_URL`/`EPPP_SESSION_SECRET` onto the validated config shape * and validates it with `assertValidConfig` (E00-S04-T02) before * returning. Locked in statically and behaviorally: the deterministic * boundary probe exercises the compiled adapter exactly as the server * consumes it (full env, defaults, bad-`PORT` fallback, valid * `HOST` hostname/IP forms, invalid `HOST` → `ConfigStartupError` naming * `host`, missing required secret → `MissingRequiredSettingError` naming * `sessionSecret`, empty `DATABASE_URL` → `ConfigStartupError`, and the * no-argument call reading the real `process.env`), and the server-boot * probes execute the issue's test plan against the committed server (a * `PORT`/`HOST` override shows up in the resolved-configuration log — the * settings really flow through the adapter; a missing required secret * still fails startup naming the missing field; `HOST=127.0.0.1` binds * loopback only, not all interfaces, and the startup log reflects the * actual bind; an invalid `HOST` fails startup naming the field without * ever echoing the raw value). * - "the HOST setting controls the actual bind interface" → the committed * server passes `config.host` to `server.listen(config.port, * config.host, ...)`, so a configured `HOST` binds exactly that interface * and the startup log never claims a bind the process does not enforce. * Locked in statically (the server source must pass `config.host` to * `server.listen`) and by the boot probes (with `HOST=127.0.0.1` the * server answers on loopback and does not answer on a non-loopback * interface; the startup log shows `http://127.0.0.1:`). * - "HOST is validated at the adapter boundary as a hostname or IP address * before it is used for binding or logged" → the adapter resolves `HOST` * with `resolveHost`, which accepts hostnames (RFC 1123) and IPv4/IPv6 * addresses and throws a field-specific `ConfigStartupError` naming * `host` for anything else — so arbitrary env content is never echoed * verbatim into logs. Locked in by the deterministic boundary probe * (valid forms pass, invalid forms throw naming `host`) and by a boot * probe (an invalid `HOST` exits non-zero naming the field, and the raw * value never appears in the process output). * * Run: `node --test tests/config-env-adapter.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, readdirSync, writeFileSync, cpSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { createServer as createNetServer, connect as netConnect } from 'node:net'; import os from 'node:os'; 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 workspace's single owner of `process.env` reads (E00-S04-T04). */ const ADAPTER_REL = 'packages/config/src/env.ts'; const INDEX_SRC = 'packages/config/src/index.ts'; const SERVER_SRC = 'apps/server/src/index.ts'; const CONFIG_DIR = 'packages/config'; /** 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 env-adapter criterion on every PR. */ const CI_JOB = 'config-env-adapter'; /** A distinctive >= 32-char admin-session secret for the probes. */ const SECRET = 's'.repeat(32); /** A connection string the probes use for the DATABASE_URL mapping. */ const DATABASE_URL = 'postgres://eppp:eppp@db:5432/eppp'; /** Directories never scanned as package source. */ const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.pnpm-store']); /** The workspace groups scanned for source files (mirrors pnpm-workspace.yaml). */ const GROUP_DIRS = ['apps', 'packages', 'extensions']; /** Extensions scanned as package source. */ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']); // --------------------------------------------------------------------------- // Comment stripping (string/comment aware, preserves line count) // --------------------------------------------------------------------------- /** * Replaces comments with whitespace while preserving line structure, so * `process.env` extraction never fires on commented-out references and * reported line numbers still match the original source. Mirrors the * stripper in tests/database-postgres-imports.test.mjs. */ function stripComments(source) { let out = ''; let i = 0; const n = source.length; let state = 'code'; // code | line | block | sq | dq | tpl const tplStack = []; // template states to resume after `${...}` closes let braceDepth = 0; while (i < n) { const c = source[i]; const next = source[i + 1]; if (state === 'code') { if (tplStack.length > 0) { if (c === '{') { braceDepth += 1; out += c; i += 1; continue; } if (c === '}') { braceDepth -= 1; out += c; i += 1; if (braceDepth === 0) state = tplStack.pop(); continue; } } if (c === '/' && next === '/') { out += ' '; i += 2; state = 'line'; continue; } if (c === '/' && next === '*') { out += ' '; i += 2; state = 'block'; continue; } if (c === "'") { out += c; i += 1; state = 'sq'; continue; } if (c === '"') { out += c; i += 1; state = 'dq'; continue; } if (c === '`') { out += c; i += 1; state = 'tpl'; continue; } out += c; i += 1; continue; } if (state === 'line') { if (c === '\n') { out += c; i += 1; state = 'code'; } else { out += ' '; i += 1; } continue; } if (state === 'block') { if (c === '*' && next === '/') { out += ' '; i += 2; state = 'code'; } else { out += c === '\n' ? c : ' '; i += 1; } continue; } if (state === 'sq' || state === 'dq') { const quote = state === 'sq' ? "'" : '"'; out += c; if (c === '\\' && next !== undefined) { out += next; i += 2; } else { if (c === quote) state = 'code'; i += 1; } continue; } // template literal out += c; if (c === '\\' && next !== undefined) { out += next; i += 2; continue; } if (c === '`') { state = 'code'; i += 1; continue; } if (c === '$' && next === '{') { out += next; i += 2; tplStack.push('tpl'); braceDepth = 1; state = 'code'; continue; } i += 1; } return out; } // --------------------------------------------------------------------------- // process.env discovery (the issue's test plan: "static check confirms only // the adapter reads process.env") // --------------------------------------------------------------------------- const PROCESS_ENV = /\bprocess\.env\b/g; function lineOf(stripped, index) { return stripped.slice(0, index).split('\n').length; } /** Returns [{ relPath, line }] for every `process.env` reference in the given files. */ function findProcessEnvUsages(files) { const usages = []; for (const file of files) { const stripped = stripComments(file.content); PROCESS_ENV.lastIndex = 0; let m; while ((m = PROCESS_ENV.exec(stripped)) !== null) { usages.push({ relPath: file.relPath, line: lineOf(stripped, m.index) }); } } return usages; } /** True when a scan-relative posix path is the environment adapter. */ function isInsideAdapter(relPath) { return relPath === ADAPTER_REL; } /** Walks the workspace groups under a root and returns [{ relPath, content }] for source files. */ async function collectSourceFiles(rootDir) { const files = []; async function walk(dir, relDir) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const abs = path.join(dir, entry.name); const rel = `${relDir}/${entry.name}`; if (entry.isDirectory()) { if (!IGNORED_DIRS.has(entry.name)) await walk(abs, rel); continue; } if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { files.push({ relPath: rel, content: readFileSync(abs, 'utf8') }); } } } for (const group of GROUP_DIRS) { await walk(path.join(rootDir, group), group); } return files; } /** A single process.env usage, formatted for violation reports. */ function formatUsage(usage) { return ` ${usage.relPath}:${usage.line} reads process.env`; } /** The process.env usages outside the adapter in a scan root (empty = compliant). */ async function scanMisplacedEnvReads(rootDir) { const files = await collectSourceFiles(rootDir); return findProcessEnvUsages(files).filter((u) => !isInsideAdapter(u.relPath)); } // --------------------------------------------------------------------------- // Temp-copy helpers (mutation probes over the committed tree) // --------------------------------------------------------------------------- /** Copies the committed tree (minus ignored dirs) into a fresh temp dir. */ function copyCommittedTree() { const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-env-adapter-')); for (const entry of readdirSync(REPO_ROOT, { withFileTypes: true })) { if (IGNORED_DIRS.has(entry.name) || entry.name === 'tests') continue; cpSync(path.join(REPO_ROOT, entry.name), path.join(dir, entry.name), { recursive: true, }); } return dir; } /** Injects a process.env read into a source file inside a tree copy. */ function injectEnvRead(rootDir, relPath, line) { const abs = path.join(rootDir, relPath); const original = readFileSync(abs, 'utf8'); writeFileSync(abs, `${line}\n${original}`); return abs; } // --------------------------------------------------------------------------- // Static assertions on the committed sources // --------------------------------------------------------------------------- /** * Asserts the adapter module is the workspace's single owner of `process.env` * reads: it exports `loadConfigFromEnv(env = process.env)`, maps every * setting (`HOST`/`PORT`/`DATABASE_URL`/`EPPP_SESSION_SECRET`) onto the * validated config shape, and validates the mapped environment with * `assertValidConfig` before returning (E00-S04-T02). Fails fast on a * deviation; the mutation probes below prove the assertions are non-vacuous. */ function assertAdapterSource(src) { assert.match( src, /import \{ assertValidConfig(?:, ConfigStartupError)? \} from '\.\/startup\.js'/, 'the adapter must validate the mapped environment with assertValidConfig (the E00-S04-T02 startup validation)', ); assert.match( src, /import type \{ Config \} from '\.\/schema\.js'/, 'the adapter must build on the E00-S04-T01 schema (the Config type)', ); assert.match( src, /export function loadConfigFromEnv\(env: NodeJS\.ProcessEnv = process\.env\): Config/, 'the adapter must export loadConfigFromEnv reading process.env by default (the workspace\'s single owner of process.env reads)', ); assert.match( src, /import \{ isIP \} from 'node:net'/, 'the adapter must validate HOST as an IP address via node:net isIP', ); assert.match( src, /env\.HOST/, 'the adapter must map HOST onto the validated config (host)', ); assert.match( src, /resolveHost\(env\.HOST\)/, 'the adapter must map HOST onto the validated config (host, resolved by resolveHost)', ); assert.match( src, /function resolveHost\(raw: string \| undefined\): string/, 'the adapter must own the HOST resolution (resolveHost, validating hostname/IP at the adapter boundary)', ); assert.match( src, /resolvePort\(env\.PORT\)/, 'the adapter must map PORT onto the validated config (port, resolved by resolvePort)', ); assert.match( src, /env\.DATABASE_URL/, 'the adapter must map DATABASE_URL onto the validated config (databaseUrl)', ); assert.match( src, /env\.EPPP_SESSION_SECRET/, 'the adapter must map EPPP_SESSION_SECRET onto the validated config (sessionSecret)', ); assert.match( src, /assertValidConfig\(\{/, 'the adapter must validate the mapped environment (assertValidConfig) before returning it', ); assert.match( src, /function resolvePort\(raw: string \| undefined\): number/, 'the adapter must own the PORT resolution (resolvePort, the pre-adapter server behavior)', ); } /** * Asserts the committed server loads its startup configuration through the * environment adapter and reads no `process.env` itself: it imports * `loadConfigFromEnv` from `@personal-blog/config`, calls it before binding, * and binds/takes every setting from the validated config (`config.port`, * `config.databaseUrl`). */ function assertServerSource(src) { assert.match( src, /import \{ loadConfigFromEnv \} from '@personal-blog\/config'/, 'the server must import the environment adapter from the config package', ); assert.match( src, /const config = loadConfigFromEnv\(\);/, 'the server must load its startup configuration through the environment adapter (const config = loadConfigFromEnv())', ); assert.match( src, /server\.listen\(config\.port, config\.host/, 'the server must pass the validated bind interface to server.listen (config.host), so a configured HOST binds exactly that interface', ); assert.match( src, /const databaseUrl = config\.databaseUrl;/, 'the server must take the databaseUrl from the validated configuration (config.databaseUrl)', ); 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)', ); const callIndex = src.indexOf('loadConfigFromEnv('); const listenIndex = src.indexOf('server.listen('); assert.ok( callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex, 'the adapter call must run before the server binds (server.listen)', ); } // --------------------------------------------------------------------------- // Criterion tests // --------------------------------------------------------------------------- test('the environment adapter exists and is the config package\'s single owner of process.env reads', () => { assert.ok(existsSyncProbe(ADAPTER_REL), `committed ${ADAPTER_REL} must exist`); assertAdapterSource(read(ADAPTER_REL)); }); test('the package boundary re-exports the environment adapter', () => { const src = read(INDEX_SRC); assert.match( src, /export \{ loadConfigFromEnv \} from '\.\/env\.js'/, 'the boundary must re-export the environment adapter (loadConfigFromEnv)', ); }); test('the server loads its configuration through the adapter and reads no process.env', () => { assert.ok(existsSyncProbe(SERVER_SRC), `committed ${SERVER_SRC} must exist`); assertServerSource(read(SERVER_SRC)); }); test('no module reads process.env directly except the configuration adapter (workspace scan)', async () => { // The issue's test plan: "static check confirms only the adapter reads // process.env". Every source file under apps/, packages/ and extensions/ // (comments stripped) is scanned; the only `process.env` references may // live in packages/config/src/env.ts. const files = await collectSourceFiles(REPO_ROOT); const usages = findProcessEnvUsages(files); // Non-vacuous: the adapter really does read process.env. const adapterUsages = usages.filter((u) => isInsideAdapter(u.relPath)); assert.ok( adapterUsages.length > 0, 'the adapter must read process.env (the workspace\'s single owner)', ); const misplaced = usages.filter((u) => !isInsideAdapter(u.relPath)); assert.deepEqual( misplaced, [], `no module may read process.env except the configuration adapter:\n${misplaced.map(formatUsage).join('\n')}`, ); }); test('the config-env-adapter 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-env-adapter.test.mjs`), `CI must run the config-env-adapter 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('injecting a direct process.env read into the server fails the workspace scan (mutation probe)', async () => { const dir = copyCommittedTree(); try { injectEnvRead(dir, SERVER_SRC, "const PORT = process.env.PORT;"); const misplaced = await scanMisplacedEnvReads(dir); assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatUsage).join('\n')}`); assert.equal(misplaced[0].relPath, SERVER_SRC); assert.equal(misplaced[0].line, 1); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('moving the process.env read out of the adapter (into startup.ts) fails the workspace scan (mutation probe)', async () => { const dir = copyCommittedTree(); try { injectEnvRead(dir, 'packages/config/src/startup.ts', "const PORT = process.env.PORT;"); const misplaced = await scanMisplacedEnvReads(dir); assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatUsage).join('\n')}`); assert.equal(misplaced[0].relPath, 'packages/config/src/startup.ts'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('a copy of the committed tree passes the workspace scan (probe sanity)', async () => { const dir = copyCommittedTree(); try { const misplaced = await scanMisplacedEnvReads(dir); assert.deepEqual( misplaced, [], `a clean copy of the committed tree must pass the scan:\n${misplaced.map(formatUsage).join('\n')}`, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('comment stripping ignores commented-out process.env references (unit probe)', () => { const src = [ "// const PORT = process.env.PORT;", "/* const HOST = process.env.HOST; */", "const DATABASE_URL = process.env.DATABASE_URL;", '', ].join('\n'); const usages = findProcessEnvUsages([{ relPath: 'apps/server/src/index.ts', content: src }]); assert.equal(usages.length, 1); assert.equal(usages[0].relPath, 'apps/server/src/index.ts'); assert.equal(usages[0].line, 3, 'the stripped line number must match the original source'); }); test('dropping the adapter call in the server fails the server wiring assertion (mutation probe)', () => { const src = read(SERVER_SRC); const withoutCall = src.replace('const config = loadConfigFromEnv();', 'const configX = loadConfigFromEnv();'); assert.notEqual(withoutCall, src, 'the mutation must actually break the loadConfigFromEnv wiring'); assert.throws(() => assertServerSource(withoutCall), /must load its startup configuration/); }); 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(() => assertServerSource(directRead), /must not read process\.env/); }); test('removing the process.env default from the adapter fails the single-owner assertion (mutation probe)', () => { const src = read(ADAPTER_REL); const noDefault = src.replace( 'export function loadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Config {', 'export function loadConfigFromEnv(env: NodeJS.ProcessEnv): Config {', ); assert.notEqual(noDefault, src, 'the mutation must actually drop the process.env default'); assert.throws(() => assertAdapterSource(noDefault), /reading process\.env by default/); }); test('the adapter not validating the mapped environment fails the validation assertion (mutation probe)', () => { const src = read(ADAPTER_REL); const noValidation = src.replace('return assertValidConfig({', 'return {'); assert.notEqual(noValidation, src, 'the mutation must actually bypass assertValidConfig'); assert.throws(() => assertAdapterSource(noValidation), /must validate the mapped environment/); }); test('the adapter mapping HOST without resolveHost fails the HOST-validation assertion (mutation probe)', () => { const src = read(ADAPTER_REL); const noResolve = src.replace('host: resolveHost(env.HOST),', "host: env.HOST ?? '0.0.0.0',"); assert.notEqual(noResolve, src, 'the mutation must actually bypass the resolveHost validation'); assert.throws(() => assertAdapterSource(noResolve), /resolveHost/); }); test('the server binding without config.host fails the bind-interface assertion (mutation probe)', () => { const src = read(SERVER_SRC); const noHost = src.replace('server.listen(config.port, config.host,', 'server.listen(config.port,'); assert.notEqual(noHost, src, 'the mutation must actually drop config.host from server.listen'); assert.throws(() => assertServerSource(noHost), /must pass the validated bind interface/); }); test('dropping the adapter export from the package boundary fails the boundary assertion (mutation probe)', () => { const src = read(INDEX_SRC); const dropped = src.replace("export { loadConfigFromEnv } from './env.js';", ''); assert.notEqual(dropped, src, 'the mutation must actually drop the loadConfigFromEnv export'); assert.throws(() => assertBoundary(dropped), /must re-export the environment adapter/); }); /** Asserts the boundary re-exports the environment adapter (shared with the mutation probe). */ function assertBoundary(src) { assert.match( src, /export \{ loadConfigFromEnv \} from '\.\/env\.js'/, 'the boundary must re-export the environment adapter (loadConfigFromEnv)', ); } // --------------------------------------------------------------------------- // 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, CONFIG_DIR, '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)'; /** True when a repo-relative path exists. */ function existsSyncProbe(relPath) { return existsSync(path.join(REPO_ROOT, relPath)); } /** * The boundary probe source: exercises the compiled `@personal-blog/config` * environment adapter (`loadConfigFromEnv`) exactly as the server consumes * it — a full environment maps onto the validated config, missing settings * fall back to the committed defaults (host 0.0.0.0, port 3000, no * databaseUrl), a bad `PORT` falls back to 3000, valid `HOST` forms * (hostnames and IPv4/IPv6) pass while an invalid `HOST` throws a * field-specific `ConfigStartupError` naming `host`, a missing required * secret throws `MissingRequiredSettingError` naming the field, an empty * `DATABASE_URL` throws `ConfigStartupError`, and the no-argument call reads * the real `process.env`. 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 { loadConfigFromEnv, MissingRequiredSettingError, ConfigStartupError } from './dist/index.js'; const SECRET = '${SECRET}'; const URL = '${DATABASE_URL}'; const capture = (fn) => { try { return { threw: false, value: fn() }; } catch (error) { return { threw: true, name: error && typeof error === 'object' ? error.name : String(error), message: error instanceof Error ? error.message : String(error), missingField: error && typeof error === 'object' ? error.missingField : undefined, isMissingRequired: error instanceof MissingRequiredSettingError, isConfigStartup: error instanceof ConfigStartupError, }; } }; // A full environment maps onto the validated config. const full = loadConfigFromEnv({ HOST: '127.0.0.1', PORT: '4123', DATABASE_URL: URL, EPPP_SESSION_SECRET: SECRET, }); // Missing optional settings fall back to the committed defaults. const defaults = loadConfigFromEnv({ EPPP_SESSION_SECRET: SECRET }); // A bad PORT falls back to the default 3000 (the pre-adapter server behavior). const badPort = loadConfigFromEnv({ PORT: 'abc', EPPP_SESSION_SECRET: SECRET }).port; const zeroPort = loadConfigFromEnv({ PORT: '0', EPPP_SESSION_SECRET: SECRET }).port; const highPort = loadConfigFromEnv({ PORT: '65536', EPPP_SESSION_SECRET: SECRET }).port; const maxPort = loadConfigFromEnv({ PORT: '65535', EPPP_SESSION_SECRET: SECRET }).port; const minPort = loadConfigFromEnv({ PORT: '1', EPPP_SESSION_SECRET: SECRET }).port; // Valid HOST forms pass the adapter-boundary validation: IPv4, IPv6, // single-label and dotted hostnames (RFC 1123). const hostForms = [ '127.0.0.1', '0.0.0.0', '10.0.0.7', '::1', '::', 'fe80::1', 'localhost', 'db', 'api.internal.example', ].map((h) => [h, loadConfigFromEnv({ HOST: h, EPPP_SESSION_SECRET: SECRET }).host]); // An invalid HOST fails at the adapter boundary: a field-specific startup // error naming host, so arbitrary env content is never used for binding or // echoed verbatim into logs. const invalidHost = capture(() => loadConfigFromEnv({ HOST: 'not a host!', EPPP_SESSION_SECRET: SECRET })); const invalidHostPort = capture(() => loadConfigFromEnv({ HOST: '127.0.0.1:3000', EPPP_SESSION_SECRET: SECRET })); const invalidHostDash = capture(() => loadConfigFromEnv({ HOST: '-bad', EPPP_SESSION_SECRET: SECRET })); // A missing required secret is a field-specific startup error naming it. const missingSecret = capture(() => loadConfigFromEnv({})); // An empty DATABASE_URL violates the schema (must be non-empty when present). const emptyDb = capture(() => loadConfigFromEnv({ EPPP_SESSION_SECRET: SECRET, DATABASE_URL: '' })); // The no-argument call reads the real process.env (the adapter is the single // owner of process.env reads). process.env.EPPP_SESSION_SECRET = SECRET; process.env.PORT = '5243'; const fromRealEnv = loadConfigFromEnv(); const result = { full: { host: full.host, port: full.port, databaseUrl: full.databaseUrl, sessionSecret: full.sessionSecret }, defaults: { host: defaults.host, port: defaults.port, hasDatabaseUrl: defaults.databaseUrl !== undefined }, badPort, zeroPort, highPort, maxPort, minPort, hostForms, invalidHost, invalidHostPort, invalidHostDash, missingSecret, emptyDb, fromRealEnv: { port: fromRealEnv.port, sessionSecret: fromRealEnv.sessionSecret }, }; console.log('CONFIG_ENV_ADAPTER_PROBE_RESULT ' + JSON.stringify(result)); `; test('the compiled environment adapter maps the environment onto the validated config (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => { const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-env-adapter-probe-${process.pid}.mjs`); try { writeFileSync(probeFile, PROBE_SOURCE); const run = spawnSync(process.execPath, [path.basename(probeFile)], { cwd: path.join(REPO_ROOT, CONFIG_DIR), 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_ENV_ADAPTER_PROBE_RESULT (\{.*\})/); assert.ok(match, `the probe must print CONFIG_ENV_ADAPTER_PROBE_RESULT:\n${run.stdout.trim()}`); const result = JSON.parse(match[1]); // A full environment maps onto the validated config. assert.deepEqual( result.full, { host: '127.0.0.1', port: 4123, databaseUrl: DATABASE_URL, sessionSecret: SECRET }, `a full env must map onto the validated config (got: ${JSON.stringify(result.full)})`, ); // Missing optional settings fall back to the committed defaults. assert.deepEqual( result.defaults, { host: '0.0.0.0', port: 3000, hasDatabaseUrl: false }, `the defaults must be host 0.0.0.0 / port 3000 / no databaseUrl (got: ${JSON.stringify(result.defaults)})`, ); // A bad PORT falls back to the default; the range edges hold. assert.equal(result.badPort, 3000, 'a non-numeric PORT must fall back to 3000'); assert.equal(result.zeroPort, 3000, 'PORT 0 must fall back to 3000 (out of the 1-65535 range)'); assert.equal(result.highPort, 3000, 'PORT above 65535 must fall back to 3000'); assert.equal(result.maxPort, 65535, 'PORT 65535 is the upper edge of the valid range'); assert.equal(result.minPort, 1, 'PORT 1 is the lower edge of the valid range'); // Valid HOST forms pass the adapter-boundary validation unchanged. assert.deepEqual( result.hostForms, [ ['127.0.0.1', '127.0.0.1'], ['0.0.0.0', '0.0.0.0'], ['10.0.0.7', '10.0.0.7'], ['::1', '::1'], ['::', '::'], ['fe80::1', 'fe80::1'], ['localhost', 'localhost'], ['db', 'db'], ['api.internal.example', 'api.internal.example'], ], `valid HOST values (IPv4/IPv6/hostname) must pass through the adapter (got: ${JSON.stringify(result.hostForms)})`, ); // An invalid HOST fails at the adapter boundary with a field-specific // startup error naming host (so arbitrary env content is never used for // binding or echoed verbatim into logs). for (const invalid of [result.invalidHost, result.invalidHostPort, result.invalidHostDash]) { assert.equal(invalid.threw, true, `an invalid HOST must throw (got: ${JSON.stringify(invalid)})`); assert.equal(invalid.isConfigStartup, true, `an invalid HOST must throw ConfigStartupError (got: ${JSON.stringify(invalid)})`); assert.match( invalid.message, /host/, `the startup error must name the violating field host (got: ${JSON.stringify(invalid)})`, ); } // A missing required secret is a field-specific startup error naming it. assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw'); assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError'); assert.equal(result.missingSecret.isConfigStartup, true, 'MissingRequiredSettingError must be a ConfigStartupError'); assert.equal( result.missingSecret.missingField, 'sessionSecret', `MissingRequiredSettingError must carry the missing field name (got: ${JSON.stringify(result.missingSecret)})`, ); assert.match( result.missingSecret.message, /missing required setting: sessionSecret/, `the startup error must name the missing field (got: ${JSON.stringify(result.missingSecret)})`, ); // An empty DATABASE_URL violates the schema. assert.equal(result.emptyDb.threw, true, 'an empty DATABASE_URL must throw'); assert.equal(result.emptyDb.isConfigStartup, true, 'an empty DATABASE_URL must throw ConfigStartupError'); assert.match( result.emptyDb.message, /databaseUrl/, `the startup error must name the violating field (got: ${JSON.stringify(result.emptyDb)})`, ); // The no-argument call reads the real process.env. assert.equal(result.fromRealEnv.port, 5243, 'the no-argument call must read PORT from the real process.env'); assert.equal(result.fromRealEnv.sessionSecret, SECRET, 'the no-argument call must read EPPP_SESSION_SECRET from the real process.env'); } finally { rmSync(probeFile, { force: true }); } }); // --------------------------------------------------------------------------- // Server-boot probes — the issue's test plan executed against the real // committed server: settings flow through the adapter // --------------------------------------------------------------------------- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** 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 * (merged over `process.env`; `DATABASE_URL` and `EPPP_SESSION_SECRET` are * stripped unless explicitly provided so the probes are deterministic). * 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 for the child to exit (it fails fast on a startup error). */ function waitForExit(child, deadlineMs = 10_000) { return new Promise((resolve, reject) => { if (child.exitCode !== null || child.signalCode !== null) { resolve({ code: child.exitCode, signal: child.signalCode }); return; } const timer = setTimeout( () => reject(new Error('the server did not exit within the deadline (expected a startup error)')), deadlineMs, ); child.once('exit', (code, signal) => { clearTimeout(timer); resolve({ code, signal }); }); }); } /** 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'); } /** 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; } /** A non-internal IPv4 address of this host, or undefined when none exists. */ function nonLoopbackIpv4() { for (const addrs of Object.values(os.networkInterfaces())) { for (const addr of addrs ?? []) { if (addr.family === 'IPv4' && !addr.internal) return addr.address; } } return undefined; } /** * Attempts a TCP connection; resolves true when it succeeds. A refused * connection or a timeout (a dropped SYN) both resolve false — the only way * this resolves true is a listening socket on that interface, which is * exactly what the loopback-only probe must rule out. */ function canConnect(host, port, timeoutMs = 1_500) { return new Promise((resolve) => { const socket = netConnect({ host, port }); let settled = false; const finish = (ok) => { if (settled) return; settled = true; socket.destroy(); resolve(ok); }; socket.setTimeout(timeoutMs); socket.once('connect', () => finish(true)); socket.once('timeout', () => finish(false)); socket.once('error', () => finish(false)); }); } test('a PORT/HOST override flows through the adapter into the resolved configuration (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { // "All settings flow through the adapter": booting the committed server // with PORT/HOST overrides must surface those values in the resolved // configuration the server logs at startup — the settings reach the app // through loadConfigFromEnv, not through direct process.env reads. const port = await reservePort(); const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET, HOST: '127.0.0.1', }); 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()}`, ); assert.match( stdout(), /\[config\] resolved configuration:/, `the server must log its resolved configuration at startup (got: ${stdout().trim()})`, ); assert.ok( stdout().includes(`"port":${port}`), `the resolved configuration must show the PORT override (got: ${stdout().trim()})`, ); assert.ok( stdout().includes('"host":"127.0.0.1"'), `the resolved configuration must show the HOST override (got: ${stdout().trim()})`, ); assert.ok( stdout().includes('"sessionSecret":"[REDACTED]"'), `the resolved configuration must still redact the secret (got: ${stdout().trim()})`, ); } finally { await stopChild(child); } }); test('starting without the required secret exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { // The adapter validates the mapped environment with assertValidConfig // (E00-S04-T02), so a missing required setting is still a startup error // through the adapter path: booting without EPPP_SESSION_SECRET must exit // non-zero naming the missing field. const port = await reservePort(); const { child, stdout, stderr } = bootServer(port); // EPPP_SESSION_SECRET stripped const { code, signal } = await waitForExit(child); assert.notEqual( code, 0, `the server must exit non-zero when a required setting is missing (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`, ); assert.match( stderr() + stdout(), /missing required setting: sessionSecret/, `the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`, ); }); test('a configured HOST binds exactly that interface: HOST=127.0.0.1 answers on loopback only, not all interfaces (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async (t) => { // The issue's test plan: "boot with HOST=127.0.0.1 and confirm the server // binds loopback only, not all interfaces", and "startup log reflects the // actual bind interface". The server passes config.host to server.listen, // so with HOST=127.0.0.1 the server answers on loopback and does NOT listen // on a non-loopback interface, and the startup log shows the loopback bind. const port = await reservePort(); const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET, HOST: '127.0.0.1', }); try { const response = await waitForAnswer(port, child, stderr); assert.equal( response.status, 200, `GET /health on loopback must answer 200 with HOST=127.0.0.1 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`, ); // The startup log reflects the actual bind interface. assert.ok( await waitForLog(stdout, new RegExp(`http://127\\.0\\.0\\.1:${port}`)), `the startup log must show the loopback bind (http://127.0.0.1:${port}); got: ${stdout().trim()}`, ); // Loopback-only: no non-loopback interface may accept the connection. const external = nonLoopbackIpv4(); if (external === undefined) { t.skip('this host has no non-loopback IPv4 interface; loopback-only binding is trivially satisfied'); return; } assert.equal( await canConnect(external, port), false, `with HOST=127.0.0.1 the server must not listen on the non-loopback interface ${external}:${port} (it would bind all interfaces)`, ); assert.equal(await canConnect('127.0.0.1', port), true, 'the loopback interface must still accept connections'); } finally { await stopChild(child); } }); test('an invalid HOST fails startup naming the field and never echoes the raw value (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => { // "HOST is validated at the adapter boundary as a hostname or IP address // before it is used for binding or logged, so arbitrary env content is // never echoed verbatim into logs": booting with an invalid HOST must exit // non-zero with a field-specific startup error naming host — and the raw // invalid value must never appear in the process output. const port = await reservePort(); const invalidHost = 'bogus host!'; const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: SECRET, HOST: invalidHost, }); const { code, signal } = await waitForExit(child); const output = stdout() + stderr(); assert.notEqual( code, 0, `the server must exit non-zero when HOST is invalid (code ${code}, signal ${signal}); output: ${output.trim()}`, ); assert.match( output, /host/, `the startup error must name the invalid field (host); got: ${output.trim()}`, ); assert.ok( !output.includes(invalidHost), `the raw invalid HOST value must never be echoed verbatim into the process output; got: ${output.trim()}`, ); });