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/apps/server/Dockerfile b/apps/server/Dockerfile index f2050e5..f8a1f85 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -21,14 +21,17 @@ # T05 the runtime stage drops root privileges (runs as the image's non-root # `node` user). # -# The app now depends on the `database-postgres` workspace package (the single -# owner of the pg/Kysely driver, E00-S03-T02). The build stage therefore also -# installs/builds that package — the server's `build`/`typecheck` scripts -# build their workspace dependency first (`pnpm --filter -# @personal-blog/database-postgres build`), and the runtime stage ships the -# compiled `packages/database-postgres/dist` next to the copied workspace -# node_modules links so the server's `@personal-blog/database-postgres` import -# resolves at run time. +# The app now depends on the `config` and `database-postgres` workspace +# packages (the configuration service — E00-S04-T02 validates the required +# settings at startup — and the single owner of the pg/Kysely driver, +# E00-S03-T02). The build stage therefore also installs/builds those +# packages — the server's `build`/`typecheck` scripts build their workspace +# dependencies first (`pnpm --filter @personal-blog/config build` and +# `pnpm --filter @personal-blog/database-postgres build`), and the runtime +# stage ships the compiled `packages/config/dist` and +# `packages/database-postgres/dist` next to the copied workspace node_modules +# links so the server's `@personal-blog/config` and +# `@personal-blog/database-postgres` imports resolve at run time. # # T08: the image embeds no secrets. The Dockerfile declares no secret-bearing # ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY @@ -68,10 +71,11 @@ COPY extensions/example/package.json extensions/example/package.json RUN pnpm install --frozen-lockfile # Compile the server package (tsc -p apps/server/tsconfig.json -> dist/). The -# server's build script builds its workspace dependency first (the -# `database-postgres` package, whose compiled dist the server imports), so a -# single command produces both dists in the right order. +# server's build script builds its workspace dependencies first (the `config` +# and `database-postgres` packages, whose compiled dists the server imports), +# so a single command produces all dists in the right order. COPY apps/server apps/server +COPY packages/config packages/config COPY packages/database-postgres packages/database-postgres RUN pnpm --filter @personal-blog/server build @@ -81,11 +85,13 @@ WORKDIR /app ENV NODE_ENV=production # The workspace install (devDependencies included — image-size pruning is a -# later E00-S02 concern) plus the compiled server output, the compiled -# database-postgres output the server imports, and the package manifests. +# later E00-S02 concern) plus the compiled server output, the compiled config +# and database-postgres outputs the server imports, and the package manifests. COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/apps/server/dist ./apps/server/dist COPY --from=build /app/apps/server/package.json ./apps/server/package.json +COPY --from=build /app/packages/config/dist ./packages/config/dist +COPY --from=build /app/packages/config/package.json ./packages/config/package.json COPY --from=build /app/packages/database-postgres/dist ./packages/database-postgres/dist COPY --from=build /app/packages/database-postgres/package.json ./packages/database-postgres/package.json diff --git a/apps/server/package.json b/apps/server/package.json index 026f594..56506f2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,13 +3,14 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story.", + "description": "EPPP public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), with a field-specific startup error when a required setting is missing (E00-S04-T02); the Fastify 5 application shell lands in a later story.", "scripts": { - "build": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", - "typecheck": "pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", + "build": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json", + "typecheck": "pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build && tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" }, "dependencies": { + "@personal-blog/config": "workspace:*", "@personal-blog/database-postgres": "workspace:*" }, "devDependencies": { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c99ebaf..f258caf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -18,11 +18,22 @@ * E00-S01-T06) there are no migrations to run, so the app reports ready * immediately. * + * [E00-S04-T02] field-specific startup error: the required settings are + * validated 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 + * with an error naming the missing field instead of booting with an invalid + * configuration. The parsed config keeps the committed defaults for + * `host`/`port`/`databaseUrl` (the `process.env` adapter that centralizes + * these reads is E00-S04-T04 and lands later); `assertValidConfig` throws + * `MissingRequiredSettingError` naming the missing field. + * * The Fastify 5 application shell (and the real HTTP API) lands in a later * story; this bootstrap keeps the application health-checkable until then. */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { assertValidConfig } from '@personal-blog/config'; import { Pool } from '@personal-blog/database-postgres'; import { MigrationLedger, MigrationRunner } from '@personal-blog/database-postgres'; import type { Migration } from '@personal-blog/database-postgres'; @@ -30,6 +41,17 @@ import type { Migration } from '@personal-blog/database-postgres'; /** Port the server listens on; `PORT` overrides the container default (3000). */ const PORT = resolvePort(process.env.PORT); +// [E00-S04-T02] field-specific startup error: validate the startup +// configuration before anything else, so a missing required setting (e.g. +// EPPP_SESSION_SECRET) crashes the process at startup with an error naming +// the missing field — never boots with an invalid configuration. +assertValidConfig({ + host: '0.0.0.0', + port: PORT, + databaseUrl: process.env.DATABASE_URL, + sessionSecret: process.env.EPPP_SESSION_SECRET, +}); + /** Health payload — reported once the startup migration run completes. */ const HEALTH_PAYLOAD = JSON.stringify({ status: 'ok' }); diff --git a/compose.yaml b/compose.yaml index 06aa600..856db96 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,6 +50,11 @@ # # All values have defaults so `docker compose up -d` works from a clean clone # without a .env file (a committed .env.example template lands in E00-S04). +# Since E00-S04-T02 the app validates its required settings at startup: the +# admin-session secret `EPPP_SESSION_SECRET` (the schema's required field, +# Security-and-Operations §32/§26) is provided here with a dev-only default — +# override it via a `.env` file / shell environment for anything beyond local +# development. services: db: @@ -93,6 +98,10 @@ services: - linux/arm64 environment: DATABASE_URL: postgres://eppp:eppp@db:5432/eppp + # E00-S04-T02: the app's required admin-session secret (the schema's + # required field, EPPP_SESSION_SECRET per Security-and-Operations + # §32/§26) — dev-only default (>= 32 chars), override via .env / shell. + EPPP_SESSION_SECRET: ${EPPP_SESSION_SECRET:-eppp-local-session-secret-change-me-0123456789} ports: - "${APP_PORT:-3000}:3000" # T02: start only once the database reports healthy (service_healthy), so diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 1956391..a7c7459 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -15,9 +15,9 @@ The workspace is a pnpm monorepo with three package groups: | Group | Path | Purpose | | --- | --- | --- | -| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story. | +| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06), and fails fast at startup with a field-specific error when a required setting is missing (E00-S04-T02); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret redaction (E00-S04-T03) land in later tasks. | +| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03) land in later tasks. | | `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | @@ -85,15 +85,23 @@ Expected result: `apps/server/dist/`, `packages/core/dist/`, ```sh # Run the compiled public server entrypoint -pnpm --filter @personal-blog/server start +EPPP_SESSION_SECRET='change-me-0123456789abcdefghijklmnopqrstuvwxyz' pnpm --filter @personal-blog/server start ``` This runs the `start` script of `apps/server` (`node dist/index.js`), i.e. the -compiled application entrypoint. Two things to know: +compiled application entrypoint. Three things to know: 1. The command **must follow `pnpm build`** — the `start` script executes the compiled artifact in `dist/`, it does not compile first. -2. Since [E00-S02-T03], `apps/server` serves the **application health +2. Since [E00-S04-T02] the server validates its **required settings at + startup**: the admin-session secret `EPPP_SESSION_SECRET` (the config + schema's required field, ≥ 32 characters, Security-and-Operations §32/§26) + must be set in the environment — if it is missing, the process fails fast + with a field-specific startup error (`missing required setting: + sessionSecret`) that names the missing field instead of booting. Provide it + in your shell or a local `.env` file (the `.env.example` template lands in + E00-S04). +3. Since [E00-S02-T03], `apps/server` serves the **application health endpoint**: starting it opens an HTTP server on port 3000 answering `GET /health`, so the process stays up. Since [E00-S03-T06] the endpoint is the **readiness probe**: when a `DATABASE_URL` is configured, the app @@ -131,7 +139,7 @@ pnpm install --frozen-lockfile # exit 0, lockfile untouched pnpm build # 5/5 packages emit dist/, exit 0 pnpm typecheck # 5/5 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 -pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up +pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up ``` ## Troubleshooting @@ -142,6 +150,7 @@ pnpm --filter @personal-blog/server start # serves GET /health on port 3000, s | `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. | | `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). | | `start` exits immediately with no output | The server crashed or exited at startup — check the process output. Since [E00-S02-T03] the entrypoint serves `GET /health` on port 3000 and stays up; a missing `pnpm build` (stale/absent `dist/`) is the usual cause (see [Run](#run)). | +| `start` fails with `missing required setting: sessionSecret` | Since [E00-S04-T02] the server validates its required settings at startup: the admin-session secret `EPPP_SESSION_SECRET` (≥ 32 chars) is missing or too short — set it in your shell or a local `.env` file (see [Run](#run)). | | `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). | ## Out of scope diff --git a/packages/config/package.json b/packages/config/package.json index 700a3da..5cddb6c 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.", + "description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01) and the field-specific startup error for a missing required setting (E00-S04-T02); the environment adapter (E00-S04-T04), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1bc4602..b6cc631 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -3,12 +3,20 @@ * * [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the * configuration schema (`configSchema`, defined with TypeBox) and the Ajv - * schema-validation entry point (`validateConfig`). The environment adapter - * (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret - * redaction (E00-S04-T03) build on this boundary in later tasks. + * schema-validation entry point (`validateConfig`). + * + * [E00-S04-T02] Field-specific startup error: the boundary also exposes the + * startup validation entry point (`assertValidConfig`) and its field-specific + * errors (`MissingRequiredSettingError` — names the missing required setting — + * and `ConfigStartupError` — names each violating field), so the application + * fails fast at startup when a required setting is missing. + * + * The environment adapter (E00-S04-T04) and secret redaction (E00-S04-T03) + * build on this boundary in later tasks. */ export { configSchema } from './schema.js'; export type { Config } from './schema.js'; export { validateConfig } from './validate.js'; export type { ConfigValidationResult } from './validate.js'; +export { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './startup.js'; diff --git a/packages/config/src/startup.ts b/packages/config/src/startup.ts new file mode 100644 index 0000000..f4953c2 --- /dev/null +++ b/packages/config/src/startup.ts @@ -0,0 +1,128 @@ +/** + * EPPP configuration startup validation — [E00-S04-T02] missing required + * setting gives a field-specific startup error. + * + * Builds on the E00-S04-T01 boundary (`configSchema` from `schema.ts`, + * validated with Ajv): `assertValidConfig` is the startup entry point the + * application calls with its parsed configuration before it binds — when a + * required setting is missing it throws `MissingRequiredSettingError`, whose + * message and `missingField` name the missing field (the issue's acceptance: + * "missing required setting gives a field-specific startup error", "the error + * names the missing field"); any other schema violation throws a + * `ConfigStartupError` whose message names each violating field too. + * + * This is deliberately NOT the T04 environment adapter: nothing here reads + * `process.env`. The adapter (E00-S04-T04) maps the environment onto the + * validated config shape and passes it to `assertValidConfig` at startup; + * secret redaction (E00-S04-T03) builds on the same boundary in a later task. + * + * Rollback note from the issue: revert the validation error handling. + */ + +import { Ajv, type ErrorObject } from 'ajv'; + +import { configSchema, type Config } from './schema.js'; + +/** Ajv instance for the config schema — `allErrors` reports every violation. */ +const ajv = new Ajv({ allErrors: true }); + +/** The compiled validator — TypeBox schemas are JSON Schema, so Ajv compiles them directly. */ +const validateConfigValue = ajv.compile(configSchema); + +/** + * The field-specific startup error thrown when a configuration value is + * invalid at startup (any schema violation). `violations` holds one + * field-prefixed message per violation (e.g. `"sessionSecret: must NOT have + * fewer than 32 characters"`), so the error names the offending field(s). + */ +export class ConfigStartupError extends Error { + /** Field-prefixed messages naming each violation (never empty). */ + readonly violations: ReadonlyArray; + + constructor(message: string, violations: readonly string[]) { + super(message); + this.name = 'ConfigStartupError'; + this.violations = violations; + } +} + +/** + * The error thrown when a required setting is missing — the E00-S04-T02 + * field-specific startup error. `missingField` and the message name the + * missing field (e.g. `"missing required setting: sessionSecret"`), so an + * operator starting the app with an incomplete configuration sees exactly + * which setting to provide. + */ +export class MissingRequiredSettingError extends ConfigStartupError { + /** The name of the required setting that is missing. */ + readonly missingField: string; + + constructor(missingField: string) { + super(`missing required setting: ${missingField}`, [`missing required setting: ${missingField}`]); + this.name = 'MissingRequiredSettingError'; + this.missingField = missingField; + } +} + +/** + * Formats one Ajv violation as a field-specific message: the field named by + * the error's `instancePath` (e.g. `/sessionSecret`) prefixes the Ajv + * message, so every startup error names the offending setting — never just a + * bare schema message. An `additionalProperties` violation points at the + * object (empty `instancePath`), so its offending key (Ajv + * `params.additionalProperty`) is used as the field instead. + */ +function formatViolation(error: ErrorObject): string { + const field = error.instancePath.replace(/^\//, ''); + const message = error.message ?? 'invalid'; + if (field !== '') { + return `${field}: ${message}`; + } + const extra = (error.params as { additionalProperty?: unknown } | undefined)?.additionalProperty; + return typeof extra === 'string' && extra.length > 0 ? `${extra}: ${message}` : message; +} + +/** + * Validates a configuration value at startup and returns it as the typed + * `Config` — or throws a field-specific startup error: + * + * - a missing required setting throws `MissingRequiredSettingError` naming + * the missing field (the issue's acceptance criteria); + * - any other schema violation throws `ConfigStartupError` whose message + * names the violating field(s). + * + * The application calls this before it starts serving, so an invalid + * configuration fails fast at startup with a clear, field-specific error + * instead of booting with a silently-wrong setting. + * + * @param value - the parsed configuration value (the T04 adapter will hand + * this the mapped environment) + * @returns the validated configuration + * @throws {MissingRequiredSettingError} when a required setting is missing + * @throws {ConfigStartupError} when the configuration violates the schema + */ +export function assertValidConfig(value: unknown): Config { + const valid = validateConfigValue(value); + if (valid) { + return value as Config; + } + + const errors = validateConfigValue.errors ?? []; + + // Missing required settings get the dedicated field-specific error — the + // Ajv `required` keyword error carries the missing property name, which is + // exactly the field the acceptance criteria require the error to name. + const missingFields = errors + .filter((error) => error.keyword === 'required') + .map((error) => { + const missing = (error.params as { missingProperty?: unknown } | undefined)?.missingProperty; + return typeof missing === 'string' ? missing : ''; + }) + .filter((field) => field.length > 0); + if (missingFields.length > 0) { + throw new MissingRequiredSettingError(missingFields.join(', ')); + } + + const violations = errors.map(formatViolation); + throw new ConfigStartupError(`invalid configuration: ${violations.join('; ')}`, violations); +} diff --git a/packages/config/src/validate.ts b/packages/config/src/validate.ts index 81435d2..ef2bf52 100644 --- a/packages/config/src/validate.ts +++ b/packages/config/src/validate.ts @@ -8,9 +8,9 @@ * * This is deliberately NOT the E00-S04-T02 field-specific startup error: * `validateConfig` returns the raw schema-validation outcome (valid or not, - * with the Ajv messages) and performs no startup wiring — the adapter - * (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it - * in later tasks. + * with the Ajv messages) and performs no startup wiring — the startup error + * formatting (E00-S04-T02, `startup.ts`) and the environment adapter + * (E00-S04-T04) build on this raw outcome in their own modules. */ import { Ajv } from 'ajv'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3669a69..6511571 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/server: dependencies: + '@personal-blog/config': + specifier: workspace:* + version: link:../../packages/config '@personal-blog/database-postgres': specifier: workspace:* version: link:../../packages/database-postgres 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,