diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ecb0c953f..df286ccc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: - run: bun run fmt:check # A single fast pass over every workspace's tests. DB-gated suites - # (e2e/* included) skip themselves when DATABASE_URL is unset — - # see e2e/db-gate.ts — so this job needs no Postgres service. + # skip themselves when DATABASE_URL is unset — + # see test/db-gate.ts — so this job needs no Postgres service. unit: needs: setup runs-on: ubuntu-latest diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 44a4aabcb8..d332f6c74b 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -9,7 +9,7 @@ import { afterAll, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = process.env["DATABASE_URL"] ?? ""; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/apps/hub/test/mailbox-persist.test.ts b/apps/hub/test/mailbox-persist.test.ts index 686d0fad28..036b7a31ba 100644 --- a/apps/hub/test/mailbox-persist.test.ts +++ b/apps/hub/test/mailbox-persist.test.ts @@ -21,7 +21,7 @@ import { } from "@corbits/mailbox"; import { createHubSessionLookups, type AgentRepoStore } from "@intx/hub-sessions"; import { createHubMailboxAuthorizeSender } from "../src/mailbox-persist"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = process.env["DATABASE_URL"] ?? ""; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/e2e/harness.ts b/e2e/harness.ts deleted file mode 100644 index e5ad50ded4..0000000000 --- a/e2e/harness.ts +++ /dev/null @@ -1,544 +0,0 @@ -// Harness for the end-to-end walking skeleton. Everything here exists -// to run the real stack — the hub and sidecar as spawned processes -// against a real Postgres — and to make a failing hop name itself. -// Nothing platform-side is mocked; when a hop cannot be exercised for -// real the suite fails and says which hop, it never fakes the result. - -import { afterAll } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -// e2eDatabaseUrl and friends live in database-url.ts so a DB-gated unit -// suite can resolve DATABASE_URL without paying for this file's -// process-spawning imports (see that file's header comment). Re-exported -// here so the suites that do spawn the hub/sidecar keep one import. -export { - REPO_ROOT, - baseUrlToE2eUrl, - e2eDatabaseUrl, - parseEnvFileDatabaseUrl, -} from "./database-url.ts"; -import { REPO_ROOT } from "./database-url.ts"; - -const HUB_DIR = path.join(REPO_ROOT, "apps", "hub"); -const SIDECAR_DIR = path.join(REPO_ROOT, "apps", "sidecar"); - -// --- hop naming ------------------------------------------------------- - -/** - * Run one hop of the skeleton. On failure the thrown error names the - * hop, so a broken skeleton reports "which hop" instead of a bare - * assertion dump. - */ -export async function hop(name: string, run: () => Promise): Promise { - try { - return await run(); - } catch (error) { - throw new Error( - `walking-skeleton hop "${name}" failed: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ); - } -} - -// --- shared cleanup registry ------------------------------------------- - -/** - * One `afterAll`-backed cleanup registry per suite: `tempDir` mkdtemps - * under the OS temp dir and queues its removal, `track` queues a - * spawned app's `stop()`. Registered cleanups run last-in-first-out - * once the suite's tests finish, whether they passed or failed — the - * same guarantee every e2e suite used to hand-roll with its own - * `cleanups` array and `try`/`finally`. - */ -/** - * LIFO sweep over registered cleanups. Every cleanup runs even when an - * earlier one throws — a failing hub.stop() must not leak the temp dirs - * registered before it. The first failure still surfaces (rethrown after - * the sweep). - */ -export async function runCleanups(cleanups: (() => Promise | void)[]): Promise { - let firstFailure: unknown; - for (const cleanup of cleanups.splice(0).reverse()) { - try { - await cleanup(); - } catch (cause) { - firstFailure ??= cause; - } - } - if (firstFailure !== undefined) throw firstFailure; -} - -export function createCleanupHarness(): { - tempDir(prefix: string): Promise; - track(app: SpawnedApp): void; -} { - const cleanups: (() => Promise)[] = []; - - // Each tracked app's own `stop()` can take up to 5s (SIGTERM, then - // SIGKILL after a 5s timeout) before it resolves; a suite that tracks - // more than one — e.g. a restart-shaped suite stopping an old hub, a - // fresh hub, and a sidecar — can exceed bun's own 5s default hook - // timeout purely on cleanup, never on the suite's own assertions. - // Generous enough for a handful of tracked apps stopping in the worst - // case (every one hitting its own SIGKILL fallback) without becoming - // the outer suite timeout itself. - afterAll(async () => { - await runCleanups(cleanups); - }, 30_000); - - return { - async tempDir(prefix) { - const dir = await mkdtemp(path.join(tmpdir(), prefix)); - cleanups.push(() => rm(dir, { recursive: true, force: true })); - return dir; - }, - track(app) { - cleanups.push(() => app.stop()); - }, - }; -} - -// --- hub-resolved postgres client ------------------------------------ - -interface SqlClient { - unsafe(query: string, params?: unknown[]): Promise[]>; - end(): Promise; -} - -/** - * A single-connection postgres client resolved through the hub's own - * dependency tree, connected to the e2e database. Used only for - * harness-side facts the platform has no route for (provisioning the - * sidecar identity row, exactly as Interchange's dev provisioning - * does). - */ -export async function connectE2eDb(databaseUrl: string): Promise { - const resolved = Bun.resolveSync("postgres", HUB_DIR); - const { default: postgres } = (await import(resolved)) as { - default: (options: Record) => SqlClient; - }; - const url = new URL(databaseUrl); - return postgres({ - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - max: 1, - onnotice: () => undefined, - }); -} - -/** - * Write the sidecar's identity row: the hub authenticates a sidecar's - * WebSocket dial-in against the token hash on the `sidecar` table, so - * the row must exist before the sidecar process starts. Mirrors - * Interchange's dev provisioning (id + sha256(token), placeholder url). - * - * At pin 692c3106, the hub only accepts a register/reconnect frame from - * a sidecar identity that resolves to a live `sidecar_allocation` (or - * `workflow_probe`) row — the standalone, unallocated ("shared" - * credential scope) sidecar this used to provision no longer - * authenticates at all, so a caller of this function gets a - * connect-and-immediately-reject loop, not a usable dial-in. Every - * suite this repo runs against CI now lets the hub's own - * process-provisioner spawn a dedicated sidecar per allocation instead - * (see `startHub`'s doc comment and `e2e/chat.test.ts`); this - * function is kept only for the handful of scripts not yet migrated off - * a directly-controlled sidecar process (e.g. ones that kill and - * restart a specific sidecar mid-scenario) and is not a working dial-in - * path until they are. - */ -export async function provisionSidecar( - databaseUrl: string, - sidecarId: string, - token: string, -): Promise { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); - const sql = await connectE2eDb(databaseUrl); - try { - await sql.unsafe( - `INSERT INTO "sidecar" ("id", "url", "token_hash_sha256") VALUES ($1, $2, $3)`, - [sidecarId, "ws://e2e-sidecar", Buffer.from(digest)], - ); - } finally { - await sql.end(); - } -} - -// --- spawned processes ------------------------------------------------ - -export interface SpawnedApp { - readonly label: string; - /** Combined stdout+stderr captured so far. */ - output(): string; - /** True once the process has exited. */ - exited(): boolean; - stop(): Promise; -} - -function spawnApp(label: string, cwd: string, env: Record): SpawnedApp { - const proc = Bun.spawn(["bun", "src/index.ts"], { - cwd, - env, - stdout: "pipe", - stderr: "pipe", - }); - let captured = ""; - let done = false; - // A killed-and-restarted process's output is otherwise only reachable - // through the handle the suite still holds; teeing to a file makes a - // crash-recovery run diagnosable after the fact. - const logDir = process.env["E2E_LOG_DIR"]; - const logPath = logDir === undefined ? undefined : `${logDir}/${label}-${String(proc.pid)}.log`; - const capture = async (stream: ReadableStream) => { - const decoder = new TextDecoder(); - for await (const chunk of stream) { - const text = decoder.decode(chunk, { stream: true }); - captured += text; - if (logPath !== undefined) await Bun.write(logPath, captured); - } - }; - void capture(proc.stdout); - void capture(proc.stderr); - void proc.exited.then(() => { - done = true; - }); - return { - label, - output: () => captured, - exited: () => done, - stop: async () => { - if (done) return; - proc.kill(); - const timeout = new Promise<"timeout">((resolveWait) => - setTimeout(() => resolveWait("timeout"), 5000), - ); - if ((await Promise.race([proc.exited, timeout])) === "timeout") { - proc.kill(9); - await proc.exited; - } - }, - }; -} - -/** - * OS facts forwarded into every spawned process; nothing else is - * inherited. USER is in the list because a DATABASE_URL without a - * username makes the postgres client resolve the role from it — a - * local-Postgres setup (see .env.example) authenticates as the OS user. - */ -function osEnv(): Record { - const env: Record = {}; - for (const key of ["PATH", "HOME", "TMPDIR", "USER"]) { - const value = process.env[key]; - if (value !== undefined) env[key] = value; - } - return env; -} - -export interface HubHandle extends SpawnedApp { - readonly baseUrl: string; -} - -/** - * Boot the hub as a real process against the e2e database and wait - * until its platform /status route answers. An early exit or a boot - * that never answers fails with the hub's own output attached. - */ -export async function startHub(options: { - databaseUrl: string; - port: number; - sessionSecret: string; - dataDir: string; - /** Extra hub config env vars, e.g. SIGNUP_RATE_LIMIT_MAX for a - * caller whose own bootstrap traffic needs headroom in the limit. */ - extraEnv?: Record; -}): Promise { - const baseUrl = `http://localhost:${options.port}`; - const dbUrl = new URL(options.databaseUrl); - const app = spawnApp("hub", HUB_DIR, { - ...osEnv(), - // BASE_URL feeds only the Corbits connections/OAuth mount's callback - // URLs now; a caller's extraEnv can override it (e.g. a public origin - // that differs from the actual listen port — see PORT below — for a - // browser-driven suite fronted by a dev-server proxy). - BASE_URL: baseUrl, - // The routine scheduler's real production cadence is a 30s - // setInterval (routine-scheduler.ts); waiting that out for real on - // every e2e test that touches a scheduled routine fire is exactly - // the sleep-dominated cost exists to cut. The default and - // the override are both proven in-process with fake timers in - // apps/hub/test/routine-scheduler.test.ts, so no e2e test needs the - // real cadence — a caller's extraEnv can still opt back into it by - // omitting or overriding this key. - ...options.extraEnv, - // Always the real bind port, independent of whatever port BASE_URL's - // own origin names — this is the same PORT/BASE_URL split the hub's - // own server already documents for a reverse proxy in front of it; - // it is what lets a caller's extraEnv point BASE_URL at a fronting - // dev server without also moving where the hub actually listens. - PORT: String(options.port), - DB_HOST: dbUrl.hostname, - DB_PORT: dbUrl.port === "" ? "5432" : dbUrl.port, - DB_USER: decodeURIComponent(dbUrl.username), - DB_PASSWORD: decodeURIComponent(dbUrl.password), - DB_NAME: dbUrl.pathname.replace(/^\//, ""), - HUB_DATA_DIR: options.dataDir, - // The e2e suite never rotates real secrets; a fixed dev-shaped hex - // key is fine here, matching .env.example's own dev key. - CREDENTIAL_ENCRYPTION_KEY: "0".repeat(64), - PRINCIPAL_KEY_ENCRYPTION_KEY: "1".repeat(64), - }); - const deadline = Date.now() + 30_000; - for (;;) { - if (app.exited()) { - throw new Error(`hub exited during boot; output:\n${app.output()}`); - } - try { - const res = await fetch(`${baseUrl}/status`); - if (res.status === 200) break; - } catch { - // Not listening yet. - } - if (Date.now() > deadline) { - await app.stop(); - throw new Error(`hub did not answer /status within 30s; output:\n${app.output()}`); - } - await Bun.sleep(250); - } - return { ...app, baseUrl }; -} - -/** - * Boot a standalone sidecar as a real process, pointed at the hub's - * WebSocket dial-in route with a `provisionSidecar`-issued identity. - * See `provisionSidecar`'s own doc comment: at pin 692c3106 this - * identity never authenticates, so the process this starts - * connects and is immediately rejected in a loop rather than dialing - * in. Kept only for the scripts not yet migrated to the hub's - * automatic, per-allocation process-provisioner spawn. - */ -export function startSidecar(options: { - hubPort: number; - sidecarId: string; - token: string; - dataDir: string; -}): SpawnedApp { - return spawnApp("sidecar", SIDECAR_DIR, { - ...osEnv(), - SIDECAR_DATA_DIR: options.dataDir, - HUB_WS_URL: `ws://localhost:${options.hubPort}/api/sidecars/ws`, - SIDECAR_ID: options.sidecarId, - SIDECAR_TOKEN: options.token, - }); -} - -/** An OS-assigned free TCP port (bound briefly, then released). */ -export function freePort(): number { - const server = Bun.serve({ port: 0, fetch: () => new Response("") }); - const port = server.port; - void server.stop(true); - if (port === undefined) throw new Error("could not allocate a free port"); - return port; -} - -// --- authenticated API calls ----------------------------------------- - -export interface ApiResult { - status: number; - data: unknown; - cookies: string[]; -} - -function mergeCookies(existing: string[], setCookies: string[]): string[] { - const out = [...existing]; - for (const setCookie of setCookies) { - const pair = setCookie.split(";")[0]; - const name = pair?.split("=")[0]; - if (pair === undefined || name === undefined || name === "") continue; - const index = out.findIndex((c) => c.startsWith(`${name}=`)); - if (index >= 0) out[index] = pair; - else out.push(pair); - } - return out; -} - -/** - * One JSON API call with cookie-jar semantics: send the given cookies, - * fold any Set-Cookie headers into the returned jar. Matches how the - * browser client holds a better-auth session. - */ -export async function api( - base: string, - method: string, - route: string, - body?: unknown, - cookies: string[] = [], -): Promise { - const headers: Record = { - "content-type": "application/json", - }; - if (cookies.length > 0) headers["cookie"] = cookies.join("; "); - const res = await fetch(`${base}${route}`, { - method, - headers, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - redirect: "manual", - }); - const nextCookies = mergeCookies(cookies, res.headers.getSetCookie()); - const contentType = res.headers.get("content-type") ?? ""; - const data: unknown = contentType.includes("json") ? await res.json() : null; - return { status: res.status, data, cookies: nextCookies }; -} - -export function expectStatus(what: string, result: ApiResult, expected: number): void { - if (result.status !== expected) { - throw new Error( - `${what}: expected HTTP ${expected}, got ${result.status}: ` + JSON.stringify(result.data), - ); - } -} - -// --- keyless-by-construction guard ------------------------------------- - -/** - * Real inference provider hosts. This is not a repo-wide invariant — - * each e2e suite chooses per file whether it stays zero-network: a - * suite that pins every inference source at its own local noop - * inference server (`./noop-inference-server.ts`; the hub itself mounts - * no such route as of) or an unreachable placeholder host - * imports this guard and calls it at every baseURL/apiKey it - * constructs, so an accidental live-provider reference fails - * immediately instead of silently attempting a real call. A suite that - * deliberately does dial a real provider host (`chat.test.ts`'s - * echo-invite test, and `local-rip.test.ts`'s task leg — see each - * file's own header comment for why) skips this guard on purpose and - * says so inline, rather than importing it and then routing around it. - * `startHub` only forwards an explicit env allowlist (see `osEnv`), so - * a real ANTHROPIC_API_KEY sitting in a developer's shell never reaches - * the spawned hub either way; this guard is the second, explicit line - * of defense for the suites that opt into it. - */ -const REAL_PROVIDER_HOSTS = [ - "api.anthropic.com", - "api.openai.com", - "openrouter.ai", - "api-inference.huggingface.co", - "huggingface.co", -]; - -/** - * Fails loudly if `value` (a baseURL, apiKey, or any other string a - * test is about to hand the hub as an inference source) names a real - * provider host. Call this at every point a smoke test builds a - * catalog/credential/deployment source, so an accidental live-provider - * reference fails the test immediately instead of silently attempting - * a real network call. - */ -export function assertNeverRealProvider(value: string, what: string): void { - const lower = value.toLowerCase(); - const hit = REAL_PROVIDER_HOSTS.find((host) => lower.includes(host)); - if (hit !== undefined) { - throw new Error( - `${what} references a real inference provider host ("${hit}"); ` + - "the e2e suite must never reach a live provider — use a local " + - "noop-inference-server.ts server or an unreachable placeholder " + - "host instead.", - ); - } -} - -export type RunEvent = { seq: number; type: string; body: unknown }; - -function runEvents(data: unknown): RunEvent[] { - if ( - typeof data === "object" && - data !== null && - "events" in data && - Array.isArray((data as Record)["events"]) - ) { - return (data as { events: RunEvent[] }).events; - } - throw new Error(`expected a run events array: ${JSON.stringify(data)}`); -} - -const TERMINAL_EVENT_TYPES = ["RunCompleted", "RunFailed", "RunCancelled"]; - -/** - * Polls a run's event log until a terminal event lands, then requires - * it to be `RunCompleted` — not merely that the run started. A trigger - * accepted by the hub only proves the mail route works; a broken - * agent launch, a wedged inference call, or a rejected step surfaces - * as `RunFailed` (or no terminal event at all before the deadline), - * either of which fails this loudly instead of a workflow silently - * "succeeding" on nothing more than its own acceptance. - */ -export async function waitForRunCompletion( - baseUrl: string, - tenantId: string, - deploymentId: string, - runId: string, - cookies: string[], - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - const res = await api( - baseUrl, - "GET", - `/api/tenants/${tenantId}/workflows/${deploymentId}/runs/${runId}/events`, - undefined, - cookies, - ); - expectStatus("read run events", res, 200); - const events = runEvents(res.data); - const terminal = events.find((e) => TERMINAL_EVENT_TYPES.includes(e.type)); - if (terminal !== undefined) { - if (terminal.type !== "RunCompleted") { - throw new Error( - `run ${runId} ended in ${terminal.type}, not RunCompleted: ` + - JSON.stringify(terminal.body), - ); - } - return events; - } - if (Date.now() > deadline) { - throw new Error( - `run ${runId} reached no terminal event within ${Math.round(timeoutMs / 1000)}s; ` + - `events so far: ${JSON.stringify(events)}`, - ); - } - await Bun.sleep(500); - } -} - -/** - * Asserts the run's event log recorded a completed step with the - * given step id — the actual per-step execution, not just the run's - * own start/stop bookkeeping. - */ -export function expectStepCompleted(events: RunEvent[], stepId: string): void { - const completed = events.find( - (e) => - e.type === "StepCompleted" && - typeof e.body === "object" && - e.body !== null && - "stepId" in e.body && - (e.body as Record)["stepId"] === stepId, - ); - if (completed === undefined) { - throw new Error( - `no StepCompleted event for step "${stepId}"; events: ${JSON.stringify(events)}`, - ); - } -} - -// pushWorkflowSource and workflowDeployBody moved to workflow-source.ts: -// both need @corbits/connections and @corbits/workflows, which pull in the -// full @intx/* module graph, and most harness consumers never touch -// them (see that file's header comment). diff --git a/e2e/noop-inference-server.ts b/e2e/noop-inference-server.ts deleted file mode 100644 index 265a1a30a5..0000000000 --- a/e2e/noop-inference-server.ts +++ /dev/null @@ -1,199 +0,0 @@ -// A standalone stand-in for a real Anthropic endpoint: an e2e suite that -// wants a deployment costing zero real inference starts this tiny server -// itself (`startNoopInferenceServer`) and plants a catalog offering -// pointed at its own `baseUrl` (`ensureNoopCatalogOffering`), never at -// the hub. -// -// The wire shape matches exactly what -// `@intx/inference/src/providers/anthropic.ts`'s `parseResponse` accepts -// (see that file's `AnthropicSSEEvent` union, ~line 490-560): -// `message_start` (with zeroed usage), one `content_block_start` at -// index 0, one `content_block_delta` carrying a `text_delta`, -// `content_block_stop`, `message_delta` (with a nonzero `output_tokens` -// so downstream usage accounting sees a real number), then -// `message_stop`. `@intx/inference`'s `parseSSE` needs no `[DONE]` -// sentinel — the stream simply ends. -// -// The delta's text is deliberately empty, not merely short: an empty -// reply is exactly the posture a real model obeying "never reply" would -// earn by producing no text, so nothing downstream mistakes the -// constant for a real answer. - -import type { ApiCall } from "../packages/hub-api-client/src/index.ts"; -import { - ensureCatalogModel, - ensureCatalogOffering, - ensureCatalogProvider, - ensureCredential, - ensureProvider, - inferenceCredentialName, - PLACEHOLDER_CATALOG_API_KEY, -} from "../packages/connections/src/seed-catalog.ts"; - -const NOOP_REPLY_TEXT = ""; - -function sseLine(event: Record): string { - return `data: ${JSON.stringify(event)}\n\n`; -} - -function noopInferenceResponse(): Response { - const body = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - const events: Record[] = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: NOOP_REPLY_TEXT }, - }, - { type: "content_block_stop", index: 0 }, - { type: "message_delta", usage: { output_tokens: 1 } }, - { type: "message_stop" }, - ]; - for (const event of events) { - controller.enqueue(encoder.encode(sseLine(event))); - } - controller.close(); - }, - }); - return new Response(body, { - headers: { "content-type": "text/event-stream" }, - }); -} - -export type NoopInferenceServer = { - readonly baseUrl: string; - stop(): void; -}; - -/** - * Starts a standalone HTTP server answering `POST /v1/messages` with the - * constant noop SSE stream above, on an OS-assigned free port. No auth - * check is needed here specifically because the handler takes no - * action, reads no state, and returns a constant: there is nothing an - * attacker could use this endpoint to do, see, or change regardless of - * what credential (if any) they present. Callers must `stop()` it in - * their own test cleanup — nothing here tracks it for them. - */ -export function startNoopInferenceServer(): NoopInferenceServer { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (request.method === "POST" && url.pathname === "/v1/messages") { - return noopInferenceResponse(); - } - return new Response("not found", { status: 404 }); - }, - }); - return { - baseUrl: `http://127.0.0.1:${server.port}`, - stop: () => server.stop(true), - }; -} - -const NOOP_PROVIDER = "anthropic"; -const NOOP_MODEL = "noop"; -// The catalog provider/credential name a noop-pinned deployment's own -// offering is planted under — distinct from any real curated provider -// name (`CATALOG_SEEDS`'s keys), so this never collides with a tenant's -// real catalog and is trivially recognizable in the hub's own catalog UI. -const NOOP_CATALOG_PROVIDER_NAME = "noop"; -// Priority is meaningless for this offering — it is never included in a -// real workflow's `sourceOfferingIds` (this offering's id is threaded -// through explicitly, never discovered by sorting), so any fixed value -// is honest here. -const NOOP_OFFERING_PRIORITY = 0; - -/** - * Plants (or finds) the dedicated catalog offering an e2e suite deploys - * a zero-cost workflow against: a catalog model named `NOOP_MODEL`, a - * provider pointed at this file's own `startNoopInferenceServer` - * `baseUrl` (never the hub — dropped the hub's own mount), and a - * placeholder credential — the same `ensureCatalogModel` / `ensureProvider` - * / `ensureCredential` / `ensureCatalogProvider` / `ensureCatalogOffering` - * sequence `seedCatalog` runs for a real provider, run here for this one - * synthetic one. Idempotent, same as every other seed step: a re-run - * finds the existing rows by name and reuses them. - */ -export async function ensureNoopCatalogOffering( - api: ApiCall, - cookies: string[], - tenantId: string, - noopServerBaseUrl: string, - log: (line: string) => void, -): Promise { - // Matches the hub-mounted route's old convention: `baseURL` is the - // endpoint's base, and the inference client itself appends - // `/v1/messages` (`buildRequest` posts to `{baseURL}/v1/messages`). - const baseURL = noopServerBaseUrl; - const modelId = await ensureCatalogModel( - api, - cookies, - { tenantId, canonicalName: NOOP_MODEL }, - log, - ); - const providerId = await ensureProvider( - api, - cookies, - { - tenantId, - name: NOOP_CATALOG_PROVIDER_NAME, - plugin: NOOP_PROVIDER, - apiBaseUrl: baseURL, - }, - log, - ); - const credentialId = await ensureCredential( - api, - cookies, - { - tenantId, - providerId, - name: inferenceCredentialName(NOOP_CATALOG_PROVIDER_NAME), - secret: PLACEHOLDER_CATALOG_API_KEY, - type: "api_key", - }, - log, - ); - const catalogProviderId = await ensureCatalogProvider( - api, - cookies, - { - tenantId, - name: NOOP_CATALOG_PROVIDER_NAME, - plugin: NOOP_PROVIDER, - baseURL, - credentialId, - }, - log, - ); - return ensureCatalogOffering( - api, - cookies, - { - tenantId, - modelId, - providerId: catalogProviderId, - priority: NOOP_OFFERING_PRIORITY, - capabilities: [], - }, - log, - ); -} diff --git a/e2e/smoke-auth.test.ts b/e2e/smoke-auth.test.ts deleted file mode 100644 index 51f7af0c11..0000000000 --- a/e2e/smoke-auth.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Smoke scenario 1/5: sign-up + session. The narrowest proof -// in the suite — a real hub process, a real better-auth email sign-up, -// and the resulting session cookie actually authorizing a session-gated -// route. No tenant, no sidecar: this only proves the auth boundary -// itself, which every other smoke scenario in this directory builds on. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, -} from "./harness.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "smoke-auth: DATABASE_URL is not set; suite skipped. Set DATABASE_URL " + - "(see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` so this skip " + - "can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -describe.skipIf(databaseUrl === undefined)("smoke: sign-up and session", () => { - test("email sign-up mints a session cookie that authorizes a session-gated route", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - await setupDatabase(url); - }); - - const dataDir = await tempDir("e2e-smoke-auth-hub-data-"); - const hub = await hop("hub boot", () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir, - }), - ); - track(hub); - const baseUrl = hub.baseUrl; - - const email = `smoke-auth-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - - await hop("anonymous call to a session-gated route is rejected", async () => { - const res = await api(baseUrl, "GET", "/api/me/principals"); - expectStatus("anonymous /api/me/principals", res, 401); - }); - - const cookies = await hop("sign-up", async () => { - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Smoke Auth Tester", - email, - password, - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res.cookies; - }); - - await hop("session cookie authorizes a session-gated route", async () => { - const res = await api(baseUrl, "GET", "/api/me/principals", undefined, cookies); - expectStatus("authenticated /api/me/principals", res, 200); - const body = res.data as { data: unknown[] }; - expect(Array.isArray(body.data)).toBe(true); - // A brand-new account belongs to no tenant yet. - expect(body.data.length).toBe(0); - }); - - await hop("sign-in with the same credentials also mints a session", async () => { - const res = await api(baseUrl, "POST", "/api/auth/sign-in/email", { - email, - password, - }); - expectStatus("sign-in", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-in returned no session cookie"); - } - stringField((res.data as { user: unknown }).user, "id", "sign-in user field"); - }); - }, 60_000); -}); diff --git a/e2e/smoke-webhook.test.ts b/e2e/smoke-webhook.test.ts deleted file mode 100644 index b509b9a863..0000000000 --- a/e2e/smoke-webhook.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -// Smoke scenario 5/5: webhook trigger. A workflow is bound to -// a `@corbits/webhook-triggers` row instead of a cadence; a correctly -// HMAC-signed payload delivered to the public ingress route launches a -// run. Zero-cost: the deployed workflow's only inference source is this -// suite's own local -// noop inference server (`./noop-inference-server.ts`), so nothing here -// ever calls a real model provider. -// -// A webhook-fired run launches a standalone folded run via -// `@corbits/folded-runs` (see `packages/webhook-triggers/src/launch.ts`). -// There is no platform route that reads an arbitrary non-anchored run's -// events by bare id (the native events route requires a deployment-anchor -// run). This test instead reads the run row the ingress delivery created -// straight out of the database — a harness-side fact with no route, -// via `connectE2eDb` — and confirms the trigger itself recorded the -// delivery (`lastFiredAt`). - -import { - signPayload, - WEBHOOK_SIGNATURE_HEADER, - WEBHOOK_TIMESTAMP_HEADER, -} from "@corbits/webhook-triggers"; -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; -import { - buildAssistantWorkflow, - serializeAssistantWorkflow, -} from "../agents/assistant/src/index.ts"; -import { ensureNoopCatalogOffering, startNoopInferenceServer } from "./noop-inference-server.ts"; -import { publishCorbitsToolsRegistry } from "../packages/tool-registry-publish/src/publish.ts"; -import { createHubAPI } from "../packages/hub-api-client/src/index.ts"; -import { - api, - connectE2eDb, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type HubHandle, -} from "./harness.ts"; -import { pushWorkflowSource, workflowDeployBody } from "./workflow-source.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "smoke-webhook: DATABASE_URL is not set; suite skipped. Set " + - "DATABASE_URL (see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` " + - "so this skip can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { - test("a signed webhook delivery launches a run against the bound trigger", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - await setupDatabase(url); - }); - - const hubDataDir = await tempDir("e2e-smoke-webhook-hub-data-"); - - const hub: HubHandle = await hop("hub boot", () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: hubDataDir, - }), - ); - track(hub); - - { - const cookies = await hop("sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Webhook Smoke Tester", - email: `smoke-webhook-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res.cookies; - }); - - const slug = `smokewh${crypto.randomUUID().slice(0, 8)}`; - const tenantId = await hop("tenant creation", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { name: "Webhook Smoke", slug }, - cookies, - ); - expectStatus("create tenant", res, 201); - return stringField(res.data, "id", "create tenant"); - }); - - // The zero-cost catalog chain: an anthropic-plugin provider whose - // base URL is this suite's own noop inference server, never the - // hub (dropped its noop-inference mount) and never a real - // model. - const noopServer = startNoopInferenceServer(); - track({ - label: "noop-inference-server", - output: () => "", - exited: () => false, - stop: async () => noopServer.stop(), - }); - const offeringId = await hop("noop catalog seeding", () => - ensureNoopCatalogOffering( - (method, path, body, cookies2) => api(hub.baseUrl, method, path, body, cookies2), - cookies, - tenantId, - noopServer.baseUrl, - () => {}, - ), - ); - - const assetName = "assistant"; - const { assetId, commitSha } = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); - - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-smoke-webhook-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - cookies, - ); - expectStatus("mint git token", minted, 201); - - const definition = buildAssistantWorkflow({ - triggerAddress: `assistant@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - const pushed = await pushWorkflowSource({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeAssistantWorkflow(definition), - }); - return { assetId: id, commitSha: pushed.commitSha }; - }); - - const definitionId = await hop("workflow deploy", async () => { - const body = workflowDeployBody({ - assetId, - commitSha, - sourceOfferingIds: [offeringId], - defaultSourceOfferingId: offeringId, - }); - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before deploy; output:\n${hub.output()}`); - } - // cutover: the launched run's sidecar initialization - // resolves its `@corbits` scope pins against the tenant's own - // `corbits-tools` package registry — without it the allocation - // fails with `sidecar_initialization_failed` and the run never - // commits events (no `inference_turn` row). Install the - // registry the same way the chat/greeting lanes do, inside the - // retry loop because the sidecar dial-in must have completed - // before the publish succeeds. - const published = await publishCorbitsToolsRegistry({ - api: createHubAPI(hub.baseUrl), - cookies, - hubUrl: hub.baseUrl, - tenantId, - log: () => undefined, - }); - if (!published.success) { - if (Date.now() > deadline) { - throw new Error( - `corbits-tools registry publish failed: ${JSON.stringify(published.summaries)}`, - ); - } - await Bun.sleep(200); - continue; - } - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/workflows/deployments`, - body, - cookies, - ); - if (res.status === 502) { - if (Date.now() > deadline) { - throw new Error( - `sidecar never became deployable (hub kept answering 502): ${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(200); - continue; - } - expectStatus("deploy assistant workflow", res, 201); - break; - } - - const listed = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/workflows/definitions`, - undefined, - cookies, - ); - expectStatus("list workflow definitions", listed, 200); - const rows = - typeof listed.data === "object" && listed.data !== null && "data" in listed.data - ? (listed.data as { data: unknown[] }).data - : (listed.data as unknown[]); - const deployed = (rows as { id: string; name?: string }[]).find( - (row) => row.name === assetName, - ); - if (deployed === undefined) { - throw new Error( - `no workflow definition named "${assetName}": ${JSON.stringify(listed.data)}`, - ); - } - return deployed.id; - }); - - const { triggerId, secret } = await hop("webhook trigger creation", async () => { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/webhook-triggers`, - { - name: "Smoke webhook", - workflowDefinitionId: definitionId, - inputTemplate: "webhook smoke: {{event}}", - }, - cookies, - ); - expectStatus("create webhook trigger", res, 201); - return { - triggerId: stringField(res.data, "id", "create webhook trigger"), - secret: stringField(res.data, "secret", "create webhook trigger"), - }; - }); - - const instanceId = await hop( - "a correctly signed delivery is accepted and launches a run", - async () => { - const rawBody = JSON.stringify({ event: "smoke-test" }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const signature = signPayload(secret, timestamp, rawBody); - const res = await fetch(`${hub.baseUrl}/api/webhooks/${triggerId}`, { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_SIGNATURE_HEADER]: signature, - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - }, - body: rawBody, - }); - const data = (await res.json()) as { - instanceId: string; - address: string; - }; - if (res.status !== 202) { - throw new Error( - `webhook delivery: expected HTTP 202, got ${res.status}: ${JSON.stringify(data)}`, - ); - } - if (data.instanceId === "" || data.address === "") { - throw new Error( - `webhook delivery response missing instanceId/address: ${JSON.stringify(data)}`, - ); - } - return data.instanceId; - }, - ); - - await hop("an incorrectly signed delivery is rejected", async () => { - const rawBody = JSON.stringify({ event: "forged" }); - const res = await fetch(`${hub.baseUrl}/api/webhooks/${triggerId}`, { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_SIGNATURE_HEADER]: "0".repeat(64), - [WEBHOOK_TIMESTAMP_HEADER]: String(Math.floor(Date.now() / 1000)), - }, - body: rawBody, - }); - expect(res.status).toBe(401); - }); - - await hop( - "the delivery is recorded and a run row exists for the launched instance", - async () => { - const triggerRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/webhook-triggers/${triggerId}`, - undefined, - cookies, - ); - expectStatus("get webhook trigger", triggerRes, 200); - const lastFiredAt = (triggerRes.data as { lastFiredAt: string | null }).lastFiredAt; - if (lastFiredAt === null) { - throw new Error( - `webhook trigger lastFiredAt was not recorded after delivery: ${JSON.stringify(triggerRes.data)}`, - ); - } - - // No platform route reads a non-anchored run's row by bare id - // (see the file header); this reads the fact the ingress - // route's own launch is required to have produced, the same - // way `connectE2eDb` reaches the database directly for a fact - // no route exposes. - const sql = await connectE2eDb(url); - try { - const rows = await sql.unsafe( - `SELECT "id", "tenant_id" FROM "workflow_run" WHERE "id" = $1`, - [instanceId], - ); - if (rows.length !== 1) { - throw new Error( - `expected exactly one workflow_run row for instance ${instanceId}, found ${rows.length}`, - ); - } - expect(rows[0]?.["tenant_id"]).toBe(tenantId); - } finally { - await sql.end(); - } - }, - ); - - await hop("the run's insights trace reads real spans once the run settles ", async () => { - // The webhook-fired run turns inference against the noop source - // asynchronously (see the sidecar dial-in above). The run itself - // (`workflow_run`) is a long-lived folded run that stays - // "running" indefinitely, the same way a chat workbench does — - // it is the per-turn `inference_turn` row (written by - // @intx/hub-sessions' event-collector) that records the turn. - // Poll straight out of the database (no route reads a - // non-anchored run's turns by bare id, per the file header) - // until one exists — the reader must surface it regardless of - // whether the turn has settled yet, rendering a still-running - // turn as `awaiting` rather than waiting it out — then assert - // the mounted RunTraceReader (apps/hub/src/index.ts) surfaces a - // non-empty, honestly-timed trace through the real HTTP route — - // never the "run_trace_reader_not_mounted" absent-shape this - // route used to return unconditionally. - const sql = await connectE2eDb(url); - const deadline = Date.now() + 60_000; - try { - for (;;) { - const rows = await sql.unsafe( - `SELECT "status" FROM "inference_turn" WHERE "instance_id" = $1`, - [instanceId], - ); - if (rows.length > 0) break; - if (Date.now() > deadline) { - throw new Error(`run ${instanceId} recorded no inference_turn within the deadline`); - } - await Bun.sleep(200); - } - } finally { - await sql.end(); - } - - const traceRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/insights/runs/${instanceId}/trace`, - undefined, - cookies, - ); - expectStatus("get run trace", traceRes, 200); - const trace = traceRes.data as { - runId: string; - spans: { id: string; kind: string; phase: string }[]; - }; - expect(trace.runId).toBe(instanceId); - if (trace.spans.length === 0) { - throw new Error( - `run trace came back empty for a run with a recorded turn: ${JSON.stringify(trace)}`, - ); - } - const turnSpan = trace.spans.find((span) => span.kind === "turn"); - if (turnSpan === undefined) { - throw new Error(`run trace had no "turn" span: ${JSON.stringify(trace)}`); - } - expect(["ok", "awaiting", "failed"]).toContain(turnSpan.phase); - }); - - console.log( - "smoke-webhook: a signed delivery to the public ingress route " + - "launched a real run row for a webhook trigger, " + - "and its insights trace reads back real spans.", - ); - } - }, 180_000); -}); diff --git a/e2e/t3-github-roundtrip.proof.test.ts b/e2e/t3-github-roundtrip.proof.test.ts deleted file mode 100644 index 04ad5b7bc6..0000000000 --- a/e2e/t3-github-roundtrip.proof.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -// evidence kept as a regression proof: exercises the browser's exact -// GitHub connect→disconnect round-trip (`connections/github/complete` → -// credentials list → `connections/github/disconnect` → credentials list) -// against a booted hub on a scratch database with the operator's real `gh` -// token, proving the native connections routes the workbench-scoped mount was -// repointed to still round-trip. The token is read once from `gh auth token` -// at module load, never logged, never stored; the suite skips cleanly when no -// database URL is set or no gh auth is available (e.g. CI without a token). -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type HubHandle, -} from "./harness.ts"; - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn("t3-proof: DATABASE_URL is not set; suite skipped."); -} - -const { tempDir, track } = createCleanupHarness(); - -function readGhAuthToken(): string | undefined { - try { - const out = Bun.spawnSync(["gh", "auth", "token"]); - if (out.exitCode !== 0) return undefined; - const text = new TextDecoder().decode(out.stdout).trim(); - return text === "" ? undefined : text; - } catch { - return undefined; - } -} - -const ghToken = readGhAuthToken(); -if (databaseUrl !== undefined && ghToken === undefined) { - console.warn("t3-proof: `gh auth token` is unavailable; suite skipped."); -} - -describe.skipIf(databaseUrl === undefined || ghToken === undefined)( - "t3-proof: github connect-disconnect round-trip", - () => { - test("complete -> status -> disconnect -> status with a real token", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-t3-proof-hub-data-"), - }), - ); - track(hub); - - const signup = await hop("genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "T3 Proof", - email: "t3-proof@example.com", - password: "password123", - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res.cookies; - }); - - const tenantId = await hop("tenant create", async () => { - const minted = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { slug: "t3proof", name: "T3 Proof" }, - signup, - ); - expectStatus("create tenant", minted, 201); - const body = minted.data as { id?: string; tenantId?: string }; - const id = - typeof body.id === "string" - ? body.id - : typeof body.tenantId === "string" - ? body.tenantId - : undefined; - if (id === undefined) { - throw new Error(`create tenant answered no id: ${JSON.stringify(minted.data)}`); - } - return id; - }); - - const token = await hop("read operator gh token", async () => { - if (ghToken === undefined) { - throw new Error("unreachable: suite is skipped"); - } - console.log("token -> present (value never printed)"); - return ghToken; - }); - - const completePath = `/api/tenants/${tenantId}/connections/github/complete`; - const disconnectPath = `/api/tenants/${tenantId}/connections/github/disconnect`; - const credentialsPath = `/api/tenants/${tenantId}/credentials`; - - async function credentialPresent(what: string, credentialId: string): Promise { - const res = await api(hub.baseUrl, "GET", credentialsPath, undefined, signup); - expectStatus(`credentials list (${what})`, res, 200); - const rows = (res.data as { data: { id: string }[] }).data; - console.log( - `credentials (${what}) -> ${res.status} count=${rows.length} present=${rows.some((row) => row.id === credentialId)}`, - ); - return rows.some((row) => row.id === credentialId); - } - - const credentialId = await hop("connect (POST complete)", async () => { - const res = await api(hub.baseUrl, "POST", completePath, { apiKey: token }, signup); - expectStatus("github complete", res, 200); - const body = res.data as { credentialId?: string; status?: string }; - console.log( - `complete -> ${res.status} status=${body.status} credentialId=${body.credentialId}`, - ); - if (body.credentialId === undefined || body.status !== "active") { - throw new Error(`complete answered no active credential: ${JSON.stringify(res.data)}`); - } - return body.credentialId; - }); - - await hop("status reads connected", async () => { - expect(await credentialPresent("after connect", credentialId)).toBe(true); - }); - - await hop("disconnect (DELETE)", async () => { - const res = await api(hub.baseUrl, "DELETE", disconnectPath, undefined, signup); - expectStatus("github disconnect", res, 204); - console.log(`disconnect -> ${res.status}`); - }); - - await hop("status reads disconnected", async () => { - expect(await credentialPresent("after disconnect", credentialId)).toBe(false); - }); - }, 180_000); - }, -); diff --git a/e2e/walking-skeleton.test.ts b/e2e/walking-skeleton.test.ts deleted file mode 100644 index ffddf398af..0000000000 --- a/e2e/walking-skeleton.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -// The end-to-end walking skeleton, run against the real stack: a fresh -// platform schema built by scripts/db-setup.ts, the hub and the sidecar -// as spawned processes, and a real Postgres named by DATABASE_URL. -// -// The path proven, hop by hop: database setup → hub boot → sidecar -// dial-in → sign-up → tenant creation → workflow-asset publication over -// the platform's git smart-HTTP surface → the native workflow deploy → -// the deployment answering at its mail address (trigger accepted for -// delivery). -// -// The gate this suite holds is deployment-addressable: the trigger mail -// is minted and accepted (HTTP 202) at the deployment's address. Full -// run-completion additionally requires a real inference credential, -// which this suite deliberately does not carry — the deploy's inference -// source is a placeholder, so the run the trigger starts is not -// asserted on. -// -// This is permanent smoke coverage, not a demo script: each run resets -// its own sibling `_e2e` database, failures name the hop that -// broke, and teardown stops every spawned process. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; -import { - buildAssistantWorkflow, - serializeAssistantWorkflow, -} from "../agents/assistant/src/index.ts"; -import { publishCorbitsToolsRegistry } from "../packages/tool-registry-publish/src/publish.ts"; -import { createHubAPI } from "../packages/hub-api-client/src/index.ts"; -import { - api, - assertNeverRealProvider, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type ApiResult, - type HubHandle, -} from "./harness.ts"; -import { pushWorkflowSource, workflowDeployBody } from "./workflow-source.ts"; - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "walking-skeleton: DATABASE_URL is not set; suite skipped. " + - "Set DATABASE_URL (see .env.example) to run it; " + - "start Postgres with `docker compose -f compose.test.yml up -d`.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -/** - * The (provider, model) pair `seedPlaceholderCatalogOffering` plants and - * the only pair the deploy below may declare as its inference preference. - * The deploy-time capability walk auto-approves exactly the (provider, - * model) pairs a step's own agent declares - * (`@intx/workflow-deploy`'s `pickStepInferenceSource`); naming any other - * model here 409s "no approved inference source" at deploy even though - * this suite never calls real inference. - */ -const PLACEHOLDER_MODEL = "noop"; - -/** - * Plants the same catalog-model/provider/credential/offering chain as - * `@corbits/connections`' `seed-catalog` `ensureNoopCatalogOffering`, but pointed at an - * unreachable placeholder host rather than the hub's own - * `noop-inference` endpoint — deliberately, since this suite never - * calls inference and asserts only that the deploy is - * address-reachable (see the module comment above). - */ -async function seedPlaceholderCatalogOffering(options: { - call: (method: string, path: string, body?: unknown, cookies?: string[]) => Promise; - tenantId: string; - cookies: string[]; - placeholderBaseUrl: string; -}): Promise<{ offeringId: string }> { - assertNeverRealProvider(options.placeholderBaseUrl, "placeholder catalog provider baseURL"); - const { call, tenantId, cookies } = options; - - const model = await call( - "POST", - `/api/tenants/${tenantId}/catalog/models`, - { canonicalName: PLACEHOLDER_MODEL }, - cookies, - ); - expectStatus("create catalog model", model, 201); - const modelId = stringField(model.data, "id", "create catalog model"); - - const provider = await call( - "POST", - `/api/tenants/${tenantId}/providers`, - { name: "anthropic", plugin: "anthropic" }, - cookies, - ); - expectStatus("create provider", provider, 201); - const providerId = stringField(provider.data, "id", "create provider"); - - const credential = await call( - "POST", - `/api/tenants/${tenantId}/credentials`, - { - providerId, - name: "anthropic-default", - type: "api_key", - secret: "noop", - }, - cookies, - ); - expectStatus("create credential", credential, 201); - const credentialId = stringField(credential.data, "id", "create credential"); - - const catalogProvider = await call( - "POST", - `/api/tenants/${tenantId}/catalog/providers`, - { - name: "anthropic", - plugin: "anthropic", - baseURL: options.placeholderBaseUrl, - credentialId, - }, - cookies, - ); - expectStatus("create catalog provider", catalogProvider, 201); - const catalogProviderId = stringField(catalogProvider.data, "id", "create catalog provider"); - - const offering = await call( - "POST", - `/api/tenants/${tenantId}/catalog/offerings`, - { modelId, providerId: catalogProviderId }, - cookies, - ); - expectStatus("create catalog offering", offering, 201); - return { - offeringId: stringField(offering.data, "id", "create catalog offering"), - }; -} - -const { tempDir, track } = createCleanupHarness(); - -describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { - test("fresh schema to addressable assistant-workflow deployment", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - // Hop: database setup. A fresh schema built by our db-setup, and - // the setup's own idempotence: a second call reports "unchanged". - await hop("database setup", async () => { - await resetSchema(url); - const first = await setupDatabase(url); - expect(first.action).toBe("migrated"); - expect(first.migrations).toBeGreaterThan(0); - const second = await setupDatabase(url); - expect(second.action).toBe("unchanged"); - expect(second.migrations).toBe(first.migrations); - }); - - // Hop: hub boot. The composition root as a real process. The hub's - // own process provisioner spawns a dedicated sidecar for every - // allocation on demand (one `apps/sidecar` per deployment) — there - // is no shared, pre-provisioned sidecar identity to dial in here. - const hub: HubHandle = await hop("hub boot", async () => { - const handle = await startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-hub-data-"), - }); - track(handle); - return handle; - }); - - // Hop: sign-up. A browser-shaped account creation through the - // platform's better-auth surface; the session cookie carries the - // rest of the skeleton. - const user = await hop("sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Skeleton Tester", - email: `skeleton-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res; - }); - - // Hop: tenant creation via the native route. The signing user - // becomes the tenant owner. - const slug = `e2e${crypto.randomUUID().slice(0, 8)}`; - const tenantId = await hop("tenant creation", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { name: "Walking Skeleton", slug }, - user.cookies, - ); - expectStatus("create tenant", res, 201); - return stringField(res.data, "id", "create tenant"); - }); - - // Hop: workflow asset. The assistant (Myra) workflow definition, - // built by its own package, published as a workflow asset whose - // source tree arrives over the platform's git smart-HTTP surface — - // the only surface that writes asset tree content. - const assetName = "assistant"; - const { assetId, commitSha } = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - user.cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); - - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-workflow-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - user.cookies, - ); - expectStatus("mint git token", minted, 201); - - const definition = buildAssistantWorkflow({ - triggerAddress: `assistant@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: PLACEHOLDER_MODEL }], - turnTimeoutMs: 60_000, - }); - const pushed = await pushWorkflowSource({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeAssistantWorkflow(definition), - }); - return { assetId: id, commitSha: pushed.commitSha }; - }); - - // Hop: noop catalog seeding. The native deploy resolves inference - // against a real catalog offering; this suite's chosen offering - // still names an unreachable placeholder host, since deployment - // never calls inference and full run-completion is not asserted. - const { offeringId } = await hop("noop catalog seeding", () => - seedPlaceholderCatalogOffering({ - call: (method, path, body, cookies) => api(hub.baseUrl, method, path, body, cookies), - tenantId, - cookies: user.cookies, - placeholderBaseUrl: "https://inference.invalid", - }), - ); - - // Hop: workflow deploy via the native deploy API. Retries while - // the hub still answers 502 (the process provisioner's spawned - // sidecar may not have dialed in yet); any other failure is final. - // The inference source is a placeholder — deployment does not call - // inference. - const deploymentId = await hop("workflow deploy", async () => { - const body = workflowDeployBody({ - assetId, - commitSha, - sourceOfferingIds: [offeringId], - defaultSourceOfferingId: offeringId, - }); - const deadline = Date.now() + 60_000; - let res: ApiResult; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before deploy; output:\n${hub.output()}`); - } - // cutover: the launched run's sidecar initialization - // resolves its `@corbits` scope pins against the tenant's own - // `corbits-tools` package registry — Myra's assistant definition - // carries several such pins, so this registry must exist before - // the deploy's sidecar allocation can succeed. - const published = await publishCorbitsToolsRegistry({ - api: createHubAPI(hub.baseUrl), - cookies: user.cookies, - hubUrl: hub.baseUrl, - tenantId, - log: () => undefined, - }); - if (!published.success) { - if (Date.now() > deadline) { - throw new Error( - `corbits-tools registry publish failed: ${JSON.stringify(published.summaries)}`, - ); - } - await Bun.sleep(200); - continue; - } - res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/workflows/deployments`, - body, - user.cookies, - ); - if (res.status !== 502) break; - if (Date.now() > deadline) { - throw new Error( - `sidecar never became deployable (hub kept answering 502): ` + - `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(200); - } - expectStatus("deploy workflow", res, 201); - return stringField(res.data, "id", "deploy workflow"); - }); - - // Hop: deployment addressable. The deployment is listed, and the - // native trigger route mints the signed trigger mail and accepts - // it for delivery at the deployment's address — proof the address - // routes to the live sidecar-hosted deployment. - await hop("deployment addressable", async () => { - const listed = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/workflows/deployments`, - undefined, - user.cookies, - ); - expectStatus("list deployments", listed, 200); - if (!JSON.stringify(listed.data).includes(deploymentId)) { - throw new Error( - `deployment ${deploymentId} missing from list: ` + JSON.stringify(listed.data), - ); - } - - const triggered = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/workflows/${deploymentId}/mail`, - { content: "hello, walking skeleton" }, - user.cookies, - ); - expectStatus("trigger deployment mail", triggered, 202); - const address = stringField(triggered.data, "address", "trigger deployment mail"); - // The deployment's stable mail address. This same value is also the - // deployment's run identity: the platform derives one run id from - // the address, shared by every trigger of the deployment. The - // per-trigger correlation handle is the response's messageId, which - // this suite does not follow up on (run-completion needs a real - // inference credential). - expect(address).toBe(`${deploymentId}@${slug}.localhost`); - }); - - console.log( - "walking-skeleton: gate achieved: deployment-addressable " + - "(trigger mail accepted for delivery). Run-completion is not " + - "asserted: it requires a real inference credential, and this " + - "suite deploys with a placeholder source.", - ); - }, 180_000); -}); diff --git a/e2e/workflow-source.ts b/e2e/workflow-source.ts deleted file mode 100644 index 41c6ae55e6..0000000000 --- a/e2e/workflow-source.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Publishing a workflow definition as a `workflow`-kind asset — split out -// of harness.ts because both helpers below need `@corbits/connections` and -// `@corbits/workflows`, which pull in the full `@intx/*` module graph. -// Most harness consumers (spawning the hub/sidecar, calling its HTTP -// API) never touch these two; keeping them here means importing -// harness.ts no longer pays that load cost on their behalf. -import { createGitWorkflowPusher } from "../packages/connections/src/workflow-push.ts"; -import { WORKFLOW_SOURCE_ENTRY } from "../packages/workflows/src/source.ts"; - -/** - * Publishes a workflow definition into its asset repo in the one shape - * a `workflow`-kind asset accepts: the source codebase - * `@corbits/workflows`'s `./source` renders. Delegates to the platform's own - * pusher so the suite exercises the same publication path the seed and - * the product use, and returns the commit a code-sourced deploy pins. - */ -export async function pushWorkflowSource(options: { - baseUrl: string; - tenantId: string; - assetName: string; - tokenSecret: string; - workflowJson: string; -}): Promise<{ commitSha: string }> { - const pushed = await createGitWorkflowPusher()({ - remoteUrl: `${options.baseUrl}/api/tenants/${options.tenantId}/assets/workflow/${options.assetName}.git`, - tokenSecret: options.tokenSecret, - workflowJson: options.workflowJson, - packageName: options.assetName, - }); - return { commitSha: pushed.commitSha }; -} - -/** - * The deploy body a code-sourced asset deployment takes: the pushed - * commit is the definition's pin, and the entry names the - * `interchange.workflow` module the sidecar evaluates. The native route - * resolves inference against the tenant's own catalog, so the caller - * supplies the ordered catalog offering ids to deploy against rather - * than a raw provider/baseURL/apiKey triple — see - * `@corbits/connections`' `seed-catalog` `ensureNoopCatalogOffering` for - * the zero-cost way to obtain one. - */ -export function workflowDeployBody(options: { - assetId: string; - commitSha: string; - sourceOfferingIds: string[]; - defaultSourceOfferingId: string; -}): Record { - return { - source: { - kind: "asset", - assetId: options.assetId, - package: { format: "source", commitSha: options.commitSha }, - }, - entry: WORKFLOW_SOURCE_ENTRY, - sourceOfferingIds: options.sourceOfferingIds, - defaultSourceOfferingId: options.defaultSourceOfferingId, - }; -} diff --git a/package.json b/package.json index 2c5bc26d19..b5b06dd3d8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "fmt:check": "oxfmt --check apps packages tools agents skills workflows scripts test e2e", "build": "bun run scripts/run-all.ts build", "test": "bun run scripts/run-all.ts test", - "test:e2e": "bun test e2e --max-concurrency=1", "dev": "bun run scripts/dev.ts", "publish-tools": "bun scripts/publish-tools.ts", "setup": "bun scripts/db-setup.ts", diff --git a/packages/agent-directory/test/routes.integration.test.ts b/packages/agent-directory/test/routes.integration.test.ts index e5d38eba50..038be30322 100644 --- a/packages/agent-directory/test/routes.integration.test.ts +++ b/packages/agent-directory/test/routes.integration.test.ts @@ -41,7 +41,7 @@ import { createAgentDefinitionRoutes } from "../src/routes"; import type { PinnedSkillIndexResolver } from "../src/routes"; import type { DefinitionAssetHistory } from "../src/definition-history"; import type { CapabilityInventoryProvider } from "../src/capability-inventory"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = process.env["DATABASE_URL"]; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/packages/agent-directory/test/tool-package-version.drizzle.test.ts b/packages/agent-directory/test/tool-package-version.drizzle.test.ts index b81a97ec9a..05e1af7814 100644 --- a/packages/agent-directory/test/tool-package-version.drizzle.test.ts +++ b/packages/agent-directory/test/tool-package-version.drizzle.test.ts @@ -18,8 +18,8 @@ import type { AssetService } from "@intx/hub-sessions"; import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; -import { dbGate } from "../../../e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../test/database-url"; +import { dbGate } from "../../../test/db-gate"; import { CapabilityOutOfInventoryError } from "../src/capability-inventory"; import { resolvePinnedVersion } from "../src/tool-package-version"; diff --git a/packages/agent-directory/test/visible-definitions.drizzle.test.ts b/packages/agent-directory/test/visible-definitions.drizzle.test.ts index 6aa3f23be2..0fb6f2a2d6 100644 --- a/packages/agent-directory/test/visible-definitions.drizzle.test.ts +++ b/packages/agent-directory/test/visible-definitions.drizzle.test.ts @@ -12,9 +12,9 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; +import { e2eDatabaseUrl } from "../../../test/database-url"; import { listVisibleAgentDefinitions } from "../src/visible-definitions"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/packages/connections/src/connect-github-setup.race.test.ts b/packages/connections/src/connect-github-setup.race.test.ts index 90e04772ac..ec290aca9f 100644 --- a/packages/connections/src/connect-github-setup.race.test.ts +++ b/packages/connections/src/connect-github-setup.race.test.ts @@ -34,8 +34,8 @@ import { } from "@corbits/webhook-triggers"; import type { GitHubRepoSummary } from "@corbits/github-tools"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; -import { dbGate } from "../../../e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../test/database-url"; +import { dbGate } from "../../../test/db-gate"; import { startReviewingRepos, webhookTriggerName, diff --git a/packages/connections/src/seed-catalog.ts b/packages/connections/src/seed-catalog.ts index ac12c92191..86e6706fc6 100644 --- a/packages/connections/src/seed-catalog.ts +++ b/packages/connections/src/seed-catalog.ts @@ -492,7 +492,7 @@ export type SeedCatalogArgs = { * not set, so a keyless dev or CI run can still launch workbench * anchors. Plain `workbench seed` never sets this — only callers that * need a launchable chain without a real key (the local dev - * bootstrap, the e2e harness) pass it. + * bootstrap) pass it. */ placeholderCredential?: boolean; /** @@ -767,12 +767,3 @@ export async function seedCatalog(args: SeedCatalogArgs): Promise ["bunx", "tsgo", "-p", path, const projectsOk = await runInParallel(projectCommands); -const [rootCode, isolationCode, hubDataDirCode] = await Promise.all([ +const [rootCode, hubDataDirCode] = await Promise.all([ run(["bunx", "tsgo", "-p", "tsconfig.json", "--noEmit"]), - run(["bunx", "tsgo", "-p", "test/isolation/tsconfig.json", "--noEmit"]), run(["bunx", "tsgo", "-p", "test/tsconfig.json", "--noEmit"]), ]); -if (!projectsOk || rootCode !== 0 || isolationCode !== 0 || hubDataDirCode !== 0) { +if (!projectsOk || rootCode !== 0 || hubDataDirCode !== 0) { process.exit(1); } diff --git a/e2e/database-url.ts b/test/database-url.ts similarity index 100% rename from e2e/database-url.ts rename to test/database-url.ts diff --git a/e2e/db-gate.ts b/test/db-gate.ts similarity index 100% rename from e2e/db-gate.ts rename to test/db-gate.ts diff --git a/test/disposable-hub-data-dir.test.ts b/test/disposable-hub-data-dir.test.ts deleted file mode 100644 index 3bad82ebca..0000000000 --- a/test/disposable-hub-data-dir.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { createDisposableHubDataDir } from "./disposable-hub-data-dir"; - -test("points HUB_DATA_DIR at a fresh directory under the OS temp dir", () => { - const previous = process.env["HUB_DATA_DIR"]; - const { hubDataDir, restore } = createDisposableHubDataDir(); - try { - expect(hubDataDir.startsWith(tmpdir())).toBe(true); - expect(existsSync(hubDataDir)).toBe(true); - expect(process.env["HUB_DATA_DIR"]).toBe(hubDataDir); - } finally { - restore(); - } - expect(existsSync(hubDataDir)).toBe(false); - if (previous === undefined) expect(process.env["HUB_DATA_DIR"]).toBeUndefined(); - else expect(process.env["HUB_DATA_DIR"]).toBe(previous); -}); - -test("clears HUB_ALLOW_GIT_INSIDE_WORK_TREE so tests cannot inherit local-dev opt-in", () => { - const previous = process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"]; - process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = "1"; - const { restore } = createDisposableHubDataDir(); - try { - expect(process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"]).toBeUndefined(); - } finally { - restore(); - } - expect(process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"]).toBe("1"); - if (previous === undefined) { - delete process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"]; - } else { - process.env["HUB_ALLOW_GIT_INSIDE_WORK_TREE"] = previous; - } -}); diff --git a/test/isolation/isolation.test.ts b/test/isolation/isolation.test.ts deleted file mode 100644 index 890a198cd2..0000000000 --- a/test/isolation/isolation.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Two-tenant isolation suite. Boots the real hub against a real -// Postgres, provisions two tenants through the native routes — one -// user in each — and proves by direct request that neither tenant's -// principal can read or act on the other's surfaces. Cross-tenant -// requests must yield the platform's forbidden/not-found envelopes and -// never data; the member controls prove those refusals are the tenant -// gate working rather than dead routes. -// -// To extend coverage to a new extension, see the registry note in -// surfaces.ts and the "chat workbench move" describe block below for -// the pattern of an extension-specific assertion. - -import { afterAll, describe, expect, test } from "bun:test"; -import { - bootIsolationHub, - prepareDatabase, - provisionTenant, - resolveDatabaseUrl, - signUpUser, - listItems, - type AppLike, - type ProvisionedTenant, -} from "./setup.ts"; -import { tenantSurfaces, type TenantSurface } from "./surfaces.ts"; - -function surfaceInit(surface: TenantSurface, cookie?: string): RequestInit | undefined { - const headers: Record = {}; - if (cookie) headers["cookie"] = cookie; - if (surface.contentType) headers["content-type"] = surface.contentType; - if (surface.method === "GET" && !cookie && !surface.contentType) { - return undefined; - } - const init: RequestInit = { method: surface.method, headers }; - if (surface.body !== undefined) init.body = surface.body; - return init; -} - -/** Asserts a response is an error envelope carrying none of the markers. */ -async function expectRefusal( - response: Response, - status: number, - code: string, - foreignMarkers: string[], -): Promise { - const text = await response.text(); - expect(response.status).toBe(status); - const parsed = JSON.parse(text) as { - error?: { code?: string; message?: string }; - }; - expect(parsed.error?.code).toBe(code); - for (const marker of foreignMarkers) { - expect(text).not.toContain(marker); - } -} - -const databaseUrl = resolveDatabaseUrl(); - -if (!databaseUrl) { - // Without a database there is nothing meaningful to assert; skipping - // loudly beats a vacuous pass. CI runs this suite with DATABASE_URL - // set, where every test below executes. - test.skip("two-tenant isolation (set DATABASE_URL or ISOLATION_DATABASE_URL to run)", () => {}); -} else { - await prepareDatabase(databaseUrl); - const hub = await bootIsolationHub(databaseUrl); - const app: AppLike = hub.app; - // `hub.shutdown()` stops the sidecar then the hub in sequence - // (`test/isolation/setup.ts`), and each `stop()` (`e2e/ - // harness.ts`) gives its process up to 5s to exit on SIGTERM before - // escalating to SIGKILL — so a graceful-but-slow exit on either - // process alone can approach Bun's 5000ms default hook timeout, and - // two of them in sequence can exceed it outright, even though nothing - // is actually hung. - afterAll(async () => { - await hub.shutdown(); - }, 20_000); - - // Unique suffix so the suite can rerun against a database that - // already holds rows from a previous run. - const nonce = Date.now().toString(36); - const cookieA = await signUpUser(app, `owner-a-${nonce}@isolation.test`, "Owner A"); - const cookieB = await signUpUser(app, `owner-b-${nonce}@isolation.test`, "Owner B"); - const tenantA: ProvisionedTenant = await provisionTenant(app, cookieA, "a", nonce); - const tenantB: ProvisionedTenant = await provisionTenant(app, cookieB, "b", nonce); - - const pairs: [string, ProvisionedTenant, ProvisionedTenant][] = [ - ["A against B", tenantA, tenantB], - ["B against A", tenantB, tenantA], - ]; - - describe("member controls", () => { - for (const surface of tenantSurfaces) { - test(`${surface.name}: a member's request reaches the handler`, async () => { - for (const own of [tenantA, tenantB]) { - const response = await app.request( - surface.path(own.tenantId), - surfaceInit(surface, own.cookie), - ); - expect(response.status).toBe(surface.memberStatus); - } - }); - } - - test("each tenant's reads return its own rows", async () => { - for (const [, own, other] of pairs) { - const response = await app.request(`/api/tenants/${own.tenantId}/credentials`, { - headers: { cookie: own.cookie }, - }); - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toContain(own.credentialId); - const ids = listItems(JSON.parse(text)).map((item) => (item as { id: string }).id); - expect(ids).not.toContain(other.credentialId); - for (const marker of other.markers) { - expect(text).not.toContain(marker); - } - } - }); - }); - - describe("cross-tenant requests are refused without data", () => { - for (const surface of tenantSurfaces) { - test(`${surface.name}: foreign principal is forbidden`, async () => { - for (const [, actor, victim] of pairs) { - const response = await app.request( - surface.path(victim.tenantId), - surfaceInit(surface, actor.cookie), - ); - await expectRefusal(response, 403, "forbidden", victim.markers); - } - }); - } - }); - - describe("foreign resource ids under your own tenant path resolve as not found", () => { - const detailReads: [string, (own: string, foreign: ProvisionedTenant) => string][] = [ - ["credential", (own, f) => `/api/tenants/${own}/credentials/${f.credentialId}`], - ["principal", (own, f) => `/api/tenants/${own}/principals/${f.principalId}`], - ["grant", (own, f) => `/api/tenants/${own}/grants/${f.grantId}`], - ]; - for (const [name, buildPath] of detailReads) { - test(`${name} detail read never crosses the boundary`, async () => { - for (const [, actor, victim] of pairs) { - const response = await app.request(buildPath(actor.tenantId, victim), { - headers: { cookie: actor.cookie }, - }); - await expectRefusal(response, 404, "not_found", victim.markers); - } - }); - } - }); - - describe("unauthenticated and unknown-tenant requests", () => { - for (const surface of tenantSurfaces) { - test(`${surface.name}: anonymous request is unauthorized`, async () => { - const response = await app.request(surface.path(tenantA.tenantId), surfaceInit(surface)); - await expectRefusal(response, 401, "unauthorized", tenantA.markers); - }); - } - - test("a tenant id that does not exist is not found", async () => { - const response = await app.request("/api/tenants/tnt_does_not_exist/principals", { - headers: { cookie: tenantA.cookie }, - }); - await expectRefusal(response, 404, "not_found", tenantB.markers); - }); - }); - - // Extension-specific block — the pattern every future extension - // copies: the generic sweep above already covers its surface entry; - // a block like this asserts the behavior only that extension has. - // - // Cutover note (stock Interchange): `POST .../chat/workbenches/:id/move` - // was deleted with the workbench-tenancy cutover. Workbenches are - // client-managed now — the client creates the conversation tenant and - // `POST .../chat/workbenches` stamps `workbenchId = tenant.id` (one - // bench per tenant, see `packages/chat/src/routes.ts`) — so there is - // no move to refuse and no `workbench_tenancy` link to re-parent. The - // extension-specific guarantee that survives is cross-tenant refusal - // on the chat detail routes themselves. -} diff --git a/test/isolation/setup.test.ts b/test/isolation/setup.test.ts deleted file mode 100644 index 622ff9db7b..0000000000 --- a/test/isolation/setup.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Unit coverage for the isolation suite's database gate: a missing -// DATABASE_URL/ISOLATION_DATABASE_URL must only ever skip quietly in -// local dev, never in CI. dbGate's CI=true convention is this repo's -// one signal for that, so this test proves resolveDatabaseUrl honors -// it identically. - -import { afterEach, describe, expect, test } from "bun:test"; -import { resolveDatabaseUrl } from "./setup.ts"; - -const gatedKeys = [ - "DATABASE_URL", - "ISOLATION_DATABASE_URL", - "CI", - "GITHUB_JOB", - "E2E_REQUIRED", -] as const; -const saved = new Map(gatedKeys.map((key) => [key, process.env[key]])); - -/** Unsets a gated variable for the duration of one test; an empty - * string reads as "not configured" to resolveDatabaseUrl, same as a - * variable the shell never set. */ -function unset(key: (typeof gatedKeys)[number]): void { - process.env[key] = ""; -} - -afterEach(() => { - for (const key of gatedKeys) { - process.env[key] = saved.get(key) ?? ""; - } -}); - -describe("resolveDatabaseUrl", () => { - test("returns undefined when no database is configured and CI is unset", () => { - unset("DATABASE_URL"); - unset("ISOLATION_DATABASE_URL"); - unset("CI"); - unset("GITHUB_JOB"); - unset("E2E_REQUIRED"); - expect(resolveDatabaseUrl()).toBeUndefined(); - }); - - test("throws when CI=true but no database is configured", () => { - unset("DATABASE_URL"); - unset("ISOLATION_DATABASE_URL"); - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(() => resolveDatabaseUrl()).toThrow(/docker compose -f compose\.test\.yml up -d/); - }); - - test("ISOLATION_DATABASE_URL wins over DATABASE_URL", () => { - process.env["DATABASE_URL"] = "postgres://localhost:5432/other"; - process.env["ISOLATION_DATABASE_URL"] = "postgres://localhost:5432/isolation"; - unset("CI"); - expect(resolveDatabaseUrl()).toBe("postgres://localhost:5432/isolation"); - }); - - test("CI=true does not throw once a database is configured", () => { - process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; - unset("ISOLATION_DATABASE_URL"); - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(resolveDatabaseUrl()).toBe("postgres://localhost:5432/workbench"); - }); - - test("does not treat E2E_REQUIRED as a required-ness signal", () => { - unset("DATABASE_URL"); - unset("ISOLATION_DATABASE_URL"); - unset("CI"); - process.env["E2E_REQUIRED"] = "1"; - expect(resolveDatabaseUrl()).toBeUndefined(); - }); -}); diff --git a/test/isolation/setup.ts b/test/isolation/setup.ts deleted file mode 100644 index 45e3e77ca6..0000000000 --- a/test/isolation/setup.ts +++ /dev/null @@ -1,334 +0,0 @@ -// Boot and provisioning helpers for the tenant-isolation suite. The -// suite talks to the hub exactly the way a client does — sign-up, -// tenant creation, and every read go through mounted routes — so a -// missing tenant filter shows up here as leaked data, not as a green -// run. - -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { freePort, startHub, type HubHandle } from "../../e2e/harness.ts"; -import { assertDatabaseConfigured } from "../../e2e/db-gate.ts"; - -/** - * The minimal surface of the hub the suite needs: a fetch-shaped - * request method. Structural on purpose — the suite stays decoupled - * from the hub's own types and exercises it purely over HTTP. - */ -export type AppLike = { - request(path: string, init?: RequestInit): Promise; -}; - -export type IsolationHub = { - app: AppLike; - /** Closes the database pool and removes the temp directories. */ - shutdown(): Promise; -}; - -/** - * The hub's sign-up rate limit as configured for every suite that - * boots through `bootIsolationHub`. Exported so a suite asserting on - * the limit itself (`apps/hub/test/rate-limit.test.ts`) never - * hardcodes a second copy of these numbers that could silently drift - * from what actually gets booted. - */ -export const ISOLATION_SIGNUP_RATE_LIMIT_MAX = 5; -export const ISOLATION_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 60; - -/** - * The database the suite runs against. The suite never invents a - * default: pointing it at a database is an explicit act, because it - * creates real rows there. Locally, an unconfigured database skips the - * suite (a fresh checkout still runs the unit gates); CI=true turns - * that same gap into a hard failure, mirroring dbGate — this suite - * guards multi-tenant authorization, so it must never vanish from CI - * silently. - */ -export function resolveDatabaseUrl(): string | undefined { - const isolationUrl = process.env["ISOLATION_DATABASE_URL"]; - const url = - isolationUrl !== undefined && isolationUrl !== "" ? isolationUrl : process.env["DATABASE_URL"]; - if (url !== undefined && url !== "") return url; - assertDatabaseConfigured(undefined, "isolation suite"); - return undefined; -} - -/** - * Seam to the repository's shared database bootstrap. The script owns - * schema creation (platform migrations plus auth tables); this suite - * only consumes it, so the two never drift. Expects - * `scripts/db-setup.ts` to export `setupDatabase(databaseUrl)`. - */ -export async function prepareDatabase(databaseUrl: string): Promise { - const modulePath = new URL("../../scripts/db-setup.ts", import.meta.url).href; - let mod: Record; - try { - mod = (await import(modulePath)) as Record; - } catch (cause) { - throw new Error( - "scripts/db-setup.ts is not importable. The isolation suite relies on it to create the platform schema before booting the hub.", - { cause }, - ); - } - const setup = mod["setupDatabase"]; - if (typeof setup !== "function") { - throw new Error( - "scripts/db-setup.ts must export setupDatabase(databaseUrl) for the isolation suite to prepare its database.", - ); - } - await (setup as (url: string) => Promise)(databaseUrl); -} - -/** - * Boots the real hub as a spawned process, exactly as - * `e2e/harness.ts` does for the walking skeleton — a - * chat-workbench create launches a real agent instance over its own - * process-provisioner-spawned sidecar's WebSocket dial-in, so an - * in-process hub with no sidecar plugin wired up cannot exercise the - * "chat workbench move" block below; it would fail every one of those - * cases with "no sidecar available" rather than proving isolation. - * `AppLike.request` wraps `fetch` against the hub's real port, matching - * how a client actually reaches it. - */ -export async function bootIsolationHub(databaseUrl: string): Promise { - const root = mkdtempSync(path.join(tmpdir(), "isolation-suite-")); - const hubDataDir = path.join(root, "hub-data"); - - const hub: HubHandle = await startHub({ - databaseUrl, - port: freePort(), - sessionSecret: "insecure-isolation-suite-secret-0000", - dataDir: hubDataDir, - extraEnv: { - // Isolation suite mints throwaway accounts over the public signup - // surface; stock Interchange composition leaves signup ungated - // (rate-limited only), so this boot needs no signup env at all. - SIGNUP_RATE_LIMIT_MAX: String(ISOLATION_SIGNUP_RATE_LIMIT_MAX), - SIGNUP_RATE_LIMIT_WINDOW_SECONDS: String(ISOLATION_SIGNUP_RATE_LIMIT_WINDOW_SECONDS), - }, - }); - - const app: AppLike = { - request: (requestPath, init) => fetch(`${hub.baseUrl}${requestPath}`, init), - }; - - await waitForSidecarDialIn(app, hub); - - return { - app, - shutdown: async () => { - await hub.stop(); - rmSync(root, { recursive: true, force: true }); - }, - }; -} - -/** - * A workbench-launching route has no "not yet connected" contract the - * way the native workflow-deploy route does (which answers 502 while - * the sidecar's dial-in is in flight, see `e2e/harness.ts`) — - * it fails outright. So this probes readiness itself: sign up a - * throwaway account, create a throwaway tenant, and retry a workbench - * create until it succeeds or the sidecar visibly died or too much - * time passed. The probe tenant is left behind, same as every other - * tenant this suite mints — it is not the database under test's - * concern to stay clean. - */ -async function waitForSidecarDialIn(app: AppLike, hub: HubHandle): Promise { - const cookie = await signUpUser( - app, - `sidecar-probe-${crypto.randomUUID()}@isolation.test`, - "Sidecar Probe", - ); - const tenantResponse = await app.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - name: "Sidecar Probe", - slug: `sidecar-probe-${crypto.randomUUID().slice(0, 8)}`, - }), - }); - if (tenantResponse.status !== 201) { - throw new Error( - `sidecar readiness probe: tenant creation failed with ` + - `${tenantResponse.status}: ${await tenantResponse.text()}`, - ); - } - const tenant = (await tenantResponse.json()) as { id: string }; - - const deadline = Date.now() + 30_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before dialing in; output:\n${hub.output()}`); - } - const workbenchResponse = await app.request(`/api/tenants/${tenant.id}/chat/workbenches`, { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ kind: "workbench", name: "sidecar-probe" }), - }); - if (workbenchResponse.status === 201) return; - if (Date.now() > deadline) { - throw new Error( - `sidecar never dialed in within 30s (workbench create kept ` + - `failing with ${workbenchResponse.status}): ` + - `${await workbenchResponse.text()}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(500); - } -} - -async function requireJson( - response: Response, - expected: number, - what: string, -): Promise> { - const text = await response.text(); - if (response.status !== expected) { - throw new Error(`${what}: expected ${expected}, got ${response.status}: ${text}`); - } - return JSON.parse(text) as Record; -} - -/** - * Signs up a fresh user through the platform's own auth surface and - * returns the session cookie a browser would hold. - */ -export async function signUpUser(app: AppLike, email: string, name: string): Promise { - const response = await app.request("/api/auth/sign-up/email", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email, name, password: "isolation-suite-pass" }), - }); - if (!response.ok) { - throw new Error( - `sign-up for ${email} failed with ${response.status}: ${await response.text()}`, - ); - } - const cookies = response.headers - .getSetCookie() - .map((cookie) => cookie.split(";")[0]) - .filter((pair): pair is string => Boolean(pair)); - if (cookies.length === 0) { - throw new Error(`sign-up for ${email} returned no session cookie`); - } - return cookies.join("; "); -} - -/** One tenant with everything the sweep asserts on. */ -export type ProvisionedTenant = { - cookie: string; - tenantId: string; - principalId: string; - grantId: string; - credentialId: string; - /** - * Strings that must never appear in any response served to the other - * tenant. Includes the stored secret: it must not surface anywhere, - * least of all across a tenant boundary. - */ - markers: string[]; -}; - -/** - * Creates a tenant through the native creation route, then seeds it - * with a provider and a credential so its read surfaces have real rows - * capable of leaking. - */ -export async function provisionTenant( - app: AppLike, - cookie: string, - label: string, - nonce: string, -): Promise { - const tag = `${label}-${nonce}`; - - const tenantJson = await requireJson( - await app.request("/api/tenants", { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - name: `Isolation ${tag}`, - slug: `isolation-${tag}`, - }), - }), - 201, - `create tenant ${tag}`, - ); - const tenantId = tenantJson["id"] as string; - - const principalsJson = await requireJson( - await app.request(`/api/tenants/${tenantId}/principals`, { - headers: { cookie }, - }), - 200, - `list principals in ${tag}`, - ); - const principals = listItems(principalsJson); - const principal = principals[0] as { id: string } | undefined; - if (principals.length !== 1 || !principal) { - throw new Error(`expected exactly the owner principal in ${tag}, got ${principals.length}`); - } - - const grantsJson = await requireJson( - await app.request(`/api/tenants/${tenantId}/grants`, { - headers: { cookie }, - }), - 200, - `list grants in ${tag}`, - ); - const grant = listItems(grantsJson)[0] as { id: string } | undefined; - if (!grant) throw new Error(`expected system grants in ${tag}, found none`); - - const providerJson = await requireJson( - await app.request(`/api/tenants/${tenantId}/providers`, { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ name: `provider-${tag}`, plugin: "noop" }), - }), - 201, - `create provider in ${tag}`, - ); - - const credentialName = `credential-${tag}`; - const credentialSecret = `leak-canary-secret-${tag}`; - const credentialJson = await requireJson( - await app.request(`/api/tenants/${tenantId}/credentials`, { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ - providerId: providerJson["id"] as string, - name: credentialName, - type: "api_key", - secret: credentialSecret, - }), - }), - 201, - `create credential in ${tag}`, - ); - - return { - cookie, - tenantId, - principalId: principal.id, - grantId: grant.id, - credentialId: credentialJson["id"] as string, - markers: [ - principal.id, - grant.id, - credentialJson["id"] as string, - providerJson["id"] as string, - credentialName, - credentialSecret, - ], - }; -} - -/** Accepts both bare-array and cursor-paginated list responses. */ -export function listItems(json: unknown): unknown[] { - if (Array.isArray(json)) return json; - const data = (json as Record)["data"]; - if (Array.isArray(data)) return data; - throw new Error(`expected a list response, got: ${JSON.stringify(json)}`); -} diff --git a/test/isolation/surfaces.ts b/test/isolation/surfaces.ts deleted file mode 100644 index 2dbbb22514..0000000000 --- a/test/isolation/surfaces.ts +++ /dev/null @@ -1,84 +0,0 @@ -// The registry of tenant-scoped surfaces the isolation sweep covers. -// Every entry is exercised three ways by the suite: a member request -// (which must reach the handler), a cross-tenant request (which must -// be refused with the platform's forbidden envelope and leak nothing), -// and an anonymous request (which must be refused outright). -// -// Adding a new extension to the sweep is two small edits, both in this -// suite's directory: -// 1. Register its route surface here — one entry per mounted route -// worth sweeping. -// 2. Add one describe block to isolation.test.ts for any behavior -// specific to that extension (see the echo block there for the -// pattern). -// No other file changes; the generic sweep picks the entry up -// automatically. - -export type TenantSurface = { - /** Stable name used in test output. */ - name: string; - method: "GET" | "POST"; - /** Builds the route path for a given tenant id. */ - path: (tenantId: string) => string; - /** Request body for POST surfaces. */ - body?: string; - contentType?: string; - /** - * Status a tenant member is expected to see on their own tenant. - * This is the control that keeps the cross-tenant refusals honest: - * the same request shape demonstrably reaches the handler for a - * member, so a refusal for a non-member is the tenant gate working, - * not a dead route. - */ - memberStatus: number; -}; - -export const tenantSurfaces: TenantSurface[] = [ - // Native platform surfaces. - { - name: "principals list", - method: "GET", - path: (t) => `/api/tenants/${t}/principals`, - memberStatus: 200, - }, - { - name: "credentials list", - method: "GET", - path: (t) => `/api/tenants/${t}/credentials`, - memberStatus: 200, - }, - { - name: "grants list", - method: "GET", - path: (t) => `/api/tenants/${t}/grants`, - memberStatus: 200, - }, - { - name: "workflow definitions list", - method: "GET", - path: (t) => `/api/tenants/${t}/workflows/definitions`, - memberStatus: 200, - }, - { - name: "workflow deployments list", - method: "GET", - path: (t) => `/api/tenants/${t}/workflows/deployments`, - memberStatus: 200, - }, - { - name: "tenant workflow runs list", - method: "GET", - path: (t) => `/api/tenants/${t}/workflows/runs`, - memberStatus: 200, - }, - { - // No deployment is seeded, so a member sees the route's own - // not-found; a non-member must still be stopped at the tenant - // gate before the deployment id is ever looked at. - name: "workflow runs list", - method: "GET", - path: (t) => `/api/tenants/${t}/workflows/wfd_isolation_probe/runs`, - memberStatus: 404, - }, - // Extension surfaces. One entry per extension route the hub mounts. -]; diff --git a/test/isolation/tsconfig.json b/test/isolation/tsconfig.json deleted file mode 100644 index 0a385236b3..0000000000 --- a/test/isolation/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["bun"], - "noEmit": true - }, - "include": ["*.ts"] -} diff --git a/tools/granola/test/credential-delivery.drizzle.test.ts b/tools/granola/test/credential-delivery.drizzle.test.ts index 88c8769970..7983436f3e 100644 --- a/tools/granola/test/credential-delivery.drizzle.test.ts +++ b/tools/granola/test/credential-delivery.drizzle.test.ts @@ -22,8 +22,8 @@ import { credentialAad } from "@intx/types"; import type { CredentialBinding } from "@intx/types"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; -import { dbGate } from "../../../e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../test/database-url"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/tools/granola/test/credential-wiring-e2e.drizzle.test.ts b/tools/granola/test/credential-wiring-e2e.drizzle.test.ts index ed3c7b4e72..3b8e63b50b 100644 --- a/tools/granola/test/credential-wiring-e2e.drizzle.test.ts +++ b/tools/granola/test/credential-wiring-e2e.drizzle.test.ts @@ -26,10 +26,7 @@ // What this does NOT cover: driving the call through a real `Agent` + // LLM inference cycle (`agent.send()` deciding to call the tool). No // scripted/deterministic tool-call inference adapter exists anywhere in -// this repo today (`@intx/agent` ships no test fixtures for one, -// and the e2e suites' local noop inference server (`e2e/noop-inference-server.ts`) emits an empty delta that never -// triggers a tool call) -- a genuine testing-infrastructure gap, not -// something this test works around. This test instead drives the tool +// this repo today (`@intx/agent` ships no test fixtures for one). This test instead drives the tool // bundle directly with a `ToolCall`, exactly as `../src/tool.test.ts` // does, so what's new here is steps 1-3: a REAL seeded credential // reaching the tool through the REAL substrate composition. @@ -50,10 +47,10 @@ import { deriveResolvedBindings } from "@corbits/credential-providers"; import type { ToolCall } from "@intx/types/runtime"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; +import { e2eDatabaseUrl } from "../../../test/database-url"; import { GRANOLA_LIST_RECENT_NOTES_TOOL, granolaTools } from "../src/tool"; import type { GranolaEnv } from "../src/tool"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/tools/linear/test/credential-delivery.drizzle.test.ts b/tools/linear/test/credential-delivery.drizzle.test.ts index e989ed079c..1239f8fb2e 100644 --- a/tools/linear/test/credential-delivery.drizzle.test.ts +++ b/tools/linear/test/credential-delivery.drizzle.test.ts @@ -22,8 +22,8 @@ import { credentialAad } from "@intx/types"; import type { CredentialBinding } from "@intx/types"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; -import { dbGate } from "../../../e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../test/database-url"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/tools/linear/test/credential-wiring-e2e.drizzle.test.ts b/tools/linear/test/credential-wiring-e2e.drizzle.test.ts index 2d54a2ef6c..6732e7a050 100644 --- a/tools/linear/test/credential-wiring-e2e.drizzle.test.ts +++ b/tools/linear/test/credential-wiring-e2e.drizzle.test.ts @@ -53,10 +53,10 @@ import { import type { ToolCall } from "@intx/types/runtime"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../e2e/database-url"; +import { e2eDatabaseUrl } from "../../../test/database-url"; import { LINEAR_LIST_RECENT_ISSUES_TOOL, linearTools } from "../src/tool"; import type { LinearEnv } from "../src/tool"; -import { dbGate } from "../../../e2e/db-gate"; +import { dbGate } from "../../../test/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path);