import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; /** * The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can * be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so * nothing they start may claim a fixed host port, a fixed project or network name, or a fixed * subnet. And when a disposable project does not become ready, the failure must say why without * printing a credential. */ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const execFileAsync = promisify(execFile); const ciScripts = [ "scripts/test-migrations-integration.sh", "scripts/smoke-infrastructure.sh", "scripts/smoke-deploy.sh", ]; async function source(relativePath: string): Promise { return readFile(path.join(repositoryRoot, relativePath), "utf8"); } /** Runs the real helper under bash with the given literal secrets and input. */ async function redact(input: string, secrets: readonly string[] = []): Promise { const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh"); const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`; const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], { env: { ...process.env, INPUT: input }, }); return stdout; } describe("CI scripts on a shared Docker daemon", () => { it("publish every host port ephemerally, never a fixed one", async () => { const migrations = await source("scripts/test-migrations-integration.sh"); // `.env.example` pins both ports for local development; the suite must override both. expect(migrations).toContain('export POSTGRES_PORT="0"'); expect(migrations).toContain('export S3_PORT="0"'); const infrastructure = await source("scripts/smoke-infrastructure.sh"); expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"'); expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"'); const deploy = await source("scripts/smoke-deploy.sh"); expect(deploy).toContain("EKKLESIA_HTTP_PORT=0"); expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0"); for (const relativePath of ciScripts) { const text = await source(relativePath); // No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=` anywhere. expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u); expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu); } }); it("give every Compose project a name unique to the invocation, not only the run", async () => { expect(await source("scripts/test-migrations-integration.sh")).toMatch( /raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u, ); expect(await source("scripts/smoke-infrastructure.sh")).toMatch( /raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u, ); const deploy = await source("scripts/smoke-deploy.sh"); expect(deploy).toContain('run_id="$(openssl rand -hex 4)"'); expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"'); for (const relativePath of ciScripts) { expect(await source(relativePath), relativePath).toContain('--project-name "$project"'); } }); it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => { const deploy = await source("scripts/smoke-deploy.sh"); expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu); expect(deploy).toContain("choose_edge_network() {"); expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}"); expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]'); }); it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => { const workflow = await source(".github/workflows/ci.yml"); expect(workflow).toContain( 'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"', ); expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"'); }); it("report every readiness failure with the project's containers and logs, through the redactor only", async () => { for (const relativePath of ciScripts) { const text = await source(relativePath); expect(text, relativePath).toContain( 'source "$repository_root/scripts/lib/compose-diagnostics.sh"', ); expect(text, relativePath).toContain("compose_report_not_ready"); expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=("); // Compose output is never printed raw: logs are only ever captured (the deploy smoke scans // the API's for secrets), and a readiness failure's own output is never discarded. for (const line of text .split("\n") .filter((candidate) => candidate.includes('compose[@]}" logs'))) { expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u); } expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u); } }); }); describe("compose diagnostics redaction", () => { it("masks credential-looking values, URL user information, and Authorization headers", async () => { const output = await redact( [ "POSTGRES_PASSWORD=hunter2hunter2", ' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"', "S3_ACCESS_KEY=AKIAEXAMPLE other=fine", "api_key: xyz123", "connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia", "Authorization: Bearer eyJhbGciOi.payload.sig", "EKKLESIA_IDENTITY_SECRET=0123456789abcdef", ].join("\n"), ); for (const secret of [ "hunter2hunter2", "abc/def+ghi", "AKIAEXAMPLE", "xyz123", "s3cr3t", "eyJhbGciOi", "0123456789abcdef", ]) { expect(output).not.toContain(secret); } expect(output).toContain("POSTGRES_PASSWORD=[redacted]"); expect(output).toContain("other=fine"); expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia"); }); it("masks every occurrence of a supplied literal, wherever it appears", async () => { const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [ "temporary-pw-12345", ]); expect(output).toBe("started with [redacted] and again [redacted]\n"); }); it("keeps the diagnosis itself readable", async () => { const diagnosis = "Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated"; expect(await redact(diagnosis)).toBe(`${diagnosis}\n`); expect(await redact("duplicate key value violates unique constraint")).toBe( "duplicate key value violates unique constraint\n", ); expect(await redact('password authentication failed for user "ekklesia"')).toBe( 'password authentication failed for user "ekklesia"\n', ); }); it("strips terminal control sequences from container logs", async () => { expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n"); }); });