[E00-S04-T01] TypeBox/Ajv schema #396
@@ -167,6 +167,33 @@ jobs:
|
|||||||
- name: Run app readiness test suite
|
- name: Run app readiness test suite
|
||||||
run: node --test tests/app-readiness.test.mjs
|
run: node --test tests/app-readiness.test.mjs
|
||||||
|
|
||||||
|
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
||||||
|
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
||||||
|
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
||||||
|
# ajv@8.20.0) with mutation probes, and the deterministic probe executes
|
||||||
|
# the issue's test plan ("validate a full config against the TypeBox/Ajv
|
||||||
|
# schema") against the committed schema through Ajv. The job installs the
|
||||||
|
# frozen workspace and builds the config package because the probe also
|
||||||
|
# exercises the compiled package boundary (@personal-blog/config) exactly
|
||||||
|
# as the later configuration adapter will consume it.
|
||||||
|
config-schema:
|
||||||
|
name: TypeBox/Ajv config schema (E00-S04-T01)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the config package (the probe exercises the compiled package boundary)
|
||||||
|
run: pnpm --filter @personal-blog/config build
|
||||||
|
- name: Run config schema test suite
|
||||||
|
run: node --test tests/config-schema.test.mjs
|
||||||
|
|
||||||
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
||||||
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
||||||
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ coverage/
|
|||||||
.lock-probe-*.mjs
|
.lock-probe-*.mjs
|
||||||
.diagnostic-probe-*.mjs
|
.diagnostic-probe-*.mjs
|
||||||
|
|
||||||
|
# Transient host-side probe file written by the config-schema test suite into
|
||||||
|
# the package (removed in its finally block)
|
||||||
|
.config-schema-probe-*.mjs
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -57,11 +57,12 @@ RUN corepack enable
|
|||||||
# invalidate the dependency layer, then install against the committed lockfile
|
# invalidate the dependency layer, then install against the committed lockfile
|
||||||
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
# (the same `--frozen-lockfile` path CI and developers use). Every workspace
|
||||||
# package manifest is copied so the in-image workspace matches the lockfile
|
# package manifest is copied so the in-image workspace matches the lockfile
|
||||||
# importers exactly (apps/server, packages/core, packages/database-postgres,
|
# importers exactly (apps/server, packages/core, packages/config,
|
||||||
# extensions/example).
|
# packages/database-postgres, extensions/example).
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/config/package.json packages/config/package.json
|
||||||
COPY packages/database-postgres/package.json packages/database-postgres/package.json
|
COPY packages/database-postgres/package.json packages/database-postgres/package.json
|
||||||
COPY extensions/example/package.json extensions/example/package.json
|
COPY extensions/example/package.json extensions/example/package.json
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ The workspace is a pnpm monorepo with three package groups:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story. |
|
| `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03) gated on the startup migration run (E00-S03-T06); the Fastify 5 application shell lands in a later story. |
|
||||||
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
| `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. |
|
||||||
|
| `packages/` | `packages/config` (`@personal-blog/config`) | Configuration service. Owns the TypeBox/Ajv configuration schema for the validated config fields (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret redaction (E00-S04-T03) land in later tasks. |
|
||||||
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. The server depends on this package to run the startup migrations behind its readiness gate (E00-S03-T06). |
|
||||||
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
| `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. |
|
||||||
|
|
||||||
@@ -76,8 +77,8 @@ pnpm typecheck
|
|||||||
`pnpm build` runs `tsc -p tsconfig.json` in each package in topological order
|
`pnpm build` runs `tsc -p tsconfig.json` in each package in topological order
|
||||||
and emits `dist/` (JavaScript + type declarations + source maps) per package.
|
and emits `dist/` (JavaScript + type declarations + source maps) per package.
|
||||||
Expected result: `apps/server/dist/`, `packages/core/dist/`,
|
Expected result: `apps/server/dist/`, `packages/core/dist/`,
|
||||||
`packages/database-postgres/dist/` and `extensions/example/dist/` are
|
`packages/config/dist/`, `packages/database-postgres/dist/` and
|
||||||
produced, all packages report `Done`, exit 0.
|
`extensions/example/dist/` are produced, all packages report `Done`, exit 0.
|
||||||
`dist/` is git-ignored; rebuild whenever you change `src/`.
|
`dist/` is git-ignored; rebuild whenever you change `src/`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -127,8 +128,8 @@ This is also the suite that enforces the dependency-boundary rule.
|
|||||||
git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog
|
git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog
|
||||||
corepack enable
|
corepack enable
|
||||||
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
||||||
pnpm build # 4/4 packages emit dist/, exit 0
|
pnpm build # 5/5 packages emit dist/, exit 0
|
||||||
pnpm typecheck # 4/4 packages pass --noEmit, exit 0
|
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
||||||
pnpm test # 10/10 pass, exit 0
|
pnpm test # 10/10 pass, exit 0
|
||||||
pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up
|
pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@personal-blog/config",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "EPPP configuration service. Owns the TypeBox/Ajv configuration schema (E00-S04-T01); the environment adapter (E00-S04-T04), field-specific startup errors (E00-S04-T02), secret redaction (E00-S04-T03) and the .env.example template (E00-S04-T05) land in later tasks.",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sinclair/typebox": "0.34.52",
|
||||||
|
"ajv": "8.20.0"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @personal-blog/config — EPPP configuration service.
|
||||||
|
*
|
||||||
|
* [E00-S04-T01] TypeBox/Ajv schema: the package boundary exposes the
|
||||||
|
* configuration schema (`configSchema`, defined with TypeBox) and the Ajv
|
||||||
|
* schema-validation entry point (`validateConfig`). The environment adapter
|
||||||
|
* (E00-S04-T04), field-specific startup errors (E00-S04-T02) and secret
|
||||||
|
* redaction (E00-S04-T03) build on this boundary in later tasks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { configSchema } from './schema.js';
|
||||||
|
export type { Config } from './schema.js';
|
||||||
|
export { validateConfig } from './validate.js';
|
||||||
|
export type { ConfigValidationResult } from './validate.js';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* EPPP configuration schema — [E00-S04-T01] TypeBox/Ajv schema.
|
||||||
|
*
|
||||||
|
* The single source of truth for the **validated configuration fields** of
|
||||||
|
* the EPPP server (the `@personal-blog/config` package). The schema is
|
||||||
|
* defined with TypeBox (`@sinclair/typebox`, golden-tuple pin 0.34.52 —
|
||||||
|
* Technology-Stack §5.2/§7) and validated with Ajv (`ajv`, golden-tuple pin
|
||||||
|
* 8.20.0) via `validateConfig` (see `validate.ts`). E00-S04-T02
|
||||||
|
* (field-specific startup errors), T03 (secret redaction), T04 (the
|
||||||
|
* `process.env` adapter) and T05 (`.env.example` placeholders) build on this
|
||||||
|
* schema; this module only defines it.
|
||||||
|
*
|
||||||
|
* The validated config fields:
|
||||||
|
*
|
||||||
|
* - `host` — the interface the HTTP server binds. Default `0.0.0.0` (the
|
||||||
|
* committed server binds all interfaces today, E00-S02-T03). Environment
|
||||||
|
* source: `HOST`.
|
||||||
|
* - `port` — the port the HTTP server listens on. Integer in the valid TCP
|
||||||
|
* port range (1–65535), default `3000` (the container default, matching
|
||||||
|
* the Dockerfile `EXPOSE 3000` and the compose `:3000` container port;
|
||||||
|
* `PORT` is read today, E00-S02-T03).
|
||||||
|
* - `databaseUrl` — the PostgreSQL connection string (the `pg` `Pool`
|
||||||
|
* `connectionString`, E00-S03-T02). Optional: when absent the app has no
|
||||||
|
* startup migration run to wait for and reports ready immediately (the
|
||||||
|
* local non-container developer path, E00-S01-T06/E00-S03-T06). Must be
|
||||||
|
* non-empty when present. Environment source: `DATABASE_URL`.
|
||||||
|
* - `sessionSecret` — the admin-session secret (`EPPP_SESSION_SECRET`, the
|
||||||
|
* required secret per Security-and-Operations §32/§26). **Required** and at
|
||||||
|
* least 32 characters: it is the story's secret field (E00-S04-T03 redacts
|
||||||
|
* it from logs) and the schema's required field (E00-S04-T02 reports a
|
||||||
|
* missing required setting). No default — a secret must never be invented
|
||||||
|
* by the schema.
|
||||||
|
*
|
||||||
|
* The object is closed (`additionalProperties: false`) so a typo'd or
|
||||||
|
* unexpected setting is rejected loudly instead of silently ignored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Type, type Static } from '@sinclair/typebox';
|
||||||
|
|
||||||
|
/** The EPPP configuration schema — validates the parsed configuration object. */
|
||||||
|
export const configSchema = Type.Object(
|
||||||
|
{
|
||||||
|
host: Type.Optional(Type.String({ default: '0.0.0.0' })),
|
||||||
|
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535, default: 3000 })),
|
||||||
|
databaseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
sessionSecret: Type.String({ minLength: 32 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The validated configuration type — `Static` of `configSchema`. */
|
||||||
|
export type Config = Static<typeof configSchema>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* EPPP configuration validation — [E00-S04-T01] TypeBox/Ajv schema.
|
||||||
|
*
|
||||||
|
* Compiles the TypeBox `configSchema` with Ajv (the golden-tuple validator,
|
||||||
|
* Technology-Stack §5.2/§7) and exposes `validateConfig`, the generic
|
||||||
|
* schema-validation entry point: given an unknown value it reports whether
|
||||||
|
* the value is a valid configuration and the Ajv error messages otherwise.
|
||||||
|
*
|
||||||
|
* This is deliberately NOT the E00-S04-T02 field-specific startup error:
|
||||||
|
* `validateConfig` returns the raw schema-validation outcome (valid or not,
|
||||||
|
* with the Ajv messages) and performs no startup wiring — the adapter
|
||||||
|
* (E00-S04-T04) and the startup error formatting (E00-S04-T02) build on it
|
||||||
|
* in later tasks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ajv } from 'ajv';
|
||||||
|
|
||||||
|
import { configSchema } from './schema.js';
|
||||||
|
|
||||||
|
/** Ajv instance for the config schema — `allErrors` reports every violation. */
|
||||||
|
const ajv = new Ajv({ allErrors: true });
|
||||||
|
|
||||||
|
/** The compiled validator — TypeBox schemas are JSON Schema, so Ajv compiles them directly. */
|
||||||
|
const validateConfigValue = ajv.compile(configSchema);
|
||||||
|
|
||||||
|
/** The outcome of validating a value against the config schema. */
|
||||||
|
export interface ConfigValidationResult {
|
||||||
|
/** True when the value is a valid configuration. */
|
||||||
|
valid: boolean;
|
||||||
|
/** Ajv error messages, empty when `valid` is true. */
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an unknown value against the config schema.
|
||||||
|
*
|
||||||
|
* @param value - the value to validate (typically the parsed config object)
|
||||||
|
* @returns `{ valid: true, errors: [] }` for a valid configuration, or
|
||||||
|
* `{ valid: false, errors }` with the Ajv messages naming each violation
|
||||||
|
* (e.g. `"must have required property 'sessionSecret'"`).
|
||||||
|
*/
|
||||||
|
export function validateConfig(value: unknown): ConfigValidationResult {
|
||||||
|
const valid = validateConfigValue(value);
|
||||||
|
if (valid) {
|
||||||
|
return { valid: true, errors: [] };
|
||||||
|
}
|
||||||
|
const errors = (validateConfigValue.errors ?? []).map((error) => error.message ?? 'invalid');
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+45
@@ -24,6 +24,15 @@ importers:
|
|||||||
|
|
||||||
extensions/example: {}
|
extensions/example: {}
|
||||||
|
|
||||||
|
packages/config:
|
||||||
|
dependencies:
|
||||||
|
'@sinclair/typebox':
|
||||||
|
specifier: 0.34.52
|
||||||
|
version: 0.34.52
|
||||||
|
ajv:
|
||||||
|
specifier: 8.20.0
|
||||||
|
version: 8.20.0
|
||||||
|
|
||||||
packages/core: {}
|
packages/core: {}
|
||||||
|
|
||||||
packages/database-postgres:
|
packages/database-postgres:
|
||||||
@@ -41,12 +50,27 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@sinclair/typebox@0.34.52':
|
||||||
|
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
'@types/pg@8.21.0':
|
'@types/pg@8.21.0':
|
||||||
resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==}
|
resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==}
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-uri@3.1.6:
|
||||||
|
resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
kysely@0.29.4:
|
kysely@0.29.4:
|
||||||
resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==}
|
resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==}
|
||||||
engines: {node: '>=22.0.0'}
|
engines: {node: '>=22.0.0'}
|
||||||
@@ -101,6 +125,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
split2@4.2.0:
|
split2@4.2.0:
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
@@ -119,6 +147,8 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@sinclair/typebox@0.34.52': {}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
@@ -129,6 +159,19 @@ snapshots:
|
|||||||
pg-protocol: 1.16.0
|
pg-protocol: 1.16.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.1.6
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-uri@3.1.6: {}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
kysely@0.29.4: {}
|
kysely@0.29.4: {}
|
||||||
|
|
||||||
pg-cloudflare@1.4.0:
|
pg-cloudflare@1.4.0:
|
||||||
@@ -176,6 +219,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..
|
|||||||
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
/** The workspace packages that must compile under the strict base config. */
|
/** The workspace packages that must compile under the strict base config. */
|
||||||
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
|
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
|
||||||
|
|
||||||
/** The strict-family flags the committed base config must set. */
|
/** The strict-family flags the committed base config must set. */
|
||||||
const STRICT_FAMILY_FLAGS = [
|
const STRICT_FAMILY_FLAGS = [
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
|||||||
const PINNED_TYPESCRIPT = '6.0.3';
|
const PINNED_TYPESCRIPT = '6.0.3';
|
||||||
|
|
||||||
/** Every workspace package that must resolve the pinned TypeScript version. */
|
/** Every workspace package that must resolve the pinned TypeScript version. */
|
||||||
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
|
const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..
|
|||||||
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*'];
|
const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*'];
|
||||||
const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example'];
|
const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/config', 'packages/database-postgres', 'extensions/example'];
|
||||||
const PINNED_PNPM = '11.23.0';
|
const PINNED_PNPM = '11.23.0';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions'];
|
|||||||
const EXPECTED_PACKAGES = {
|
const EXPECTED_PACKAGES = {
|
||||||
'@personal-blog/server': 'apps/server',
|
'@personal-blog/server': 'apps/server',
|
||||||
'@personal-blog/core': 'packages/core',
|
'@personal-blog/core': 'packages/core',
|
||||||
|
'@personal-blog/config': 'packages/config',
|
||||||
'@personal-blog/database-postgres': 'packages/database-postgres',
|
'@personal-blog/database-postgres': 'packages/database-postgres',
|
||||||
'@personal-blog/example-extension': 'extensions/example',
|
'@personal-blog/example-extension': 'extensions/example',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user