/** * 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 }); } });