test: lock in the field-specific startup error with static, mutation and deterministic probes (E00-S04-T02)

- tests/config-startup-error.test.mjs: static assertions on the committed
  startup-error module, the package boundary, the server wiring (validation
  before bind), the compose secret and the Dockerfile shipping, each backed
  by mutation probes; the deterministic probes execute the issue's test plan
  ("start with a missing required field and confirm the error names it") —
  the compiled boundary throws MissingRequiredSettingError naming
  sessionSecret, and booting the committed server without EPPP_SESSION_SECRET
  exits non-zero naming the field while a valid secret boots to /health 200
- health-endpoint/app-readiness boot probes: provide a valid
  EPPP_SESSION_SECRET (the required setting is validated at startup)
- ci.yml: new config-startup-error job (builds config + database-postgres,
  runs the suite); app-readiness job now builds the config package too
- .gitignore: transient .config-startup-probe-*.mjs files
This commit is contained in:
implementer
2026-08-30 03:00:45 +00:00
parent 0ce790fca3
commit 1a9fd592d9
5 changed files with 695 additions and 10 deletions
+634
View File
@@ -0,0 +1,634 @@
/**
* Config startup error test — locks in the [E00-S04-T02] guarantee that a
* missing required setting gives a field-specific startup error.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "missing required setting gives a field-specific startup error" → the
* `packages/config` package exposes the startup validation entry point
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
* and the committed `apps/server/src/index.ts` calls it before the server
* binds, so a deployment missing a required setting (the admin-session
* secret `EPPP_SESSION_SECRET` — the schema's required field,
* Security-and-Operations §32/§26) fails fast at startup instead of
* booting with an invalid configuration. Locked in statically (mutation
* probes prove non-vacuity: dropping the startup validation call,
* moving it after the bind, or dropping the compose/Dockerfile support
* all fail) and behaviorally by the deterministic probes (the issue's
* test plan: "start with a missing required field and confirm the error
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
* exits non-zero with the error naming the missing field).
* - "the error names the missing field" → a missing required setting throws
* `MissingRequiredSettingError` whose message and `missingField` name the
* missing field (e.g. `"missing required setting: sessionSecret"`); other
* schema violations throw `ConfigStartupError` whose message names the
* violating field too. Locked in statically and by the deterministic
* probes (boundary + booted server).
* - the compose stack and the server image stay runnable with the required
* secret: `compose.yaml` provides `EPPP_SESSION_SECRET` for the `app`
* service (dev-only default, ≥ 32 chars — override via .env / shell) and
* the `apps/server/Dockerfile` ships the compiled `packages/config` next
* to the other workspace deps, so the app container boots (the
* deterministic probe boots with a valid secret and `GET /health` answers
* 200 — a valid startup still works).
*
* Run: `node --test tests/config-startup-error.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, existsSync, writeFileSync, rmSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { createServer as createNetServer } from 'node:net';
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 committed config package and server entrypoint under test. */
const CONFIG_DIR = 'packages/config';
const STARTUP_SRC = `${CONFIG_DIR}/src/startup.ts`;
const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`;
const SERVER_SRC = 'apps/server/src/index.ts';
const COMPOSE_PATH = 'compose.yaml';
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
/** 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 startup-error criterion on every PR. */
const CI_JOB = 'config-startup-error';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Static assertions on the committed sources
// ---------------------------------------------------------------------------
/**
* Asserts the config package exposes the field-specific startup error: the
* startup module compiles the TypeBox `configSchema` with Ajv and exports
* `assertValidConfig` plus the field-specific errors — `ConfigStartupError`
* for any schema violation, and `MissingRequiredSettingError` (whose message
* and `missingField` name the missing field, e.g.
* `"missing required setting: sessionSecret"`) for a missing required
* setting. Fails fast on a deviation; the mutation probes below prove the
* assertions are non-vacuous.
*/
function assertStartupErrorSource(src) {
// Built on the E00-S04-T01 schema boundary: the module compiles the
// TypeBox configSchema with Ajv (same golden-tuple validator as
// validate.ts) and derives the missing-field names from the Ajv `required`
// keyword errors.
assert.match(
src,
/import \{ Ajv, type ErrorObject \} from 'ajv'/,
'the startup module must import Ajv (the golden-tuple validator) and the ErrorObject type',
);
assert.match(
src,
/import \{ configSchema, type Config \} from '\.\/schema\.js'/,
'the startup module must build on the committed configSchema (import from ./schema.js)',
);
assert.match(
src,
/new Ajv\(\{ allErrors: true \}\)/,
'the startup module must create the Ajv instance with allErrors (report every violation)',
);
assert.match(
src,
/\.compile\(configSchema\)/,
'the startup module must compile the TypeBox configSchema with Ajv',
);
// The field-specific errors.
assert.match(
src,
/export class ConfigStartupError extends Error/,
'the startup module must export ConfigStartupError (any invalid configuration is a field-specific startup error)',
);
assert.match(
src,
/export class MissingRequiredSettingError extends ConfigStartupError/,
'the startup module must export MissingRequiredSettingError (a missing required setting is the field-specific startup error)',
);
assert.match(
src,
/missing required setting: \$\{missingField\}/,
'the MissingRequiredSettingError message must name the missing field ("missing required setting: <field>")',
);
assert.match(
src,
/readonly missingField: string/,
'MissingRequiredSettingError must carry the missing field name (missingField)',
);
// The startup entry point: validates and throws the field-specific error
// for a missing required setting (Ajv `required` keyword -> missingProperty).
assert.match(
src,
/export function assertValidConfig\(value: unknown\): Config/,
'the startup module must export assertValidConfig (the startup validation entry point)',
);
assert.match(
src,
/error\.keyword === 'required'/,
'assertValidConfig must detect missing required settings from the Ajv "required" keyword errors',
);
assert.match(
src,
/throw new MissingRequiredSettingError\(missingFields\.join\(', '\)\)/,
'assertValidConfig must throw MissingRequiredSettingError naming the missing field(s)',
);
}
/**
* Asserts the committed server validates the required settings at startup:
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
* with the parsed startup configuration (including the required
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
* setting is a startup error, never a silently-booted invalid configuration.
*/
function assertServerStartupValidation(src) {
assert.match(
src,
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
'the server must import the startup validation entry point from the config package',
);
assert.match(
src,
/assertValidConfig\(\{/,
'the server must call assertValidConfig with its startup configuration',
);
assert.match(
src,
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
);
const callIndex = src.indexOf('assertValidConfig({');
const listenIndex = src.indexOf('server.listen(');
assert.ok(
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
);
}
/**
* Asserts the compose app service provides the required admin-session secret
* (`EPPP_SESSION_SECRET`) with a dev-only default of at least 32 characters
* (the schema's required field, Security-and-Operations §32/§26), so
* `docker compose up -d` keeps working from a clean clone while the app's
* startup validation has the secret it requires.
*/
function assertComposeSecret(composeText) {
// The app service block runs from the top-level " app:" key to the
// top-level "volumes:" map (the db service has its own nested "volumes:"
// key earlier, so slice to the root-level one).
const appBlock = composeText.slice(composeText.indexOf(' app:'), composeText.indexOf('\nvolumes:'));
assert.match(
appBlock,
/EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]{32,}\}/,
'the app service must provide EPPP_SESSION_SECRET with a >= 32 char dev-only default (${EPPP_SESSION_SECRET:-...}), so the app boots with the required secret from a clean clone',
);
}
/**
* Asserts the server image ships the config package: the build stage copies
* `packages/config` source (the server's build compiles it) and the runtime
* stage copies the compiled `packages/config/dist` + manifest next to the
* other workspace deps the server imports.
*/
function assertDockerfileConfig(dockerfile) {
assert.match(
dockerfile,
/COPY packages\/config packages\/config/,
'the build stage must copy the config package source (the server build compiles its workspace dependency)',
);
assert.match(
dockerfile,
/COPY --from=build \/app\/packages\/config\/dist \.\/packages\/config\/dist/,
'the runtime stage must ship the compiled config package (packages/config/dist) so the server import resolves in the image',
);
}
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the config package exposes the field-specific startup error (MissingRequiredSettingError + assertValidConfig)', () => {
assert.ok(existsSync(path.join(REPO_ROOT, STARTUP_SRC)), `committed ${STARTUP_SRC} must exist`);
assertStartupErrorSource(read(STARTUP_SRC));
});
test('the package boundary re-exports the startup validation entry point and its errors', () => {
const src = read(INDEX_SRC);
assert.match(
src,
/export \{ assertValidConfig, ConfigStartupError, MissingRequiredSettingError \} from '\.\/startup\.js'/,
'the boundary must re-export assertValidConfig and the field-specific startup errors',
);
});
test('the server validates the required settings at startup, before it binds', () => {
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
assertServerStartupValidation(read(SERVER_SRC));
});
test('the compose app service provides the required admin-session secret (EPPP_SESSION_SECRET)', () => {
assertComposeSecret(read(COMPOSE_PATH));
});
test('the server image ships the config package (build source + runtime dist)', () => {
assertDockerfileConfig(read(DOCKERFILE_PATH));
});
test('the config-startup-error 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-startup-error.test.mjs`),
`CI must run the config-startup-error 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('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
});
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
const src = read(SERVER_SRC);
const moved = src
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
.replace(
'server.listen(PORT, () => {',
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
);
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
});
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
assert.notEqual(noField, src, 'the mutation must actually drop the field name from the message');
assert.throws(() => assertStartupErrorSource(noField), /must name the missing field/);
});
test('dropping MissingRequiredSettingError fails the field-specific error assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noError = src.replace('export class MissingRequiredSettingError extends ConfigStartupError', 'export class MissingRequiredSettingErrorX extends ConfigStartupError');
assert.notEqual(noError, src, 'the mutation must actually rename the error class');
assert.throws(() => assertStartupErrorSource(noError), /must export MissingRequiredSettingError/);
});
test('dropping the missing-field detection fails the required-setting assertion (mutation probe)', () => {
const src = read(STARTUP_SRC);
const noDetection = src.replace("error.keyword === 'required'", "error.keyword === 'minLength'");
assert.notEqual(noDetection, src, 'the mutation must actually change the missing-field detection');
assert.throws(() => assertStartupErrorSource(noDetection), /"required" keyword/);
});
test('removing EPPP_SESSION_SECRET from the compose app service fails the compose assertion (mutation probe)', () => {
const composeText = read(COMPOSE_PATH);
const withoutSecret = composeText.replace(
/ EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]*\}\n/,
'',
);
assert.notEqual(withoutSecret, composeText, 'the mutation must actually remove the secret env entry');
assert.throws(() => assertComposeSecret(withoutSecret), /EPPP_SESSION_SECRET/);
});
test('dropping the config package from the image fails the Dockerfile assertion (mutation probe)', () => {
const dockerfile = read(DOCKERFILE_PATH);
const withoutDist = dockerfile.replace(
'COPY --from=build /app/packages/config/dist ./packages/config/dist\n',
'',
);
assert.notEqual(withoutDist, dockerfile, 'the mutation must actually drop the runtime dist copy');
assert.throws(() => assertDockerfileConfig(withoutDist), /runtime stage must ship the compiled config package/);
});
// ---------------------------------------------------------------------------
// Deterministic behavioral probe — the issue's test plan: "start with a
// missing required field and confirm the error names it"
// ---------------------------------------------------------------------------
/**
* 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)';
/**
* The boundary probe source: exercises the compiled `@personal-blog/config`
* startup boundary (`assertValidConfig` + the field-specific errors) exactly
* as the server consumes it — a valid configuration passes (including the
* no-database local path), a missing required setting throws
* `MissingRequiredSettingError` naming the field, and other violations throw
* `ConfigStartupError` naming the violating field. 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 { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './dist/index.js';
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,
};
}
};
const result = {
// A valid configuration passes and is returned — including the local
// non-container path with no databaseUrl (host/port/databaseUrl optional).
validReturnsConfig: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32) })).threw === false,
fullValid: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000, databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', sessionSecret: 's'.repeat(32) })).threw === false,
// The issue's test plan: start with a missing required field — the error
// must be MissingRequiredSettingError and name the missing field.
missingSecret: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000 })),
// Other violations are field-specific too (the message names the field).
shortSecret: capture(() => assertValidConfig({ sessionSecret: 'short' })),
unknownProperty: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32), extra: true })),
};
console.log('CONFIG_STARTUP_PROBE_RESULT ' + JSON.stringify(result));
`;
test('the compiled startup boundary throws a field-specific error naming the missing required setting (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-startup-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_STARTUP_PROBE_RESULT (\{.*\})/);
assert.ok(match, `the probe must print CONFIG_STARTUP_PROBE_RESULT:\n${run.stdout.trim()}`);
const result = JSON.parse(match[1]);
// A valid configuration passes (including the no-database local path).
assert.equal(result.validReturnsConfig, true, 'a valid config (only the required secret) must pass assertValidConfig');
assert.equal(result.fullValid, true, 'a full valid config must pass assertValidConfig');
// Missing required setting: field-specific startup error naming the field.
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)})`,
);
// Other violations are field-specific too.
assert.equal(result.shortSecret.threw, true, 'a too-short secret must throw');
assert.equal(result.shortSecret.isMissingRequired, false, 'a too-short secret is not a missing required setting');
assert.equal(result.shortSecret.isConfigStartup, true, 'a too-short secret must throw ConfigStartupError');
assert.match(
result.shortSecret.message,
/sessionSecret/,
`the startup error must name the violating field (got: ${JSON.stringify(result.shortSecret)})`,
);
assert.equal(result.unknownProperty.threw, true, 'an unknown property must throw');
assert.match(
result.unknownProperty.message,
/extra/,
`the startup error must name the unexpected field (got: ${JSON.stringify(result.unknownProperty)})`,
);
} finally {
rmSync(probeFile, { force: true });
}
});
// ---------------------------------------------------------------------------
// Server-boot probes — the issue's test plan executed against the real
// committed startup: "start with a missing required field and confirm the
// error names it"
// ---------------------------------------------------------------------------
/** 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.
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
* and `EPPP_SESSION_SECRET` (the missing-secret case must be deterministic)
* are stripped unless explicitly provided. Returns `{ child, stdout, stderr }`
* with closures for diagnostics.
*/
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 };
}
/** 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 });
});
});
}
/**
* 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()})`,
);
}
/** 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('starting without the required setting exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// The issue's test plan: "start with a missing required field and confirm
// the error names it". The committed server validates its required settings
// at startup, so booting it without EPPP_SESSION_SECRET must fail fast —
// the process exits non-zero and the error names 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('starting with an invalid required secret exits non-zero naming the field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// A required setting that violates the schema (a too-short secret, < 32
// chars) is also a field-specific startup error naming the field.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 'short' });
const { code, signal } = await waitForExit(child);
assert.notEqual(
code,
0,
`the server must exit non-zero when the required secret is invalid (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.match(
stderr() + stdout(),
/sessionSecret/,
`the startup error must name the invalid field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
);
});
test('starting with the required secret present boots to GET /health 200 (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
// The startup validation must not reject a valid configuration: with the
// required admin-session secret (and no DATABASE_URL — the local
// non-container path) the server reports ready immediately and GET /health
// answers 200 {"status":"ok"}.
const port = await reservePort();
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 's'.repeat(32) });
try {
const response = await waitForAnswer(port, child, stderr);
assert.equal(
response.status,
200,
`GET /health with a valid required secret must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
);
assert.deepEqual(
await response.json(),
{ status: 'ok' },
'the app must report a healthy application ({"status":"ok"}) when the required settings are valid',
);
} finally {
await stopChild(child);
}
});