96 lines
5.6 KiB
TypeScript
96 lines
5.6 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 { readFileSync } from "node:fs";
|
|
|
|
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
|
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("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");
|
|
});
|