From 20173a8241627781df3bd1b683aed951bb5ab073 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 04:17:22 +0000 Subject: [PATCH] test: lock in the env adapter as the single owner of process.env with static scan, mutation and deterministic probes (E00-S04-T04) --- .gitea/workflows/ci.yml | 30 + .gitignore | 4 + tests/config-env-adapter.test.mjs | 930 ++++++++++++++++++++++++++++++ 3 files changed, 964 insertions(+) create mode 100644 tests/config-env-adapter.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f8e49a3..30916ad 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -226,6 +226,36 @@ jobs: - name: Run config log redaction test suite run: node --test tests/config-log-redaction.test.mjs + # E00-S04-T04: the static assertions of tests/config-env-adapter.test.mjs + # gate every PR — the suite locks in the environment adapter (packages/config + # is the single owner of process.env reads; the server and every other module + # read no process.env, all settings flow through loadConfigFromEnv into the + # validated config) with a comment-stripped workspace scan, mutation probes + # (injecting a direct process.env read into any other module fails the scan), + # a deterministic boundary probe (full env mapping, defaults, bad-PORT + # fallback, missing required secret -> MissingRequiredSettingError) and + # server-boot probes (a PORT/HOST override shows up in the resolved + # configuration; a missing required secret still fails startup). 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-env-adapter: + name: Env adapter owns process.env (E00-S04-T04) + 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 env adapter test suite + run: node --test tests/config-env-adapter.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/.gitignore b/.gitignore index 53a8c6f..03e8a15 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,9 @@ coverage/ # suite into the package (removed in its finally block) .config-startup-probe-*.mjs +# Transient host-side probe file written by the config-env-adapter test +# suite into the package (removed in its finally block) +.config-env-adapter-probe-*.mjs + # OS / editor .DS_Store diff --git a/tests/config-env-adapter.test.mjs b/tests/config-env-adapter.test.mjs new file mode 100644 index 0000000..130b4bf --- /dev/null +++ b/tests/config-env-adapter.test.mjs @@ -0,0 +1,930 @@ +/** + * 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`, 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, 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 — and a missing required secret still fails startup naming + * the missing field). + * + * 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, mkdirSync, 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 } 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 \} 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, + /env\.HOST/, + 'the adapter must map HOST onto the validated config (host)', + ); + 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/, + 'the server must bind the port from the validated configuration (config.port)', + ); + 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('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, 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; + +// 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, + 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'); + + // 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'); +} + +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()}`, + ); +});