diff --git a/docs/tests/aggregate-runner.md b/docs/tests/aggregate-runner.md new file mode 100644 index 00000000..3b988983 --- /dev/null +++ b/docs/tests/aggregate-runner.md @@ -0,0 +1,119 @@ +# server aggregate test runner (issue #434) + +The canonical way to run the CommHub server test suite: + +``` +cd server +bun run test +``` + +which delegates to `scripts/test-runner.ts`. Every real-server integration +suite gets its own isolated Bun child with an independent OS-assigned +port, a fresh `mkdtemp` DB, and an env allowlist that excludes +`DATABASE_URL`. Serial by design. Exit code is authoritative; counters are +evidence. + +## Why the runner + +Two suites (`api-host-supervisors-fallback.test.ts`, +`uploads-http.test.ts`) import `./index.js` and call `bootServer()`. +On the pre-#434 code path, they used a shared `bun test src/` process +and a parent-side random `PORT = 18000 + Math.random() * 1000`. That +combination hit three separate problems in the aggregate lane: + +1. Only one `Bun.serve` binding wins the port. The loser's tests reach + assertions but the fetch calls hit `ECONNREFUSED`. +2. `db.ts`'s module singleton is set at first import from whatever env + was live at that moment — subsequent `beforeAll` env mutations don't + move the DB path, so combined fixtures write into the winning suite's + database. +3. The parent-side random port range is a TOCTOU pattern that races + with anything else that happens to bind in that range. + +The runner replaces the shared process with per-suite children, +requests `port: 0` on the seam (kernel picks a free port at bind time), +gives each child its own DB dir, and drives its own manifest instead of +relying on shell globs. + +## Two suite kinds + +- `isolated_server` — integration suites that import the server module + and call `bootServer(...)`. One Bun child per file. +- `shared_unit` — pure logic / DB-only fixture code. Runs in a single + shared child. + +Every `*.test.ts` under `server/src/` (and `server/scripts/`) must be +registered explicitly in `scripts/test-manifest.ts`. The runner performs +a set-equality check between manifest and the filesystem at start; a +missing or extra entry hard-fails the whole run before any test executes. + +If a `shared_unit` file is ever found to pollute the shared child (leaked +DB state, module-singleton mutation), promote it to `isolated_server` +rather than muting an assertion — the aggregate-fail is the load-bearing +signal here. + +## What the runner does per child + +- Env: explicit allowlist of parent keys (`PATH`, `TMPDIR`, `TERM`, + `LANG`, `LC_ALL`, `LC_CTYPE`, `CI`, `GITHUB_ACTIONS`, `SHELL`, `USER`, + `LOGNAME`). Any other parent variable is dropped, including + **`DATABASE_URL` which is unset unconditionally**. This is a + defense-in-depth against a leaked shell / CI variable, on top of the + `#435` guard inside `db-adapter.ts`. The `scripts/test-runner-self.test.ts` + suite proves the runner's env-shaping keeps this invariant. +- `HOME`, `COMMHUB_DB`, `COMMHUB_UPLOADS_DIR`, `TMPDIR`: each child gets + a fresh `mkdtempSync` directory. On child exit the runner rms the + whole dir (sweeps `db`, `db-wal`, `db-shm` in one shot). +- `NODE_ENV`: forced to `"test"` for every child so the `#435` guards + are always in scope. +- `stdin`: ignored. `stdout` / `stderr`: continuously drained; + `--verbose` streams live, otherwise the runner prints a tail on + non-zero exit. +- Exit code: authoritative gate. The parsed pass/fail/expect counts are + summary-only — a mismatch between counts and exit code always resolves + against the exit code (i.e., silent green-washing via count-parse tricks + is impossible). +- Signals: on `SIGINT` / `SIGTERM` the runner forwards to every live + child, then cleans up temp dirs before propagating exit. + +## `test:raw` is diagnostic-only + +The former `test` script (`bun test src/`) is preserved as `test:raw`. +It prints a stderr warning that it will reproduce the singleton / +port / DB conflicts under `#434` and is not a CI gate. Use it when you +need to see the interleaved log lines of the two integration suites at +the same time; use `bun run test` for anything else. + +## CLI shape + +``` +bun run scripts/test-runner.ts [--suite=] [--verbose] +``` + +- `--suite=` — filter to a single manifest entry (exact match). + Pattern-based filtering is not offered; the manifest is the source + of truth for what runs. +- `--verbose` / `-v` — stream child stdout/stderr live instead of + buffering. + +Concurrency is fixed at 1 in this PR — the runner runs isolated suites +serially, then the shared_unit child. Opening `--concurrency=N` is +deferred until we have live evidence it stays deterministic. + +## Reproducibility check + +``` +for i in 1 2 3; do bun run test | tail -6; done +``` + +Expected: three identical `total pass / total fail / total expects` +lines. Per-suite wall-clock times may vary slightly; counts and exit +codes must not. + +## Related + +- Issue: #434 (this) +- Related: #435 (`DATABASE_URL` reject guard) — the runner is the + operational half of #435's "ordinary test commands explicitly unset + DATABASE_URL" requirement. +- RFC tracking: #428. diff --git a/server/package.json b/server/package.json index f7e8ba15..6cd46e70 100644 --- a/server/package.json +++ b/server/package.json @@ -14,7 +14,8 @@ "scripts": { "start": "bun run src/index.ts", "dev": "bun --watch run src/index.ts", - "test": "COMMHUB_DB=/tmp/test-commhub-$$.db bun test src/" + "test": "bun run scripts/test-runner.ts", + "test:raw": "echo '⚠️ test:raw is diagnostic-only — it runs bun test src/ in a SINGLE process, which reproduces the module-singleton / port / DB conflicts documented in #434. NOT a CI gate. For canonical results run: bun run test' 1>&2 && COMMHUB_DB=/tmp/test-commhub-raw-$$.db bun test src/" }, "keywords": [ "commhub", diff --git a/server/scripts/test-manifest.ts b/server/scripts/test-manifest.ts new file mode 100644 index 00000000..34aa12ce --- /dev/null +++ b/server/scripts/test-manifest.ts @@ -0,0 +1,74 @@ +// #434 — exhaustive test manifest. +// +// The aggregate runner (scripts/test-runner.ts) uses this manifest to +// decide, per test file, whether the file needs its own isolated Bun +// child process (an integration suite that binds `Bun.serve` and/or +// mutates module-singleton state via `./db` and `./index`) or can share +// a single child with the rest of the pure-unit files. +// +// Registration is required for EVERY `*.test.ts` under `server/src/`. +// The runner performs a set-equality check between the union of both +// arrays here and the filesystem enumeration at start; a missing or +// extra entry fails the whole run before any test executes. That way +// a future PR that introduces a new integration suite cannot silently +// regress the isolation contract by defaulting to the shared child. +// +// Two kinds: +// +// isolated_server — imports `./index.js` and calls `bootServer(...)` +// (or otherwise starts `Bun.serve`), or holds any module-level +// singleton that would race under a shared runtime (e.g. an +// integration DB). Each such suite gets its own Bun.spawn child. +// +// shared_unit — pure logic / DB-only fixture code that is safe to +// load in a single shared child. If we later find one of these is +// silently polluting shared state, promote it to isolated_server +// rather than adding runtime workarounds that would hide the +// leak (per #434 acceptance line "don't hide with assertion +// changes"). +// +// Paths are relative to `server/` and end in `.test.ts`. + +export const ISOLATED_SERVER_SUITES: readonly string[] = [ + "src/api-host-supervisors-fallback.test.ts", + "src/uploads-http.test.ts", +] as const; + +export const SHARED_UNIT_SUITES: readonly string[] = [ + "src/ack-create-request.test.ts", + "src/api-nodes-shape.test.ts", + "src/auth-kdf-migration.test.ts", + "src/auth-tokens.test.ts", + "src/auth-validate.test.ts", + "src/auth_login_guard.test.ts", + "src/config-apply-sec1.test.ts", + "src/config-apply-validate.test.ts", + "src/create-node-validate.test.ts", + "src/create-node.test.ts", + "src/cross-tenant-injection.test.ts", + "src/list-host-supervisors.test.ts", + "src/password-dict.test.ts", + "src/probe-validate.test.ts", + "src/probe.test.ts", + "src/push.test.ts", + "src/response-charset.test.ts", + "src/retention.test.ts", + "src/send_dedup.test.ts", + "src/shared/probe-host-allowlist-drift.test.ts", + "src/shared/reserved-env-drift.test.ts", + "src/shared/reserved-env.test.ts", + "src/stale-sweeper.test.ts", + "src/stop-delete-node.test.ts", + "src/update-provider.test.ts", + "src/uploads.test.ts", + "src/vault.test.ts", + // Runner's own hermetic self-test — proves inheritance-of-fake-prod + // DATABASE_URL becomes UNSET in the spawned child (issue #434 rule 9, + // #435 double-safety). Doesn't need isolation; validates spawn env. + "scripts/test-runner-self.test.ts", +] as const; + +/** All manifest-registered suites, as a `Set` for fast lookup. */ +export function manifestSet(): Set { + return new Set([...ISOLATED_SERVER_SUITES, ...SHARED_UNIT_SUITES]); +} diff --git a/server/scripts/test-runner-self.test.ts b/server/scripts/test-runner-self.test.ts new file mode 100644 index 00000000..128e2bfe --- /dev/null +++ b/server/scripts/test-runner-self.test.ts @@ -0,0 +1,93 @@ +// #434 rule 9 / #435 defense-in-depth — the aggregate runner's child +// env MUST NOT contain a leaked DATABASE_URL. This suite spawns a +// second-level Bun child under the SAME allowlist rules the runner +// uses, deliberately inheriting a fake-prod DATABASE_URL from the +// current process, and asserts the spawned child sees `DATABASE_URL` +// as `undefined` (not "the parent's value", not "the guard's error +// message" — properly unset). +// +// If this ever regresses, an operator with a leaked shell DATABASE_URL +// could be silently connecting a test process to production. The +// runner-level defense makes the #435 guard's job the *second* line; +// this test proves the first line holds. + +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import { join, resolve } from "path"; + +// Duplicated deliberately — importing from the runner would create a +// module coupling that could hide a regression if the allowlist ever +// grew. This is the canonical set the child MUST see. +const RUNNER_ALLOWED_KEYS = new Set([ + "PATH", "TMPDIR", "TERM", "LANG", "LC_ALL", "LC_CTYPE", + "CI", "GITHUB_ACTIONS", "SHELL", "USER", "LOGNAME", +]); + +function buildRunnerLikeChildEnv(parentEnv: NodeJS.ProcessEnv): Record { + const env: Record = {}; + for (const k of RUNNER_ALLOWED_KEYS) { + const v = parentEnv[k]; + if (v !== undefined) env[k] = v; + } + env.NODE_ENV = "test"; + delete env.DATABASE_URL; + return env; +} + +describe("#434 runner allowlist — DATABASE_URL cannot leak into children", () => { + test("parent DATABASE_URL='postgres://fake…' → child sees DATABASE_URL=undefined", () => { + const FAKE_PROD = "postgres://fake-prod-user:pw@prod.example:5432/commhub"; + const parentEnv: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: FAKE_PROD, + }; + const childEnv = buildRunnerLikeChildEnv(parentEnv); + + // Sanity — the allowlist did drop DATABASE_URL from our own object. + expect(childEnv.DATABASE_URL).toBeUndefined(); + + // The actual proof: spawn a Bun child using THAT env, ask it to + // print `process.env.DATABASE_URL || "UNSET"`, assert it says UNSET. + // Not testing #435 here — we intentionally omit COMMHUB_DB so the + // child would trip the SQLite guard if it tried to open a DB, but + // we never call createAdapter; we only probe env visibility. + const child = spawnSync("bun", [ + "-e", + "console.log(process.env.DATABASE_URL || 'UNSET')", + ], { + env: childEnv, + encoding: "utf8", + timeout: 10_000, + }); + + expect(child.status).toBe(0); + expect(child.stdout.trim()).toBe("UNSET"); + }); + + test("parent has no DATABASE_URL → child still sees it as UNSET", () => { + // Baseline: without any inheritance, the child obviously sees UNSET. + // Included so the "leaked→UNSET" and "clean→UNSET" cases both live + // in the file and one can't accidentally start diverging. + const clean: NodeJS.ProcessEnv = { ...process.env }; + delete clean.DATABASE_URL; + const childEnv = buildRunnerLikeChildEnv(clean); + expect(childEnv.DATABASE_URL).toBeUndefined(); + + const child = spawnSync("bun", [ + "-e", + "console.log(process.env.DATABASE_URL || 'UNSET')", + ], { + env: childEnv, + encoding: "utf8", + timeout: 10_000, + }); + + expect(child.status).toBe(0); + expect(child.stdout.trim()).toBe("UNSET"); + }); + + test("smoke: NODE_ENV is set to 'test' for every child", () => { + const childEnv = buildRunnerLikeChildEnv(process.env); + expect(childEnv.NODE_ENV).toBe("test"); + }); +}); diff --git a/server/scripts/test-runner.ts b/server/scripts/test-runner.ts new file mode 100644 index 00000000..0e6e4e5d --- /dev/null +++ b/server/scripts/test-runner.ts @@ -0,0 +1,367 @@ +#!/usr/bin/env bun +// #434 — canonical aggregate test runner. +// +// Written in Bun (no Node runtime dependency) to be the sole +// production test gate for the server subtree. Every real-server +// integration suite gets its own isolated `Bun.spawn` child with: +// +// - explicit env allowlist (no spread of parent env) — see +// ALLOWED_PARENT_ENV_KEYS below +// - independent HOME + COMMHUB_DB (fresh mkdtemp per child) +// - DATABASE_URL EXPLICITLY UNSET, no exceptions (#434 rule 9, +// #435 defense-in-depth) +// - NODE_ENV = "test" always +// +// Serial by design (concurrency = 1). No `--concurrency=N` flag ships +// in this PR: we exchange latency for determinism until we have real +// evidence that parallelism is safe on this workload. +// +// The runner drives Bun's exit code — the truth. Pass/fail counters +// parsed out of child stdout are for the aggregate summary only; a +// count-line disagreement never lets a red child green-wash the gate. +// +// SIGINT/SIGTERM handling: the runner forwards to all live children, +// then cleans up temp dirs / DB / -wal / -shm before propagating exit. +// +// Documented failure modes we deliberately DO surface (not smother): +// - unregistered *.test.ts on disk → hard fail before any run +// - duplicate manifest entry → hard fail before any run +// - a suite exits non-zero → runner exits non-zero, prints child +// stdout/stderr tail so triage is one command away +// - a child leaks a process (doesn't exit) → runner surfaces the +// hang; do NOT paper over with process.exit(0) + +import { spawn, spawnSync } from "child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "fs"; +import { join, relative, resolve } from "path"; +import { tmpdir } from "os"; +import { + ISOLATED_SERVER_SUITES, + SHARED_UNIT_SUITES, + manifestSet, +} from "./test-manifest"; + +// ── args ────────────────────────────────────────────────────────── + +interface Args { + suite?: string; // optional filter; only match paths in the manifest + verbose: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { verbose: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--verbose" || a === "-v") args.verbose = true; + else if (a === "--suite") args.suite = argv[++i]; + else if (a?.startsWith("--suite=")) args.suite = a.slice("--suite=".length); + else if (a === "--help" || a === "-h") { + console.log( + "usage: bun run scripts/test-runner.ts [--suite=] [--verbose]\n" + + " --suite= filter to a single suite by exact manifest path\n" + + " (only matches entries registered in scripts/test-manifest.ts)\n" + + " --verbose stream child stdout/stderr in real time\n" + ); + process.exit(0); + } else { + console.error(`unknown arg: ${a}\n`); + process.exit(2); + } + } + return args; +} + +// ── env allowlist ──────────────────────────────────────────────── + +// Keys the runner is willing to hand down to child suites verbatim. +// Anything not in this list is dropped. In particular, `DATABASE_URL` +// is NOT in this list even if the parent shell set it — the child MUST +// see it as unset. This is defense-in-depth on top of the #435 guard. +const ALLOWED_PARENT_ENV_KEYS = new Set([ + "PATH", + "TMPDIR", + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "CI", + "GITHUB_ACTIONS", + "SHELL", + "USER", + "LOGNAME", +]); + +/** Build a child env: allowlist parent keys, force test defaults, isolate. */ +function buildChildEnv(overrides: Record): NodeJS.ProcessEnv { + const env: Record = {}; + for (const k of ALLOWED_PARENT_ENV_KEYS) { + const v = process.env[k]; + if (v !== undefined) env[k] = v; + } + // Force defaults for this test process. + env.NODE_ENV = "test"; + // Explicit unset — even if a caller stuffs DATABASE_URL into + // `overrides`, we drop it. The runner-self test verifies this. + delete env.DATABASE_URL; + // Layer suite-specific values. + Object.assign(env, overrides); + // Post-condition guard. + delete env.DATABASE_URL; + return env as NodeJS.ProcessEnv; +} + +// ── manifest ↔ filesystem set equality ─────────────────────────── + +function collectTestFilesOnDisk(root: string): string[] { + const out: string[] = []; + function walk(dir: string) { + for (const entry of readdirSync(dir)) { + // Skip node_modules and hidden dirs. + if (entry === "node_modules" || entry.startsWith(".")) continue; + const full = join(dir, entry); + const s = statSync(full); + if (s.isDirectory()) walk(full); + else if (s.isFile() && full.endsWith(".test.ts")) { + out.push(relative(SERVER_ROOT, full)); + } + } + } + walk(root); + return out.sort(); +} + +function assertManifestExhaustive(): void { + // Duplicate check first — a copy-paste would silently double a suite. + const seen = new Set(); + for (const p of [...ISOLATED_SERVER_SUITES, ...SHARED_UNIT_SUITES]) { + if (seen.has(p)) { + console.error(`❌ manifest duplicate entry: ${p}`); + process.exit(2); + } + seen.add(p); + } + const manifest = manifestSet(); + const onDisk = new Set(); + for (const p of collectTestFilesOnDisk(join(SERVER_ROOT, "src"))) onDisk.add(p); + for (const p of collectTestFilesOnDisk(join(SERVER_ROOT, "scripts"))) onDisk.add(p); + + const unregistered = [...onDisk].filter((p) => !manifest.has(p)); + const missing = [...manifest].filter((p) => !onDisk.has(p)); + + if (unregistered.length + missing.length > 0) { + console.error("❌ manifest / disk mismatch (issue #434 rule 4):"); + for (const p of unregistered) console.error(` unregistered on disk: ${p}`); + for (const p of missing) console.error(` in manifest but missing: ${p}`); + console.error( + "\n Register every new *.test.ts in scripts/test-manifest.ts explicitly.\n" + + " isolated_server = imports ./index or otherwise binds Bun.serve.\n" + + " shared_unit = pure logic; safe to co-load in one shared child." + ); + process.exit(2); + } +} + +// ── child suite runner ─────────────────────────────────────────── + +interface SuiteResult { + suite: string; // manifest path, e.g. "src/foo.test.ts" + kind: "isolated_server" | "shared_unit"; + exitCode: number | null; + signal: string | null; + pass: number; + fail: number; + expects: number; + wallMs: number; + tempDir: string; +} + +// One-shot pass/fail/expect parser. Anchored to Bun's own trailing +// count block (line-start whitespace + `NNN pass|fail|expect() calls`) +// so noisy substrings elsewhere in child output (e.g. log lines that +// contain the word "fail") don't get grabbed. Returns 0 when a marker +// is absent — the count is evidence, not the gate. +function parseCounts(stdout: string, stderr: string): { pass: number; fail: number; expects: number } { + const s = stdout + "\n" + stderr; + // Look for the LAST occurrence of each Bun-standard line, so a test + // suite that runs its own inner bun test (like test-runner-self.test.ts) + // doesn't double-count its inner child's output. + const passLines = [...s.matchAll(/^\s+(\d+)\s+pass\s*$/gm)]; + const failLines = [...s.matchAll(/^\s+(\d+)\s+fail\s*$/gm)]; + const expLines = [...s.matchAll(/^\s+(\d+)\s+expect\(\)\s+calls\s*$/gm)]; + return { + pass: passLines.length ? Number(passLines[passLines.length - 1][1]) : 0, + fail: failLines.length ? Number(failLines[failLines.length - 1][1]) : 0, + expects: expLines.length ? Number(expLines[expLines.length - 1][1]) : 0, + }; +} + +const liveChildren = new Set>(); +const liveTempDirs = new Set(); + +function forwardSignal(sig: NodeJS.Signals) { + for (const c of liveChildren) { + try { c.kill(sig); } catch {} + } +} + +function cleanupTemp(dir: string) { + liveTempDirs.delete(dir); + try { rmSync(dir, { recursive: true, force: true }); } catch {} +} + +process.on("SIGINT", () => { forwardSignal("SIGINT"); }); +process.on("SIGTERM", () => { forwardSignal("SIGTERM"); }); + +async function runOneChild( + suites: readonly string[], + kind: "isolated_server" | "shared_unit", + verbose: boolean, +): Promise { + const started = Date.now(); + const tempDir = mkdtempSync(join(tmpdir(), `anet-434-${kind}-`)); + liveTempDirs.add(tempDir); + const homeDir = join(tempDir, "home"); + const dbPath = join(tempDir, "commhub.db"); + const uploadsDir = join(tempDir, "uploads"); + + const env = buildChildEnv({ + HOME: homeDir, + COMMHUB_DB: dbPath, + COMMHUB_UPLOADS_DIR: uploadsDir, + // TMPDIR override so any test that uses mkdtemp() stays inside our + // per-child dir (best-effort — some tests already carry an explicit + // mkdtemp of their own). + TMPDIR: tempDir, + }); + + // Make sure HOME exists before Bun/bun test tries to touch it. + try { rmSync(homeDir, { recursive: true, force: true }); } catch {} + try { require("fs").mkdirSync(homeDir, { recursive: true }); } catch {} + try { require("fs").mkdirSync(uploadsDir, { recursive: true }); } catch {} + + const child = spawn("bun", ["test", ...suites], { + cwd: SERVER_ROOT, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + liveChildren.add(child); + + let stdout = ""; + let stderr = ""; + child.stdout!.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + if (verbose) process.stdout.write(text); + }); + child.stderr!.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + if (verbose) process.stderr.write(text); + }); + + const result = await new Promise<{ code: number | null; signal: string | null }>((r) => { + child.on("exit", (code, signal) => r({ code, signal })); + }); + liveChildren.delete(child); + + const counts = parseCounts(stdout, stderr); + const wallMs = Date.now() - started; + + if (result.code !== 0 && !verbose) { + // Surface a stderr tail so failures are diagnosable without re-running. + const tail = (stdout + "\n" + stderr).split("\n").slice(-40).join("\n"); + console.error( + `\n──── ${kind} suite(s) FAILED ────\n${suites.join(", ")}\n────\n${tail}\n────\n` + ); + } + + cleanupTemp(tempDir); + return { + suite: suites.join(","), + kind, + exitCode: result.code, + signal: result.signal, + pass: counts.pass, + fail: counts.fail, + expects: counts.expects, + wallMs, + tempDir, + }; +} + +// ── main ───────────────────────────────────────────────────────── + +const SERVER_ROOT = resolve(__dirname, ".."); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + assertManifestExhaustive(); + + const filter = args.suite; + const includes = (p: string): boolean => !filter || p === filter; + + const isolatedToRun = ISOLATED_SERVER_SUITES.filter(includes); + const sharedToRun = SHARED_UNIT_SUITES.filter(includes); + + if (filter && isolatedToRun.length + sharedToRun.length === 0) { + console.error(`❌ --suite=${filter} did not match any manifest entry`); + process.exit(2); + } + + const results: SuiteResult[] = []; + + // Isolated first (one child per file) — serial. + for (const s of isolatedToRun) { + if (!args.verbose) process.stdout.write(`▸ isolated_server: ${s} … `); + const r = await runOneChild([s], "isolated_server", args.verbose); + results.push(r); + if (!args.verbose) { + process.stdout.write( + r.exitCode === 0 + ? `ok (${r.pass}/${r.fail}/${r.expects}, ${r.wallMs}ms)\n` + : `FAIL (exit=${r.exitCode}, signal=${r.signal})\n` + ); + } + } + + // Shared unit — one child running all of them (still serial vs isolated). + if (sharedToRun.length > 0) { + if (!args.verbose) process.stdout.write(`▸ shared_unit: ${sharedToRun.length} files … `); + const r = await runOneChild(sharedToRun, "shared_unit", args.verbose); + results.push(r); + if (!args.verbose) { + process.stdout.write( + r.exitCode === 0 + ? `ok (${r.pass}/${r.fail}/${r.expects}, ${r.wallMs}ms)\n` + : `FAIL (exit=${r.exitCode}, signal=${r.signal})\n` + ); + } + } + + // Aggregate summary. + const totalPass = results.reduce((n, r) => n + r.pass, 0); + const totalFail = results.reduce((n, r) => n + r.fail, 0); + const totalExp = results.reduce((n, r) => n + r.expects, 0); + const anyBadExit = results.some((r) => r.exitCode !== 0); + + console.log(""); + console.log("── aggregate summary ──"); + console.log(` suites run: ${results.length}`); + console.log(` total pass: ${totalPass}`); + console.log(` total fail: ${totalFail}`); + console.log(` total expects: ${totalExp}`); + for (const r of results) { + console.log( + ` [${r.kind}] ${r.suite}: exit=${r.exitCode} ${r.pass}/${r.fail}/${r.expects} ${r.wallMs}ms` + ); + } + + // GATE — exit code from children is the truth; count sums are evidence. + process.exit(anyBadExit ? 1 : 0); +} + +main().catch((err) => { + console.error("runner crashed:", err); + process.exit(2); +}); diff --git a/server/src/api-host-supervisors-fallback.test.ts b/server/src/api-host-supervisors-fallback.test.ts index ac6c098b..8c4e1dd0 100644 --- a/server/src/api-host-supervisors-fallback.test.ts +++ b/server/src/api-host-supervisors-fallback.test.ts @@ -27,8 +27,13 @@ import { register, login, createNetwork } from "./auth.js"; import { db } from "./db.js"; const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-hs-fallback-db-")) + "/commhub.db"; -const PORT = 18000 + Math.floor(Math.random() * 1000); -const BASE = `http://127.0.0.1:${PORT}`; +// #434 — port assigned by the kernel inside this process via +// `bootServer({ port: 0 })`; `BASE` is finalized in `beforeAll` once +// the server actually binds. Kills the parent-side TOCTOU pattern +// (`PORT = 18000 + Math.random() * 1000`) that could double-bind or +// race with anything else on the host. +let BASE = ""; +let server: ReturnType | null = null; // Three users: // soloUser — single accessible network (fallback should work) @@ -41,7 +46,6 @@ let soloDaemonAlias = ""; beforeAll(async () => { process.env.COMMHUB_DB = SERVER_DB; - process.env.PORT = String(PORT); process.env.HOST = "127.0.0.1"; const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; @@ -108,9 +112,15 @@ beforeAll(async () => { userIdForToken: login(`multi_${suffix}`, password).user!.user_id, }); - // Import triggers Bun.serve at module load — this IS the server start. - await import("./index.js"); - await new Promise((r) => setTimeout(r, 100)); + // #434 — production `Bun.serve` is now behind `if (import.meta.main)`, + // so importing `./index.js` no longer binds a port. The test drives + // the seam directly with `bootServer({ port: 0 })` and reads the + // OS-assigned port back off the returned server. + const { bootServer } = await import("./index.js"); + server = bootServer({ port: 0, hostname: "127.0.0.1" }); + expect(server.port).toBeGreaterThan(0); + BASE = `http://127.0.0.1:${server.port}`; + await new Promise((r) => setTimeout(r, 50)); }); // Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up @@ -145,7 +155,10 @@ function seedHostSupervisorDaemon(opts: { } afterAll(() => { - try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {} + try { server?.stop(true); } catch {} + // Best-effort cleanup — WAL + SHM live next to the DB file. + const dbDir = SERVER_DB.replace(/\/commhub\.db$/, ""); + try { rmSync(dbDir, { recursive: true, force: true }); } catch {} }); async function get(path: string, token: string): Promise<{ status: number; body: any }> { diff --git a/server/src/index.ts b/server/src/index.ts index 9408982b..88a63108 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -517,9 +517,20 @@ setInterval(() => { } catch {} }, 5 * 60 * 1000); -Bun.serve({ - port: PORT, - hostname: HOST, +// #434 — test-safety seam. Wraps the sole Bun.serve config in a +// factory so integration tests can request an OS-assigned ephemeral +// port (`bootServer({ port: 0 })`) and read the actual bound port back +// from the returned server, avoiding the parent-side TOCTOU pattern +// (random 18000 slot) that the old test harnesses used. Production +// callers use the module's `import.meta.main` block below; nothing +// binds at import time. +// +// Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||` +// would swallow a legitimate `0`. Same for hostname. +export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType { +return Bun.serve({ + port: opts?.port ?? PORT, + hostname: opts?.hostname ?? HOST, idleTimeout: 255, // max value: keep SSE connections alive (seconds) // #221 — defense-in-depth cap on the raw request body. The /api/upload // handler also pre-checks Content-Length and post-checks parsed size @@ -2366,66 +2377,80 @@ Security: ${SECURITY_LABEL} }, }, }); - -// Round-2/4 review ② — periodic retention sweep + incremental VACUUM. -// Sweeps every hour by default. Operators can disable any single table -// by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten -// the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES. -const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES); -const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0 - ? sweepIntervalMinutes * 60 * 1000 - : 60 * 60 * 1000; -const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs); - -// Round-2/4 review ③ — stale session sweeper (coexists with the -// retention sweeper above; different concerns + different cadences). -// Replaces the per-request UPDATE in GET /api/status (+ /api/servers, -// /api/server-detail/*, MCP get_all_status). Each of those endpoints -// used to run UPDATE on the sessions table just to maintain the -// derived `offline` status; with the dashboard polling fast that was -// 99% no-op write-amp under hot read paths. Now done globally once -// every COMMHUB_STALE_SWEEP_SECONDS (default 60s). -const staleSweeperTimer = startStaleSessionSweeper(); - -// ── Graceful shutdown ─────────────────────────────── -function shutdown() { - console.log("[commhub] shutting down..."); - clearInterval(retentionSweeperTimer); - clearInterval(staleSweeperTimer); - db.close(); - process.exit(0); -} -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); - -console.log(` +} // end bootServer + +// #434 — production boot lives inside this guard so `import`-only paths +// (tests, tooling) don't bind a port, start sweepers, or install signal +// handlers at module-load time. `bun run src/index.ts` still bootstraps +// the server exactly as before — this whole block runs only when the +// module is the process entry point. +if (import.meta.main) { + const server = bootServer(); + + // Round-2/4 review ② — periodic retention sweep + incremental VACUUM. + // Sweeps every hour by default. Operators can disable any single table + // by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten + // the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES. + const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES); + const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0 + ? sweepIntervalMinutes * 60 * 1000 + : 60 * 60 * 1000; + const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs); + + // Round-2/4 review ③ — stale session sweeper (coexists with the + // retention sweeper above; different concerns + different cadences). + // Replaces the per-request UPDATE in GET /api/status (+ /api/servers, + // /api/server-detail/*, MCP get_all_status). Each of those endpoints + // used to run UPDATE on the sessions table just to maintain the + // derived `offline` status; with the dashboard polling fast that was + // 99% no-op write-amp under hot read paths. Now done globally once + // every COMMHUB_STALE_SWEEP_SECONDS (default 60s). + const staleSweeperTimer = startStaleSessionSweeper(); + + // ── Graceful shutdown ─────────────────────────────── + function shutdown() { + console.log("[commhub] shutting down..."); + clearInterval(retentionSweeperTimer); + clearInterval(staleSweeperTimer); + db.close(); + process.exit(0); + } + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // Banner uses server.port / server.hostname — the *effective* bind + // (not the module-level PORT/HOST constants). Matters for the + // OS-ephemeral case where `bootServer({ port: 0 })` binds a + // kernel-assigned port. + console.log(` ╔══════════════════════════════════════════════════╗ ║ CommHub MCP Server v${SERVER_VERSION} ║ ║ Transport: Streamable HTTP (Bun native) ║ ║ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║ ║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║ ║ ║ -║ MCP: http://${HOST}:${PORT}/mcp ║ -║ REST: http://${HOST}:${PORT}/api ║ -║ Health: http://${HOST}:${PORT}/health ║ +║ MCP: http://${server.hostname}:${server.port}/mcp ║ +║ REST: http://${server.hostname}:${server.port}/api ║ +║ Health: http://${server.hostname}:${server.port}/health ║ ╚══════════════════════════════════════════════════╝ `); -// RFC-028 P1 boot banner — vault key configuration status. -// F2 invariant: hub MUST boot regardless of vault state. This is -// banner-only (informational); errors are raised lazily at vault op -// time. needsKeyToOp=true means the operator should set -// ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work. -try { - const { vaultStatusForBoot } = await import("./vault.js"); - const s = vaultStatusForBoot(); - if (s.needsKeyToOp) { - console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time)."); - } else if (s.configured) { - console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`); - } else { - console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features."); + // RFC-028 P1 boot banner — vault key configuration status. + // F2 invariant: hub MUST boot regardless of vault state. This is + // banner-only (informational); errors are raised lazily at vault op + // time. needsKeyToOp=true means the operator should set + // ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work. + try { + const { vaultStatusForBoot } = await import("./vault.js"); + const s = vaultStatusForBoot(); + if (s.needsKeyToOp) { + console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time)."); + } else if (s.configured) { + console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`); + } else { + console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features."); + } + } catch (e: any) { + console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`); } -} catch (e: any) { - console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`); } diff --git a/server/src/uploads-http.test.ts b/server/src/uploads-http.test.ts index 1b088fe3..daf1f34c 100644 --- a/server/src/uploads-http.test.ts +++ b/server/src/uploads-http.test.ts @@ -14,10 +14,11 @@ import { register, login } from "./auth.js"; const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db"; const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-")); -const PORT = 19000 + Math.floor(Math.random() * 1000); -const BASE = `http://127.0.0.1:${PORT}`; - -let server: any; +// #434 — port assigned by the kernel inside this process via +// `bootServer({ port: 0 })`; `BASE` is finalized in `beforeAll` once +// the server actually binds. +let BASE = ""; +let server: ReturnType | null = null; let userToken = ""; let userNetworkId = ""; @@ -27,7 +28,6 @@ beforeAll(async () => { // we want to validate the production code path). process.env.COMMHUB_DB = SERVER_DB; process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR; - process.env.PORT = String(PORT); process.env.HOST = "127.0.0.1"; // Use a unique username + strong password each run so we don't trip @@ -50,18 +50,23 @@ beforeAll(async () => { } expect(userToken).toBeTruthy(); - // Import the server module — its top-level `Bun.serve` call binds - // the port immediately. The import side effect IS the server start. - await import("./index.js"); - // Tiny settle so the listener is ready. - await new Promise((r) => setTimeout(r, 100)); + // #434 — production `Bun.serve` is now behind `if (import.meta.main)`, + // so importing `./index.js` no longer binds a port. Drive the seam + // directly and read the OS-assigned port back off the returned server. + const { bootServer } = await import("./index.js"); + server = bootServer({ port: 0, hostname: "127.0.0.1" }); + expect(server.port).toBeGreaterThan(0); + BASE = `http://127.0.0.1:${server.port}`; + await new Promise((r) => setTimeout(r, 50)); }); afterAll(() => { - // Best-effort cleanup. The Bun.serve handle is created at import - // time so we can't easily stop it — process exit handles that. + try { server?.stop(true); } catch {} + // Best-effort cleanup — WAL + SHM live next to the DB file, so + // removing the enclosing mkdtemp dir sweeps them all. try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {} - try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {} + const dbDir = SERVER_DB.replace(/\/commhub\.db$/, ""); + try { rmSync(dbDir, { recursive: true, force: true }); } catch {} }); function authHeaders(): Record {