From b2352b837233de7f58bfc8d632c1e275b7559930 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 09:25:32 -0700 Subject: [PATCH] test(e2e): one bootHub helper owns every hub's lifecycle (CL-9621) --- e2e/grants/grants-not-walkable.test.ts | 35 ++-------- e2e/hub/composition.test.ts | 45 +++--------- e2e/lib/browser.ts | 81 ++++----------------- e2e/lib/hub.ts | 97 ++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 131 deletions(-) create mode 100644 e2e/lib/hub.ts diff --git a/e2e/grants/grants-not-walkable.test.ts b/e2e/grants/grants-not-walkable.test.ts index 1fe9d7c52..eb5f33daf 100644 --- a/e2e/grants/grants-not-walkable.test.ts +++ b/e2e/grants/grants-not-walkable.test.ts @@ -2,43 +2,22 @@ // from an ancestor tenant, grants never do. Booting the hub runs package // migrations, so the suite skips without a reachable DATABASE_URL. -import { afterAll, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { type } from "arktype"; import { dbGate } from "../lib/db-gate"; -import { installDisposableHubDataDir } from "../lib/disposable-hub-data-dir"; - -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -installDisposableHubDataDir(); -process.env["CREDENTIAL_ENCRYPTION_KEY"] ??= "0".repeat(64); -process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"] ??= "1".repeat(64); -process.env["SIDECAR_CREDENTIAL_ENCRYPTION_KEY"] ??= "2".repeat(64); -if (databaseUrl !== "") { - const url = new URL(databaseUrl); - process.env["DB_HOST"] = url.hostname; - process.env["DB_PORT"] = url.port === "" ? "5432" : url.port; - process.env["DB_USER"] = decodeURIComponent(url.username); - process.env["DB_PASSWORD"] = decodeURIComponent(url.password); - process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); -} - -// A hub left running keeps reconciling the shared database and fails the -// allocations of every hub booted after it in the same process. -const closers: (() => Promise)[] = []; -afterAll(async () => { - for (const close of closers) await close(); -}); +import { bootHub } from "../lib/hub"; + +const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); const Id = type({ id: "string" }); const GrantPage = type({ data: type({ id: "string" }).array() }); const Evaluation = type({ effect: "string", matchingGrants: type({ id: "string" }).array() }); describeIfDb("grants are explicit per tenant", () => { + const booted = bootHub(); + test("a credential resolves from a child tenant; a grant does not", async () => { - const { createHubServer } = await import("../../apps/hub/src/server"); - const hub = await createHubServer(); - closers.push(() => hub.shutdown()); + const hub = booted(); const origin = "http://localhost"; const suffix = crypto.randomUUID().slice(0, 8); diff --git a/e2e/hub/composition.test.ts b/e2e/hub/composition.test.ts index deb4ebd8c..7503b078a 100644 --- a/e2e/hub/composition.test.ts +++ b/e2e/hub/composition.test.ts @@ -5,45 +5,18 @@ // migrations, so a reachable DATABASE_URL is required and the suite skips // without one. -import { afterAll, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { expect, test } from "bun:test"; import { dbGate } from "../lib/db-gate"; +import { bootHub } from "../lib/hub"; -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const root = mktempHubDataDir(); -function mktempHubDataDir(): string { - return mkdtempSync(path.join(tmpdir(), "hub-composition-")); -} - -process.env["HUB_DATA_DIR"] = root; -process.env["CREDENTIAL_ENCRYPTION_KEY"] ??= "0".repeat(64); -process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"] ??= "1".repeat(64); -process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = "1"; -if (databaseUrl !== "") { - const url = new URL(databaseUrl); - process.env["DB_HOST"] = url.hostname; - process.env["DB_PORT"] = url.port === "" ? "5432" : url.port; - process.env["DB_USER"] = decodeURIComponent(url.username); - process.env["DB_PASSWORD"] = decodeURIComponent(url.password); - process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); -} - -const closers: (() => Promise)[] = []; - -afterAll(async () => { - for (const close of closers) await close(); - rmSync(root, { recursive: true, force: true }); -}); +const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); describeIfDb("boot", () => { + const first = bootHub(); + const second = bootHub(); + test("serves stock platform routes", async () => { - const { createHubServer } = await import("../../apps/hub/src/server"); - const opts = await createHubServer(); - closers.push(() => opts.shutdown()); + const opts = first(); const status = await opts.fetch(new Request("http://localhost/status")); expect(status.status).toBe(200); @@ -53,9 +26,7 @@ describeIfDb("boot", () => { }); test("a Corbits mount (mailbox) sits inside the native tenant middleware", async () => { - const { createHubServer } = await import("../../apps/hub/src/server"); - const opts = await createHubServer(); - closers.push(() => opts.shutdown()); + const opts = second(); // Anonymous request to a Corbits-mounted route: the platform's tenant // middleware answers 401 before the mailbox library's own handler runs. diff --git a/e2e/lib/browser.ts b/e2e/lib/browser.ts index c75feecc1..40a45fc9a 100644 --- a/e2e/lib/browser.ts +++ b/e2e/lib/browser.ts @@ -4,11 +4,11 @@ // local Chrome. A UI test file calls `bootBrowserApp()` once and gets a // fresh page per test via `newPage()`. import { afterAll, beforeAll, describe } from "bun:test"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; import puppeteer, { type Browser, type Page } from "puppeteer-core"; import { dbGate } from "./db-gate"; -import { createDisposableHubDataDir } from "./disposable-hub-data-dir"; +import { bootHub, type BootedHub } from "./hub"; import { REPO_ROOT } from "./database-url"; const DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; @@ -54,79 +54,31 @@ async function ensureWebBuild(): Promise { if ((await proc.exited) !== 0) throw new Error("apps/web build failed"); } -// The process provisioner deliberately lets sidecars outlive the hub (they -// are recovered from pid files on the next boot), so a test hub must reap -// its own or they redial a dead port forever. -function killSidecars(hubDataDir: string): void { - const pidFiles: string[] = []; - try { - for (const dir of readdirSync(hubDataDir)) { - if (!dir.startsWith("process-provisioner")) continue; - const root = path.join(hubDataDir, dir, "allocations"); - for (const alloc of readdirSync(root)) { - for (const unit of readdirSync(path.join(root, alloc))) { - pidFiles.push(path.join(root, alloc, unit, "sidecar.pid")); - } - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - for (const file of pidFiles) { - let pid: number; - try { - pid = Number(readFileSync(file, "utf8").trim()); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; - throw error; - } - if (!Number.isInteger(pid) || pid <= 0) continue; - try { - process.kill(pid, "SIGKILL"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } - } -} - /** Registers beforeAll/afterAll that boot and tear down hub + web + Chrome. */ export function bootBrowserApp(): () => BrowserApp { - let dataDir: ReturnType | undefined; - let hub: - | Awaited> - | undefined; let app: BrowserApp | undefined; let browser: Browser | undefined; let server: ReturnType | undefined; beforeAll(async () => { - const executablePath = chromePath(); - if (executablePath === undefined) throw new Error("Chrome not found"); - // Per file, at run time: every file boots against its own data dir. - dataDir = createDisposableHubDataDir(); + if (chromePath() === undefined) throw new Error("Chrome not found"); await ensureWebBuild(); - const url = new URL(process.env["DATABASE_URL"] ?? ""); - process.env["DB_HOST"] = url.hostname; - process.env["DB_PORT"] = url.port === "" ? "5432" : url.port; - process.env["DB_USER"] = decodeURIComponent(url.username); - process.env["DB_PASSWORD"] = decodeURIComponent(url.password); - process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); - process.env["CREDENTIAL_ENCRYPTION_KEY"] ??= "0".repeat(64); - process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"] ??= "1".repeat(64); - process.env["SIDECAR_CREDENTIAL_ENCRYPTION_KEY"] ??= "2".repeat(64); - process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = "1"; - // Bind first so BASE_URL (Better Auth's trusted origin) names the real port. server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }) }); - const origin = `http://localhost:${String(server.port)}`; - process.env["BASE_URL"] = origin; - // The in-process sidecar dials the hub's own port back over WebSocket. - process.env["PORT"] = String(server.port); + }, 300_000); + + // The in-process sidecar dials the hub's own port back over WebSocket. + const hub = bootHub({ + baseUrl: () => `http://localhost:${String(server?.port)}`, + port: () => server?.port ?? 0, + }); - const { createHubServer } = await import("../../apps/hub/src/server"); - hub = await createHubServer(); - const booted = hub; + beforeAll(async () => { + const executablePath = chromePath(); + if (executablePath === undefined || server === undefined) throw new Error("Chrome not found"); + const origin = `http://localhost:${String(server.port)}`; + const booted: BootedHub = hub(); server.reload({ websocket: booted.websocket as Bun.WebSocketHandler, fetch: async (req, srv) => { @@ -162,9 +114,6 @@ export function bootBrowserApp(): () => BrowserApp { afterAll(async () => { await browser?.close(); await server?.stop(true); - await hub?.shutdown(); - if (dataDir !== undefined) killSidecars(dataDir.hubDataDir); - dataDir?.restore(); }); return () => { diff --git a/e2e/lib/hub.ts b/e2e/lib/hub.ts new file mode 100644 index 000000000..04fb848cc --- /dev/null +++ b/e2e/lib/hub.ts @@ -0,0 +1,97 @@ +// The one place an e2e suite boots a hub. It owns the whole lifecycle: a +// disposable data dir, the env the hub needs, shutdown, sidecar reaping, and +// data-dir removal. A leaked hub keeps reconciling the shared database and +// fails every other suite's allocations, so no test calls `createHubServer` +// directly. +import { afterAll, beforeAll } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { createDisposableHubDataDir } from "./disposable-hub-data-dir"; + +export type BootedHub = Awaited< + ReturnType +>; + +export type BootHubOptions = { + /** Better Auth's trusted origin, read at boot; defaults to http://localhost. */ + baseUrl?: () => string; + /** Port the in-process sidecar dials back, read at boot; unset for fetch-only hubs. */ + port?: () => number; +}; + +// The process provisioner deliberately lets sidecars outlive the hub (they +// are recovered from pid files on the next boot), so a test hub must reap +// its own or they redial a dead port forever. +function killSidecars(hubDataDir: string): void { + const pidFiles: string[] = []; + try { + for (const dir of readdirSync(hubDataDir)) { + if (!dir.startsWith("process-provisioner")) continue; + const root = path.join(hubDataDir, dir, "allocations"); + for (const alloc of readdirSync(root)) { + for (const unit of readdirSync(path.join(root, alloc))) { + pidFiles.push(path.join(root, alloc, unit, "sidecar.pid")); + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + for (const file of pidFiles) { + let pid: number; + try { + pid = Number(readFileSync(file, "utf8").trim()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (!Number.isInteger(pid) || pid <= 0) continue; + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } +} + +/** + * Registers beforeAll/afterAll that boot and tear down one hub. Call it + * synchronously inside the (gated) describe body: Bun runs an `afterAll` + * registered from inside a hook or test immediately, so teardown can only be + * registered while the file is being collected. Safe to call more than once + * per file; each call owns its own hub and data dir. + */ +export function bootHub(opts: BootHubOptions = {}): () => BootedHub { + let hub: BootedHub | undefined; + let dataDir: ReturnType | undefined; + + beforeAll(async () => { + dataDir = createDisposableHubDataDir(); + const url = new URL(process.env["DATABASE_URL"] ?? ""); + process.env["DB_HOST"] = url.hostname; + process.env["DB_PORT"] = url.port === "" ? "5432" : url.port; + process.env["DB_USER"] = decodeURIComponent(url.username); + process.env["DB_PASSWORD"] = decodeURIComponent(url.password); + process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); + process.env["CREDENTIAL_ENCRYPTION_KEY"] ??= "0".repeat(64); + process.env["PRINCIPAL_KEY_ENCRYPTION_KEY"] ??= "1".repeat(64); + process.env["SIDECAR_CREDENTIAL_ENCRYPTION_KEY"] ??= "2".repeat(64); + process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = "1"; + process.env["BASE_URL"] = opts.baseUrl?.() ?? "http://localhost"; + if (opts.port !== undefined) process.env["PORT"] = String(opts.port()); + + const { createHubServer } = await import("../../apps/hub/src/server"); + hub = await createHubServer(); + }, 300_000); + + afterAll(async () => { + await hub?.shutdown(); + if (dataDir !== undefined) killSidecars(dataDir.hubDataDir); + dataDir?.restore(); + }); + + return () => { + if (hub === undefined) throw new Error("bootHub: hub not booted"); + return hub; + }; +}