Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 7 additions & 28 deletions e2e/grants/grants-not-walkable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>)[] = [];
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);

Expand Down
45 changes: 8 additions & 37 deletions e2e/hub/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>)[] = [];

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);
Expand All @@ -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.
Expand Down
81 changes: 15 additions & 66 deletions e2e/lib/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,79 +54,31 @@ async function ensureWebBuild(): Promise<void> {
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<typeof createDisposableHubDataDir> | undefined;
let hub:
| Awaited<ReturnType<typeof import("../../apps/hub/src/server").createHubServer>>
| undefined;
let app: BrowserApp | undefined;
let browser: Browser | undefined;
let server: ReturnType<typeof Bun.serve> | 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<unknown>,
fetch: async (req, srv) => {
Expand Down Expand Up @@ -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 () => {
Expand Down
97 changes: 97 additions & 0 deletions e2e/lib/hub.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../apps/hub/src/server").createHubServer>
>;

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<typeof createDisposableHubDataDir> | 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;
};
}
Loading