Resolve the security review of #389 (findings 1-4): - .dockerignore: every env/credential pattern is now **/-prefixed (**/.env, **/.env.*, **/node_modules, **/.npmrc, ..., **/secrets, **/*.pem, **/*.key, ...) and the redundant 'secrets/' line is dropped. Docker's matcher (moby/patternmatcher) anchors slash-less patterns to the context root, so the bare forms excluded nothing under apps/server/; **/ matches the root AND any nested depth. (finding 1, 3) - tests/secrets-not-embedded.test.mjs: the dockerignore matcher is now a faithful port of moby/patternmatcher (filepath.Clean + anchored full-path match + parent-directory propagation), not gitignore basename semantics; asserts nested example paths (apps/server/.npmrc, config/server.key, apps/server/secrets/...) are excluded; requires no redundant equivalent patterns verbatim; adds mutation probes for bare-pattern and duplicate-pattern regressions. (finding 2, 3) - apps/server/Dockerfile + compose.yaml: guarantee restated precisely (credential files excluded at the context root AND at any depth). - .gitea/workflows/ci.yml: new job runs 'node --test tests/secrets-not-embedded.test.mjs' on every PR; the docker-gated layer-scan probe runs where a daemon exists, skips cleanly otherwise. (finding 4) - tests/compose-config.test.mjs: .dockerignore presence list updated to the **/-prefixed forms (node_modules, .env). Tested: secrets suite 16 tests -> 15 pass / 1 docker-gated skip / 0 fail; full suite 101 pass / 12 fail / 8 skip, failures identical to clean main (env-dependent pnpm/Node-24 suites); matcher port verified against the moby/patternmatcher evidence table.
758 lines
31 KiB
JavaScript
758 lines
31 KiB
JavaScript
/**
|
|
* Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03/T04]
|
|
* `docker compose up -d` DB + app baseline, the PostgreSQL health gate, the
|
|
* app health endpoint and the DB volume persistence for the workspace.
|
|
*
|
|
* Acceptance criteria covered (each test fails without the committed state):
|
|
* - "docker compose up -d starts the database" → the committed
|
|
* `compose.yaml` declares a `db` service backed by a PostgreSQL image
|
|
* with the credentials the app expects and a published default port, so
|
|
* `docker compose up -d` creates and starts the database container. When
|
|
* a Docker daemon + Compose plugin are available (CI/dev machines), the
|
|
* suite additionally runs the real stack (`docker compose up -d` →
|
|
* `docker compose ps` → `docker compose down`) and asserts the `db`
|
|
* container is up (and healthy).
|
|
* - "docker compose up -d starts the application" → the committed
|
|
* `compose.yaml` declares an `app` service built from the committed
|
|
* `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim +
|
|
* frozen pnpm install → `tsc` build of `@personal-blog/server` →
|
|
* `node apps/server/dist/index.js`), with a published default port. Since
|
|
* T03 the real-stack probe asserts the `app` container stays **running**
|
|
* (the server now serves the health endpoint instead of exiting) and the
|
|
* health endpoint answers HTTP 200 with a healthy body inside the
|
|
* container.
|
|
* - "PostgreSQL health check gates application start" → the committed
|
|
* `compose.yaml` declares a `healthcheck` on the `db` service that probes
|
|
* readiness with `pg_isready` against the same credentials the database
|
|
* was created with (`$$`-escaped so the container env applies), with an
|
|
* interval/retries so a still-booting database is re-probed rather than
|
|
* failed instantly.
|
|
* - "app waits for the database before starting" → the `app` service
|
|
* depends on `db` with `condition: service_healthy`, so Compose only
|
|
* starts the application once PostgreSQL reports healthy (the real-stack
|
|
* probe asserts the `db` container is healthy when Docker reports it).
|
|
* - "database volume persists across restart" and "database volume
|
|
* persists across recreate" → the committed `compose.yaml` declares a
|
|
* named `db-data` volume and mounts it at PostgreSQL's data directory
|
|
* (`/var/lib/postgresql/data`), so the database files survive
|
|
* `docker compose restart` (same containers) and `docker compose down` →
|
|
* `docker compose up -d` (containers recreated — the container
|
|
* filesystem is discarded, so only a named volume carries the data).
|
|
* When Docker is available, the real-stack probe writes a fixture row,
|
|
* then asserts it survives both operations; data is reset with
|
|
* `docker compose down -v` (the issue's rollback note).
|
|
*
|
|
* Run: `node --test tests/compose-config.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 } 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 Compose file and app image definition under test. */
|
|
const COMPOSE_PATH = 'compose.yaml';
|
|
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
|
|
const DOCKERIGNORE_PATH = '.dockerignore';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal block-YAML parser (no dependencies, lockfile untouched)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Covers exactly the subset the committed compose.yaml uses: nested block
|
|
// mappings, block sequences of scalars, plain / single-quoted / double-quoted
|
|
// scalars and `#` comments. Anything else (flow collections, anchors/aliases,
|
|
// `|`/`>` block scalars, merge keys) is rejected loudly so the parser can
|
|
// never silently misread the structure it locks in.
|
|
|
|
/** Drops a `#` comment that is not inside a quoted scalar. */
|
|
function stripComment(line) {
|
|
let quote = null;
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (quote) {
|
|
if (ch === quote) quote = null;
|
|
} else if (ch === "'" || ch === '"') {
|
|
quote = ch;
|
|
} else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
return line.slice(0, i).trimEnd();
|
|
}
|
|
}
|
|
return line.trimEnd();
|
|
}
|
|
|
|
/** Unquotes a plain / single-quoted / double-quoted scalar. */
|
|
function scalarValue(raw) {
|
|
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
|
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Parses the supported block-YAML subset into plain JS objects/arrays.
|
|
* Throws on any construct the committed file does not use.
|
|
*/
|
|
function parseYaml(text) {
|
|
const lines = [];
|
|
for (const raw of text.split(/\r?\n/)) {
|
|
const expanded = raw.replace(/\t/g, ' ');
|
|
if (!expanded.trim() || expanded.trim().startsWith('#')) continue;
|
|
const indent = expanded.length - expanded.trimStart().length;
|
|
const content = stripComment(expanded.trimStart()).trim();
|
|
if (!content) continue;
|
|
lines.push({ indent, content });
|
|
}
|
|
|
|
let pos = 0;
|
|
|
|
const parseBlock = (indent) => {
|
|
const node = {};
|
|
while (pos < lines.length && lines[pos].indent >= indent) {
|
|
if (lines[pos].indent > indent) {
|
|
throw new Error(`unexpected indentation at "${lines[pos].content}"`);
|
|
}
|
|
const { content } = lines[pos];
|
|
const match = /^([^:#][^:]*):(?:\s+(.*))?$/.exec(content);
|
|
if (!match) {
|
|
throw new Error(`expected "key: value", got "${content}"`);
|
|
}
|
|
const key = scalarValue(match[1].trim());
|
|
const rest = match[2] === undefined ? undefined : match[2].trim();
|
|
pos += 1;
|
|
if (rest === undefined || rest === '') {
|
|
if (pos < lines.length && lines[pos].indent > indent) {
|
|
if (lines[pos].content.startsWith('-')) {
|
|
node[key] = parseSequence(lines[pos].indent);
|
|
} else {
|
|
node[key] = parseBlock(lines[pos].indent);
|
|
}
|
|
} else {
|
|
node[key] = null;
|
|
}
|
|
} else {
|
|
node[key] = scalarValue(rest);
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
|
|
const parseSequence = (indent) => {
|
|
const items = [];
|
|
while (pos < lines.length && lines[pos].indent >= indent) {
|
|
if (lines[pos].indent > indent) {
|
|
throw new Error(`unexpected indentation in sequence at "${lines[pos].content}"`);
|
|
}
|
|
const { content } = lines[pos];
|
|
if (!content.startsWith('-')) break;
|
|
const rest = content.slice(1).trim();
|
|
if (!rest) throw new Error(`empty sequence item at "-"`);
|
|
items.push(scalarValue(rest));
|
|
pos += 1;
|
|
}
|
|
return items;
|
|
};
|
|
|
|
if (lines.length === 0) return {};
|
|
return parseBlock(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compose structure assertions (shared by the tests and the mutation probes)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Asserts the committed compose.yaml declares a `db` service that
|
|
* `docker compose up -d` can start: a PostgreSQL image, the credentials the
|
|
* app expects, and a published default port.
|
|
*/
|
|
function assertDbService(compose) {
|
|
assert.ok(compose.services, 'compose.yaml must declare a top-level "services" map');
|
|
const db = compose.services.db;
|
|
assert.ok(db, 'compose.yaml must declare a "db" service (docker compose up -d starts the database)');
|
|
assert.equal(
|
|
db.image,
|
|
'postgres:18-bookworm',
|
|
'the "db" service must use the committed PostgreSQL 18 image (postgres:18-bookworm)',
|
|
);
|
|
const env = db.environment ?? {};
|
|
assert.equal(env.POSTGRES_DB, '${POSTGRES_DB:-eppp}', 'db must set POSTGRES_DB (with a default)');
|
|
assert.equal(env.POSTGRES_USER, '${POSTGRES_USER:-eppp}', 'db must set POSTGRES_USER (with a default)');
|
|
assert.ok(
|
|
typeof env.POSTGRES_PASSWORD === 'string' && env.POSTGRES_PASSWORD.length > 0,
|
|
'db must set POSTGRES_PASSWORD (with a default)',
|
|
);
|
|
assert.ok(
|
|
Array.isArray(db.ports) && db.ports.some((p) => p.endsWith(':5432')),
|
|
'db must publish the PostgreSQL port (a mapping ending in ":5432")',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Asserts the committed compose.yaml declares an `app` service that
|
|
* `docker compose up -d` can start: built from the committed Dockerfile,
|
|
* pointed at the db service, and started only after the database is healthy
|
|
* (depends_on with condition: service_healthy).
|
|
*/
|
|
function assertAppService(compose) {
|
|
const app = compose.services?.app;
|
|
assert.ok(app, 'compose.yaml must declare an "app" service (docker compose up -d starts the application)');
|
|
assert.equal(
|
|
app.build?.context,
|
|
'.',
|
|
'the "app" service must build from the repository root context (build.context: ".")',
|
|
);
|
|
assert.equal(
|
|
app.build?.dockerfile,
|
|
DOCKERFILE_PATH,
|
|
`the "app" service must build the committed ${DOCKERFILE_PATH} image`,
|
|
);
|
|
const env = app.environment ?? {};
|
|
assert.ok(
|
|
typeof env.DATABASE_URL === 'string' && /@db:5432\//.test(env.DATABASE_URL),
|
|
'app must set DATABASE_URL pointing at the db service host (postgres://…@db:5432/…)',
|
|
);
|
|
assert.ok(
|
|
Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':3000')),
|
|
'app must publish the application port (a mapping ending in ":3000")',
|
|
);
|
|
assert.equal(
|
|
app.depends_on?.db?.condition,
|
|
'service_healthy',
|
|
'app must depend on db with condition: service_healthy so it waits for the database before starting',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Asserts the committed compose.yaml gates the app on PostgreSQL health: the
|
|
* `db` service declares a `pg_isready` healthcheck against the credentials it
|
|
* was created with, with an interval and retries so a booting database is
|
|
* re-probed instead of failed instantly.
|
|
*/
|
|
function assertDbHealthcheck(compose) {
|
|
const db = compose.services?.db;
|
|
assert.ok(db, 'compose.yaml must declare a "db" service');
|
|
const hc = db.healthcheck;
|
|
assert.ok(hc, 'the "db" service must declare a healthcheck (PostgreSQL health check gates application start)');
|
|
assert.match(
|
|
hc.test ?? '',
|
|
/pg_isready/,
|
|
'the db healthcheck must probe readiness with pg_isready',
|
|
);
|
|
assert.ok(
|
|
(hc.test ?? '').includes('$${POSTGRES_USER}') && (hc.test ?? '').includes('$${POSTGRES_DB}'),
|
|
'the db healthcheck must probe the same credentials the database was created with ' +
|
|
'($${POSTGRES_USER}/$${POSTGRES_DB}, $$-escaped so the container env applies)',
|
|
);
|
|
assert.ok(hc.interval, 'the db healthcheck must declare an interval so readiness is re-probed');
|
|
assert.ok(
|
|
Number(hc.retries) >= 1,
|
|
'the db healthcheck must declare retries so a booting database is not failed instantly',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Asserts the committed compose.yaml makes the database volume persist: the
|
|
* `db` service mounts the named `db-data` volume at PostgreSQL's data
|
|
* directory (`/var/lib/postgresql/data`) and the top-level `volumes` map
|
|
* declares that volume. That is what lets the database survive `docker
|
|
* compose restart` (containers restarted in place) and `docker compose down`
|
|
* → `docker compose up -d` (containers recreated — the container filesystem
|
|
* is discarded, so only a named volume carries the data across). Removing
|
|
* either half breaks the criterion (see the mutation probes below).
|
|
*/
|
|
function assertDbVolume(compose) {
|
|
const db = compose.services?.db;
|
|
assert.ok(db, 'compose.yaml must declare a "db" service');
|
|
assert.ok(
|
|
Array.isArray(db.volumes) &&
|
|
db.volumes.includes('db-data:/var/lib/postgresql/data'),
|
|
"the \"db\" service must mount the named db-data volume at PostgreSQL's data directory " +
|
|
'(a "db-data:/var/lib/postgresql/data" entry) so the database survives restart and recreate',
|
|
);
|
|
const volumes = compose.volumes ?? {};
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(volumes, 'db-data'),
|
|
'compose.yaml must declare the named "db-data" volume (top-level "volumes: db-data:") ' +
|
|
'so the mount target exists and is preserved across recreate',
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Docker probe helpers (integration tests skip cleanly without Docker)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function run(cmd, args, opts = {}) {
|
|
return spawnSync(cmd, args, {
|
|
encoding: 'utf8',
|
|
timeout: 600_000,
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
/** True when the `docker` CLI with the Compose plugin is on PATH. */
|
|
function dockerComposeAvailable() {
|
|
try {
|
|
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** True when a reachable Docker daemon exists. */
|
|
function dockerDaemonAvailable() {
|
|
try {
|
|
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Parses `docker compose ps --format json` (JSON array or one object per line). */
|
|
function parsePsJson(stdout) {
|
|
const text = String(stdout).trim();
|
|
if (!text) return [];
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
return Array.isArray(parsed) ? parsed : [parsed];
|
|
} catch {
|
|
return text
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
}
|
|
|
|
/** Tolerant field lookup across compose ps JSON shapes. */
|
|
function field(container, ...names) {
|
|
for (const name of names) {
|
|
if (container[name] !== undefined) return container[name];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Polls `docker compose ps` until the db container reports healthy (or the
|
|
* deadline passes). Used by the T04 persistence probe after `up`, `restart`
|
|
* and `down` + `up` so fixture queries never race a still-booting database.
|
|
*/
|
|
function waitForDbHealthy(deadlineMs = 60_000) {
|
|
const deadline = Date.now() + deadlineMs;
|
|
let last = '';
|
|
while (Date.now() < deadline) {
|
|
const ps = run('docker', ['compose', 'ps', '--format', 'json'], {
|
|
cwd: REPO_ROOT,
|
|
timeout: 15_000,
|
|
});
|
|
if (ps.status === 0) {
|
|
last = ps.stdout;
|
|
const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db');
|
|
if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return;
|
|
}
|
|
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry
|
|
}
|
|
throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`);
|
|
}
|
|
|
|
const DOCKER_COMPOSE = dockerComposeAvailable();
|
|
const DOCKER_DAEMON = dockerDaemonAvailable();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests — the committed state that makes `docker compose up -d` work
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('compose.yaml exists, parses, and declares exactly the db and app services', () => {
|
|
assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`);
|
|
const compose = parseYaml(read(COMPOSE_PATH));
|
|
assert.deepEqual(
|
|
Object.keys(compose.services ?? {}).sort(),
|
|
['app', 'db'],
|
|
'compose.yaml must declare exactly the "db" and "app" services at T01/T02/T03',
|
|
);
|
|
});
|
|
|
|
test('the database service is defined so "docker compose up -d" starts the database', () => {
|
|
assertDbService(parseYaml(read(COMPOSE_PATH)));
|
|
});
|
|
|
|
test('the application service is defined so "docker compose up -d" starts the application', () => {
|
|
assertAppService(parseYaml(read(COMPOSE_PATH)));
|
|
});
|
|
|
|
test('PostgreSQL health check gates application start (db declares a pg_isready healthcheck)', () => {
|
|
assertDbHealthcheck(parseYaml(read(COMPOSE_PATH)));
|
|
});
|
|
|
|
test('the app waits for the database before starting (depends_on db with condition service_healthy)', () => {
|
|
const app = parseYaml(read(COMPOSE_PATH)).services?.app;
|
|
assert.ok(app, 'compose.yaml must declare an "app" service');
|
|
assert.equal(
|
|
app.depends_on?.db?.condition,
|
|
'service_healthy',
|
|
'app must depend on db with condition: service_healthy so it starts only after PostgreSQL is healthy',
|
|
);
|
|
});
|
|
|
|
test('the database volume persists across restart and recreate (db mounts the named db-data volume)', () => {
|
|
assertDbVolume(parseYaml(read(COMPOSE_PATH)));
|
|
});
|
|
|
|
test('the application image is defined by a committed multi-stage Dockerfile', () => {
|
|
assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`);
|
|
const dockerfile = read(DOCKERFILE_PATH);
|
|
assert.match(
|
|
dockerfile,
|
|
/FROM node:24\.19\.0-bookworm-slim AS build/,
|
|
'the Dockerfile must build on the committed Node 24.19.0 bookworm-slim base (FROM node:24.19.0-bookworm-slim AS build)',
|
|
);
|
|
assert.match(
|
|
dockerfile,
|
|
/FROM node:24\.19\.0-bookworm-slim AS runtime/,
|
|
'the Dockerfile must ship a Node 24.19.0 bookworm-slim runtime stage (FROM node:24.19.0-bookworm-slim AS runtime)',
|
|
);
|
|
assert.match(
|
|
dockerfile,
|
|
/pnpm install --frozen-lockfile/,
|
|
'the Dockerfile must install with the frozen lockfile (reproducible builds)',
|
|
);
|
|
assert.match(
|
|
dockerfile,
|
|
/pnpm --filter @personal-blog\/server build/,
|
|
'the Dockerfile must compile the server package (pnpm --filter @personal-blog/server build)',
|
|
);
|
|
assert.match(
|
|
dockerfile,
|
|
/node\b[^\n]*apps\/server\/dist\/index\.js/,
|
|
'the Dockerfile runtime stage must run the compiled server entrypoint (node apps/server/dist/index.js)',
|
|
);
|
|
});
|
|
|
|
test('the build context excludes local artifacts and environment files', () => {
|
|
assert.ok(
|
|
existsSync(path.join(REPO_ROOT, DOCKERIGNORE_PATH)),
|
|
`committed ${DOCKERIGNORE_PATH} must exist so local artifacts stay out of the build context`,
|
|
);
|
|
const patterns = read(DOCKERIGNORE_PATH)
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith('#'));
|
|
// T08 hardened these to their `**/`-prefixed forms so the exclusions also
|
|
// apply at any nested depth (Docker's matcher anchors slash-less patterns to
|
|
// the context root).
|
|
for (const required of ['**/node_modules', 'dist', '**/.env', '.git']) {
|
|
assert.ok(
|
|
patterns.includes(required),
|
|
`.dockerignore must exclude "${required}" (got: ${patterns.join(', ')})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Real-stack probes — run the actual acceptance command when Docker is present
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('the committed compose.yaml validates against the Compose spec (docker compose config)', { skip: !DOCKER_COMPOSE }, () => {
|
|
const result = run('docker', ['compose', 'config', '--quiet'], { cwd: REPO_ROOT });
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`"docker compose config" must exit 0 for the committed ${COMPOSE_PATH}:\n` +
|
|
`${(result.stdout || '')}\n${(result.stderr || '')}`.trim(),
|
|
);
|
|
});
|
|
|
|
test('docker compose up -d starts the database and application containers', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, (t) => {
|
|
const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT });
|
|
assert.equal(
|
|
up.status,
|
|
0,
|
|
`"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
|
|
);
|
|
try {
|
|
const ps = run('docker', ['compose', 'ps', '-a', '--format', 'json'], { cwd: REPO_ROOT });
|
|
assert.equal(ps.status, 0, `"docker compose ps" must exit 0:\n${(ps.stderr || ps.stdout || '').trim()}`);
|
|
const containers = parsePsJson(ps.stdout);
|
|
|
|
const db = containers.find((c) => field(c, 'Service', 'service') === 'db');
|
|
assert.ok(db, 'docker compose up -d must create the "db" container');
|
|
const dbState = String(field(db, 'State', 'state') ?? '');
|
|
assert.match(
|
|
dbState,
|
|
/running|up/i,
|
|
`the "db" container must be running after "docker compose up -d" (state: "${dbState}")`,
|
|
);
|
|
const dbHealth = String(field(db, 'Health', 'health') ?? '');
|
|
if (dbHealth) {
|
|
assert.match(
|
|
dbHealth,
|
|
/healthy/i,
|
|
`the "db" container must be healthy before the app starts (health: "${dbHealth}")`,
|
|
);
|
|
}
|
|
|
|
const app = containers.find((c) => field(c, 'Service', 'service') === 'app');
|
|
assert.ok(app, 'docker compose up -d must create the "app" container');
|
|
const appState = String(field(app, 'State', 'state') ?? '');
|
|
assert.match(
|
|
appState,
|
|
/running|up/i,
|
|
`the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`,
|
|
);
|
|
|
|
// T03: the app serves the health endpoint — HTTP smoke test against the
|
|
// endpoint inside the app container (no host-port dependency), polling
|
|
// until it answers or times out.
|
|
let healthOutput = '';
|
|
let healthOk = false;
|
|
for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) {
|
|
const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', `
|
|
fetch('http://127.0.0.1:3000/health')
|
|
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); })
|
|
.catch(() => process.exit(2));
|
|
`], { cwd: REPO_ROOT, timeout: 15_000 });
|
|
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
|
|
if (probe.status === 0) {
|
|
healthOk = true;
|
|
healthOutput = output;
|
|
} else if (probe.status === 1) {
|
|
healthOutput = output; // answered but not 2xx — fail fast
|
|
break;
|
|
} else {
|
|
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry
|
|
}
|
|
}
|
|
assert.ok(
|
|
healthOk,
|
|
`GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`,
|
|
);
|
|
assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`);
|
|
assert.match(
|
|
healthOutput,
|
|
/"status":"ok"/,
|
|
`GET /health must report a healthy application (got: "${healthOutput.trim()}")`,
|
|
);
|
|
} finally {
|
|
run('docker', ['compose', 'down'], { cwd: REPO_ROOT });
|
|
}
|
|
});
|
|
|
|
test('a database fixture survives docker compose restart and recreate (named db-data volume)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => {
|
|
// T04 acceptance criteria: the database volume persists across restart and
|
|
// across recreate. The probe writes a fixture row through the running db
|
|
// service, then asserts it is still there after `docker compose restart`
|
|
// (containers restarted in place) and after `docker compose down` +
|
|
// `docker compose up -d` (containers **recreated** — the container
|
|
// filesystem is discarded, so only the named `db-data` volume can carry
|
|
// the data across). `docker compose down -v` (the issue's rollback note)
|
|
// cleans up in `finally` so runs stay isolated. Credentials use the
|
|
// committed defaults, the same ones the app's DATABASE_URL hardcodes.
|
|
const execPsql = (args) =>
|
|
run('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], {
|
|
cwd: REPO_ROOT,
|
|
timeout: 60_000,
|
|
});
|
|
const createFixture = () =>
|
|
execPsql([
|
|
'-v', 'ON_ERROR_STOP=1', '-c',
|
|
"CREATE TABLE t04_persist_probe (note text); INSERT INTO t04_persist_probe VALUES ('t04-fixture');",
|
|
]);
|
|
const countFixture = () =>
|
|
execPsql(['-tA', '-c', 'SELECT count(*) FROM t04_persist_probe;']);
|
|
|
|
const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT });
|
|
assert.equal(
|
|
up.status,
|
|
0,
|
|
`"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(),
|
|
);
|
|
try {
|
|
waitForDbHealthy();
|
|
|
|
const created = createFixture();
|
|
assert.equal(
|
|
created.status,
|
|
0,
|
|
`fixture creation via psql must succeed:\n${(created.stdout || '')}\n${(created.stderr || '')}`.trim(),
|
|
);
|
|
|
|
// Acceptance 1 — "database volume persists across restart": `docker
|
|
// compose restart` stops and starts the same containers; the fixture
|
|
// must still be queryable afterwards.
|
|
const restart = run('docker', ['compose', 'restart'], { cwd: REPO_ROOT, timeout: 120_000 });
|
|
assert.equal(
|
|
restart.status,
|
|
0,
|
|
`"docker compose restart" must exit 0:\n${(restart.stdout || '')}\n${(restart.stderr || '')}`.trim(),
|
|
);
|
|
waitForDbHealthy();
|
|
const afterRestart = countFixture();
|
|
assert.equal(
|
|
afterRestart.status,
|
|
0,
|
|
`fixture query after restart must succeed:\n${(afterRestart.stdout || '')}\n${(afterRestart.stderr || '')}`.trim(),
|
|
);
|
|
assert.match(
|
|
afterRestart.stdout.trim(),
|
|
/^1$/m,
|
|
`the database fixture must survive "docker compose restart" (volume persists across restart; got: "${afterRestart.stdout.trim()}")`,
|
|
);
|
|
|
|
// Acceptance 2 — "database volume persists across recreate": `docker
|
|
// compose down` (without -v, so named volumes survive) removes the
|
|
// containers, then `docker compose up -d` recreates them from scratch —
|
|
// the only thing that can carry the fixture across is the named volume.
|
|
const down = run('docker', ['compose', 'down'], { cwd: REPO_ROOT, timeout: 120_000 });
|
|
assert.equal(
|
|
down.status,
|
|
0,
|
|
`"docker compose down" must exit 0:\n${(down.stdout || '')}\n${(down.stderr || '')}`.trim(),
|
|
);
|
|
const upAgain = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT, timeout: 120_000 });
|
|
assert.equal(
|
|
upAgain.status,
|
|
0,
|
|
`"docker compose up -d" after down must exit 0:\n${(upAgain.stdout || '')}\n${(upAgain.stderr || '')}`.trim(),
|
|
);
|
|
waitForDbHealthy();
|
|
const afterRecreate = countFixture();
|
|
assert.equal(
|
|
afterRecreate.status,
|
|
0,
|
|
`fixture query after recreate must succeed:\n${(afterRecreate.stdout || '')}\n${(afterRecreate.stderr || '')}`.trim(),
|
|
);
|
|
assert.match(
|
|
afterRecreate.stdout.trim(),
|
|
/^1$/m,
|
|
`the database fixture must survive "docker compose down" + "docker compose up -d" (volume persists across recreate; got: "${afterRecreate.stdout.trim()}")`,
|
|
);
|
|
} finally {
|
|
// Rollback note from the issue: `docker compose down -v` resets the data.
|
|
run('docker', ['compose', 'down', '-v'], { cwd: REPO_ROOT, timeout: 120_000 });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Non-vacuous probes — the assertions above really do fail on violations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('the YAML parser reads the committed structure (non-vacuous parser probe)', () => {
|
|
const parsed = parseYaml(`
|
|
services:
|
|
db:
|
|
image: postgres:18-bookworm
|
|
environment:
|
|
POSTGRES_DB: eppp
|
|
ports:
|
|
- "5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"]
|
|
interval: 5s
|
|
retries: 5
|
|
app:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/server/Dockerfile
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
`);
|
|
assert.equal(parsed.services.db.image, 'postgres:18-bookworm');
|
|
assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp');
|
|
assert.deepEqual(parsed.services.db.ports, ['5432:5432']);
|
|
assert.match(parsed.services.db.healthcheck.test, /pg_isready/);
|
|
assert.equal(parsed.services.db.healthcheck.retries, '5');
|
|
assert.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile');
|
|
assert.equal(parsed.services.app.depends_on.db.condition, 'service_healthy');
|
|
});
|
|
|
|
test('removing the db service makes the database criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutDb = text.replace(/^ db:\n(?: .*\n?)*/m, '');
|
|
assert.notEqual(withoutDb, text, 'the mutation must actually remove the db service block');
|
|
const compose = parseYaml(withoutDb);
|
|
assert.throws(() => assertDbService(compose), /"db" service/);
|
|
});
|
|
|
|
test('removing the app service makes the application criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutApp = text.replace(/^ app:\n(?: .*\n?)*/m, '');
|
|
assert.notEqual(withoutApp, text, 'the mutation must actually remove the app service block');
|
|
const compose = parseYaml(withoutApp);
|
|
assert.throws(() => assertAppService(compose), /"app" service/);
|
|
});
|
|
|
|
test('removing the db healthcheck makes the health-gate criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutHealthcheck = text.replace(/^ healthcheck:\n(?: .*\n?)*/m, '');
|
|
assert.notEqual(withoutHealthcheck, text, 'the mutation must actually remove the db healthcheck block');
|
|
const compose = parseYaml(withoutHealthcheck);
|
|
assert.throws(() => assertDbHealthcheck(compose), /healthcheck/);
|
|
});
|
|
|
|
test('reverting depends_on to a plain list breaks the healthy-gate criterion (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutGate = text.replace(
|
|
/^ depends_on:\n db:\n condition: service_healthy\n/m,
|
|
' depends_on:\n - db\n',
|
|
);
|
|
assert.notEqual(withoutGate, text, 'the mutation must actually replace the healthy-conditioned depends_on');
|
|
const compose = parseYaml(withoutGate);
|
|
assert.throws(() => {
|
|
const app = compose.services?.app;
|
|
assert.equal(
|
|
app?.depends_on?.db?.condition,
|
|
'service_healthy',
|
|
'app must depend on db with condition: service_healthy',
|
|
);
|
|
}, /service_healthy/);
|
|
});
|
|
|
|
test('a Dockerfile without the runtime entrypoint fails the image criterion (mutation probe)', () => {
|
|
const dockerfile = read(DOCKERFILE_PATH);
|
|
const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, '');
|
|
assert.notEqual(withoutCmd, dockerfile, 'the mutation must actually remove the CMD');
|
|
const asserts = () => {
|
|
assert.match(withoutCmd, /node\s+apps\/server\/dist\/index\.js/, 'must run the compiled entrypoint');
|
|
};
|
|
assert.throws(asserts, /compiled entrypoint/);
|
|
});
|
|
|
|
test('removing the db volume mount makes the persistence criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutMount = text.replace(
|
|
' volumes:\n - db-data:/var/lib/postgresql/data\n',
|
|
'',
|
|
);
|
|
assert.notEqual(withoutMount, text, 'the mutation must actually remove the db volume mount');
|
|
const compose = parseYaml(withoutMount);
|
|
assert.throws(() => assertDbVolume(compose), /db-data/);
|
|
});
|
|
|
|
test('removing the named db-data volume declaration makes the persistence criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutVolume = text.replace('volumes:\n db-data:\n', '');
|
|
assert.notEqual(withoutVolume, text, 'the mutation must actually remove the top-level volumes declaration');
|
|
const compose = parseYaml(withoutVolume);
|
|
assert.throws(() => assertDbVolume(compose), /db-data/);
|
|
});
|
|
|
|
test('mounting the volume at the wrong path fails the persistence criterion (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const wrongPath = text.replace(
|
|
'db-data:/var/lib/postgresql/data',
|
|
'db-data:/var/lib/postgresql',
|
|
);
|
|
assert.notEqual(wrongPath, text, 'the mutation must actually change the mount target');
|
|
const compose = parseYaml(wrongPath);
|
|
assert.throws(() => assertDbVolume(compose), /data directory/);
|
|
});
|