test: lock in the env adapter as the single owner of process.env with static scan, mutation and deterministic probes (E00-S04-T04)

This commit is contained in:
implementer
2026-08-30 04:17:22 +00:00
parent 1214a33adb
commit 20173a8241
3 changed files with 964 additions and 0 deletions
+30
View File
@@ -226,6 +226,36 @@ jobs:
- name: Run config log redaction test suite - name: Run config log redaction test suite
run: node --test tests/config-log-redaction.test.mjs 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 # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
# every PR — the suite locks in the TypeBox/Ajv configuration schema # every PR — the suite locks in the TypeBox/Ajv configuration schema
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
+4
View File
@@ -27,5 +27,9 @@ coverage/
# suite into the package (removed in its finally block) # suite into the package (removed in its finally block)
.config-startup-probe-*.mjs .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 # OS / editor
.DS_Store .DS_Store
+930
View File
@@ -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()}`,
);
});