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