diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3d51984..7ef9c5e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -145,10 +145,11 @@ jobs: # migration run is blocked behind a held ACCESS EXCLUSIVE lock on the # migration ledger, /health stays not-ready, then flips ready once the lock # releases) runs where a Docker daemon is available and skips cleanly - # otherwise. The job installs the frozen workspace and builds the - # database-postgres package because the probes boot the committed server - # from the host (it imports @personal-blog/database-postgres through the - # package's own links). + # otherwise. The job installs the frozen workspace and builds the config + # and database-postgres packages because the probes boot the committed + # server from the host (it imports @personal-blog/config and + # @personal-blog/database-postgres through the packages' own links; the + # required EPPP_SESSION_SECRET is provided by the probe's boot env). app-readiness: name: App readiness after migrations (E00-S03-T06) runs-on: ubuntu-latest @@ -162,11 +163,40 @@ jobs: run: corepack enable - name: Install dependencies (frozen lockfile) run: pnpm install --frozen-lockfile - - name: Build the database-postgres package (the probes boot the committed server which imports it) - run: pnpm --filter @personal-blog/database-postgres build + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build - name: Run app readiness test suite run: node --test tests/app-readiness.test.mjs + # E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs + # gate every PR — the suite locks in the field-specific startup error (a + # missing required setting fails startup with an error naming the missing + # field: packages/config's MissingRequiredSettingError/assertValidConfig, + # wired into the committed server before it binds) with mutation probes, and + # the deterministic probes execute 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 naming + # sessionSecret, while a valid secret boots to GET /health 200. The job + # installs the frozen workspace and builds the config and database-postgres + # packages because the probes boot the committed server which imports them. + config-startup-error: + name: Field-specific startup errors (E00-S04-T02) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Build the config and database-postgres packages (the probes boot the committed server which imports them) + run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build + - name: Run config startup error test suite + run: node --test tests/config-startup-error.test.mjs + # E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate # every PR — the suite locks in the TypeBox/Ajv configuration schema # (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 + diff --git a/.gitignore b/.gitignore index e0dcf9c..53a8c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,9 @@ coverage/ # the package (removed in its finally block) .config-schema-probe-*.mjs +# Transient host-side probe file written by the config-startup-error test +# suite into the package (removed in its finally block) +.config-startup-probe-*.mjs + # OS / editor .DS_Store diff --git a/tests/app-readiness.test.mjs b/tests/app-readiness.test.mjs index 4c08e81..fccde91 100644 --- a/tests/app-readiness.test.mjs +++ b/tests/app-readiness.test.mjs @@ -246,7 +246,11 @@ function reservePort() { /** * Boots the committed server source on `port` with the given env overrides * (merged over `process.env`; `DATABASE_URL` is stripped unless explicitly - * provided). Returns `{ child, stderr, stdout }`; the child writes its + * provided, and a valid `EPPP_SESSION_SECRET` is provided unless explicitly + * overridden — since E00-S04-T02 the required admin-session secret is + * validated at startup, and a missing secret is the field-specific + * startup-error path locked in by tests/config-startup-error.test.mjs). + * Returns `{ child, stderr, stdout }`; the child writes its * stdout/stderr into closures for diagnostics. */ function bootServer(port, envOverrides = {}) { @@ -254,7 +258,12 @@ function bootServer(port, envOverrides = {}) { tsExecMode() === 'strip-types-flag' ? ['--experimental-strip-types', SERVER_SRC] : [SERVER_SRC]; - const env = { ...process.env, PORT: String(port), ...envOverrides }; + // Strip an inherited DATABASE_URL unless the caller explicitly provides one + // (the no-database path must be deterministic), and provide the required + // admin-session secret (E00-S04-T02) unless the caller overrides it. + const env = { ...process.env, PORT: String(port) }; + delete env.DATABASE_URL; + Object.assign(env, { EPPP_SESSION_SECRET: 's'.repeat(32) }, envOverrides); const child = spawn(process.execPath, args, { cwd: REPO_ROOT, env, diff --git a/tests/config-startup-error.test.mjs b/tests/config-startup-error.test.mjs new file mode 100644 index 0000000..69a831c --- /dev/null +++ b/tests/config-startup-error.test.mjs @@ -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: ")', + ); + 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); + } +}); diff --git a/tests/health-endpoint.test.mjs b/tests/health-endpoint.test.mjs index cc49cd7..27c4f59 100644 --- a/tests/health-endpoint.test.mjs +++ b/tests/health-endpoint.test.mjs @@ -116,14 +116,22 @@ function reservePort() { * env): since E00-S03-T06 the health endpoint is the readiness probe, and the * no-DATABASE_URL path is the one with no startup migration run to wait for — * the server reports ready immediately, so this smoke test stays deterministic - * and exercises exactly the committed no-migration readiness path. + * and exercises exactly the committed no-migration readiness path. Since + * E00-S04-T02 the required admin-session secret (EPPP_SESSION_SECRET, the + * schema's required field) is validated at startup, so the boot provides a + * valid one — a missing secret is the field-specific startup-error path + * locked in by tests/config-startup-error.test.mjs. */ function bootServer(port) { const args = tsExecMode() === 'strip-types-flag' ? ['--experimental-strip-types', SERVER_SRC] : [SERVER_SRC]; - const env = { ...process.env, PORT: String(port) }; + const env = { + ...process.env, + PORT: String(port), + EPPP_SESSION_SECRET: 's'.repeat(32), + }; delete env.DATABASE_URL; const child = spawn(process.execPath, args, { cwd: REPO_ROOT,