|
|
@@ -0,0 +1,634 @@
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Config startup error test — locks in the [E00-S04-T02] guarantee that a
|
|
|
|
|
|
|
|
* missing required setting gives a field-specific startup error.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* Acceptance criteria covered (each test fails without the committed state):
|
|
|
|
|
|
|
|
* - "missing required setting gives a field-specific startup error" → the
|
|
|
|
|
|
|
|
* `packages/config` package exposes the startup validation entry point
|
|
|
|
|
|
|
|
* (`assertValidConfig`, building on the E00-S04-T01 TypeBox/Ajv schema)
|
|
|
|
|
|
|
|
* and the committed `apps/server/src/index.ts` calls it before the server
|
|
|
|
|
|
|
|
* binds, so a deployment missing a required setting (the admin-session
|
|
|
|
|
|
|
|
* secret `EPPP_SESSION_SECRET` — the schema's required field,
|
|
|
|
|
|
|
|
* Security-and-Operations §32/§26) fails fast at startup instead of
|
|
|
|
|
|
|
|
* booting with an invalid configuration. Locked in statically (mutation
|
|
|
|
|
|
|
|
* probes prove non-vacuity: dropping the startup validation call,
|
|
|
|
|
|
|
|
* moving it after the bind, or dropping the compose/Dockerfile support
|
|
|
|
|
|
|
|
* all fail) and behaviorally by the deterministic probes (the issue's
|
|
|
|
|
|
|
|
* test plan: "start with a missing required field and confirm the error
|
|
|
|
|
|
|
|
* names it" — booting the committed server without `EPPP_SESSION_SECRET`
|
|
|
|
|
|
|
|
* exits non-zero with the error naming the missing field).
|
|
|
|
|
|
|
|
* - "the error names the missing field" → a missing required setting throws
|
|
|
|
|
|
|
|
* `MissingRequiredSettingError` whose message and `missingField` name the
|
|
|
|
|
|
|
|
* missing field (e.g. `"missing required setting: sessionSecret"`); other
|
|
|
|
|
|
|
|
* schema violations throw `ConfigStartupError` whose message names the
|
|
|
|
|
|
|
|
* violating field too. Locked in statically and by the deterministic
|
|
|
|
|
|
|
|
* probes (boundary + booted server).
|
|
|
|
|
|
|
|
* - the compose stack and the server image stay runnable with the required
|
|
|
|
|
|
|
|
* secret: `compose.yaml` provides `EPPP_SESSION_SECRET` for the `app`
|
|
|
|
|
|
|
|
* service (dev-only default, ≥ 32 chars — override via .env / shell) and
|
|
|
|
|
|
|
|
* the `apps/server/Dockerfile` ships the compiled `packages/config` next
|
|
|
|
|
|
|
|
* to the other workspace deps, so the app container boots (the
|
|
|
|
|
|
|
|
* deterministic probe boots with a valid secret and `GET /health` answers
|
|
|
|
|
|
|
|
* 200 — a valid startup still works).
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* Run: `node --test tests/config-startup-error.test.mjs`
|
|
|
|
|
|
|
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.
|
|
|
|
|
|
|
|
* The deterministic probes boot the committed server, which imports
|
|
|
|
|
|
|
|
* `@personal-blog/config` and `@personal-blog/database-postgres` — build those
|
|
|
|
|
|
|
|
* packages first, exactly as the CI job does.)
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import test from 'node:test';
|
|
|
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
|
|
|
import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs';
|
|
|
|
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
|
|
|
|
|
|
import { once } from 'node:events';
|
|
|
|
|
|
|
|
import { createServer as createNetServer } from 'node:net';
|
|
|
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** The committed config package and server entrypoint under test. */
|
|
|
|
|
|
|
|
const CONFIG_DIR = 'packages/config';
|
|
|
|
|
|
|
|
const STARTUP_SRC = `${CONFIG_DIR}/src/startup.ts`;
|
|
|
|
|
|
|
|
const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`;
|
|
|
|
|
|
|
|
const SERVER_SRC = 'apps/server/src/index.ts';
|
|
|
|
|
|
|
|
const COMPOSE_PATH = 'compose.yaml';
|
|
|
|
|
|
|
|
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */
|
|
|
|
|
|
|
|
const ROOT_TEST_GLOB = 'tests/**/*.test.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** The CI job that gates the startup-error criterion on every PR. */
|
|
|
|
|
|
|
|
const CI_JOB = 'config-startup-error';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Static assertions on the committed sources
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Asserts the config package exposes the field-specific startup error: the
|
|
|
|
|
|
|
|
* startup module compiles the TypeBox `configSchema` with Ajv and exports
|
|
|
|
|
|
|
|
* `assertValidConfig` plus the field-specific errors — `ConfigStartupError`
|
|
|
|
|
|
|
|
* for any schema violation, and `MissingRequiredSettingError` (whose message
|
|
|
|
|
|
|
|
* and `missingField` name the missing field, e.g.
|
|
|
|
|
|
|
|
* `"missing required setting: sessionSecret"`) for a missing required
|
|
|
|
|
|
|
|
* setting. Fails fast on a deviation; the mutation probes below prove the
|
|
|
|
|
|
|
|
* assertions are non-vacuous.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function assertStartupErrorSource(src) {
|
|
|
|
|
|
|
|
// Built on the E00-S04-T01 schema boundary: the module compiles the
|
|
|
|
|
|
|
|
// TypeBox configSchema with Ajv (same golden-tuple validator as
|
|
|
|
|
|
|
|
// validate.ts) and derives the missing-field names from the Ajv `required`
|
|
|
|
|
|
|
|
// keyword errors.
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/import \{ Ajv, type ErrorObject \} from 'ajv'/,
|
|
|
|
|
|
|
|
'the startup module must import Ajv (the golden-tuple validator) and the ErrorObject type',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/import \{ configSchema, type Config \} from '\.\/schema\.js'/,
|
|
|
|
|
|
|
|
'the startup module must build on the committed configSchema (import from ./schema.js)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/new Ajv\(\{ allErrors: true \}\)/,
|
|
|
|
|
|
|
|
'the startup module must create the Ajv instance with allErrors (report every violation)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/\.compile\(configSchema\)/,
|
|
|
|
|
|
|
|
'the startup module must compile the TypeBox configSchema with Ajv',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// The field-specific errors.
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/export class ConfigStartupError extends Error/,
|
|
|
|
|
|
|
|
'the startup module must export ConfigStartupError (any invalid configuration is a field-specific startup error)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/export class MissingRequiredSettingError extends ConfigStartupError/,
|
|
|
|
|
|
|
|
'the startup module must export MissingRequiredSettingError (a missing required setting is the field-specific startup error)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/missing required setting: \$\{missingField\}/,
|
|
|
|
|
|
|
|
'the MissingRequiredSettingError message must name the missing field ("missing required setting: <field>")',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/readonly missingField: string/,
|
|
|
|
|
|
|
|
'MissingRequiredSettingError must carry the missing field name (missingField)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// The startup entry point: validates and throws the field-specific error
|
|
|
|
|
|
|
|
// for a missing required setting (Ajv `required` keyword -> missingProperty).
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/export function assertValidConfig\(value: unknown\): Config/,
|
|
|
|
|
|
|
|
'the startup module must export assertValidConfig (the startup validation entry point)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/error\.keyword === 'required'/,
|
|
|
|
|
|
|
|
'assertValidConfig must detect missing required settings from the Ajv "required" keyword errors',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/throw new MissingRequiredSettingError\(missingFields\.join\(', '\)\)/,
|
|
|
|
|
|
|
|
'assertValidConfig must throw MissingRequiredSettingError naming the missing field(s)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Asserts the committed server validates the required settings at startup:
|
|
|
|
|
|
|
|
* it imports `assertValidConfig` from `@personal-blog/config` and calls it
|
|
|
|
|
|
|
|
* with the parsed startup configuration (including the required
|
|
|
|
|
|
|
|
* `EPPP_SESSION_SECRET`) BEFORE the server binds — so a missing required
|
|
|
|
|
|
|
|
* setting is a startup error, never a silently-booted invalid configuration.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function assertServerStartupValidation(src) {
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/import \{ assertValidConfig \} from '@personal-blog\/config'/,
|
|
|
|
|
|
|
|
'the server must import the startup validation entry point from the config package',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/assertValidConfig\(\{/,
|
|
|
|
|
|
|
|
'the server must call assertValidConfig with its startup configuration',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/sessionSecret: process\.env\.EPPP_SESSION_SECRET/,
|
|
|
|
|
|
|
|
'the server must feed the required admin-session secret (EPPP_SESSION_SECRET) into the startup validation',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const callIndex = src.indexOf('assertValidConfig({');
|
|
|
|
|
|
|
|
const listenIndex = src.indexOf('server.listen(');
|
|
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
|
|
callIndex !== -1 && listenIndex !== -1 && callIndex < listenIndex,
|
|
|
|
|
|
|
|
'the startup validation must run before the server binds (server.listen) so a missing required setting is a startup error',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Asserts the compose app service provides the required admin-session secret
|
|
|
|
|
|
|
|
* (`EPPP_SESSION_SECRET`) with a dev-only default of at least 32 characters
|
|
|
|
|
|
|
|
* (the schema's required field, Security-and-Operations §32/§26), so
|
|
|
|
|
|
|
|
* `docker compose up -d` keeps working from a clean clone while the app's
|
|
|
|
|
|
|
|
* startup validation has the secret it requires.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function assertComposeSecret(composeText) {
|
|
|
|
|
|
|
|
// The app service block runs from the top-level " app:" key to the
|
|
|
|
|
|
|
|
// top-level "volumes:" map (the db service has its own nested "volumes:"
|
|
|
|
|
|
|
|
// key earlier, so slice to the root-level one).
|
|
|
|
|
|
|
|
const appBlock = composeText.slice(composeText.indexOf(' app:'), composeText.indexOf('\nvolumes:'));
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
appBlock,
|
|
|
|
|
|
|
|
/EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]{32,}\}/,
|
|
|
|
|
|
|
|
'the app service must provide EPPP_SESSION_SECRET with a >= 32 char dev-only default (${EPPP_SESSION_SECRET:-...}), so the app boots with the required secret from a clean clone',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Asserts the server image ships the config package: the build stage copies
|
|
|
|
|
|
|
|
* `packages/config` source (the server's build compiles it) and the runtime
|
|
|
|
|
|
|
|
* stage copies the compiled `packages/config/dist` + manifest next to the
|
|
|
|
|
|
|
|
* other workspace deps the server imports.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function assertDockerfileConfig(dockerfile) {
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
dockerfile,
|
|
|
|
|
|
|
|
/COPY packages\/config packages\/config/,
|
|
|
|
|
|
|
|
'the build stage must copy the config package source (the server build compiles its workspace dependency)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
dockerfile,
|
|
|
|
|
|
|
|
/COPY --from=build \/app\/packages\/config\/dist \.\/packages\/config\/dist/,
|
|
|
|
|
|
|
|
'the runtime stage must ship the compiled config package (packages/config/dist) so the server import resolves in the image',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Criterion tests
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the config package exposes the field-specific startup error (MissingRequiredSettingError + assertValidConfig)', () => {
|
|
|
|
|
|
|
|
assert.ok(existsSync(path.join(REPO_ROOT, STARTUP_SRC)), `committed ${STARTUP_SRC} must exist`);
|
|
|
|
|
|
|
|
assertStartupErrorSource(read(STARTUP_SRC));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the package boundary re-exports the startup validation entry point and its errors', () => {
|
|
|
|
|
|
|
|
const src = read(INDEX_SRC);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
src,
|
|
|
|
|
|
|
|
/export \{ assertValidConfig, ConfigStartupError, MissingRequiredSettingError \} from '\.\/startup\.js'/,
|
|
|
|
|
|
|
|
'the boundary must re-export assertValidConfig and the field-specific startup errors',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the server validates the required settings at startup, before it binds', () => {
|
|
|
|
|
|
|
|
assert.ok(existsSync(path.join(REPO_ROOT, SERVER_SRC)), `committed ${SERVER_SRC} must exist`);
|
|
|
|
|
|
|
|
assertServerStartupValidation(read(SERVER_SRC));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the compose app service provides the required admin-session secret (EPPP_SESSION_SECRET)', () => {
|
|
|
|
|
|
|
|
assertComposeSecret(read(COMPOSE_PATH));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the server image ships the config package (build source + runtime dist)', () => {
|
|
|
|
|
|
|
|
assertDockerfileConfig(read(DOCKERFILE_PATH));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the config-startup-error criterion is enforced in CI', () => {
|
|
|
|
|
|
|
|
// Picked up by the root test command (root `scripts.test` glob).
|
|
|
|
|
|
|
|
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
|
|
scripts.test,
|
|
|
|
|
|
|
|
`node --test "${ROOT_TEST_GLOB}"`,
|
|
|
|
|
|
|
|
`root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
// And a dedicated CI job gates it on every PR.
|
|
|
|
|
|
|
|
const workflow = read('.gitea/workflows/ci.yml');
|
|
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
|
|
workflow.includes(`node --test tests/config-startup-error.test.mjs`),
|
|
|
|
|
|
|
|
`CI must run the config-startup-error suite (job "${CI_JOB}") on every PR`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
|
|
workflow.includes(
|
|
|
|
|
|
|
|
'pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build',
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
'the CI job must build the config and database-postgres packages (the probes boot the committed server which imports them)',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Mutation probes — the static assertions are non-vacuous
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('dropping the startup validation call fails the server wiring assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const src = read(SERVER_SRC);
|
|
|
|
|
|
|
|
const withoutCall = src.replace('assertValidConfig({\n', 'assertValidConfigx({\n');
|
|
|
|
|
|
|
|
assert.notEqual(withoutCall, src, 'the mutation must actually replace the assertValidConfig call');
|
|
|
|
|
|
|
|
assert.throws(() => assertServerStartupValidation(withoutCall), /must call assertValidConfig/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('moving the startup validation after the server binds fails the order assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const src = read(SERVER_SRC);
|
|
|
|
|
|
|
|
const moved = src
|
|
|
|
|
|
|
|
.replace(/assertValidConfig\(\{\n host: '0\.0\.0\.0',\n port: PORT,\n databaseUrl: process\.env\.DATABASE_URL,\n sessionSecret: process\.env\.EPPP_SESSION_SECRET,\n\}\);\n/, '')
|
|
|
|
|
|
|
|
.replace(
|
|
|
|
|
|
|
|
'server.listen(PORT, () => {',
|
|
|
|
|
|
|
|
'server.listen(PORT, () => {\n assertValidConfig({ sessionSecret: process.env.EPPP_SESSION_SECRET });',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.notEqual(moved, src, 'the mutation must actually move the validation call after the bind');
|
|
|
|
|
|
|
|
assert.throws(() => assertServerStartupValidation(moved), /before the server binds/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('an error message that does not name the missing field fails the naming assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const src = read(STARTUP_SRC);
|
|
|
|
|
|
|
|
const noField = src.replace(/missing required setting: \$\{missingField\}/g, 'missing required setting');
|
|
|
|
|
|
|
|
assert.notEqual(noField, src, 'the mutation must actually drop the field name from the message');
|
|
|
|
|
|
|
|
assert.throws(() => assertStartupErrorSource(noField), /must name the missing field/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('dropping MissingRequiredSettingError fails the field-specific error assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const src = read(STARTUP_SRC);
|
|
|
|
|
|
|
|
const noError = src.replace('export class MissingRequiredSettingError extends ConfigStartupError', 'export class MissingRequiredSettingErrorX extends ConfigStartupError');
|
|
|
|
|
|
|
|
assert.notEqual(noError, src, 'the mutation must actually rename the error class');
|
|
|
|
|
|
|
|
assert.throws(() => assertStartupErrorSource(noError), /must export MissingRequiredSettingError/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('dropping the missing-field detection fails the required-setting assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const src = read(STARTUP_SRC);
|
|
|
|
|
|
|
|
const noDetection = src.replace("error.keyword === 'required'", "error.keyword === 'minLength'");
|
|
|
|
|
|
|
|
assert.notEqual(noDetection, src, 'the mutation must actually change the missing-field detection');
|
|
|
|
|
|
|
|
assert.throws(() => assertStartupErrorSource(noDetection), /"required" keyword/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('removing EPPP_SESSION_SECRET from the compose app service fails the compose assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const composeText = read(COMPOSE_PATH);
|
|
|
|
|
|
|
|
const withoutSecret = composeText.replace(
|
|
|
|
|
|
|
|
/ EPPP_SESSION_SECRET: \$\{EPPP_SESSION_SECRET:-[^}]*\}\n/,
|
|
|
|
|
|
|
|
'',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.notEqual(withoutSecret, composeText, 'the mutation must actually remove the secret env entry');
|
|
|
|
|
|
|
|
assert.throws(() => assertComposeSecret(withoutSecret), /EPPP_SESSION_SECRET/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('dropping the config package from the image fails the Dockerfile assertion (mutation probe)', () => {
|
|
|
|
|
|
|
|
const dockerfile = read(DOCKERFILE_PATH);
|
|
|
|
|
|
|
|
const withoutDist = dockerfile.replace(
|
|
|
|
|
|
|
|
'COPY --from=build /app/packages/config/dist ./packages/config/dist\n',
|
|
|
|
|
|
|
|
'',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.notEqual(withoutDist, dockerfile, 'the mutation must actually drop the runtime dist copy');
|
|
|
|
|
|
|
|
assert.throws(() => assertDockerfileConfig(withoutDist), /runtime stage must ship the compiled config package/);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Deterministic behavioral probe — the issue's test plan: "start with a
|
|
|
|
|
|
|
|
// missing required field and confirm the error names it"
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
|
|
|
|
|
|
|
|
* stripping on by default), `strip-types-flag` (>= 22.6 via
|
|
|
|
|
|
|
|
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
|
|
|
|
|
|
|
|
* workspace pins engines.node to 24.x, where type stripping is stable.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function tsExecMode() {
|
|
|
|
|
|
|
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
|
|
|
|
|
|
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
|
|
|
|
|
|
|
|
if (major === 22 && minor >= 6) return 'strip-types-flag';
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** True when this Node can execute the committed `.ts` server source (>= 22.6, type stripping). */
|
|
|
|
|
|
|
|
const TS_STRIPPING = tsExecMode() !== null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** The compiled config package boundary the probes import (built by the CI job first). */
|
|
|
|
|
|
|
|
const CONFIG_DIST = existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'));
|
|
|
|
|
|
|
|
/** The compiled database-postgres package the booted server also imports. */
|
|
|
|
|
|
|
|
const DATABASE_POSTGRES_DIST = existsSync(
|
|
|
|
|
|
|
|
path.join(REPO_ROOT, 'packages/database-postgres', 'dist', 'index.js'),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Why the boot probes may be skipped on a clean clone without a build step. */
|
|
|
|
|
|
|
|
const BUILD_HINT =
|
|
|
|
|
|
|
|
'build the config and database-postgres packages first (pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build)';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* The boundary probe source: exercises the compiled `@personal-blog/config`
|
|
|
|
|
|
|
|
* startup boundary (`assertValidConfig` + the field-specific errors) exactly
|
|
|
|
|
|
|
|
* as the server consumes it — a valid configuration passes (including the
|
|
|
|
|
|
|
|
* no-database local path), a missing required setting throws
|
|
|
|
|
|
|
|
* `MissingRequiredSettingError` naming the field, and other violations throw
|
|
|
|
|
|
|
|
* `ConfigStartupError` naming the violating field. Written to a temp file
|
|
|
|
|
|
|
|
* inside `packages/config/` so `ajv`/`@sinclair/typebox` resolve through the
|
|
|
|
|
|
|
|
* package's own dependency links, then removed.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
const PROBE_SOURCE = `
|
|
|
|
|
|
|
|
import { assertValidConfig, ConfigStartupError, MissingRequiredSettingError } from './dist/index.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const capture = (fn) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return { threw: false, value: fn() };
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
threw: true,
|
|
|
|
|
|
|
|
name: error && typeof error === 'object' ? error.name : String(error),
|
|
|
|
|
|
|
|
message: error instanceof Error ? error.message : String(error),
|
|
|
|
|
|
|
|
missingField: error && typeof error === 'object' ? error.missingField : undefined,
|
|
|
|
|
|
|
|
isMissingRequired: error instanceof MissingRequiredSettingError,
|
|
|
|
|
|
|
|
isConfigStartup: error instanceof ConfigStartupError,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
|
|
|
// A valid configuration passes and is returned — including the local
|
|
|
|
|
|
|
|
// non-container path with no databaseUrl (host/port/databaseUrl optional).
|
|
|
|
|
|
|
|
validReturnsConfig: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32) })).threw === false,
|
|
|
|
|
|
|
|
fullValid: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000, databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', sessionSecret: 's'.repeat(32) })).threw === false,
|
|
|
|
|
|
|
|
// The issue's test plan: start with a missing required field — the error
|
|
|
|
|
|
|
|
// must be MissingRequiredSettingError and name the missing field.
|
|
|
|
|
|
|
|
missingSecret: capture(() => assertValidConfig({ host: '0.0.0.0', port: 3000 })),
|
|
|
|
|
|
|
|
// Other violations are field-specific too (the message names the field).
|
|
|
|
|
|
|
|
shortSecret: capture(() => assertValidConfig({ sessionSecret: 'short' })),
|
|
|
|
|
|
|
|
unknownProperty: capture(() => assertValidConfig({ sessionSecret: 's'.repeat(32), extra: true })),
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.log('CONFIG_STARTUP_PROBE_RESULT ' + JSON.stringify(result));
|
|
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('the compiled startup boundary throws a field-specific error naming the missing required setting (deterministic probe)', { skip: !CONFIG_DIST ? BUILD_HINT : false }, () => {
|
|
|
|
|
|
|
|
const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-startup-probe-${process.pid}.mjs`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
writeFileSync(probeFile, PROBE_SOURCE);
|
|
|
|
|
|
|
|
const run = spawnSync(process.execPath, [path.basename(probeFile)], {
|
|
|
|
|
|
|
|
cwd: path.join(REPO_ROOT, CONFIG_DIR),
|
|
|
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
|
|
|
timeout: 60_000,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
|
|
run.status,
|
|
|
|
|
|
|
|
0,
|
|
|
|
|
|
|
|
`the probe must exit 0 (status ${run.status}):\n${(run.stderr || run.stdout || '').trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const match = run.stdout.match(/CONFIG_STARTUP_PROBE_RESULT (\{.*\})/);
|
|
|
|
|
|
|
|
assert.ok(match, `the probe must print CONFIG_STARTUP_PROBE_RESULT:\n${run.stdout.trim()}`);
|
|
|
|
|
|
|
|
const result = JSON.parse(match[1]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// A valid configuration passes (including the no-database local path).
|
|
|
|
|
|
|
|
assert.equal(result.validReturnsConfig, true, 'a valid config (only the required secret) must pass assertValidConfig');
|
|
|
|
|
|
|
|
assert.equal(result.fullValid, true, 'a full valid config must pass assertValidConfig');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Missing required setting: field-specific startup error naming the field.
|
|
|
|
|
|
|
|
assert.equal(result.missingSecret.threw, true, 'a config missing the required secret must throw');
|
|
|
|
|
|
|
|
assert.equal(result.missingSecret.isMissingRequired, true, 'a missing required setting must throw MissingRequiredSettingError');
|
|
|
|
|
|
|
|
assert.equal(result.missingSecret.isConfigStartup, true, 'MissingRequiredSettingError must be a ConfigStartupError');
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
|
|
result.missingSecret.missingField,
|
|
|
|
|
|
|
|
'sessionSecret',
|
|
|
|
|
|
|
|
`MissingRequiredSettingError must carry the missing field name (got: ${JSON.stringify(result.missingSecret)})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
result.missingSecret.message,
|
|
|
|
|
|
|
|
/missing required setting: sessionSecret/,
|
|
|
|
|
|
|
|
`the startup error must name the missing field (got: ${JSON.stringify(result.missingSecret)})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Other violations are field-specific too.
|
|
|
|
|
|
|
|
assert.equal(result.shortSecret.threw, true, 'a too-short secret must throw');
|
|
|
|
|
|
|
|
assert.equal(result.shortSecret.isMissingRequired, false, 'a too-short secret is not a missing required setting');
|
|
|
|
|
|
|
|
assert.equal(result.shortSecret.isConfigStartup, true, 'a too-short secret must throw ConfigStartupError');
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
result.shortSecret.message,
|
|
|
|
|
|
|
|
/sessionSecret/,
|
|
|
|
|
|
|
|
`the startup error must name the violating field (got: ${JSON.stringify(result.shortSecret)})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.equal(result.unknownProperty.threw, true, 'an unknown property must throw');
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
result.unknownProperty.message,
|
|
|
|
|
|
|
|
/extra/,
|
|
|
|
|
|
|
|
`the startup error must name the unexpected field (got: ${JSON.stringify(result.unknownProperty)})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
rmSync(probeFile, { force: true });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Server-boot probes — the issue's test plan executed against the real
|
|
|
|
|
|
|
|
// committed startup: "start with a missing required field and confirm the
|
|
|
|
|
|
|
|
// error names it"
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
|
|
|
|
|
|
|
|
function reservePort() {
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
const probe = createNetServer();
|
|
|
|
|
|
|
|
probe.once('error', reject);
|
|
|
|
|
|
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
|
|
|
|
|
|
const address = probe.address();
|
|
|
|
|
|
|
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
|
|
|
|
|
|
probe.close(() => resolve(port));
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Boots the committed server source on `port` with the given env overrides.
|
|
|
|
|
|
|
|
* An inherited `DATABASE_URL` (the no-database path must be deterministic)
|
|
|
|
|
|
|
|
* and `EPPP_SESSION_SECRET` (the missing-secret case must be deterministic)
|
|
|
|
|
|
|
|
* are stripped unless explicitly provided. Returns `{ child, stdout, stderr }`
|
|
|
|
|
|
|
|
* with closures for diagnostics.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function bootServer(port, envOverrides = {}) {
|
|
|
|
|
|
|
|
const args =
|
|
|
|
|
|
|
|
tsExecMode() === 'strip-types-flag'
|
|
|
|
|
|
|
|
? ['--experimental-strip-types', SERVER_SRC]
|
|
|
|
|
|
|
|
: [SERVER_SRC];
|
|
|
|
|
|
|
|
const env = { ...process.env, PORT: String(port) };
|
|
|
|
|
|
|
|
delete env.DATABASE_URL;
|
|
|
|
|
|
|
|
delete env.EPPP_SESSION_SECRET;
|
|
|
|
|
|
|
|
Object.assign(env, envOverrides);
|
|
|
|
|
|
|
|
const child = spawn(process.execPath, args, {
|
|
|
|
|
|
|
|
cwd: REPO_ROOT,
|
|
|
|
|
|
|
|
env,
|
|
|
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
let stdout = '';
|
|
|
|
|
|
|
|
let stderr = '';
|
|
|
|
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
|
|
|
|
|
|
stdout += String(chunk);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
|
|
|
|
|
|
stderr += String(chunk);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
return { child, stdout: () => stdout, stderr: () => stderr };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Waits for the child to exit (it fails fast on a startup error). */
|
|
|
|
|
|
|
|
function waitForExit(child, deadlineMs = 10_000) {
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
|
|
|
|
|
|
resolve({ code: child.exitCode, signal: child.signalCode });
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const timer = setTimeout(
|
|
|
|
|
|
|
|
() => reject(new Error('the server did not exit within the deadline (expected a startup error)')),
|
|
|
|
|
|
|
|
deadlineMs,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
child.once('exit', (code, signal) => {
|
|
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
|
|
resolve({ code, signal });
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Polls `GET /health` until the server answers with ANY status, the child
|
|
|
|
|
|
|
|
* exits, or the deadline passes. Returns the fetch Response.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
async function waitForAnswer(port, child, stderr, deadlineMs = 10_000) {
|
|
|
|
|
|
|
|
const deadline = Date.now() + deadlineMs;
|
|
|
|
|
|
|
|
let lastError = '';
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
|
|
|
if (child.exitCode !== null) {
|
|
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
|
|
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return await fetch(`http://127.0.0.1:${port}/health`, {
|
|
|
|
|
|
|
|
signal: AbortSignal.timeout(1_000),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
lastError = err instanceof Error ? err.message : String(err);
|
|
|
|
|
|
|
|
await delay(100);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
|
|
`GET /health did not answer within ${deadlineMs}ms (last error: ${lastError}; server stderr: ${stderr().trim()})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Kills a booted child (SIGTERM, then SIGKILL if needed) and waits for exit. */
|
|
|
|
|
|
|
|
async function stopChild(child) {
|
|
|
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
|
|
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
|
|
|
|
await Promise.race([once(child, 'exit'), delay(2_000)]);
|
|
|
|
|
|
|
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('starting without the required setting exits non-zero naming the missing field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
|
|
|
|
|
|
|
// The issue's test plan: "start with a missing required field and confirm
|
|
|
|
|
|
|
|
// the error names it". The committed server validates its required settings
|
|
|
|
|
|
|
|
// at startup, so booting it without EPPP_SESSION_SECRET must fail fast —
|
|
|
|
|
|
|
|
// the process exits non-zero and the error names the missing field.
|
|
|
|
|
|
|
|
const port = await reservePort();
|
|
|
|
|
|
|
|
const { child, stdout, stderr } = bootServer(port); // EPPP_SESSION_SECRET stripped
|
|
|
|
|
|
|
|
const { code, signal } = await waitForExit(child);
|
|
|
|
|
|
|
|
assert.notEqual(
|
|
|
|
|
|
|
|
code,
|
|
|
|
|
|
|
|
0,
|
|
|
|
|
|
|
|
`the server must exit non-zero when a required setting is missing (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
stderr() + stdout(),
|
|
|
|
|
|
|
|
/missing required setting: sessionSecret/,
|
|
|
|
|
|
|
|
`the startup error must name the missing field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('starting with an invalid required secret exits non-zero naming the field (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
|
|
|
|
|
|
|
// A required setting that violates the schema (a too-short secret, < 32
|
|
|
|
|
|
|
|
// chars) is also a field-specific startup error naming the field.
|
|
|
|
|
|
|
|
const port = await reservePort();
|
|
|
|
|
|
|
|
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 'short' });
|
|
|
|
|
|
|
|
const { code, signal } = await waitForExit(child);
|
|
|
|
|
|
|
|
assert.notEqual(
|
|
|
|
|
|
|
|
code,
|
|
|
|
|
|
|
|
0,
|
|
|
|
|
|
|
|
`the server must exit non-zero when the required secret is invalid (code ${code}, signal ${signal}); output: ${stdout().trim()} ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
|
|
stderr() + stdout(),
|
|
|
|
|
|
|
|
/sessionSecret/,
|
|
|
|
|
|
|
|
`the startup error must name the invalid field (sessionSecret); got: ${stdout().trim()} ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('starting with the required secret present boots to GET /health 200 (server boot probe)', { skip: !TS_STRIPPING || !CONFIG_DIST || !DATABASE_POSTGRES_DIST ? BUILD_HINT : false }, async () => {
|
|
|
|
|
|
|
|
// The startup validation must not reject a valid configuration: with the
|
|
|
|
|
|
|
|
// required admin-session secret (and no DATABASE_URL — the local
|
|
|
|
|
|
|
|
// non-container path) the server reports ready immediately and GET /health
|
|
|
|
|
|
|
|
// answers 200 {"status":"ok"}.
|
|
|
|
|
|
|
|
const port = await reservePort();
|
|
|
|
|
|
|
|
const { child, stdout, stderr } = bootServer(port, { EPPP_SESSION_SECRET: 's'.repeat(32) });
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const response = await waitForAnswer(port, child, stderr);
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
|
|
response.status,
|
|
|
|
|
|
|
|
200,
|
|
|
|
|
|
|
|
`GET /health with a valid required secret must answer 200 (got ${response.status}); server output: ${stdout().trim()} ${stderr().trim()}`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
|
|
await response.json(),
|
|
|
|
|
|
|
|
{ status: 'ok' },
|
|
|
|
|
|
|
|
'the app must report a healthy application ({"status":"ok"}) when the required settings are valid',
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
await stopChild(child);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|