test: lock in the config schema with static, mutation and deterministic probes (E00-S04-T01)
tests/config-schema.test.mjs covers both acceptance criteria: the schema is defined with TypeBox/Ajv (static assertions on the committed package — golden-tuple exact pins, Type.Object schema, Ajv compile, boundary re-exports — each backed by a mutation probe proving non-vacuity) and the schema covers the validated config fields (host, port, databaseUrl, sessionSecret with their constraints). The deterministic probe executes the issue's test plan — 'validate a full config against the TypeBox/Ajv schema' — against the committed schema through Ajv via Node type stripping (no build step), plus the negative cases (missing required field naming sessionSecret, secret too short, unknown property, port bounds, and empty databaseUrl); when the package is built (as in the CI job) it also exercises the compiled validateConfig boundary exactly as the later adapter will consume it.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user