diff --git a/tests/config-schema.test.mjs b/tests/config-schema.test.mjs new file mode 100644 index 0000000..69c8b56 --- /dev/null +++ b/tests/config-schema.test.mjs @@ -0,0 +1,415 @@ +/** + * Config schema test — locks in the [E00-S04-T01] TypeBox/Ajv configuration + * schema for the workspace. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "configuration schema is defined with TypeBox/Ajv" → the new + * `packages/config` package (`@personal-blog/config`) pins the + * golden-tuple runtime deps exactly (`@sinclair/typebox@0.34.52`, + * `ajv@8.20.0` — Technology-Stack §5.2/§7), its `src/schema.ts` defines + * `configSchema` with TypeBox (`Type.Object`), and its `src/validate.ts` + * compiles that schema with Ajv (`new Ajv({ allErrors: true })` + + * `.compile(configSchema)`) and exports `validateConfig`; the package + * boundary re-exports both. Mutation probes prove the assertions are + * non-vacuous (dropping a field, relaxing a constraint, replacing + * TypeBox/Ajv with a hand-rolled object all fail). + * - "schema covers the validated config fields" → the schema defines the + * four validated config fields with their constraints: `host` (string, + * default `0.0.0.0`), `port` (integer 1–65535, default `3000`), + * `databaseUrl` (optional non-empty string — the local non-container path + * has no database), `sessionSecret` (required, ≥ 32 chars — the story's + * secret field, EPPP_SESSION_SECRET, Security-and-Operations §32/§26), + * and the object is closed (`additionalProperties: false`). + * - the issue's test plan — "validate a full config against the + * TypeBox/Ajv schema" → the deterministic probe imports the committed + * `src/schema.ts` (Node type stripping, no build step) and validates a + * full config through Ajv, plus the negative cases (missing required + * field naming `sessionSecret`, secret too short, unknown property, port + * out of range / not an integer, empty `databaseUrl`); when the package + * is built (CI builds it first) the probe additionally exercises the + * compiled `validateConfig` boundary exactly as the later configuration + * adapter will consume it. + * + * Run: `node --test tests/config-schema.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +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 under test. */ +const CONFIG_DIR = 'packages/config'; +const MANIFEST_PATH = `${CONFIG_DIR}/package.json`; +const SCHEMA_SRC = `${CONFIG_DIR}/src/schema.ts`; +const VALIDATE_SRC = `${CONFIG_DIR}/src/validate.ts`; +const INDEX_SRC = `${CONFIG_DIR}/src/index.ts`; + +/** 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 config-schema criterion on every PR. */ +const CI_JOB = 'config-schema'; + +/** Golden-tuple pins for the configuration schema (Technology-Stack §5.2/§7). */ +const GOLDEN_TYPEBOX = '0.34.52'; +const GOLDEN_AJV = '8.20.0'; + +// --------------------------------------------------------------------------- +// Static assertions on the committed config package +// --------------------------------------------------------------------------- + +/** + * Asserts the schema is defined with TypeBox and covers the validated config + * fields with their documented constraints. Fails fast on any deviation; the + * mutation probes below prove the assertions are non-vacuous. + */ +function assertSchemaShape(src) { + // Defined with TypeBox: the schema object is built by Type.Object from the + // @sinclair/typebox `Type` factory (never a hand-rolled plain object). + assert.match( + src, + /import \{ Type, type Static \} from '@sinclair\/typebox'/, + 'the schema module must import Type (and the Static type) from @sinclair/typebox', + ); + assert.match( + src, + /export const configSchema = Type\.Object\(/, + 'the schema must be defined with TypeBox (Type.Object) — not a hand-rolled object', + ); + + // The validated config fields, each with its documented constraint. + assert.match( + src, + /host: Type\.Optional\(Type\.String\(\{ default: '0\.0\.0\.0' \}\)\)/, + 'the schema must cover the host field (optional string, default 0.0.0.0 — the bind address)', + ); + assert.match( + src, + /port: Type\.Optional\(Type\.Integer\(\{ minimum: 1, maximum: 65535, default: 3000 \}\)\)/, + 'the schema must cover the port field (optional integer 1-65535, default 3000)', + ); + assert.match( + src, + /databaseUrl: Type\.Optional\(Type\.String\(\{ minLength: 1 \}\)\)/, + 'the schema must cover the databaseUrl field (optional non-empty string)', + ); + assert.match( + src, + /sessionSecret: Type\.String\(\{ minLength: 32 \}\)/, + 'the schema must cover the sessionSecret field (required string, at least 32 chars — never Type.Optional)', + ); + assert.match( + src, + /additionalProperties: false/, + 'the schema must close the object (additionalProperties: false) so unexpected settings are rejected', + ); +} + +/** Asserts the validator compiles the schema with Ajv and exports validateConfig. */ +function assertValidateShape(src) { + assert.match( + src, + /import \{ Ajv \} from 'ajv'/, + 'the validator module must import Ajv (the golden-tuple validator)', + ); + assert.match( + src, + /new Ajv\(\{ allErrors: true \}\)/, + 'the validator must create the Ajv instance with allErrors (report every violation)', + ); + assert.match( + src, + /\.compile\(configSchema\)/, + 'the validator must compile the TypeBox configSchema with Ajv', + ); + assert.match( + src, + /export function validateConfig/, + 'the validator module must export the validateConfig entry point', + ); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the config package exists, pins the golden-tuple runtime deps exactly, and builds with tsc', () => { + const manifest = JSON.parse(read(MANIFEST_PATH)); + assert.equal(manifest.name, '@personal-blog/config'); + assert.equal( + manifest.dependencies?.['@sinclair/typebox'], + GOLDEN_TYPEBOX, + `@sinclair/typebox must be pinned exactly to the golden tuple (${GOLDEN_TYPEBOX})`, + ); + assert.match( + manifest.dependencies?.['@sinclair/typebox'], + /^\d+\.\d+\.\d+$/, + 'the @sinclair/typebox pin must be exact (no ^ / ~ / range)', + ); + assert.equal( + manifest.dependencies?.ajv, + GOLDEN_AJV, + `ajv must be pinned exactly to the golden tuple (${GOLDEN_AJV})`, + ); + assert.match( + manifest.dependencies?.ajv, + /^\d+\.\d+\.\d+$/, + 'the ajv pin must be exact (no ^ / ~ / range)', + ); + // The CI job and the local build path compile the package with tsc. + assert.equal(manifest.scripts?.build, 'tsc -p tsconfig.json'); + assert.equal(manifest.scripts?.typecheck, 'tsc -p tsconfig.json --noEmit'); +}); + +test('the configuration schema is defined with TypeBox and covers the validated config fields', () => { + assert.ok(existsSync(path.join(REPO_ROOT, SCHEMA_SRC)), `committed ${SCHEMA_SRC} must exist`); + assertSchemaShape(read(SCHEMA_SRC)); +}); + +test('the schema is validated with Ajv: validate.ts compiles configSchema and exports validateConfig', () => { + assert.ok(existsSync(path.join(REPO_ROOT, VALIDATE_SRC)), `committed ${VALIDATE_SRC} must exist`); + assertValidateShape(read(VALIDATE_SRC)); +}); + +test('the package boundary re-exports the schema and the validator', () => { + const src = read(INDEX_SRC); + assert.match(src, /export \{ configSchema \} from '\.\/schema\.js'/, 'the boundary must re-export configSchema'); + assert.match(src, /export type \{ Config \} from '\.\/schema\.js'/, 'the boundary must re-export the Config type'); + assert.match(src, /export \{ validateConfig \} from '\.\/validate\.js'/, 'the boundary must re-export validateConfig'); + assert.match(src, /export type \{ ConfigValidationResult \} from '\.\/validate\.js'/, 'the boundary must re-export the ConfigValidationResult type'); +}); + +test('the config-schema 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-schema.test.mjs`), + `CI must run the config-schema suite (job "${CI_JOB}") on every PR`, + ); + assert.ok( + workflow.includes(`pnpm --filter @personal-blog/config build`), + 'the CI job must build the config package (the probe exercises the compiled boundary)', + ); +}); + +// --------------------------------------------------------------------------- +// Mutation probes — the static assertions are non-vacuous +// --------------------------------------------------------------------------- + +test('removing a validated config field from the schema fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace( + /sessionSecret: Type\.String\(\{ minLength: 32 \}\),\n/, + '', + ); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually remove the sessionSecret field'); + assert.throws(() => assertSchemaShape(mutated), /sessionSecret/); +}); + +test('making the secret optional fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace( + 'sessionSecret: Type.String({ minLength: 32 })', + 'sessionSecret: Type.Optional(Type.String({ minLength: 32 }))', + ); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually make sessionSecret optional'); + assert.throws(() => assertSchemaShape(mutated), /never Type\.Optional/); +}); + +test('relaxing the secret length fails the coverage assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC).replace('minLength: 32', 'minLength: 8'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually relax the minLength'); + assert.throws(() => assertSchemaShape(mutated), /at least 32 chars/); +}); + +test('opening the object fails the coverage assertion (mutation probe)', () => { + // Replace every occurrence (the docstring mentions the keyword too) so the + // committed schema code itself is the mutation target. + const mutated = read(SCHEMA_SRC).replace(/additionalProperties: false/g, 'additionalProperties: true'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually open the object'); + assert.throws(() => assertSchemaShape(mutated), /additionalProperties: false/); +}); + +test('replacing TypeBox with a hand-rolled object fails the TypeBox assertion (mutation probe)', () => { + const mutated = read(SCHEMA_SRC) + .replace("import { Type, type Static } from '@sinclair/typebox';", '// no TypeBox') + .replace('export const configSchema = Type.Object(', 'export const configSchema = {'); + assert.notEqual(mutated, read(SCHEMA_SRC), 'the mutation must actually drop TypeBox'); + assert.throws(() => assertSchemaShape(mutated), /must import Type/); +}); + +test('dropping the Ajv compile fails the validator assertion (mutation probe)', () => { + const mutated = read(VALIDATE_SRC) + .replace("import { Ajv } from 'ajv';", '// no Ajv') + .replace('const validateConfigValue = ajv.compile(configSchema);', 'const validateConfigValue = () => true;'); + assert.notEqual(mutated, read(VALIDATE_SRC), 'the mutation must actually drop the Ajv compile'); + assert.throws(() => assertValidateShape(mutated), /Ajv|compile/); +}); + +// --------------------------------------------------------------------------- +// Deterministic behavioral probe — the issue's test plan: validate a full +// config against the TypeBox/Ajv schema +// --------------------------------------------------------------------------- + +/** + * 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; +} + +const TS_STRIPPING = tsExecMode() !== null; + +/** + * The probe source: imports the committed `src/schema.ts` (type-stripped, + * no build step) and validates a full config plus the negative cases through + * Ajv — the issue's test plan. When the package is built (`dist/` present, + * as in the CI job), it additionally exercises the compiled + * `validateConfig` boundary exactly as the later configuration adapter will. + * Written to a temp file inside `packages/config/` so `@sinclair/typebox` and + * `ajv` resolve through the package's own dependency links, then removed. + */ +const PROBE_SOURCE = ` +import { configSchema } from './src/schema.ts'; +import { Ajv } from 'ajv'; +import { existsSync } from 'node:fs'; + +const validate = new Ajv({ allErrors: true }).compile(configSchema); +const errorsOf = (value) => { + validate(value); + return (validate.errors ?? []).map((error) => error.message ?? 'invalid'); +}; + +const FULL = { + host: '0.0.0.0', + port: 3000, + databaseUrl: 'postgres://eppp:eppp@db:5432/eppp', + sessionSecret: 's'.repeat(32), +}; + +const result = { + // The issue's test plan: "validate a full config against the TypeBox/Ajv + // schema" — a full config is valid. + fullValid: validate(FULL), + // host/port/databaseUrl are optional (port and host carry defaults; the + // local non-container path has no database), so a config with only the + // required secret is valid too. + defaultsValid: validate({ sessionSecret: 's'.repeat(32) }), + // The required field is enforced and the error names it (E00-S04-T02 will + // format these as field-specific startup errors). + missingSecretErrors: errorsOf({ host: '0.0.0.0', port: 3000 }), + shortSecretErrors: errorsOf({ ...FULL, sessionSecret: 'short' }), + // The object is closed: an unexpected setting is rejected loudly. + unknownPropertyErrors: errorsOf({ ...FULL, extra: true }), + // Port is an integer in 1-65535. + portTooHighErrors: errorsOf({ ...FULL, port: 65536 }), + portNotIntegerErrors: errorsOf({ ...FULL, port: '3000' }), + // databaseUrl must be non-empty when present. + emptyDatabaseUrlErrors: errorsOf({ ...FULL, databaseUrl: '' }), +}; + +// Compiled boundary (built by the CI job): the committed validateConfig. +if (existsSync('./dist/index.js')) { + const { validateConfig } = await import('./dist/index.js'); + result.boundaryFullValid = validateConfig(FULL).valid; + result.boundaryMissingSecretErrors = validateConfig({ host: '0.0.0.0', port: 3000 }).errors; + result.boundaryUnknownPropertyErrors = validateConfig({ ...FULL, extra: true }).errors; +} + +console.log('CONFIG_SCHEMA_PROBE_RESULT ' + JSON.stringify(result)); +`; + +test('a full config validates against the committed TypeBox/Ajv schema, and violations are rejected (deterministic probe)', { skip: !TS_STRIPPING }, () => { + // The issue's test plan: "validate a full config against the TypeBox/Ajv + // schema". The probe runs the committed schema.ts through Ajv (Node type + // stripping, no build step) from inside packages/config so the golden-tuple + // deps resolve through the package's own links. + const probeFile = path.join(REPO_ROOT, CONFIG_DIR, `.config-schema-probe-${process.pid}.mjs`); + const args = + tsExecMode() === 'strip-types-flag' + ? ['--experimental-strip-types', path.basename(probeFile)] + : [path.basename(probeFile)]; + try { + writeFileSync(probeFile, PROBE_SOURCE); + const run = spawnSync(process.execPath, args, { + 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_SCHEMA_PROBE_RESULT (\{.*\})/); + assert.ok(match, `the probe must print CONFIG_SCHEMA_PROBE_RESULT:\n${run.stdout.trim()}`); + const result = JSON.parse(match[1]); + + // The issue's test plan: a full config validates. + assert.equal(result.fullValid, true, 'a full config must validate against the TypeBox/Ajv schema'); + assert.equal(result.defaultsValid, true, 'host/port/databaseUrl are optional (defaults; no-database path)'); + + // The required field is enforced and the error names it. + assert.ok( + result.missingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)), + `missing sessionSecret must be rejected naming the field (got: ${JSON.stringify(result.missingSecretErrors)})`, + ); + assert.ok( + result.shortSecretErrors.some((msg) => /fewer than 32/.test(msg)), + `a short sessionSecret must be rejected (got: ${JSON.stringify(result.shortSecretErrors)})`, + ); + + // The object is closed and the numeric/string constraints hold. + assert.ok( + result.unknownPropertyErrors.some((msg) => /additional properties/.test(msg)), + `an unknown property must be rejected (got: ${JSON.stringify(result.unknownPropertyErrors)})`, + ); + assert.ok( + result.portTooHighErrors.some((msg) => /<= 65535/.test(msg)), + `a port above 65535 must be rejected (got: ${JSON.stringify(result.portTooHighErrors)})`, + ); + assert.ok( + result.portNotIntegerErrors.some((msg) => /integer/.test(msg)), + `a non-integer port must be rejected (got: ${JSON.stringify(result.portNotIntegerErrors)})`, + ); + assert.ok( + result.emptyDatabaseUrlErrors.some((msg) => /fewer than 1/.test(msg)), + `an empty databaseUrl must be rejected (got: ${JSON.stringify(result.emptyDatabaseUrlErrors)})`, + ); + + // When the package is built (the CI job builds it), the compiled + // validateConfig boundary behaves identically. + if (existsSync(path.join(REPO_ROOT, CONFIG_DIR, 'dist', 'index.js'))) { + assert.equal(result.boundaryFullValid, true, 'validateConfig must accept a full config'); + assert.ok( + result.boundaryMissingSecretErrors.some((msg) => /required property 'sessionSecret'/.test(msg)), + `validateConfig must reject a missing secret naming the field (got: ${JSON.stringify(result.boundaryMissingSecretErrors)})`, + ); + assert.ok( + result.boundaryUnknownPropertyErrors.some((msg) => /additional properties/.test(msg)), + `validateConfig must reject an unknown property (got: ${JSON.stringify(result.boundaryUnknownPropertyErrors)})`, + ); + } + } finally { + rmSync(probeFile, { force: true }); + } +});