[E00-S04-T01] TypeBox/Ajv schema #396
@@ -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