Files
plainpages/src/compose.test.ts
T
2026-08-05 22:40:36 +02:00

131 lines
7.9 KiB
TypeScript

// Guards the dev/prod compose split + stack ordering: every image is pinned to an
// exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so
// `depends_on: service_healthy` works, the web app waits for the services it talks to
// (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes
// the ones a browser must reach, and the visual E2E stays Ory-free. Real boot is verified
// by running the stack; this catches edits.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const composeFiles = (dir: string) =>
readdirSync(new URL(`../${dir}`, import.meta.url))
.filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
.map((f) => `${dir}${f}`);
const compose = read("compose.yml");
const override = read("compose.override.yml");
const visual = read("e2e-tests/compose.visual.yml");
// compose.yml lists web first, postgres second — slice the web service block.
const webBlock = compose.slice(compose.indexOf("\n web:"), compose.indexOf("\n postgres:"));
test("every image is pinned to an exact version, never a floating tag", () => {
// One scan over all compose files (postgres, the three Ory pairs, mailpit); web/e2e build.
const images = [...[compose, override, visual].join("\n").matchAll(/image:\s*(\S+)/g)].map((m) => m[1]!);
assert.ok(images.length >= 8, "scans the pinned images");
for (const img of images) {
assert.match(img.split(":").at(-1)!, /^v?\d+\.\d+/, `${img} pins a version-like tag`);
assert.doesNotMatch(img, /latest|edge|[\^~*]/, `${img} is exact, not floating`);
}
});
test("each Ory service and its migrate sidecar share one pinned version", () => {
for (const svc of ["hydra", "keto", "kratos"]) {
const tags = [...compose.matchAll(new RegExp(`image:\\s*oryd/${svc}:(\\S+)`, "g"))].map((m) => m[1]);
assert.equal(tags.length, 2, `${svc} + ${svc}-migrate both present`);
assert.equal(tags[0], tags[1], `${svc} server + migrate pinned to the same version`);
}
});
test("long-running Ory services declare readiness healthchecks", () => {
for (const [svc, port] of [["kratos", 4433], ["keto", 4466], ["hydra", 4444]] as const)
assert.match(compose, new RegExp(`wget[^\\n]*:${port}/health/ready`),
`${svc} probes :${port}/health/ready`);
});
test("web waits for kratos, keto and hydra to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the OAuth2 login/consent handler talks to its admin API.
for (const svc of ["kratos", "keto", "hydra"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`);
});
test("prod base publishes no internal Ory ports; dev exposes the host-facing ones", () => {
for (const p of [4433, 4434, 4444, 4445, 4466, 4467])
assert.ok(!compose.includes(`${p}:${p}`), `base does not publish :${p}`);
// Browser completes Kratos flows at kratos public (kratos.yml base_url localhost:4433, shares
// APP_URL's host) and OAuth2 at hydra public — both reachable on the host only in dev.
assert.match(override, /"4433:4433"/, "dev publishes kratos public");
assert.match(override, /"4444:4444"/, "dev publishes hydra public");
});
test("prod base supplies the app secret via env and mounts no source; dev override flips it", () => {
// prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that
// REQUIRE_SECURE_SECRETS rejects in prod — see config.ts); the base never bind-mounts the
// source tree (runs the built image), while the dev override does for live editing.
assert.match(webBlock, /CSRF_SECRET:\s*\$\{CSRF_SECRET\b/, "base wires CSRF_SECRET from env");
assert.doesNotMatch(webBlock, /-\s+\.:\/app\b/, "base mounts no source tree");
assert.match(override, /-\s+\.:\/app\b/, "dev override bind-mounts the source");
// Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass.
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway");
// observability: prod emits structured JSON logs; dev flips it to human-readable text.
assert.match(webBlock, /LOG_FORMAT:\s*"json"/, "prod logs structured JSON");
assert.match(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text");
// Postgres credentials are env-supplied (dev default), never a baked-in literal.
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => {
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
assert.match(boot, /node src\/auth\/bootstrap\.ts/, "bootstrap runs the seed script");
for (const svc of ["kratos", "keto"])
assert.match(boot, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`bootstrap waits for ${svc} healthy`);
// Generates the JWKS into the committed tokenizer dir if absent → needs it writable (no :ro).
assert.match(boot, /\.\/ory\/kratos\/tokenizer:\/etc\/config\/kratos\/tokenizer(?!:ro)/,
"bootstrap mounts the tokenizer dir read-write");
assert.match(webBlock, /bootstrap:\s*\n\s*condition:\s*service_completed_successfully/,
"web waits for bootstrap to finish");
});
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
// A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
const dockerfile = read("Dockerfile");
// split() returns the whole file when the marker is missing, widening "before" to "anywhere".
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
});
test("the E2E runner writes its artifacts as the invoking user, never as root", () => {
// Same trap as the node_modules mountpoint above, but the runner must write into the checkout,
// so the fix is the uid: root-owned output needs sudo to delete, which a dev box may not have.
// Matched independently of flag order, and counted: a reordered flag that slips out of the
// filter would otherwise leave that command silently unguarded.
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(documented.length, 10, "5 compose headers + 5 README blocks");
for (const l of documented)
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
assert.ok(existsSync(new URL("../e2e-tests/artifacts/.gitkeep", import.meta.url)),
"the mount point exists in the checkout");
const gitignore = read(".gitignore");
assert.match(gitignore, /^\/e2e-tests\/artifacts\/\*$/m, "its output stays ignored");
assert.match(gitignore, /^!\/e2e-tests\/artifacts\/\.gitkeep$/m, "the mount point stays tracked");
});
test("the visual E2E does not drag in the Ory stack", () => {
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
});