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:
@@ -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()}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user