diff --git a/packages/host-service/package.json b/packages/host-service/package.json index b2c0a3ce223..055dfc7fa90 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -42,6 +42,8 @@ "dev": "bun run src/serve.ts", "build:host": "bun run build.ts", "generate": "drizzle-kit generate", + "test": "bun test --pass-with-no-tests", + "test:integration": "bun test --pass-with-no-tests test/integration", "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index f374d28390e..8bb005de33f 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -6,7 +6,7 @@ import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { createApiClient } from "./api"; -import { createDb } from "./db"; +import { createDb, type HostDb } from "./db"; import { EventBus, registerEventBusRoute } from "./events"; import type { ApiAuthProvider } from "./providers/auth"; import type { HostAuthProvider } from "./providers/host-auth"; @@ -35,29 +35,46 @@ export interface CreateAppOptions { credentials: GitCredentialProvider; modelResolver: ModelProviderRuntimeResolver; }; + /** + * Test-harness override hooks. Production never sets these — `createApp` + * builds each subsystem itself when omitted. `db` is overridden so tests + * can swap in `bun:sqlite` (better-sqlite3 isn't loadable under Bun; + * prod uses it on bundled Node). `api`, `github`, `chatRuntime`, and + * `chatService` are overridden to keep tests off the network and out of + * mastra storage. + */ + db?: HostDb; + api?: ApiClient; + github?: () => Promise; + chatRuntime?: ChatRuntimeManager; + chatService?: ChatService; } export interface CreateAppResult { app: Hono; injectWebSocket: ReturnType["injectWebSocket"]; api: ApiClient; + dispose: () => Promise; } export function createApp(options: CreateAppOptions): CreateAppResult { const { config, providers } = options; - const api = createApiClient(config.cloudApiUrl, providers.auth); - const db = createDb(config.dbPath, config.migrationsFolder); + const api = + options.api ?? createApiClient(config.cloudApiUrl, providers.auth); + const db = options.db ?? createDb(config.dbPath, config.migrationsFolder); const git = createGitFactory(providers.credentials); - const github = async () => { - const token = await providers.credentials.getToken("github.com"); - if (!token) { - throw new Error( - "No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.", - ); - } - return new Octokit({ auth: token }); - }; + const github = + options.github ?? + (async () => { + const token = await providers.credentials.getToken("github.com"); + if (!token) { + throw new Error( + "No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.", + ); + } + return new Octokit({ auth: token }); + }); const pullRequestRuntime = new PullRequestRuntimeManager({ db, @@ -66,14 +83,16 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }); pullRequestRuntime.start(); const filesystem = new WorkspaceFilesystemManager({ db }); - const chatRuntime = new ChatRuntimeManager({ - db, - runtimeResolver: providers.modelResolver, - }); + const chatRuntime = + options.chatRuntime ?? + new ChatRuntimeManager({ + db, + runtimeResolver: providers.modelResolver, + }); // Provider auth (Anthropic / OpenAI OAuth + API keys) is per-machine, not // per-workspace. ChatService is a long-lived singleton wrapping mastra's // auth storage; the `host.auth.*` router proxies to it. - const chatService = new ChatService(); + const chatService = options.chatService ?? new ChatService(); const runtime = { auth: chatService, @@ -146,5 +165,29 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }), ); - return { app, injectWebSocket, api }; + const ownsDb = options.db === undefined; + const dispose = async (): Promise => { + // Each step is best-effort and isolated: a throw in one cleanup must + // not skip the others, otherwise a flaky `.stop()` could leak the + // open SQLite handle for the rest of the process lifetime. + try { + pullRequestRuntime.stop(); + } catch (err) { + console.warn("[host-service] pullRequestRuntime.stop failed:", err); + } + try { + eventBus.close(); + } catch (err) { + console.warn("[host-service] eventBus.close failed:", err); + } + if (ownsDb) { + try { + (db as unknown as { $client?: { close: () => void } }).$client?.close(); + } catch { + // best-effort close; tests should not fail on teardown + } + } + }; + + return { app, injectWebSocket, api, dispose }; } diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 325b61d183b..38b23bd737a 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -16,6 +16,7 @@ import type { PullRequestReviewThread, PullRequestState, } from "./types"; +import { gitConfigWrite } from "./utils/config-write"; import { buildBranch, countUntrackedFileLines, @@ -313,15 +314,17 @@ export const gitRouter = router({ }); } if (input.baseBranch) { - await git.raw([ + await gitConfigWrite(git, [ "config", `branch.${currentBranch}.base`, input.baseBranch, ]); } else { - await git - .raw(["config", "--unset", `branch.${currentBranch}.base`]) - .catch(() => {}); + await gitConfigWrite(git, [ + "config", + "--unset", + `branch.${currentBranch}.base`, + ]).catch(() => {}); } return { baseBranch: input.baseBranch }; }), diff --git a/packages/host-service/src/trpc/router/git/utils/config-write.ts b/packages/host-service/src/trpc/router/git/utils/config-write.ts new file mode 100644 index 00000000000..a76a0f99c46 --- /dev/null +++ b/packages/host-service/src/trpc/router/git/utils/config-write.ts @@ -0,0 +1,43 @@ +import type { SimpleGit } from "simple-git"; + +/** + * Run a `git config` write with bounded retries on `.git/config.lock` + * contention. + * + * `git config` takes a per-config flock that's held for milliseconds. + * Two concurrent writes (e.g. a renderer double-click on the base-branch + * picker, or `setBaseBranch` racing with `workspaceCreation.create`'s + * own config write) cause one to fail with: + * + * error: could not lock config file .git/config: File exists + * + * We catch that specific shape and retry with a short backoff so the + * second writer just waits its turn instead of bubbling a confusing 500 + * to the renderer. + */ +export async function gitConfigWrite( + git: SimpleGit, + args: string[], + options: { retries?: number; baseDelayMs?: number } = {}, +): Promise { + // `retries` is the number of *additional* attempts after the first try, + // so default 4 == 1 initial + 4 retries (5 total), with backoff + // 30/60/120/240ms between them. Clamped at 0 to keep the loop sane. + const retries = Math.max(0, options.retries ?? 4); + const baseDelayMs = options.baseDelayMs ?? 30; + let lastErr: unknown = new Error("gitConfigWrite: no attempt completed"); + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await git.raw(args); + } catch (err) { + lastErr = err; + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("could not lock config file")) throw err; + if (attempt === retries) break; + await new Promise((resolve) => + setTimeout(resolve, baseDelayMs * 2 ** attempt), + ); + } + } + throw lastErr; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts index c03d239e460..82b6a81490a 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts @@ -4,6 +4,7 @@ import { and, eq, ne, or } from "drizzle-orm"; import { workspaces } from "../../../../db/schema"; import type { HostServiceContext } from "../../../../types"; import { protectedProcedure } from "../../../index"; +import { gitConfigWrite } from "../../git/utils/config-write"; import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace"; import { adoptInputSchema } from "../schemas"; import { @@ -102,14 +103,16 @@ async function recordBaseBranch( baseBranch: string | undefined, ): Promise { if (!baseBranch) return; - await git - .raw(["config", `branch.${branch}.base`, baseBranch]) - .catch((err) => { - console.warn( - `[workspaceCreation.adopt] failed to record base branch ${baseBranch}:`, - err, - ); - }); + await gitConfigWrite(git as Parameters[0], [ + "config", + `branch.${branch}.base`, + baseBranch, + ]).catch((err) => { + console.warn( + `[workspaceCreation.adopt] failed to record base branch ${baseBranch}:`, + err, + ); + }); } export const adopt = protectedProcedure diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts index 8ac3a156363..3ca06bdc60f 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts @@ -18,36 +18,167 @@ import { derivePrLocalBranchName } from "../utils/pr-branch-name"; export const checkout = protectedProcedure .input(checkoutInputSchema) .mutation(async ({ ctx, input }) => { - setProgress(input.pendingId, "ensuring_repo"); + // Single seam for clearing progress on every throw path. Mirrors + // `workspaceCreation.create`. The scattered clearProgress calls + // inside are now redundant but harmless (idempotent). + try { + setProgress(input.pendingId, "ensuring_repo"); + + const localProject = requireLocalProject(ctx, input.projectId); + await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); + + setProgress(input.pendingId, "creating_worktree"); + + // ── PR path ──────────────────────────────────────────────────────── + if (input.pr) { + const branch = derivePrLocalBranchName(input.pr); - const localProject = requireLocalProject(ctx, input.projectId); - await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); + // Idempotency: existing workspace for this PR's branch → + // return it. Renderer navigates to it via `alreadyExists: true` + // instead of treating as a new create. + const existing = ctx.db.query.workspaces + .findFirst({ + where: and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.branch, branch), + ), + }) + .sync(); + if (existing) { + clearProgress(input.pendingId); + return { + workspace: { id: existing.id }, + terminals: [], + warnings: [], + alreadyExists: true as const, + }; + } - setProgress(input.pendingId, "creating_worktree"); + let worktreePath: string; + try { + worktreePath = safeResolveWorktreePath(localProject.id, branch); + } catch (err) { + clearProgress(input.pendingId); + throw err; + } + let git: Awaited>; + try { + mkdirSync(dirname(worktreePath), { recursive: true }); + git = await ctx.git(localProject.repoPath); + } catch (err) { + clearProgress(input.pendingId); + throw err; + } - // ── PR path ──────────────────────────────────────────────────────── - if (input.pr) { - const branch = derivePrLocalBranchName(input.pr); + // Detect a pre-existing local branch with the same derived name + // BEFORE running `gh pr checkout --force`. The idempotency check + // above rules out Superset-managed worktrees, but a branch can + // exist outside any workspace — e.g., from a prior manual + // `gh pr checkout` in the primary working tree. `--force` would + // reset it to the PR HEAD, silently losing any unpushed commits. + // We surface a warning pointing at reflog for recovery rather + // than blocking, so the point-and-click flow stays smooth. + let preExistingLocalBranch = false; + try { + await git.raw([ + "show-ref", + "--verify", + "--quiet", + `refs/heads/${branch}`, + ]); + preExistingLocalBranch = true; + } catch { + // Non-zero exit = branch doesn't exist. Expected path. + } + + // Detached worktree first — `gh pr checkout` inside it creates the + // branch with correct fork-remote + upstream config. Mirrors v1's + // `createWorktreeFromPr`. + try { + await git.raw(["worktree", "add", "--detach", worktreePath]); + } catch (err) { + clearProgress(input.pendingId); + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error + ? err.message + : "Failed to add detached worktree", + }); + } + + try { + await execGh( + [ + "pr", + "checkout", + String(input.pr.number), + "--branch", + branch, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (err) { + await git + .raw(["worktree", "remove", "--force", worktreePath]) + .catch((rollbackErr) => { + console.warn( + "[workspaceCreation.checkout] failed to rollback PR worktree", + { worktreePath, err: rollbackErr }, + ); + }); + clearProgress(input.pendingId); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `gh pr checkout failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + + // Push ergonomics. `gh pr checkout` sets per-branch push config + // to the fork URL for cross-repo PRs; this covers the same-repo + // case where upstream isn't auto-set. + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaceCreation.checkout]", + ); + + const extraWarnings: string[] = []; + if (input.pr.state !== "open") { + extraWarnings.push( + `PR is ${input.pr.state} — commits are included, but the PR may not merge.`, + ); + } + if (preExistingLocalBranch) { + extraWarnings.push( + `Reset existing local branch "${branch}" to PR HEAD. If you had unpushed commits there, recover them via \`git reflog show ${branch}\`.`, + ); + } + + return await finishCheckout(ctx, { + pendingId: input.pendingId, + projectId: input.projectId, + workspaceName: input.workspaceName, + branch, + worktreePath, + baseBranch: input.composer.baseBranch, + runSetupScript: input.composer.runSetupScript ?? false, + git, + extraWarnings, + }); + } - // Idempotency: existing workspace for this PR's branch → - // return it. Renderer navigates to it via `alreadyExists: true` - // instead of treating as a new create. - const existing = ctx.db.query.workspaces - .findFirst({ - where: and( - eq(workspaces.projectId, input.projectId), - eq(workspaces.branch, branch), - ), - }) - .sync(); - if (existing) { + // ── Branch path ──────────────────────────────────────────────────── + const branch = (input.branch ?? "").trim(); + if (!branch) { clearProgress(input.pendingId); - return { - workspace: { id: existing.id }, - terminals: [], - warnings: [], - alreadyExists: true as const, - }; + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Branch name is empty", + }); } let worktreePath: string; @@ -66,94 +197,77 @@ export const checkout = protectedProcedure throw err; } - // Detect a pre-existing local branch with the same derived name - // BEFORE running `gh pr checkout --force`. The idempotency check - // above rules out Superset-managed worktrees, but a branch can - // exist outside any workspace — e.g., from a prior manual - // `gh pr checkout` in the primary working tree. `--force` would - // reset it to the PR HEAD, silently losing any unpushed commits. - // We surface a warning pointing at reflog for recovery rather - // than blocking, so the point-and-click flow stays smooth. - let preExistingLocalBranch = false; - try { - await git.raw([ - "show-ref", - "--verify", - "--quiet", - `refs/heads/${branch}`, - ]); - preExistingLocalBranch = true; - } catch { - // Non-zero exit = branch doesn't exist. Expected path. - } - - // Detached worktree first — `gh pr checkout` inside it creates the - // branch with correct fork-remote + upstream config. Mirrors v1's - // `createWorktreeFromPr`. - try { - await git.raw(["worktree", "add", "--detach", worktreePath]); - } catch (err) { + // Resolve via the discriminated-ref helper so we don't infer kind + // from a refname string (a local branch named `origin/foo` would + // otherwise be misclassified). See GIT_REFS.md. + const resolved = await resolveRef(git, branch); + if (!resolved || resolved.kind === "head" || resolved.kind === "tag") { clearProgress(input.pendingId); throw new TRPCError({ - code: "CONFLICT", + code: "BAD_REQUEST", message: - err instanceof Error - ? err.message - : "Failed to add detached worktree", + resolved?.kind === "tag" + ? `"${branch}" is a tag, not a branch — cannot check out into a workspace` + : `Branch "${branch}" does not exist locally or on origin`, }); } + if (resolved.kind === "remote-tracking") { + try { + await git.fetch([ + resolved.remote, + resolved.shortName, + "--quiet", + "--no-tags", + ]); + } catch (err) { + console.warn( + `[workspaceCreation.checkout] fetch ${resolved.remoteShortName} failed:`, + err, + ); + } + } + try { - await execGh( - [ - "pr", - "checkout", - String(input.pr.number), - "--branch", - branch, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, + // For a remote-only branch, create a local tracking branch + // explicitly. `git worktree add origin/` without + // --track/-b produces a detached HEAD because the fully-qualified + // ref is treated as a commit-ish, not a branch shorthand. + await git.raw( + resolved.kind === "remote-tracking" + ? [ + "worktree", + "add", + "--track", + "-b", + branch, + worktreePath, + resolved.remoteShortName, + ] + : ["worktree", "add", worktreePath, resolved.shortName], ); } catch (err) { - await git - .raw(["worktree", "remove", "--force", worktreePath]) - .catch((rollbackErr) => { - console.warn( - "[workspaceCreation.checkout] failed to rollback PR worktree", - { worktreePath, err: rollbackErr }, - ); - }); clearProgress(input.pendingId); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `gh pr checkout failed: ${ - err instanceof Error ? err.message : String(err) - }`, - }); + const message = + err instanceof Error ? err.message : "Failed to add worktree"; + // Most common cause here is "branch already checked out elsewhere". + // Client disables the button for known cases via isCheckedOut, but + // we still get here for races. + throw new TRPCError({ code: "CONFLICT", message }); } - // Push ergonomics. `gh pr checkout` sets per-branch push config - // to the fork URL for cross-repo PRs; this covers the same-repo - // case where upstream isn't auto-set. + // Enable autoSetupRemote so the first terminal `git push` on a + // local-only branch creates origin/ without requiring -u. + // Branches checked out from a remote already have upstream set + // via --track above, so this config is a no-op for them. + // `--local` in a linked worktree writes to the shared repo config, + // so this applies repo-wide — intentional. await enablePushAutoSetupRemote( git, worktreePath, "[workspaceCreation.checkout]", ); - const extraWarnings: string[] = []; - if (input.pr.state !== "open") { - extraWarnings.push( - `PR is ${input.pr.state} — commits are included, but the PR may not merge.`, - ); - } - if (preExistingLocalBranch) { - extraWarnings.push( - `Reset existing local branch "${branch}" to PR HEAD. If you had unpushed commits there, recover them via \`git reflog show ${branch}\`.`, - ); - } - return await finishCheckout(ctx, { pendingId: input.pendingId, projectId: input.projectId, @@ -163,116 +277,9 @@ export const checkout = protectedProcedure baseBranch: input.composer.baseBranch, runSetupScript: input.composer.runSetupScript ?? false, git, - extraWarnings, - }); - } - - // ── Branch path ──────────────────────────────────────────────────── - const branch = (input.branch ?? "").trim(); - if (!branch) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - let worktreePath: string; - try { - worktreePath = safeResolveWorktreePath(localProject.id, branch); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - let git: Awaited>; - try { - mkdirSync(dirname(worktreePath), { recursive: true }); - git = await ctx.git(localProject.repoPath); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - - // Resolve via the discriminated-ref helper so we don't infer kind - // from a refname string (a local branch named `origin/foo` would - // otherwise be misclassified). See GIT_REFS.md. - const resolved = await resolveRef(git, branch); - if (!resolved || resolved.kind === "head" || resolved.kind === "tag") { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: - resolved?.kind === "tag" - ? `"${branch}" is a tag, not a branch — cannot check out into a workspace` - : `Branch "${branch}" does not exist locally or on origin`, + extraWarnings: [], }); - } - - if (resolved.kind === "remote-tracking") { - try { - await git.fetch([ - resolved.remote, - resolved.shortName, - "--quiet", - "--no-tags", - ]); - } catch (err) { - console.warn( - `[workspaceCreation.checkout] fetch ${resolved.remoteShortName} failed:`, - err, - ); - } - } - - try { - // For a remote-only branch, create a local tracking branch - // explicitly. `git worktree add origin/` without - // --track/-b produces a detached HEAD because the fully-qualified - // ref is treated as a commit-ish, not a branch shorthand. - await git.raw( - resolved.kind === "remote-tracking" - ? [ - "worktree", - "add", - "--track", - "-b", - branch, - worktreePath, - resolved.remoteShortName, - ] - : ["worktree", "add", worktreePath, resolved.shortName], - ); - } catch (err) { + } finally { clearProgress(input.pendingId); - const message = - err instanceof Error ? err.message : "Failed to add worktree"; - // Most common cause here is "branch already checked out elsewhere". - // Client disables the button for known cases via isCheckedOut, but - // we still get here for races. - throw new TRPCError({ code: "CONFLICT", message }); } - - // Enable autoSetupRemote so the first terminal `git push` on a - // local-only branch creates origin/ without requiring -u. - // Branches checked out from a remote already have upstream set - // via --track above, so this config is a no-op for them. - // `--local` in a linked worktree writes to the shared repo config, - // so this applies repo-wide — intentional. - await enablePushAutoSetupRemote( - git, - worktreePath, - "[workspaceCreation.checkout]", - ); - - return await finishCheckout(ctx, { - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch, - worktreePath, - baseBranch: input.composer.baseBranch, - runSetupScript: input.composer.runSetupScript ?? false, - git, - extraWarnings: [], - }); }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts index 2faa576f5f0..965b26dc057 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts @@ -10,6 +10,7 @@ import { resolveUpstream, } from "../../../../runtime/git/refs"; import { protectedProcedure } from "../../../index"; +import { gitConfigWrite } from "../../git/utils/config-write"; import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace"; import { createInputSchema } from "../schemas"; import { enablePushAutoSetupRemote } from "../shared/git-config"; @@ -27,299 +28,335 @@ import { deduplicateBranchName } from "../utils/sanitize-branch"; export const create = protectedProcedure .input(createInputSchema) .mutation(async ({ ctx, input }) => { - const machineId = getHostId(); - const hostName = getHostName(); - setProgress(input.pendingId, "ensuring_repo"); + // Single seam for clearing the progress entry: any throw inside + // the body funnels through this finally so the renderer never + // sees a stuck "active" step. The scattered clearProgress calls + // inside are now redundant but harmless (the call is idempotent). + try { + const machineId = getHostId(); + const hostName = getHostName(); + setProgress(input.pendingId, "ensuring_repo"); - const localProject = requireLocalProject(ctx, input.projectId); - await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); + const localProject = requireLocalProject(ctx, input.projectId); + await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); - setProgress(input.pendingId, "creating_worktree"); + setProgress(input.pendingId, "creating_worktree"); - // Renderer already sanitized/slugified. Host-service only validates - // and deduplicates — doesn't re-sanitize (which would strip case, - // slashes, etc. the user intended). - if (!input.names.branchName.trim()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } + // Renderer already sanitized/slugified. Host-service only validates + // and deduplicates — doesn't re-sanitize (which would strip case, + // slashes, etc. the user intended). + if (!input.names.branchName.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Branch name is empty", + }); + } - const existingBranches = await listBranchNames(ctx, localProject.repoPath); - const branchName = deduplicateBranchName( - input.names.branchName, - existingBranches, - ); + const existingBranches = await listBranchNames( + ctx, + localProject.repoPath, + ); + const branchName = deduplicateBranchName( + input.names.branchName, + existingBranches, + ); - const worktreePath = safeResolveWorktreePath(localProject.id, branchName); - mkdirSync(dirname(worktreePath), { recursive: true }); + const worktreePath = safeResolveWorktreePath(localProject.id, branchName); + mkdirSync(dirname(worktreePath), { recursive: true }); - const git = await ctx.git(localProject.repoPath); + const git = await ctx.git(localProject.repoPath); - // Trust the picker's hint when provided: it knows whether the row - // the user clicked was local or remote-only. Re-resolving here - // races against stale cached refs (a workspace branch with an - // incidental `refs/remotes/origin/` cache would silently win). - // Falls back to probing for callers that don't pass the hint. - let startPoint: ResolvedRef = - input.composer.baseBranch && input.composer.baseBranchSource - ? buildStartPointFromHint( - input.composer.baseBranch, - input.composer.baseBranchSource, - ) - : await resolveStartPoint(git, input.composer.baseBranch); + // Trust the picker's hint when provided: it knows whether the row + // the user clicked was local or remote-only. Re-resolving here + // races against stale cached refs (a workspace branch with an + // incidental `refs/remotes/origin/` cache would silently win). + // Falls back to probing for callers that don't pass the hint. + let startPoint: ResolvedRef = + input.composer.baseBranch && input.composer.baseBranchSource + ? buildStartPointFromHint( + input.composer.baseBranch, + input.composer.baseBranchSource, + ) + : await resolveStartPoint(git, input.composer.baseBranch); - // Local default branches are rarely fast-forwarded; swap to the - // branch's configured upstream so we fork from the real tip, not a - // stale local ref. Non-default branches stay local-first by design. - if (startPoint.kind === "local") { - const defaultBranchName = await resolveDefaultBranchName(git); - if (startPoint.shortName === defaultBranchName) { - const upstream = await resolveUpstream(git, defaultBranchName); - if (upstream) { - const remoteRef = asRemoteRef(upstream.remote, upstream.remoteBranch); - const remoteExists = await git - .raw(["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]) - .then(() => true) - .catch(() => false); - if (remoteExists) { - startPoint = { - kind: "remote-tracking", - fullRef: remoteRef, - shortName: upstream.remoteBranch, - remote: upstream.remote, - remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, - }; + // Local default branches are rarely fast-forwarded; swap to the + // branch's configured upstream so we fork from the real tip, not a + // stale local ref. Non-default branches stay local-first by design. + if (startPoint.kind === "local") { + const defaultBranchName = await resolveDefaultBranchName(git); + if (startPoint.shortName === defaultBranchName) { + const upstream = await resolveUpstream(git, defaultBranchName); + if (upstream) { + const remoteRef = asRemoteRef( + upstream.remote, + upstream.remoteBranch, + ); + const remoteExists = await git + .raw([ + "rev-parse", + "--verify", + "--quiet", + `${remoteRef}^{commit}`, + ]) + .then(() => true) + .catch(() => false); + if (remoteExists) { + startPoint = { + kind: "remote-tracking", + fullRef: remoteRef, + shortName: upstream.remoteBranch, + remote: upstream.remote, + remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, + }; + } } } } - } - console.log( - `[workspaceCreation.create] start point: ${startPoint.kind} (${ - input.composer.baseBranchSource ? "from hint" : "resolved" - })`, - ); + console.log( + `[workspaceCreation.create] start point: ${startPoint.kind} (${ + input.composer.baseBranchSource ? "from hint" : "resolved" + })`, + ); - // If we resolved to a remote-tracking ref, fetch just that branch - // to ensure we're branching from the latest remote state. - if (startPoint.kind === "remote-tracking") { + // If we resolved to a remote-tracking ref, fetch just that branch + // to ensure we're branching from the latest remote state. + if (startPoint.kind === "remote-tracking") { + try { + await git.fetch([ + startPoint.remote, + startPoint.shortName, + "--quiet", + "--no-tags", + ]); + } catch (err) { + console.warn( + `[workspaceCreation.create] fetch ${startPoint.remoteShortName} failed, proceeding with local ref:`, + err, + ); + } + } + + // Always create a new branch — never check out an existing one. + // Checking out existing branches is a separate intent (createFromPr, + // or the picker's Check out action via the `checkout` procedure). + // --no-track keeps `git pull` / ahead-behind counts from treating + // the start point as the branch's home. Push targeting is handled + // separately by push.autoSetupRemote (set below). + const startPointArg = + startPoint.kind === "head" ? "HEAD" : startPoint.shortName; try { - await git.fetch([ - startPoint.remote, - startPoint.shortName, - "--quiet", - "--no-tags", + await git.raw([ + "worktree", + "add", + "--no-track", + "-b", + branchName, + worktreePath, + startPoint.kind === "remote-tracking" + ? startPoint.remoteShortName + : startPointArg, ]); } catch (err) { - console.warn( - `[workspaceCreation.create] fetch ${startPoint.remoteShortName} failed, proceeding with local ref:`, - err, - ); + clearProgress(input.pendingId); + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error ? err.message : "Failed to add worktree", + }); } - } - - // Always create a new branch — never check out an existing one. - // Checking out existing branches is a separate intent (createFromPr, - // or the picker's Check out action via the `checkout` procedure). - // --no-track keeps `git pull` / ahead-behind counts from treating - // the start point as the branch's home. Push targeting is handled - // separately by push.autoSetupRemote (set below). - const startPointArg = - startPoint.kind === "head" ? "HEAD" : startPoint.shortName; - try { - await git.raw([ - "worktree", - "add", - "--no-track", - "-b", - branchName, - worktreePath, - startPoint.kind === "remote-tracking" - ? startPoint.remoteShortName - : startPointArg, - ]); - } catch (err) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "CONFLICT", - message: err instanceof Error ? err.message : "Failed to add worktree", - }); - } - - // Enable autoSetupRemote so the first terminal `git push` creates - // origin/ and sets it as upstream without requiring - // `-u`. Note: `--local` in a linked worktree writes to the shared - // repo config, so this applies repo-wide — intentional, every - // workspace worktree wants the same ergonomics. Safe against - // wrong-upstream targeting because --no-track above guarantees no - // upstream exists at first push, so auto-create always wins and - // always uses the branch's own name (never the base branch). - await enablePushAutoSetupRemote( - git, - worktreePath, - "[workspaceCreation.create]", - ); - // Record the base branch in git config so the Changes tab knows what - // to compare against on first open. startPoint.shortName is the ref - // we actually forked from (user selection, resolved against local / - // remote). Skipped for "head" start point — no meaningful base. - if (startPoint.kind !== "head") { - await git - .raw(["config", `branch.${branchName}.base`, startPoint.shortName]) - .catch((err) => { + // Past worktree-add: any throw must roll back the on-disk worktree + // before bubbling, otherwise the user is left with an orphaned + // `/.worktrees/` and a dangling local branch + // the next create attempt will collide with. + const rollbackWorktree = async () => { + try { + await git.raw(["worktree", "remove", worktreePath]); + } catch (err) { console.warn( - `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, - err, + "[workspaceCreation.create] failed to rollback worktree", + { + worktreePath, + err, + }, ); - }); - } + } + }; - setProgress(input.pendingId, "registering"); + try { + // Enable autoSetupRemote so the first terminal `git push` + // creates origin/ and sets it as upstream without + // requiring `-u`. `--local` in a linked worktree writes to the + // shared repo config, so this applies repo-wide — intentional, + // every workspace worktree wants the same ergonomics. Safe + // against wrong-upstream targeting because --no-track above + // guarantees no upstream exists at first push, so auto-create + // always wins and always uses the branch's own name (never + // the base branch). + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaceCreation.create]", + ); - const rollbackWorktree = async () => { + // Record the base branch in git config so the Changes tab + // knows what to compare against on first open. + // startPoint.shortName is the ref we actually forked from + // (user selection, resolved against local / remote). Skipped + // for "head" start point — no meaningful base. + if (startPoint.kind !== "head") { + await gitConfigWrite(git, [ + "config", + `branch.${branchName}.base`, + startPoint.shortName, + ]).catch((err) => { + console.warn( + `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, + err, + ); + }); + } + } catch (err) { + await rollbackWorktree(); + throw err; + } + + setProgress(input.pendingId, "registering"); + + let host: { machineId: string }; try { - await git.raw(["worktree", "remove", worktreePath]); + host = await ctx.api.host.ensure.mutate({ + organizationId: ctx.organizationId, + machineId, + name: hostName, + }); } catch (err) { - console.warn("[workspaceCreation.create] failed to rollback worktree", { - worktreePath, - err, + console.error("[workspaceCreation.create] host.ensure failed", err); + clearProgress(input.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, }); } - }; - let host: { machineId: string }; - try { - host = await ctx.api.host.ensure.mutate({ - organizationId: ctx.organizationId, - machineId, - name: hostName, - }); - } catch (err) { - console.error("[workspaceCreation.create] host.ensure failed", err); - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, - }); - } + const cloudRow = await ctx.api.v2Workspace.create + .mutate({ + organizationId: ctx.organizationId, + projectId: input.projectId, + name: input.names.workspaceName, + branch: branchName, + hostId: host.machineId, + }) + .catch(async (err) => { + console.error( + "[workspaceCreation.create] v2Workspace.create failed", + err, + ); + clearProgress(input.pendingId); + await rollbackWorktree(); + throw err; + }); - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: input.projectId, - name: input.names.workspaceName, - branch: branchName, - hostId: host.machineId, - }) - .catch(async (err) => { + if (!cloudRow) { + clearProgress(input.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloud workspace create returned no row", + }); + } + + try { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: input.projectId, + worktreePath, + branch: branchName, + }) + .run(); + } catch (err) { console.error( - "[workspaceCreation.create] v2Workspace.create failed", + "[workspaceCreation.create] local workspaces insert failed", err, ); clearProgress(input.pendingId); await rollbackWorktree(); - throw err; - }); - - if (!cloudRow) { - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } + await ctx.api.v2Workspace.delete + .mutate({ id: cloudRow.id }) + .catch((cleanupErr) => { + console.warn( + "[workspaceCreation.create] failed to rollback cloud workspace", + { workspaceId: cloudRow.id, err: cleanupErr }, + ); + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, + }); + } - try { - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: input.projectId, + // Fire-and-forget AI rename from the composer prompt. A single + // structured-output call generates both a display title and a + // kebab-case branch name, and we apply each independently. + // Electric syncs updates to the renderer via v2_workspaces, so + // the pending/workspace page updates in place once the model + // responds. + // + // Name precedence (matches renderer `resolveNames`): + // 1. user-typed title → skip AI rename (flag = false) + // 2. friendly fallback + prompt → AI rename (this branch) + // 3. friendly fallback, no prompt → keep fallback + // + // `expectedCurrentName` covers the race where a user edits the + // title after create but before the AI response lands. + const composerPrompt = input.composer.prompt?.trim(); + const allowAiRename = input.names.workspaceNameWasAutoGenerated !== false; + if (composerPrompt && allowAiRename) { + void applyAiWorkspaceRename({ + ctx, + workspaceId: cloudRow.id, + repoPath: localProject.repoPath, worktreePath, - branch: branchName, - }) - .run(); - } catch (err) { - console.error( - "[workspaceCreation.create] local workspaces insert failed", - err, - ); - clearProgress(input.pendingId); - await rollbackWorktree(); - await ctx.api.v2Workspace.delete - .mutate({ id: cloudRow.id }) - .catch((cleanupErr) => { + oldBranchName: branchName, + oldWorkspaceName: input.names.workspaceName, + prompt: composerPrompt, + }).catch((err) => { console.warn( - "[workspaceCreation.create] failed to rollback cloud workspace", - { workspaceId: cloudRow.id, err: cleanupErr }, + "[workspaceCreation.create] AI workspace rename failed", + err, ); }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - // Fire-and-forget AI rename from the composer prompt. A single - // structured-output call generates both a display title and a - // kebab-case branch name, and we apply each independently. - // Electric syncs updates to the renderer via v2_workspaces, so - // the pending/workspace page updates in place once the model - // responds. - // - // Name precedence (matches renderer `resolveNames`): - // 1. user-typed title → skip AI rename (flag = false) - // 2. friendly fallback + prompt → AI rename (this branch) - // 3. friendly fallback, no prompt → keep fallback - // - // `expectedCurrentName` covers the race where a user edits the - // title after create but before the AI response lands. - const composerPrompt = input.composer.prompt?.trim(); - const allowAiRename = input.names.workspaceNameWasAutoGenerated !== false; - if (composerPrompt && allowAiRename) { - void applyAiWorkspaceRename({ - ctx, - workspaceId: cloudRow.id, - repoPath: localProject.repoPath, - worktreePath, - oldBranchName: branchName, - oldWorkspaceName: input.names.workspaceName, - prompt: composerPrompt, - }).catch((err) => { - console.warn( - "[workspaceCreation.create] AI workspace rename failed", - err, - ); - }); - } + } - const terminals: TerminalDescriptor[] = []; - const warnings: string[] = []; + const terminals: TerminalDescriptor[] = []; + const warnings: string[] = []; - if (input.composer.runSetupScript) { - const { terminal, warning } = startSetupTerminalIfPresent({ - ctx, - workspaceId: cloudRow.id, - worktreePath, - }); - if (warning) { - warnings.push(warning); - } - if (terminal) { - terminals.push(terminal); + if (input.composer.runSetupScript) { + const { terminal, warning } = startSetupTerminalIfPresent({ + ctx, + workspaceId: cloudRow.id, + worktreePath, + }); + if (warning) { + warnings.push(warning); + } + if (terminal) { + terminals.push(terminal); + } } - } - clearProgress(input.pendingId); + clearProgress(input.pendingId); - return { - workspace: cloudRow, - terminals, - warnings, - }; + return { + workspace: cloudRow, + terminals, + warnings, + }; + } finally { + clearProgress(input.pendingId); + } }); diff --git a/packages/host-service/test/helpers/cloud-fakes.ts b/packages/host-service/test/helpers/cloud-fakes.ts new file mode 100644 index 00000000000..7f3b81a3b34 --- /dev/null +++ b/packages/host-service/test/helpers/cloud-fakes.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import type { FakeApiOverrides } from "./fakes"; + +/** + * Pre-canned cloud-API response factories. Tests compose these into + * `apiOverrides` so common mocks aren't redefined inline. `cloudOk.*` + * are the building blocks; `cloudFlows.*` bundles them for whole flows. + */ + +interface CloudWorkspace { + id: string; + projectId: string; + branch: string; + name: string; + type?: "main" | "feature"; +} + +export const cloudOk = { + hostEnsure: + (machineId = "test-machine-1") => + () => ({ machineId }), + + /** + * Echoes branch/name back with a fresh UUID id per call. Many + * procedures call `ensureMainWorkspace` first, which hits this same + * mock — each invocation needs a distinct id to avoid PK collisions. + */ + workspaceCreate: + (overrides: Partial = {}) => + (input: unknown): CloudWorkspace => { + const i = input as { branch: string; name: string; projectId: string }; + return { + id: randomUUID(), + projectId: i.projectId, + branch: i.branch, + name: i.name, + ...overrides, + }; + }, + + workspaceDelete: () => () => ({ success: true }), + + /** Returns a feature workspace by default; override `type: "main"` to + * exercise the main-workspace guard paths. */ + workspaceGetFromHost: + (workspace: { type?: "main" | "feature" } = { type: "feature" }) => + () => + workspace, + + v2ProjectFindByGitHubRemote: + (candidates: Array<{ id: string; name: string }> = []) => + () => ({ candidates }), +}; + +/** + * Whole-flow bundles. Spread into `apiOverrides` so a test reads as + * "I want the workspace-create flow to succeed" rather than enumerating + * each procedure mock. + */ +export const cloudFlows = { + workspaceCreateOk(overrides: Partial = {}): FakeApiOverrides { + return { + "host.ensure.mutate": cloudOk.hostEnsure(), + "v2Workspace.create.mutate": cloudOk.workspaceCreate(overrides), + }; + }, + + workspaceDeleteOk( + options: { type?: "main" | "feature" } = { type: "feature" }, + ): FakeApiOverrides { + return { + "v2Workspace.getFromHost.query": cloudOk.workspaceGetFromHost(options), + "v2Workspace.delete.mutate": cloudOk.workspaceDelete(), + }; + }, +}; diff --git a/packages/host-service/test/helpers/createTestHost.ts b/packages/host-service/test/helpers/createTestHost.ts new file mode 100644 index 00000000000..8f1a50c0a5d --- /dev/null +++ b/packages/host-service/test/helpers/createTestHost.ts @@ -0,0 +1,180 @@ +import { Database as BunDatabase } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import SuperJSON from "superjson"; +import { + type CreateAppOptions, + type CreateAppResult, + createApp, +} from "../../src/app"; +import type { HostDb } from "../../src/db"; +import * as schema from "../../src/db/schema"; +import type { AppRouter as HostAppRouter } from "../../src/trpc/router"; +import { + createFakeApiClient, + FakeApiAuthProvider, + type FakeApiOverrides, + FakeHostAuthProvider, + FakeModelResolver, + MemoryGitCredentialProvider, +} from "./fakes"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../drizzle"); + +export interface TestHostOptions { + organizationId?: string; + cloudApiUrl?: string; + allowedOrigins?: string[]; + psk?: string; + apiOverrides?: FakeApiOverrides; + githubToken?: string | null; + /** + * Fake-runtime overrides typed as `unknown` so tests only need to + * implement the methods they exercise — the real surfaces (Octokit, + * ChatRuntimeManager, ChatService) are far too large to stub fully. + */ + githubFactory?: () => Promise; + chatRuntime?: unknown; + chatService?: unknown; +} + +export interface TestHost { + app: CreateAppResult["app"]; + api: CreateAppResult["api"]; + db: HostDb; + dispose: () => Promise; + psk: string; + dbPath: string; + apiCalls: Array<{ path: string; input: unknown }>; + setApi: ( + path: string, + impl: (input: unknown) => unknown | Promise, + ) => void; + + /** tRPC client that talks to the real Hono app via in-process fetch. */ + trpc: ReturnType>; + /** tRPC client without the auth header — for testing 401 paths. */ + unauthenticatedTrpc: ReturnType>; + /** Raw fetch into the app, useful for non-tRPC routes (CORS, websockets). */ + fetch: (input: Request | string, init?: RequestInit) => Promise; +} + +/** + * Boot the host-service `createApp` against an isolated `bun:sqlite` db with + * fake providers, then return a tRPC client that round-trips through + * `app.fetch` (no real network or port). Caller must `await dispose()`. + * + * `bun:sqlite` is used instead of `better-sqlite3` because Bun can't dlopen + * the better-sqlite3 native binding (oven-sh/bun#4290). Both back the same + * drizzle `BaseSQLiteDatabase` API; production still uses better-sqlite3 in + * the bundled-Node host process. + */ +export async function createTestHost( + options: TestHostOptions = {}, +): Promise { + const psk = options.psk ?? "test-psk-secret"; + const dataDir = mkdtempSync(join(tmpdir(), "host-service-test-db-")); + const dbPath = join(dataDir, "host.db"); + + const sqlite = new BunDatabase(dbPath, { create: true, readwrite: true }); + sqlite.exec("PRAGMA journal_mode = WAL"); + sqlite.exec("PRAGMA foreign_keys = ON"); + const db = drizzle(sqlite, { schema }); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + + const fakeApi = createFakeApiClient(options.apiOverrides); + + const createOptions: CreateAppOptions = { + config: { + organizationId: + options.organizationId ?? "00000000-0000-0000-0000-000000000001", + dbPath, + cloudApiUrl: options.cloudApiUrl ?? "http://localhost:0/cloud", + migrationsFolder: MIGRATIONS_FOLDER, + allowedOrigins: options.allowedOrigins ?? ["http://localhost:5173"], + }, + providers: { + auth: new FakeApiAuthProvider(), + hostAuth: new FakeHostAuthProvider(psk), + credentials: new MemoryGitCredentialProvider(options.githubToken ?? null), + modelResolver: new FakeModelResolver(), + }, + db: db as unknown as HostDb, + api: fakeApi.client, + github: options.githubFactory + ? (options.githubFactory as CreateAppOptions["github"]) + : undefined, + chatRuntime: options.chatRuntime as CreateAppOptions["chatRuntime"], + chatService: options.chatService as CreateAppOptions["chatService"], + }; + + const result = createApp(createOptions); + + // Hono's `app.fetch(req, env, ctx)` second arg is the Cloudflare-style + // env binding, NOT a `RequestInit`. Build a proper `Request` first and + // pass it alone; otherwise tests that supply a pre-built `Request` plus + // extra `init` would silently see the init ignored. + const fetchApp = async ( + input: Request | string, + init?: RequestInit, + ): Promise => { + const request = + typeof input === "string" ? new Request(input, init) : input; + return result.app.fetch(request); + }; + + const buildClient = (authorized: boolean) => + createTRPCClient({ + links: [ + httpBatchLink({ + url: "http://host-service.test/trpc", + transformer: SuperJSON, + fetch: async (url, init) => { + return fetchApp(new Request(url as string, init as RequestInit)); + }, + headers: () => (authorized ? { authorization: `Bearer ${psk}` } : {}), + }), + ], + }); + + const trpc = buildClient(true); + const unauthenticatedTrpc = buildClient(false); + + const dispose = async (): Promise => { + // Run sqlite + temp-dir cleanup in a finally so a thrown + // `result.dispose()` can't leak the bun:sqlite handle or leave + // `host-service-test-db-*` directories behind for later runs. + try { + await result.dispose(); + } finally { + try { + sqlite.close(); + } catch { + // best-effort + } + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + }; + + return { + app: result.app, + api: fakeApi.client, + db: db as unknown as HostDb, + dispose, + psk, + dbPath, + apiCalls: fakeApi.calls, + setApi: fakeApi.set, + trpc, + unauthenticatedTrpc, + fetch: fetchApp, + }; +} diff --git a/packages/host-service/test/helpers/fakes.ts b/packages/host-service/test/helpers/fakes.ts new file mode 100644 index 00000000000..d8d8d4a38bb --- /dev/null +++ b/packages/host-service/test/helpers/fakes.ts @@ -0,0 +1,108 @@ +import type { ApiAuthProvider } from "../../src/providers/auth"; +import type { HostAuthProvider } from "../../src/providers/host-auth"; +import type { ModelProviderRuntimeResolver } from "../../src/providers/model-providers"; +import type { GitCredentialProvider } from "../../src/runtime/git/types"; +import type { ApiClient } from "../../src/types"; + +export class FakeApiAuthProvider implements ApiAuthProvider { + constructor(private readonly headers: Record = {}) {} + async getHeaders(): Promise> { + return { ...this.headers }; + } +} + +export class FakeHostAuthProvider implements HostAuthProvider { + constructor(private readonly psk: string) {} + validate(request: Request): boolean { + const header = request.headers.get("authorization"); + const token = header?.startsWith("Bearer ") ? header.slice(7) : null; + return token === this.psk; + } + validateToken(token: string): boolean { + return token === this.psk; + } +} + +export class MemoryGitCredentialProvider implements GitCredentialProvider { + constructor(private readonly token: string | null = null) {} + async getCredentials(): Promise<{ env: Record }> { + return { env: {} }; + } + async getToken(): Promise { + return this.token; + } +} + +export class FakeModelResolver implements ModelProviderRuntimeResolver { + async hasUsableRuntimeEnv(): Promise { + return true; + } + async prepareRuntimeEnv(): Promise {} +} + +/** + * Build a stand-in for the cloud `ApiClient` (a `TRPCClient`). + * + * Tests register procedure implementations by dotted path (e.g. + * `"organization.getByIdFromJwt.query"`). Anything unregistered throws so + * an unmocked codepath fails loudly instead of silently returning undefined. + */ +export type FakeApiOverrides = Record< + string, + (input: unknown) => unknown | Promise +>; + +export function createFakeApiClient(overrides: FakeApiOverrides = {}): { + client: ApiClient; + calls: Array<{ path: string; input: unknown }>; + set: ( + path: string, + impl: (input: unknown) => unknown | Promise, + ) => void; +} { + const calls: Array<{ path: string; input: unknown }> = []; + const handlers = new Map< + string, + (input: unknown) => unknown | Promise + >(Object.entries(overrides)); + + const set = ( + path: string, + impl: (input: unknown) => unknown | Promise, + ): void => { + handlers.set(path, impl); + }; + + const proxy = new Proxy( + {}, + { + get(_target, prop: string): unknown { + return makePathProxy([prop]); + }, + }, + ); + + function makePathProxy(path: string[]): unknown { + const handler: ProxyHandler = { + get(_target, prop: string): unknown { + if (prop === "query" || prop === "mutate") { + return async (input: unknown) => { + const key = `${path.join(".")}.${prop}`; + calls.push({ path: key, input }); + const impl = handlers.get(key); + if (!impl) { + throw new Error( + `[fake-api] unmocked procedure: ${key}. Register via createFakeApiClient({ "${key}": (input) => ... }) or .set(...)`, + ); + } + return impl(input); + }; + } + return makePathProxy([...path, prop]); + }, + }; + return new Proxy(() => undefined, handler); + } + + return { client: proxy as ApiClient, calls, set }; +} diff --git a/packages/host-service/test/helpers/git-fixture.ts b/packages/host-service/test/helpers/git-fixture.ts new file mode 100644 index 00000000000..4c7dd93e3c3 --- /dev/null +++ b/packages/host-service/test/helpers/git-fixture.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import simpleGit, { type SimpleGit } from "simple-git"; + +export interface GitFixture { + repoPath: string; + git: SimpleGit; + commit: (message: string, files?: Record) => Promise; + dispose: () => void; +} + +/** + * Create a real on-disk git repo in a temp directory. Configures user.name / + * user.email locally so commits don't depend on the developer's global config, + * and seeds an initial commit on `main` so HEAD is resolvable. Caller MUST + * call `dispose()` to clean up. + */ +export async function createGitFixture(): Promise { + // Resolve symlinks (e.g. macOS /var → /private/var) so paths handed to + // workspace-fs match what its realpath-based root checks see. + const repoPath = realpathSync( + mkdtempSync(join(tmpdir(), "host-service-test-repo-")), + ); + const git = simpleGit(repoPath); + + await git.init(["--initial-branch=main"]); + await git.addConfig("user.email", "test@superset.local"); + await git.addConfig("user.name", "Test Runner"); + await git.addConfig("commit.gpgsign", "false"); + + const commit = async ( + message: string, + files: Record = { "README.md": message }, + ): Promise => { + for (const [path, contents] of Object.entries(files)) { + writeFileSync(join(repoPath, path), contents); + await git.add(path); + } + const result = await git.commit(message, undefined, { + "--allow-empty": null, + }); + return result.commit; + }; + + await commit("initial commit"); + + const dispose = (): void => { + rmSync(repoPath, { recursive: true, force: true }); + }; + + return { repoPath, git, commit, dispose }; +} diff --git a/packages/host-service/test/helpers/scenarios.ts b/packages/host-service/test/helpers/scenarios.ts new file mode 100644 index 00000000000..e1f32b1eaa3 --- /dev/null +++ b/packages/host-service/test/helpers/scenarios.ts @@ -0,0 +1,130 @@ +import { join } from "node:path"; +import { + createTestHost, + type TestHost, + type TestHostOptions, +} from "./createTestHost"; +import { createGitFixture, type GitFixture } from "./git-fixture"; +import { seedProject, seedWorkspace } from "./seed"; + +/** + * Composable scenarios that bundle the host-service test harness with + * a real git fixture and pre-seeded DB rows. Tests use these instead of + * repeating the four-step `beforeEach` (host + repo + project + workspace) + * across every file. + * + * Each scenario owns its `dispose()` — call it from `afterEach` to + * clean up both the host and the on-disk repo in the right order. + */ + +export interface BasicScenario { + host: TestHost; + repo: GitFixture; + projectId: string; + /** Workspace whose `worktreePath` points at the project root (i.e. the + * "main" workspace by `workspace.delete`'s path-equality rule). */ + workspaceId: string; + dispose(): Promise; +} + +export interface BasicScenarioOptions { + hostOptions?: TestHostOptions; +} + +/** + * The most common test setup: a host with one project rooted at a real + * git repo, plus a workspace row pointing at the repo root. + */ +export async function createBasicScenario( + options: BasicScenarioOptions = {}, +): Promise { + const host = await createTestHost(options.hostOptions); + const repo = await createGitFixture(); + + const { id: projectId } = seedProject(host, { repoPath: repo.repoPath }); + const { id: workspaceId } = seedWorkspace(host, { + projectId, + worktreePath: repo.repoPath, + branch: "main", + }); + + return { + host, + repo, + projectId, + workspaceId, + dispose: async () => { + await host.dispose(); + repo.dispose(); + }, + }; +} + +export interface FeatureWorktreeScenario extends BasicScenario { + /** Path to the feature worktree (under `/.worktrees/...`). */ + worktreePath: string; + branch: string; + /** Workspace id of the feature worktree (distinct from `workspaceId`, + * which is the main workspace at the repo root). */ + featureWorkspaceId: string; +} + +export interface FeatureWorktreeScenarioOptions extends BasicScenarioOptions { + /** Defaults to "feature/cleanup". Slashes get replaced with `-` for the + * on-disk worktree directory under `.worktrees/`. */ + branch?: string; +} + +/** + * Basic scenario plus a real `git worktree add` for a feature branch and + * a workspace row pointing at it. Used by workspace-cleanup, adopt, and + * the workspace-create-delete tests. + */ +export async function createFeatureWorktreeScenario( + options: FeatureWorktreeScenarioOptions = {}, +): Promise { + const basic = await createBasicScenario(options); + const branch = options.branch ?? "feature/cleanup"; + const worktreePath = join( + basic.repo.repoPath, + ".worktrees", + branch.replace(/\//g, "-"), + ); + await basic.repo.git.raw(["worktree", "add", "-b", branch, worktreePath]); + + const { id: featureWorkspaceId } = seedWorkspace(basic.host, { + projectId: basic.projectId, + worktreePath, + branch, + }); + + return { + ...basic, + worktreePath, + branch, + featureWorkspaceId, + }; +} + +/** Convenience for tests that need just a project id and no workspace. */ +export async function createProjectScenario( + options: BasicScenarioOptions = {}, +): Promise<{ + host: TestHost; + repo: GitFixture; + projectId: string; + dispose(): Promise; +}> { + const host = await createTestHost(options.hostOptions); + const repo = await createGitFixture(); + const { id: projectId } = seedProject(host, { repoPath: repo.repoPath }); + return { + host, + repo, + projectId, + dispose: async () => { + await host.dispose(); + repo.dispose(); + }, + }; +} diff --git a/packages/host-service/test/helpers/seed.ts b/packages/host-service/test/helpers/seed.ts new file mode 100644 index 00000000000..06b42f0e912 --- /dev/null +++ b/packages/host-service/test/helpers/seed.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "node:crypto"; +import { + projects, + pullRequests, + terminalSessions, + workspaces, +} from "../../src/db/schema"; +import type { TestHost } from "./createTestHost"; + +/** + * Focused DB-seed helpers. Tests express *what* they need ("a project at + * this path") rather than the drizzle insert shape, so future schema + * changes (renames, new columns) only touch this file. + * + * Each helper: + * - generates a UUID id when none is supplied (so tests can ignore ids + * they don't care about) + * - returns the fully-populated row's identity fields + * - is synchronous against the bun:sqlite-backed db (matches drizzle + * `.run()` semantics) + */ + +export interface SeedProjectOptions { + id?: string; + repoPath: string; + repoOwner?: string; + repoName?: string; + repoUrl?: string; + repoProvider?: string; + remoteName?: string; +} + +export function seedProject( + host: TestHost, + options: SeedProjectOptions, +): { id: string } { + const id = options.id ?? randomUUID(); + host.db + .insert(projects) + .values({ + id, + repoPath: options.repoPath, + repoOwner: options.repoOwner, + repoName: options.repoName, + repoUrl: options.repoUrl, + repoProvider: options.repoProvider, + remoteName: options.remoteName, + }) + .run(); + return { id }; +} + +export interface SeedWorkspaceOptions { + id?: string; + projectId: string; + worktreePath: string; + branch: string; + headSha?: string | null; + upstreamOwner?: string | null; + upstreamRepo?: string | null; + upstreamBranch?: string | null; + pullRequestId?: string | null; +} + +export function seedWorkspace( + host: TestHost, + options: SeedWorkspaceOptions, +): { id: string } { + const id = options.id ?? randomUUID(); + host.db + .insert(workspaces) + .values({ + id, + projectId: options.projectId, + worktreePath: options.worktreePath, + branch: options.branch, + headSha: options.headSha, + upstreamOwner: options.upstreamOwner, + upstreamRepo: options.upstreamRepo, + upstreamBranch: options.upstreamBranch, + pullRequestId: options.pullRequestId, + }) + .run(); + return { id }; +} + +export interface SeedTerminalSessionOptions { + id?: string; + originWorkspaceId: string | null; + status?: string; +} + +export function seedTerminalSession( + host: TestHost, + options: SeedTerminalSessionOptions, +): { id: string } { + const id = options.id ?? randomUUID(); + host.db + .insert(terminalSessions) + .values({ + id, + originWorkspaceId: options.originWorkspaceId, + status: options.status ?? "active", + }) + .run(); + return { id }; +} + +export interface SeedPullRequestOptions { + id?: string; + projectId: string; + repoOwner?: string; + repoName?: string; + repoProvider?: string; + prNumber: number; + url?: string; + title?: string; + state?: string; + headBranch: string; + headSha?: string; + checksStatus?: string; + checksJson?: string; + reviewDecision?: string | null; + error?: string | null; +} + +export function seedPullRequest( + host: TestHost, + options: SeedPullRequestOptions, +): { id: string } { + const id = options.id ?? randomUUID(); + host.db + .insert(pullRequests) + .values({ + id, + projectId: options.projectId, + repoProvider: options.repoProvider ?? "github", + repoOwner: options.repoOwner ?? "octocat", + repoName: options.repoName ?? "hello", + prNumber: options.prNumber, + url: + options.url ?? + `https://github.com/${options.repoOwner ?? "octocat"}/${options.repoName ?? "hello"}/pull/${options.prNumber}`, + title: options.title ?? `PR #${options.prNumber}`, + state: options.state ?? "open", + headBranch: options.headBranch, + headSha: options.headSha ?? "deadbeef", + checksStatus: options.checksStatus ?? "none", + checksJson: options.checksJson ?? "[]", + reviewDecision: options.reviewDecision, + error: options.error, + }) + .run(); + return { id }; +} diff --git a/packages/host-service/test/integration/auth.integration.test.ts b/packages/host-service/test/integration/auth.integration.test.ts new file mode 100644 index 00000000000..1bb496a36d2 --- /dev/null +++ b/packages/host-service/test/integration/auth.integration.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("auth (provider OAuth/API key) router with stub ChatService", () => { + let host: TestHost; + const calls: Array<{ method: string; args: unknown }> = []; + + const stubChatService = { + getAnthropicAuthStatus: () => { + calls.push({ method: "getAnthropicAuthStatus", args: undefined }); + return { kind: "none" as const }; + }, + startAnthropicOAuth: () => { + calls.push({ method: "startAnthropicOAuth", args: undefined }); + return { url: "https://anthropic.example/oauth" }; + }, + completeAnthropicOAuth: (args: unknown) => { + calls.push({ method: "completeAnthropicOAuth", args }); + return { ok: true }; + }, + cancelAnthropicOAuth: () => { + calls.push({ method: "cancelAnthropicOAuth", args: undefined }); + return { ok: true }; + }, + disconnectAnthropicOAuth: () => { + calls.push({ method: "disconnectAnthropicOAuth", args: undefined }); + return { ok: true }; + }, + setAnthropicApiKey: (args: unknown) => { + calls.push({ method: "setAnthropicApiKey", args }); + return { ok: true }; + }, + clearAnthropicApiKey: () => { + calls.push({ method: "clearAnthropicApiKey", args: undefined }); + return { ok: true }; + }, + getAnthropicEnvConfig: () => { + calls.push({ method: "getAnthropicEnvConfig", args: undefined }); + return { envText: "" }; + }, + setAnthropicEnvConfig: (args: unknown) => { + calls.push({ method: "setAnthropicEnvConfig", args }); + return { ok: true }; + }, + clearAnthropicEnvConfig: () => { + calls.push({ method: "clearAnthropicEnvConfig", args: undefined }); + return { ok: true }; + }, + getOpenAIAuthStatus: () => { + calls.push({ method: "getOpenAIAuthStatus", args: undefined }); + return { kind: "none" as const }; + }, + startOpenAIOAuth: () => { + calls.push({ method: "startOpenAIOAuth", args: undefined }); + return { url: "https://openai.example/oauth" }; + }, + completeOpenAIOAuth: (args: unknown) => { + calls.push({ method: "completeOpenAIOAuth", args }); + return { ok: true }; + }, + cancelOpenAIOAuth: () => { + calls.push({ method: "cancelOpenAIOAuth", args: undefined }); + return { ok: true }; + }, + disconnectOpenAIOAuth: () => { + calls.push({ method: "disconnectOpenAIOAuth", args: undefined }); + return { ok: true }; + }, + setOpenAIApiKey: (args: unknown) => { + calls.push({ method: "setOpenAIApiKey", args }); + return { ok: true }; + }, + clearOpenAIApiKey: () => { + calls.push({ method: "clearOpenAIApiKey", args: undefined }); + return { ok: true }; + }, + }; + + beforeEach(async () => { + calls.length = 0; + host = await createTestHost({ chatService: stubChatService }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("getAnthropicStatus delegates", async () => { + const result = await host.trpc.auth.getAnthropicStatus.query(); + expect(result).toEqual({ kind: "none" }); + expect(calls[0].method).toBe("getAnthropicAuthStatus"); + }); + + test("startAnthropicOAuth returns the OAuth url", async () => { + const result = await host.trpc.auth.startAnthropicOAuth.mutate(); + expect(result).toEqual({ url: "https://anthropic.example/oauth" }); + }); + + test("completeAnthropicOAuth forwards the code", async () => { + await host.trpc.auth.completeAnthropicOAuth.mutate({ code: "abc-123" }); + expect(calls[0]).toMatchObject({ + method: "completeAnthropicOAuth", + args: { code: "abc-123" }, + }); + }); + + test("setAnthropicApiKey rejects empty string at the zod boundary", async () => { + await expect( + host.trpc.auth.setAnthropicApiKey.mutate({ apiKey: "" }), + ).rejects.toBeInstanceOf(TRPCClientError); + expect(calls).toHaveLength(0); + }); + + test("setAnthropicApiKey forwards a non-empty key", async () => { + await host.trpc.auth.setAnthropicApiKey.mutate({ apiKey: "sk-test" }); + expect(calls[0]).toMatchObject({ + method: "setAnthropicApiKey", + args: { apiKey: "sk-test" }, + }); + }); + + test("getOpenAIStatus + setOpenAIApiKey delegate to the OpenAI methods", async () => { + await host.trpc.auth.getOpenAIStatus.query(); + await host.trpc.auth.setOpenAIApiKey.mutate({ apiKey: "sk-openai" }); + expect(calls.map((c) => c.method)).toEqual([ + "getOpenAIAuthStatus", + "setOpenAIApiKey", + ]); + }); + + test("disconnect endpoints delegate to the right ChatService method", async () => { + await host.trpc.auth.disconnectAnthropicOAuth.mutate(); + await host.trpc.auth.disconnectOpenAIOAuth.mutate(); + expect(calls.map((c) => c.method)).toEqual([ + "disconnectAnthropicOAuth", + "disconnectOpenAIOAuth", + ]); + }); + + test("requires authentication", async () => { + await expect( + host.unauthenticatedTrpc.auth.getAnthropicStatus.query(), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/bug-hunt-2.integration.test.ts b/packages/host-service/test/integration/bug-hunt-2.integration.test.ts new file mode 100644 index 00000000000..122fe3fb555 --- /dev/null +++ b/packages/host-service/test/integration/bug-hunt-2.integration.test.ts @@ -0,0 +1,396 @@ +/** + * Round 2 of bug-hunting. Probes more aggressive escapes and partial-failure + * paths. Same convention: passing test = defense holds, failing test = bug. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { eq } from "drizzle-orm"; +import { projects, workspaces } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("bug-hunt-2: symlink and additional sandbox probes", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + let outsideDir: string; + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + outsideDir = join(dirname(repo.repoPath), `outside-${randomUUID()}`); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, "secret.txt"), "PII"); + + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + try { + rmSync(outsideDir, { recursive: true, force: true }); + } catch {} + }); + + test("readFile rejects reads through a symlink that points outside the workspace", async () => { + // Plant a symlink inside the workspace that points outside. + const link = join(repo.repoPath, "evil-link"); + symlinkSync(outsideDir, link); + + await expect( + host.trpc.filesystem.readFile.query({ + workspaceId, + absolutePath: join(link, "secret.txt"), + encoding: "utf8", + }), + ).rejects.toThrow(); + }); + + test("writeFile through a symlinked dir into outside the workspace is rejected", async () => { + const link = join(repo.repoPath, "evil-link"); + symlinkSync(outsideDir, link); + + await expect( + host.trpc.filesystem.writeFile.mutate({ + workspaceId, + absolutePath: join(link, "planted.txt"), + content: "should-not-write", + options: { create: true, overwrite: true }, + }), + ).rejects.toThrow(); + expect(existsSync(join(outsideDir, "planted.txt"))).toBe(false); + }); + + test("createDirectory rejects '..' traversal", async () => { + await expect( + host.trpc.filesystem.createDirectory.mutate({ + workspaceId, + absolutePath: join(repo.repoPath, "..", "evil-mkdir"), + recursive: true, + }), + ).rejects.toThrow(); + expect(existsSync(join(dirname(repo.repoPath), "evil-mkdir"))).toBe(false); + }); + + test("copyPath rejects destinations outside the workspace root", async () => { + const src = join(repo.repoPath, "src.txt"); + writeFileSync(src, "src"); + const dst = join(outsideDir, "copied.txt"); + + await expect( + host.trpc.filesystem.copyPath.mutate({ + workspaceId, + sourceAbsolutePath: src, + destinationAbsolutePath: dst, + }), + ).rejects.toThrow(); + expect(existsSync(dst)).toBe(false); + }); + + test("copyPath rejects sources outside the workspace root", async () => { + await expect( + host.trpc.filesystem.copyPath.mutate({ + workspaceId, + sourceAbsolutePath: join(outsideDir, "secret.txt"), + destinationAbsolutePath: join(repo.repoPath, "leaked.txt"), + }), + ).rejects.toThrow(); + expect(existsSync(join(repo.repoPath, "leaked.txt"))).toBe(false); + }); + + test("statPath with absolute path returns truthy result for files anywhere on host (documented behavior)", async () => { + // `filesystem.statPath` is intentionally unconfined for terminal + // link-clicks. This test pins that behavior so a future tightening + // is a deliberate, visible change. + const result = await host.trpc.filesystem.statPath.mutate({ + workspaceId, + path: join(outsideDir, "secret.txt"), + }); + expect(result).not.toBeNull(); + expect(result?.resolvedPath).toBe(join(outsideDir, "secret.txt")); + }); +}); + +describe("bug-hunt-2: partial-failure consistency", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("workspace.create (v1) does NOT roll back the worktree when persistLocalWorkspace fails post-cloud", async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + // Return a fresh id for ensureMainWorkspace's call, then a colliding + // id for the feature workspace's call so the local insert throws. + "v2Workspace.create.mutate": (() => { + let n = 0; + return (input: unknown) => { + n++; + const i = input as { branch: string; name: string }; + return { + id: n === 1 ? randomUUID() : "duplicate-id", + projectId, + branch: i.branch, + name: i.name, + }; + }; + })(), + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + // First create succeeds; ensureMainWorkspace consumes call #1 and + // the feature workspace consumes call #2 ("duplicate-id"). Now + // preload "duplicate-id" into the workspaces table so the post- + // cloud insert hits the PK and throws. + host.db + .insert(workspaces) + .values({ + id: "duplicate-id", + projectId, + worktreePath: "/tmp/preload-conflict", + branch: "preload", + }) + .run(); + + // The legacy `workspace.create` (v1) doesn't roll back the worktree + // when the local DB insert fails — that's by design (a stale local + // row would block the retry forever). Pin that behavior: the call + // must throw, AND the on-disk worktree must remain (so the user + // can inspect/recover it before the next attempt). If a future + // change adds a rollback, this test flips and signals the change. + await expect( + host.trpc.workspace.create.mutate({ + projectId, + name: "ws", + branch: "feature/post-cloud-fail", + }), + ).rejects.toBeDefined(); + + const expectedWorktree = join( + repo.repoPath, + ".worktrees", + "feature/post-cloud-fail", + ); + expect(existsSync(expectedWorktree)).toBe(true); + }); + + test("workspace.delete with a worktree dir already removed manually still cleans up the row", async () => { + const workspaceId = randomUUID(); + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => ({ type: "feature" }), + "v2Workspace.delete.mutate": () => ({ success: true }), + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + // Insert a workspace row pointing at a path that doesn't exist. + const ghostPath = join(repo.repoPath, ".worktrees", "ghost"); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: ghostPath, + branch: "feature/ghost", + }) + .run(); + + const result = await host.trpc.workspace.delete.mutate({ id: workspaceId }); + expect(result).toEqual({ success: true }); + + const remaining = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .all(); + expect(remaining).toHaveLength(0); + }); + + test("workspaceCleanup.destroy succeeds even if the worktree dir was deleted manually", async () => { + const workspaceId = randomUUID(); + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => ({ type: "feature" }), + "v2Workspace.delete.mutate": () => ({ success: true }), + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: join(repo.repoPath, ".worktrees", "vanished"), + branch: "feature/vanished", + }) + .run(); + + const result = await host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId, + }); + expect(result.success).toBe(true); + // The cleanup code treats "is not a working tree" / ENOENT as + // success-equivalent, so worktreeRemoved should be true. + expect(result.worktreeRemoved).toBe(true); + }); +}); + +describe("bug-hunt-2: input edges", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("setBaseBranch throws PRECONDITION_FAILED on detached HEAD", async () => { + const sha = await repo.commit("for-detach", { "d.txt": "d" }); + await repo.git.checkout(sha); + + await expect( + host.trpc.git.setBaseBranch.mutate({ + workspaceId, + baseBranch: "main", + }), + ).rejects.toThrow(/detached HEAD/i); + }); + + test("setBaseBranch null on a branch with no configured base is a no-op (no throw)", async () => { + const result = await host.trpc.git.setBaseBranch.mutate({ + workspaceId, + baseBranch: null, + }); + expect(result.baseBranch).toBeNull(); + }); + + test("workspaceCreation.adopt rejects empty/whitespace branch names", async () => { + await expect( + host.trpc.workspaceCreation.adopt.mutate({ + projectId, + workspaceName: "x", + branch: " ", + }), + ).rejects.toThrow(/branch name is empty/i); + }); + + test("notifications.hook with eventType only (no terminalId) returns ignored without DB lookup", async () => { + // Even with a known event type, missing terminalId short-circuits. + const result = await host.unauthenticatedTrpc.notifications.hook.mutate({ + eventType: "Stop", + }); + expect(result).toEqual({ success: true, ignored: true }); + }); + + test("filesystem.searchFiles with whitespace-only query returns no matches without scanning", async () => { + // Whitespace-only query is short-circuited; should never index large repos. + const result = await host.trpc.filesystem.searchFiles.query({ + workspaceId, + query: "\t\n \r", + }); + expect(result.matches).toEqual([]); + }); + + test("filesystem.searchContent with empty query is short-circuited", async () => { + const result = await host.trpc.filesystem.searchContent.query({ + workspaceId, + query: "", + }); + expect(result.matches).toEqual([]); + }); + + test("getStatus on detached HEAD doesn't crash", async () => { + const sha = await repo.commit("detach-target", { "d.txt": "d" }); + await repo.git.checkout(sha); + + // Just shouldn't throw — `currentBranch` may be empty / HEAD. + const status = await host.trpc.git.getStatus.query({ workspaceId }); + expect(status).toBeDefined(); + expect(status.staged).toBeDefined(); + expect(status.unstaged).toBeDefined(); + }); + + test("ports.getAll with very long workspaceIds list doesn't blow up", async () => { + const ids = Array.from({ length: 500 }, () => randomUUID()); + const result = await host.trpc.ports.getAll.query({ workspaceIds: ids }); + expect(result).toEqual([]); + }); + + test("pullRequests.getByWorkspaces with 200 ids returns one row per id (or filters cleanly)", async () => { + const ids = Array.from({ length: 200 }, () => randomUUID()); + const result = await host.trpc.pullRequests.getByWorkspaces.query({ + workspaceIds: ids, + }); + // None of the random ids exist, so we expect an empty workspaces array. + expect(result.workspaces).toEqual([]); + }); +}); + +// Persistence-after-restart was removed — the test harness creates a +// fresh tmp dbPath per `createTestHost`, so two hosts can never share +// the same on-disk file by design. A real cross-host persistence probe +// would need a shared-dbPath option on the harness; add one if/when +// that scenario actually matters. diff --git a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts new file mode 100644 index 00000000000..1bde3d557f7 --- /dev/null +++ b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts @@ -0,0 +1,291 @@ +/** + * Round 3 of bug-hunting. Targets: path-traversal in *.create where the + * branch / name comes from the renderer. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { projects } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("bug-hunt-3: branch-name path traversal in workspace.create", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + let escapeDir: string; + + beforeEach(async () => { + repo = await createGitFixture(); + // Plant a sibling we can prove a bug by creating a worktree inside. + escapeDir = resolve(dirname(repo.repoPath), `pwn-${randomUUID()}`); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + try { + rmSync(escapeDir, { recursive: true, force: true }); + } catch {} + }); + + test("workspace.create with a '../escape' branch name is rejected (no worktree outside project)", async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + "v2Workspace.create.mutate": (input: unknown) => { + const i = input as { branch: string; name: string }; + return { + id: randomUUID(), + projectId, + branch: i.branch, + name: i.name, + }; + }, + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + // path.join('', '.worktrees', '../../') normalizes + // to /. If the procedure + // uses `join` without re-validating, it creates a worktree well + // outside the project root. + const evilBranch = `../../pwn-${randomUUID()}`; + const expectedEscapePath = join(repo.repoPath, ".worktrees", evilBranch); // path.join collapses .. segments + + const result = await host.trpc.workspace.create + .mutate({ + projectId, + name: "x", + branch: evilBranch, + }) + .catch((err) => err); + + // The procedure should have rejected the input. If it didn't, a + // worktree was placed at expectedEscapePath, outside the repo. + // Log the error message so we can see WHY it rejected (git's branch + // name validation? or our own?) — important for understanding the + // defense. + if (result instanceof Error) { + console.error("[bug-hunt-3] rejected with:", result.message); + } + expect(existsSync(expectedEscapePath)).toBe(false); + // And the call should have errored out. + expect(result).toBeInstanceOf(Error); + }); +}); + +describe("bug-hunt-3: branch-name path traversal in workspaceCreation.create", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("workspaceCreation.create rejects a '../escape' branchName (safeResolveWorktreePath guard)", async () => { + // safeResolveWorktreePath uses `resolve` and rejects escapes. This + // is the v2 entry point and is the one we expect to be locked down. + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId: randomUUID(), + projectId, + names: { workspaceName: "x", branchName: "../../escape" }, + composer: {}, + }), + ).rejects.toThrow(); + }); +}); + +describe("bug-hunt-3: workspace.delete + dirty worktree", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("workspace.delete (legacy v1 API) lets a dirty workspace be deleted without warning", async () => { + // This pins legacy behavior. The new v2 `workspaceCleanup.destroy` + // has a phase-0 dirty check; `workspace.delete` does not. If a + // future PR adds a dirty check to `workspace.delete`, this test + // will flip and signal the change. + const workspaceId = randomUUID(); + const worktreePath = join(repo.repoPath, ".worktrees", "feature-dirty"); + await repo.git.raw([ + "worktree", + "add", + "-b", + "feature/dirty", + worktreePath, + ]); + // Make it dirty. + const { writeFileSync } = await import("node:fs"); + writeFileSync(join(worktreePath, "dirt.txt"), "uncommitted"); + + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => ({ type: "feature" }), + "v2Workspace.delete.mutate": () => ({ success: true }), + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + const { workspaces } = await import("../../src/db/schema"); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath, + branch: "feature/dirty", + }) + .run(); + + const result = await host.trpc.workspace.delete.mutate({ id: workspaceId }); + // No CONFLICT: dirty work is silently destroyed by the legacy path. + expect(result).toEqual({ success: true }); + }); +}); + +describe("bug-hunt-3: race + repeated config writes", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + const { workspaces } = await import("../../src/db/schema"); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + // Regression: two concurrent setBaseBranch calls used to race on + // `.git/config.lock`. One would return a 500 with "error: could not + // lock config file .git/config: File exists" on a renderer double- + // click during a slow request. Fixed by routing config writes through + // `gitConfigWrite`, which retries on lock contention. + test("parallel setBaseBranch writes converge without a config-lock 500", async () => { + await Promise.all([ + host.trpc.git.setBaseBranch.mutate({ + workspaceId, + baseBranch: "main", + }), + host.trpc.git.setBaseBranch.mutate({ + workspaceId, + baseBranch: "develop", + }), + ]); + + const result = await host.trpc.git.getBaseBranch.query({ workspaceId }); + expect(["main", "develop"]).toContain(result.baseBranch); + }); +}); + +describe("bug-hunt-3: more concurrency probes", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("BUG: parallel workspace.create calls for different branches can race on the same .git/config", async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + "v2Workspace.create.mutate": (input: unknown) => { + const i = input as { branch: string; name: string }; + return { + id: randomUUID(), + projectId, + branch: i.branch, + name: i.name, + }; + }, + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + // Two different branches in parallel — they both call + // `git worktree add` and `git branch..base` writes via + // ensureMainWorkspace / inside the procedure. + const results = await Promise.allSettled([ + host.trpc.workspace.create.mutate({ + projectId, + name: "a", + branch: "feature/a", + }), + host.trpc.workspace.create.mutate({ + projectId, + name: "b", + branch: "feature/b", + }), + ]); + + // Document current behavior. If both succeed, great — we have no + // bug. If one fails with a config-lock or worktree-lock error, + // that's a real issue to file. + const failures = results.filter((r) => r.status === "rejected"); + if (failures.length > 0) { + console.warn( + "[bug-hunt-3] parallel workspace.create failure(s):", + failures.map((r) => (r.status === "rejected" ? String(r.reason) : "")), + ); + } + // We currently expect this to be tolerated. If it starts failing, + // flip to a `test.todo` documenting the regression. + expect(failures.length).toBe(0); + }); +}); diff --git a/packages/host-service/test/integration/bug-hunt-4.integration.test.ts b/packages/host-service/test/integration/bug-hunt-4.integration.test.ts new file mode 100644 index 00000000000..10b7cc83e68 --- /dev/null +++ b/packages/host-service/test/integration/bug-hunt-4.integration.test.ts @@ -0,0 +1,199 @@ +/** + * Round 4 of bug-hunting. Cross-project leakage, double-call cloud + * propagation, abort-signal handling. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { projects, workspaces } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("bug-hunt-4: cross-project leakage", () => { + let host: TestHost; + let repoA: GitFixture; + let repoB: GitFixture; + const projectIdA = randomUUID(); + const projectIdB = randomUUID(); + + beforeEach(async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + "v2Workspace.create.mutate": (input: unknown) => { + const i = input as { branch: string; name: string }; + return { + id: randomUUID(), + projectId: projectIdA, + branch: i.branch, + name: i.name, + }; + }, + }, + }); + repoA = await createGitFixture(); + repoB = await createGitFixture(); + host.db + .insert(projects) + .values([ + { id: projectIdA, repoPath: repoA.repoPath }, + { id: projectIdB, repoPath: repoB.repoPath }, + ]) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repoA.dispose(); + repoB.dispose(); + }); + + test("adopt with worktreePath from a different project's repo doesn't bind it to this project", async () => { + // Create a real worktree in repoB, then ask host to adopt it + // against projectIdA. The procedure pulls `localProject.repoPath` + // from projectIdA; passing repoB's worktree path is a confusion + // attack — would give projectIdA a worktree row that points at + // repoB's filesystem. + const worktreePathInB = join(repoB.repoPath, ".worktrees", "feature-x"); + await repoB.git.raw([ + "worktree", + "add", + "-b", + "feature/x", + worktreePathInB, + ]); + + const result = await host.trpc.workspaceCreation.adopt + .mutate({ + projectId: projectIdA, + workspaceName: "x", + branch: "feature/x", + worktreePath: worktreePathInB, + }) + .catch((err) => err); + + // If this SUCCEEDED, host has bound projectIdA → repoB worktree — + // data leak. We expect it to fail (`getWorktreeBranchAtPath` runs + // against repoA's git so it won't find the worktree from repoB). + expect(result).toBeInstanceOf(Error); + + // Confirm no row was written. + const rows = host.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, projectIdA)) + .all(); + expect( + rows.find((r) => r.worktreePath === worktreePathInB), + ).toBeUndefined(); + }); +}); + +describe("bug-hunt-4: double-call cloud propagation", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const worktreePath = ""; + let actualWorktreePath: string; + + beforeEach(async () => { + repo = await createGitFixture(); + actualWorktreePath = join(repo.repoPath, ".worktrees", "feature-double"); + await repo.git.raw([ + "worktree", + "add", + "-b", + "feature/double", + actualWorktreePath, + ]); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("workspaceCleanup.destroy called twice: first succeeds, second propagates cloud's response", async () => { + // Mock cloud to return success the first time, then 404 on second call. + let callCount = 0; + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => ({ type: "feature" }), + "v2Workspace.delete.mutate": () => { + callCount++; + if (callCount === 1) return { success: true }; + throw new Error("Workspace not found in cloud (404)"); + }, + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: actualWorktreePath, + branch: "feature/double", + }) + .run(); + + const first = await host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId, + }); + expect(first.success).toBe(true); + + // Second call: local row is gone, but cloud delete is still + // attempted. The procedure currently throws on cloud failure — + // pin that behavior so a future "swallow 404 on second-destroy" + // fix flips this test. + await expect( + host.trpc.workspaceCleanup.destroy.mutate({ workspaceId }), + ).rejects.toThrow(/not found/i); + }); + + void worktreePath; // keep variable name for line-skew stability +}); + +describe("bug-hunt-4: abort-signal handling", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("filesystem.listDirectory completes normally without an abort signal", async () => { + const result = await host.trpc.filesystem.listDirectory.query({ + workspaceId, + absolutePath: repo.repoPath, + }); + expect(result.entries).toBeDefined(); + }); +}); diff --git a/packages/host-service/test/integration/bug-hunt-v2.integration.test.ts b/packages/host-service/test/integration/bug-hunt-v2.integration.test.ts new file mode 100644 index 00000000000..29d3d0bfc4a --- /dev/null +++ b/packages/host-service/test/integration/bug-hunt-v2.integration.test.ts @@ -0,0 +1,216 @@ +/** + * v2-specific bug hunt. v1 (workspace.*) is sunset; ignore those surfaces. + * Pass = defense holds. Fail / .todo = real v2 bug. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { projects, workspaces } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("bug-hunt-v2: progress-store leak on early errors in workspaceCreation.create", () => { + let host: TestHost; + let repo: GitFixture; + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("PROJECT_NOT_SETUP error in create() does not leak a stale progress entry", async () => { + // Regression: previously `setProgress(pendingId, 'ensuring_repo')` + // ran BEFORE `requireLocalProject` threw, and the throw path did not + // call clearProgress, leaving a stale "active" step for up to 5 min. + // Fixed via the outer try/finally in create.ts. + const pendingId = randomUUID(); + + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId, + projectId: randomUUID(), + names: { workspaceName: "ws", branchName: "feature/x" }, + composer: {}, + }), + ).rejects.toThrow(); + + const progress = await host.trpc.workspaceCreation.getProgress.query({ + pendingId, + }); + expect(progress).toBeNull(); + }); + + test("whitespace-only branchName error in create() does not leak progress", async () => { + const projectId = randomUUID(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + const pendingId = randomUUID(); + + // Whitespace-only passes the zod min(1) check but fails the + // `.trim()` guard inside the procedure — exercises the throw + // path between the two `setProgress` calls. + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId, + projectId, + names: { workspaceName: "ws", branchName: " " }, + composer: {}, + }), + ).rejects.toThrow(); + + const progress = await host.trpc.workspaceCreation.getProgress.query({ + pendingId, + }); + expect(progress).toBeNull(); + }); +}); + +describe("bug-hunt-v2: workspaceCleanup.destroy phase ordering", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("destroy rejects a main workspace BEFORE running teardown or cloud-delete", async () => { + // We can't exercise the actual `teardown.sh` script in bun:test + // (the harness has no PTY). What we *can* verify here is the + // phase-0 main-workspace guard fires first, so a destructive cloud + // delete is never attempted on a main workspace even if teardown + // would otherwise be skipped. Real TEARDOWN_FAILED behavior would + // need a PTY-enabled harness to cover. + const workspaceId = randomUUID(); + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => ({ type: "feature" }), + "v2Workspace.delete.mutate": () => ({ success: true }), + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + + await expect( + host.trpc.workspaceCleanup.destroy.mutate({ workspaceId }), + ).rejects.toThrow(/Main workspaces cannot be deleted/i); + + expect( + host.apiCalls.some((c) => c.path === "v2Workspace.delete.mutate"), + ).toBe(false); + }); +}); + +describe("bug-hunt-v2: workspaceCreation.adopt cross-project safety", () => { + let host: TestHost; + let repoA: GitFixture; + let repoB: GitFixture; + const projectIdA = randomUUID(); + const projectIdB = randomUUID(); + + beforeEach(async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + "v2Workspace.create.mutate": (input: unknown) => { + const i = input as { branch: string; name: string }; + return { + id: randomUUID(), + projectId: projectIdA, + branch: i.branch, + name: i.name, + }; + }, + }, + }); + repoA = await createGitFixture(); + repoB = await createGitFixture(); + host.db + .insert(projects) + .values([ + { id: projectIdA, repoPath: repoA.repoPath }, + { id: projectIdB, repoPath: repoB.repoPath }, + ]) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repoA.dispose(); + repoB.dispose(); + }); + + test("adopt with worktreePath belonging to a different project is rejected", async () => { + const { join } = await import("node:path"); + const worktreeInB = join(repoB.repoPath, ".worktrees", "feature-x"); + await repoB.git.raw(["worktree", "add", "-b", "feature/x", worktreeInB]); + + await expect( + host.trpc.workspaceCreation.adopt.mutate({ + projectId: projectIdA, + workspaceName: "x", + branch: "feature/x", + worktreePath: worktreeInB, + }), + ).rejects.toThrow(); + }); +}); + +describe("bug-hunt-v2: chat.sendMessage cloud failure must not break the turn", () => { + let host: TestHost; + const sessionId = randomUUID(); + const workspaceId = randomUUID(); + + const stubChatRuntime = { + sendMessage: async () => ({ ok: true, messageId: "m1" }), + }; + + beforeEach(async () => { + host = await createTestHost({ + chatRuntime: stubChatRuntime, + apiOverrides: { + "chat.updateSession.mutate": () => { + throw new Error("cloud-down"); + }, + }, + }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("chat.sendMessage swallows cloud chat.updateSession failures", async () => { + // The procedure does `void ctx.api.chat.updateSession.mutate(...).catch(() => {})` + // — the user-visible turn must not fail because of a cloud blip. + const result = await host.trpc.chat.sendMessage.mutate({ + sessionId, + workspaceId, + payload: { content: "hi" }, + }); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/host-service/test/integration/bug-hunt.integration.test.ts b/packages/host-service/test/integration/bug-hunt.integration.test.ts new file mode 100644 index 00000000000..043624f1940 --- /dev/null +++ b/packages/host-service/test/integration/bug-hunt.integration.test.ts @@ -0,0 +1,366 @@ +/** + * Deliberate bug-hunting suite. Each test probes a hazard the code should + * defend against. A passing test = defense holds; a failing test = real + * bug worth fixing. + * + * Categories: + * - sandbox / path traversal in workspace-fs operations + * - shell-arg / git-flag injection through user-controlled refs + * - idempotency / double-fire correctness + * - auth-header parsing edge cases + * - partial-failure consistency + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { TRPCClientError } from "@trpc/client"; +import { eq } from "drizzle-orm"; +import { projects, workspaces } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("bug-hunt: filesystem sandbox", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("writeFile rejects '..' traversal escaping the workspace root", async () => { + const escapeWritePath = `${repo.repoPath}/../escape.txt`; + await expect( + host.trpc.filesystem.writeFile.mutate({ + workspaceId, + absolutePath: escapeWritePath, + content: "should not exist", + options: { create: true, overwrite: true }, + }), + ).rejects.toThrow(); + // Sibling of repoPath must not have been written. + expect(existsSync(escapeWritePath)).toBe(false); + }); + + test("readFile rejects paths outside the workspace root", async () => { + // Try to read the test repo's parent /etc/hostname-equivalent + await expect( + host.trpc.filesystem.readFile.query({ + workspaceId, + absolutePath: `${repo.repoPath}/../../../etc/hosts`, + encoding: "utf8", + }), + ).rejects.toThrow(); + }); + + test("deletePath rejects targets outside the workspace root", async () => { + // Make a sibling we shouldn't be able to delete. + const sibling = join(repo.repoPath, "..", "do-not-delete"); + mkdirSync(sibling, { recursive: true }); + writeFileSync(join(sibling, "marker"), "x"); + + await expect( + host.trpc.filesystem.deletePath.mutate({ + workspaceId, + absolutePath: sibling, + }), + ).rejects.toThrow(); + expect(existsSync(join(sibling, "marker"))).toBe(true); + + // Cleanup + const { rmSync } = await import("node:fs"); + rmSync(sibling, { recursive: true, force: true }); + }); + + test("movePath rejects destinations outside the workspace root", async () => { + const src = join(repo.repoPath, "src.txt"); + writeFileSync(src, "src"); + const escapePath = join(repo.repoPath, "..", "escape-mv.txt"); + + await expect( + host.trpc.filesystem.movePath.mutate({ + workspaceId, + sourceAbsolutePath: src, + destinationAbsolutePath: escapePath, + }), + ).rejects.toThrow(); + expect(existsSync(escapePath)).toBe(false); + expect(existsSync(src)).toBe(true); + }); + + test("statPath does not crash on tilde paths when HOME is unset", async () => { + const oldHome = process.env.HOME; + const oldUserprofile = process.env.USERPROFILE; + delete process.env.HOME; + delete process.env.USERPROFILE; + try { + const result = await host.trpc.filesystem.statPath.mutate({ + workspaceId, + path: "~/some-file", + }); + expect(result).toBeNull(); + } finally { + if (oldHome !== undefined) process.env.HOME = oldHome; + if (oldUserprofile !== undefined) + process.env.USERPROFILE = oldUserprofile; + } + }); + + test("listDirectory rejects absolute paths outside workspace root", async () => { + await expect( + host.trpc.filesystem.listDirectory.query({ + workspaceId, + absolutePath: "/etc", + }), + ).rejects.toThrow(); + }); +}); + +describe("bug-hunt: git-flag injection", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + host = await createTestHost(); + repo = await createGitFixture(); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + host.db + .insert(workspaces) + .values({ + id: workspaceId, + projectId, + worktreePath: repo.repoPath, + branch: "main", + }) + .run(); + }); + + afterEach(async () => { + await host.dispose(); + repo.dispose(); + }); + + test("setBaseBranch with a flag-shaped value stores it as a literal config value", async () => { + // `git config branch.main.base --global` would only be a flag-injection + // risk if simple-git ran a shell — it doesn't (argv spawn), so the + // value lands as literal text. Pin that round-trip behavior. + await host.trpc.git.setBaseBranch.mutate({ + workspaceId, + baseBranch: "--global", + }); + const round = await host.trpc.git.getBaseBranch.query({ workspaceId }); + expect(round.baseBranch).toBe("--global"); + }); + + test("renameBranch with a flag-shaped new name has no destructive side effect", async () => { + await repo.git.checkoutLocalBranch("rename-target"); + host.db + .update(workspaces) + .set({ branch: "rename-target" }) + .where(eq(workspaces.id, workspaceId)) + .run(); + + await host.trpc.git.renameBranch + .mutate({ + workspaceId, + oldName: "rename-target", + newName: "--force", + }) + .catch(() => {}); + + const branches = await repo.git.branchLocal(); + // Either git refused the rename (target still there) or accepted + // `--force` as a literal branch name — never both gone, never main + // affected. + expect( + branches.all.includes("rename-target") || + branches.all.includes("--force"), + ).toBe(true); + expect(branches.all).toContain("main"); + }); +}); + +describe("bug-hunt: idempotency + double-fire", () => { + let host: TestHost; + let repo: GitFixture; + const projectId = randomUUID(); + const _workspaceId = randomUUID(); + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("workspaceCleanup.destroy is idempotent on a non-existent workspace id", async () => { + host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": () => null, + "v2Workspace.delete.mutate": () => ({ success: true }), + }, + }); + const id = randomUUID(); + const a = await host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: id, + }); + const b = await host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: id, + }); + expect(a.success).toBe(true); + expect(b.success).toBe(true); + }); + + test("project.remove is idempotent across two calls", async () => { + host = await createTestHost(); + const id = randomUUID(); + const a = await host.trpc.project.remove.mutate({ projectId: id }); + const b = await host.trpc.project.remove.mutate({ projectId: id }); + expect(a).toEqual({ success: true }); + expect(b).toEqual({ success: true }); + }); + + test("two concurrent workspace.create calls with the same branch don't collide silently", async () => { + host = await createTestHost({ + apiOverrides: { + "host.ensure.mutate": () => ({ machineId: "m1" }), + "v2Workspace.create.mutate": (input: unknown) => { + const i = input as { branch: string; name: string }; + return { + id: randomUUID(), + projectId, + branch: i.branch, + name: i.name, + }; + }, + }, + }); + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + await Promise.allSettled([ + host.trpc.workspace.create.mutate({ + projectId, + name: "w", + branch: "feature/race", + }), + host.trpc.workspace.create.mutate({ + projectId, + name: "w", + branch: "feature/race", + }), + ]); + + // We must never end up with more than one workspace row pointing + // at the same branch — that's the actual collision we're guarding + // against. Either both calls collide (one row, one error) or git's + // own worktree-add lock causes one to fail; never two rows. + const rows = host.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, projectId)) + .all(); + const featureRows = rows.filter((r) => r.branch === "feature/race"); + expect(featureRows.length).toBeLessThanOrEqual(1); + }); +}); + +describe("bug-hunt: auth header parsing", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("Bearer with empty token is rejected", async () => { + const res = await host.fetch("http://host-service.test/events", { + headers: { authorization: "Bearer " }, + }); + expect(res.status).toBe(401); + }); + + test("Bearer with leading whitespace is rejected", async () => { + const res = await host.fetch("http://host-service.test/events", { + headers: { authorization: `Bearer ${host.psk}` }, + }); + expect(res.status).toBe(401); + }); + + test("token query param with multiple values uses only the first (or rejects)", async () => { + // Hono's `c.req.query("token")` returns the first match. Make sure + // a wrong-then-right pair doesn't authenticate. + const res = await host.fetch( + `http://host-service.test/events?token=wrong&token=${encodeURIComponent(host.psk)}`, + ); + expect(res.status).toBe(401); + }); + + test("Authorization with non-Bearer scheme is rejected", async () => { + const res = await host.fetch("http://host-service.test/events", { + headers: { authorization: `Basic ${host.psk}` }, + }); + expect(res.status).toBe(401); + }); +}); + +describe("bug-hunt: SQL/identifier injection smoke", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("workspace.get with id containing SQL meta is safe (drizzle params)", async () => { + // Should resolve to NOT_FOUND, not 500 / SQL error. + await expect( + host.trpc.workspace.get.query({ id: "x'; DROP TABLE workspaces;--" }), + ).rejects.toBeInstanceOf(TRPCClientError); + + // Table still exists. A second NOT_FOUND with a benign id proves + // the schema is intact — assert the rejection explicitly instead + // of swallowing it, otherwise a schema corruption would silently + // pass this test. + await expect( + host.trpc.workspace.get.query({ id: "no-such-row" }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/chat.integration.test.ts b/packages/host-service/test/integration/chat.integration.test.ts new file mode 100644 index 00000000000..5959d67959a --- /dev/null +++ b/packages/host-service/test/integration/chat.integration.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("chat router integration with stub ChatRuntimeManager", () => { + let host: TestHost; + const calls: Array<{ method: string; args: unknown }> = []; + + const stubChatRuntime = { + getDisplayState: (input: unknown) => { + calls.push({ method: "getDisplayState", args: input }); + return { running: false }; + }, + listMessages: async (input: unknown) => { + calls.push({ method: "listMessages", args: input }); + return []; + }, + getSnapshot: async (input: unknown) => { + calls.push({ method: "getSnapshot", args: input }); + return { messages: [], displayState: { running: false } }; + }, + sendMessage: async (input: unknown) => { + calls.push({ method: "sendMessage", args: input }); + return { ok: true }; + }, + disposeRuntime: async (sessionId: string, workspaceId: string) => { + calls.push({ + method: "disposeRuntime", + args: { sessionId, workspaceId }, + }); + }, + restartFromMessage: async (input: unknown) => { + calls.push({ method: "restartFromMessage", args: input }); + return { ok: true }; + }, + stop: async (input: unknown) => { + calls.push({ method: "stop", args: input }); + return { ok: true }; + }, + respondToApproval: async (input: unknown) => { + calls.push({ method: "respondToApproval", args: input }); + return { ok: true }; + }, + respondToQuestion: async (input: unknown) => { + calls.push({ method: "respondToQuestion", args: input }); + return { ok: true }; + }, + respondToPlan: async (input: unknown) => { + calls.push({ method: "respondToPlan", args: input }); + return { ok: true }; + }, + getSlashCommands: async (input: unknown) => { + calls.push({ method: "getSlashCommands", args: input }); + return []; + }, + resolveSlashCommand: async (input: unknown) => { + calls.push({ method: "resolveSlashCommand", args: input }); + return { resolved: null }; + }, + }; + + const sessionId = randomUUID(); + const workspaceId = randomUUID(); + + beforeEach(async () => { + calls.length = 0; + host = await createTestHost({ + chatRuntime: stubChatRuntime, + apiOverrides: { + "chat.updateSession.mutate": () => ({ ok: true }), + }, + }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("getDisplayState delegates with sessionId+workspaceId", async () => { + await host.trpc.chat.getDisplayState.query({ sessionId, workspaceId }); + expect(calls[0]).toMatchObject({ + method: "getDisplayState", + args: { sessionId, workspaceId }, + }); + }); + + test("listMessages delegates", async () => { + const result = await host.trpc.chat.listMessages.query({ + sessionId, + workspaceId, + }); + expect(result).toEqual([]); + expect(calls[0].method).toBe("listMessages"); + }); + + test("getSnapshot delegates", async () => { + await host.trpc.chat.getSnapshot.query({ sessionId, workspaceId }); + expect(calls[0].method).toBe("getSnapshot"); + }); + + test("sendMessage delegates and fires cloud lastActiveAt update", async () => { + await host.trpc.chat.sendMessage.mutate({ + sessionId, + workspaceId, + payload: { content: "hello" }, + }); + expect(calls[0].method).toBe("sendMessage"); + // fire-and-forget — give microtask queue a chance to flush + await new Promise((r) => setTimeout(r, 10)); + expect( + host.apiCalls.some((c) => c.path === "chat.updateSession.mutate"), + ).toBe(true); + }); + + test("endSession delegates to disposeRuntime and returns ok", async () => { + const result = await host.trpc.chat.endSession.mutate({ + sessionId, + workspaceId, + }); + expect(result).toEqual({ ok: true }); + expect(calls[0]).toMatchObject({ + method: "disposeRuntime", + args: { sessionId, workspaceId }, + }); + }); + + test("respondToApproval validates decision enum", async () => { + await host.trpc.chat.respondToApproval.mutate({ + sessionId, + workspaceId, + payload: { decision: "approve" }, + }); + expect(calls[0].method).toBe("respondToApproval"); + + await expect( + host.trpc.chat.respondToApproval.mutate({ + sessionId, + workspaceId, + // biome-ignore lint/suspicious/noExplicitAny: testing zod rejection + payload: { decision: "garbage" as any }, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("getSlashCommands and resolveSlashCommand delegate with workspaceId only", async () => { + await host.trpc.chat.getSlashCommands.query({ workspaceId }); + expect(calls[0]).toMatchObject({ + method: "getSlashCommands", + args: { workspaceId }, + }); + + await host.trpc.chat.resolveSlashCommand.mutate({ + workspaceId, + text: "/foo bar", + }); + expect(calls[1]).toMatchObject({ + method: "resolveSlashCommand", + args: { workspaceId, text: "/foo bar" }, + }); + }); + + test("requires authentication", async () => { + await expect( + host.unauthenticatedTrpc.chat.getDisplayState.query({ + sessionId, + workspaceId, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/cloud.integration.test.ts b/packages/host-service/test/integration/cloud.integration.test.ts new file mode 100644 index 00000000000..c9e5ed03bed --- /dev/null +++ b/packages/host-service/test/integration/cloud.integration.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("cloud router integration", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost({ + apiOverrides: { + "user.me.query": () => ({ + id: "user-1", + email: "test@superset.local", + name: "Test User", + }), + }, + }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("whoami proxies to cloud user.me", async () => { + const result = await host.trpc.cloud.whoami.query(); + expect(result).toEqual({ + id: "user-1", + email: "test@superset.local", + name: "Test User", + }); + expect(host.apiCalls.map((c) => c.path)).toContain("user.me.query"); + }); + + test("whoami requires authentication", async () => { + await expect( + host.unauthenticatedTrpc.cloud.whoami.query(), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/filesystem.integration.test.ts b/packages/host-service/test/integration/filesystem.integration.test.ts new file mode 100644 index 00000000000..c4ec8d38ad9 --- /dev/null +++ b/packages/host-service/test/integration/filesystem.integration.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { TRPCClientError } from "@trpc/client"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; + +describe("filesystem router integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("listDirectory enumerates files in workspace root", async () => { + writeFileSync(join(scenario.repo.repoPath, "alpha.txt"), "a"); + writeFileSync(join(scenario.repo.repoPath, "beta.txt"), "b"); + mkdirSync(join(scenario.repo.repoPath, "subdir")); + + const result = await scenario.host.trpc.filesystem.listDirectory.query({ + workspaceId: scenario.workspaceId, + absolutePath: scenario.repo.repoPath, + }); + const names = result.entries.map((e) => e.name); + expect(names).toContain("alpha.txt"); + expect(names).toContain("beta.txt"); + expect(names).toContain("subdir"); + }); + + test("listDirectory throws NOT_FOUND for unknown workspace", async () => { + await expect( + scenario.host.trpc.filesystem.listDirectory.query({ + workspaceId: "no-such-ws", + absolutePath: scenario.repo.repoPath, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("readFile returns text content", async () => { + const filePath = join(scenario.repo.repoPath, "hello.txt"); + writeFileSync(filePath, "hello world"); + + const result = await scenario.host.trpc.filesystem.readFile.query({ + workspaceId: scenario.workspaceId, + absolutePath: filePath, + encoding: "utf8", + }); + expect(result.kind).toBe("text"); + if (result.kind === "text") { + expect(result.content).toBe("hello world"); + } + }); + + test("writeFile creates a file with the given content", async () => { + const filePath = join(scenario.repo.repoPath, "written.txt"); + await scenario.host.trpc.filesystem.writeFile.mutate({ + workspaceId: scenario.workspaceId, + absolutePath: filePath, + content: "from-trpc", + options: { create: true, overwrite: true }, + }); + expect(readFileSync(filePath, "utf8")).toBe("from-trpc"); + }); + + test("getMetadata returns size and type for an existing file", async () => { + const filePath = join(scenario.repo.repoPath, "meta.txt"); + writeFileSync(filePath, "abcdef"); + const result = await scenario.host.trpc.filesystem.getMetadata.query({ + workspaceId: scenario.workspaceId, + absolutePath: filePath, + }); + expect(result.size).toBe(6); + }); + + test("statPath resolves a relative path inside workspace root", async () => { + writeFileSync(join(scenario.repo.repoPath, "stat-target.txt"), "x"); + const result = await scenario.host.trpc.filesystem.statPath.mutate({ + workspaceId: scenario.workspaceId, + path: "stat-target.txt", + }); + expect(result).not.toBeNull(); + expect(result?.isDirectory).toBe(false); + expect(result?.resolvedPath).toBe( + join(scenario.repo.repoPath, "stat-target.txt"), + ); + }); + + test("statPath returns null for nonexistent paths", async () => { + const result = await scenario.host.trpc.filesystem.statPath.mutate({ + workspaceId: scenario.workspaceId, + path: "nope.txt", + }); + expect(result).toBeNull(); + }); + + test("searchFiles with empty query returns no matches", async () => { + const result = await scenario.host.trpc.filesystem.searchFiles.query({ + workspaceId: scenario.workspaceId, + query: " ", + }); + expect(result.matches).toEqual([]); + }); +}); diff --git a/packages/host-service/test/integration/git-history.integration.test.ts b/packages/host-service/test/integration/git-history.integration.test.ts new file mode 100644 index 00000000000..952f6e15eae --- /dev/null +++ b/packages/host-service/test/integration/git-history.integration.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; + +describe("git history + diff procedures", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("listCommits returns [] when on default branch with nothing ahead", async () => { + const result = await scenario.host.trpc.git.listCommits.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.commits).toEqual([]); + }); + + test("listCommits returns commits on a feature branch ahead of base", async () => { + // Synthesize an `origin/main` ref pointing at the current main without + // configuring a real remote — `resolveBaseComparison` falls back to + // `origin/` when no upstream is configured, so the ref must + // exist for `git log origin/main..HEAD` to resolve. + await scenario.repo.git.raw([ + "update-ref", + "refs/remotes/origin/main", + "refs/heads/main", + ]); + await scenario.repo.git.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); + + await scenario.repo.git.checkoutLocalBranch("feature/x"); + await scenario.repo.commit("first feature commit", { "a.txt": "a" }); + await scenario.repo.commit("second feature commit", { "b.txt": "b" }); + + const result = await scenario.host.trpc.git.listCommits.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.commits.length).toBeGreaterThanOrEqual(2); + expect(result.commits[0].message).toBe("second feature commit"); + expect( + result.commits.some((c) => c.message === "first feature commit"), + ).toBe(true); + }); + + test("getCommitFiles lists files changed in a commit", async () => { + const sha = await scenario.repo.commit("add files", { + "x.txt": "x content", + "y.txt": "y content", + }); + + const result = await scenario.host.trpc.git.getCommitFiles.query({ + workspaceId: scenario.workspaceId, + commitHash: sha, + }); + const paths = result.files.map((f) => f.path).sort(); + expect(paths).toContain("x.txt"); + expect(paths).toContain("y.txt"); + }); + + test("getDiff returns staged content for a staged change", async () => { + const filePath = join(scenario.repo.repoPath, "README.md"); + writeFileSync(filePath, "modified line\n"); + await scenario.repo.git.add("README.md"); + + const result = await scenario.host.trpc.git.getDiff.query({ + workspaceId: scenario.workspaceId, + path: "README.md", + category: "staged", + }); + expect(result.newFile.name).toBe("README.md"); + expect(result.newFile.contents).toContain("modified line"); + }); + + test("getBranchSyncStatus reflects no-remote / no-upstream state", async () => { + const result = await scenario.host.trpc.git.getBranchSyncStatus.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.hasRepo).toBe(false); + expect(result.hasUpstream).toBe(false); + expect(result.pushCount).toBe(0); + expect(result.pullCount).toBe(0); + expect(result.isDetached).toBe(false); + expect(result.currentBranch).toBe("main"); + }); + + test("getBranchSyncStatus reports detached HEAD when checked out at a sha", async () => { + const sha = await scenario.repo.commit("for-detach", { "d.txt": "d" }); + await scenario.repo.git.checkout(sha); + + const result = await scenario.host.trpc.git.getBranchSyncStatus.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.isDetached).toBe(true); + expect(result.currentBranch).toBeNull(); + }); +}); diff --git a/packages/host-service/test/integration/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts new file mode 100644 index 00000000000..270347b3c4e --- /dev/null +++ b/packages/host-service/test/integration/git.integration.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { TRPCClientError } from "@trpc/client"; +import { eq } from "drizzle-orm"; +import { workspaces } from "../../src/db/schema"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; + +describe("git router integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("listBranches returns the current and other branches", async () => { + await scenario.repo.git.checkoutLocalBranch("feature/x"); + await scenario.repo.commit("x work", { "x.txt": "x" }); + await scenario.repo.git.checkout("main"); + + const result = await scenario.host.trpc.git.listBranches.query({ + workspaceId: scenario.workspaceId, + }); + const names = result.branches.map((b) => b.name); + expect(names).toContain("main"); + expect(names).toContain("feature/x"); + }); + + test("listBranches throws NOT_FOUND for unknown workspace", async () => { + await expect( + scenario.host.trpc.git.listBranches.query({ workspaceId: "no-such-ws" }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("getStatus on a clean repo reports no staged or unstaged changes", async () => { + const status = await scenario.host.trpc.git.getStatus.query({ + workspaceId: scenario.workspaceId, + }); + expect(status.staged).toEqual([]); + expect(status.unstaged).toEqual([]); + }); + + test("getStatus reports modified and untracked files in unstaged", async () => { + writeFileSync(join(scenario.repo.repoPath, "README.md"), "modified"); + writeFileSync(join(scenario.repo.repoPath, "new.txt"), "new file"); + + const status = await scenario.host.trpc.git.getStatus.query({ + workspaceId: scenario.workspaceId, + }); + const paths = status.unstaged.map((f) => f.path); + expect(paths).toContain("README.md"); + expect(paths).toContain("new.txt"); + expect(status.unstaged.find((f) => f.path === "new.txt")?.status).toBe( + "untracked", + ); + }); + + test("getBaseBranch returns null when not configured", async () => { + const result = await scenario.host.trpc.git.getBaseBranch.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.baseBranch).toBeNull(); + }); + + test("setBaseBranch persists to git config and is read back by getBaseBranch", async () => { + await scenario.host.trpc.git.setBaseBranch.mutate({ + workspaceId: scenario.workspaceId, + baseBranch: "main", + }); + + const result = await scenario.host.trpc.git.getBaseBranch.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.baseBranch).toBe("main"); + }); + + test("setBaseBranch with null clears the configured base", async () => { + await scenario.host.trpc.git.setBaseBranch.mutate({ + workspaceId: scenario.workspaceId, + baseBranch: "main", + }); + await scenario.host.trpc.git.setBaseBranch.mutate({ + workspaceId: scenario.workspaceId, + baseBranch: null, + }); + + const result = await scenario.host.trpc.git.getBaseBranch.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.baseBranch).toBeNull(); + }); + + test("renameBranch renames an unpushed branch", async () => { + await scenario.repo.git.checkoutLocalBranch("feature/old"); + await scenario.repo.commit("work", { "f.txt": "f" }); + + scenario.host.db + .update(workspaces) + .set({ branch: "feature/old" }) + .where(eq(workspaces.id, scenario.workspaceId)) + .run(); + + const result = await scenario.host.trpc.git.renameBranch.mutate({ + workspaceId: scenario.workspaceId, + oldName: "feature/old", + newName: "feature/new", + }); + + expect(result.name).toBe("feature/new"); + const branches = await scenario.repo.git.branchLocal(); + expect(branches.all).toContain("feature/new"); + expect(branches.all).not.toContain("feature/old"); + }); +}); diff --git a/packages/host-service/test/integration/github-mocked.integration.test.ts b/packages/host-service/test/integration/github-mocked.integration.test.ts new file mode 100644 index 00000000000..a9705c9644e --- /dev/null +++ b/packages/host-service/test/integration/github-mocked.integration.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("github router with mocked Octokit", () => { + let host: TestHost; + const calls: Array<{ method: string; args: unknown }> = []; + + const fakeOctokit = { + pulls: { + list: async (args: unknown) => { + calls.push({ method: "pulls.list", args }); + return { + data: [ + { + number: 1, + title: "Open PR", + state: "open", + head: { ref: "feature/x" }, + }, + ], + }; + }, + get: async (args: unknown) => { + calls.push({ method: "pulls.get", args }); + return { + data: { + number: 42, + title: "PR 42", + state: "open", + body: "hello", + }, + }; + }, + merge: async (args: unknown) => { + calls.push({ method: "pulls.merge", args }); + return { + data: { + sha: "deadbeefcafe", + merged: true, + message: "Pull Request successfully merged", + }, + }; + }, + }, + repos: { + get: async (args: unknown) => { + calls.push({ method: "repos.get", args }); + return { + data: { + id: 1, + name: "hello", + full_name: "octocat/hello", + default_branch: "main", + }, + }; + }, + listDeployments: async (args: unknown) => { + calls.push({ method: "repos.listDeployments", args }); + return { + data: [{ id: 100, ref: "main", environment: "production" }], + }; + }, + listDeploymentStatuses: async (args: unknown) => { + calls.push({ method: "repos.listDeploymentStatuses", args }); + return { + data: [{ id: 1, state: "success", environment: "production" }], + }; + }, + }, + users: { + getAuthenticated: async () => { + calls.push({ method: "users.getAuthenticated", args: {} }); + return { data: { login: "octocat", id: 583231 } }; + }, + }, + }; + + beforeEach(async () => { + calls.length = 0; + host = await createTestHost({ + githubFactory: async () => fakeOctokit, + }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("getPRStatus delegates to octokit.pulls.list and returns the first row", async () => { + const result = await host.trpc.github.getPRStatus.query({ + owner: "octocat", + repo: "hello", + branch: "feature/x", + }); + expect(result).not.toBeNull(); + expect(result?.number).toBe(1); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("pulls.list"); + expect(calls[0].args).toMatchObject({ + owner: "octocat", + repo: "hello", + head: "octocat:feature/x", + state: "open", + }); + }); + + test("getPR delegates to octokit.pulls.get with pull_number", async () => { + const result = await host.trpc.github.getPR.query({ + owner: "octocat", + repo: "hello", + pullNumber: 42, + }); + expect(result.number).toBe(42); + expect(calls[0].method).toBe("pulls.get"); + expect(calls[0].args).toMatchObject({ + owner: "octocat", + repo: "hello", + pull_number: 42, + }); + }); + + test("listPRs forwards pagination params to octokit", async () => { + await host.trpc.github.listPRs.query({ + owner: "octocat", + repo: "hello", + state: "all", + perPage: 10, + page: 2, + }); + expect(calls[0].method).toBe("pulls.list"); + expect(calls[0].args).toMatchObject({ + state: "all", + per_page: 10, + page: 2, + }); + }); + + test("getRepo delegates to octokit.repos.get", async () => { + const result = await host.trpc.github.getRepo.query({ + owner: "octocat", + repo: "hello", + }); + expect(result.full_name).toBe("octocat/hello"); + expect(calls[0].method).toBe("repos.get"); + }); + + test("listDeployments forwards filters to octokit", async () => { + await host.trpc.github.listDeployments.query({ + owner: "octocat", + repo: "hello", + environment: "production", + ref: "main", + }); + expect(calls[0].method).toBe("repos.listDeployments"); + expect(calls[0].args).toMatchObject({ + owner: "octocat", + repo: "hello", + environment: "production", + ref: "main", + }); + }); + + test("listDeploymentStatuses forwards deploymentId", async () => { + await host.trpc.github.listDeploymentStatuses.query({ + owner: "octocat", + repo: "hello", + deploymentId: 100, + }); + expect(calls[0].method).toBe("repos.listDeploymentStatuses"); + expect(calls[0].args).toMatchObject({ deployment_id: 100 }); + }); + + test("getUser delegates to octokit.users.getAuthenticated", async () => { + const result = await host.trpc.github.getUser.query(); + expect(result.login).toBe("octocat"); + }); + + test("mergePR forwards mergeMethod to octokit.pulls.merge", async () => { + const result = await host.trpc.github.mergePR.mutate({ + owner: "octocat", + repo: "hello", + pullNumber: 42, + mergeMethod: "squash", + }); + expect(result.merged).toBe(true); + expect(calls[0].method).toBe("pulls.merge"); + expect(calls[0].args).toMatchObject({ + pull_number: 42, + merge_method: "squash", + }); + }); +}); diff --git a/packages/host-service/test/integration/github.integration.test.ts b/packages/host-service/test/integration/github.integration.test.ts new file mode 100644 index 00000000000..8d59c5c5c9e --- /dev/null +++ b/packages/host-service/test/integration/github.integration.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("github router integration", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("getPR throws when no GitHub token is available", async () => { + await expect( + host.trpc.github.getPR.query({ + owner: "octocat", + repo: "hello-world", + pullNumber: 1, + }), + ).rejects.toThrow(/no github token/i); + }); + + test("listPRs throws when no GitHub token is available", async () => { + await expect( + host.trpc.github.listPRs.query({ owner: "o", repo: "r" }), + ).rejects.toThrow(/no github token/i); + }); + + test("getPR rejects unauthenticated callers before reaching handler", async () => { + await expect( + host.unauthenticatedTrpc.github.getPR.query({ + owner: "o", + repo: "r", + pullNumber: 1, + }), + ).rejects.toThrow(/UNAUTHORIZED|Invalid or missing/i); + }); +}); diff --git a/packages/host-service/test/integration/notifications.integration.test.ts b/packages/host-service/test/integration/notifications.integration.test.ts new file mode 100644 index 00000000000..07bcde666e6 --- /dev/null +++ b/packages/host-service/test/integration/notifications.integration.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; +import { seedTerminalSession } from "../helpers/seed"; + +describe("notifications.hook integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("ignores unknown event types without authentication", async () => { + const result = + await scenario.host.unauthenticatedTrpc.notifications.hook.mutate({ + eventType: "garbage", + terminalId: "terminal-1", + }); + expect(result).toEqual({ success: true, ignored: true }); + }); + + test("ignores hook with missing terminalId", async () => { + const result = + await scenario.host.unauthenticatedTrpc.notifications.hook.mutate({ + eventType: "Stop", + }); + expect(result).toEqual({ success: true, ignored: true }); + }); + + test("ignores hook for unknown terminalId", async () => { + const result = + await scenario.host.unauthenticatedTrpc.notifications.hook.mutate({ + eventType: "Stop", + terminalId: "no-such-terminal", + }); + expect(result).toEqual({ success: true, ignored: true }); + }); + + test("broadcasts when terminal session resolves to a workspace", async () => { + const { id: terminalId } = seedTerminalSession(scenario.host, { + id: randomUUID(), + originWorkspaceId: scenario.workspaceId, + }); + + const result = + await scenario.host.unauthenticatedTrpc.notifications.hook.mutate({ + eventType: "Stop", + terminalId, + }); + expect(result).toEqual({ success: true, ignored: false }); + }); +}); diff --git a/packages/host-service/test/integration/ports.integration.test.ts b/packages/host-service/test/integration/ports.integration.test.ts new file mode 100644 index 00000000000..febacb02191 --- /dev/null +++ b/packages/host-service/test/integration/ports.integration.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("ports router integration", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + // Optional chain so a setup failure in beforeEach (which leaves + // `host` undefined at runtime) doesn't mask the original error + // with a teardown crash. + await host?.dispose(); + }); + + test("getAll returns [] when no ports are tracked for the workspace", async () => { + const result = await host.trpc.ports.getAll.query({ + workspaceIds: ["no-such-workspace"], + }); + expect(result).toEqual([]); + }); + + test("getAll requires at least one workspaceId", async () => { + await expect( + host.trpc.ports.getAll.query({ workspaceIds: [] }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("getAll requires authentication", async () => { + await expect( + host.unauthenticatedTrpc.ports.getAll.query({ workspaceIds: ["x"] }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/project-setup.integration.test.ts b/packages/host-service/test/integration/project-setup.integration.test.ts new file mode 100644 index 00000000000..3e58c538758 --- /dev/null +++ b/packages/host-service/test/integration/project-setup.integration.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { TRPCClientError } from "@trpc/client"; +import { projects } from "../../src/db/schema"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; + +describe("project.setup error paths", () => { + let host: TestHost; + let repo: GitFixture; + + beforeEach(async () => { + repo = await createGitFixture(); + }); + + afterEach(async () => { + if (host) await host.dispose(); + repo.dispose(); + }); + + test("rejects clone when cloud project has no repoCloneUrl", async () => { + host = await createTestHost({ + apiOverrides: { + "v2Project.get.query": () => ({ id: randomUUID(), repoCloneUrl: null }), + }, + }); + + await expect( + host.trpc.project.setup.mutate({ + projectId: randomUUID(), + mode: { kind: "clone", parentDir: "/tmp/parent-does-not-matter" }, + }), + ).rejects.toThrow(/no linked GitHub repository/i); + }); + + test("rejects clone when cloud repoCloneUrl is unparseable", async () => { + host = await createTestHost({ + apiOverrides: { + "v2Project.get.query": () => ({ + id: randomUUID(), + repoCloneUrl: "not-a-github-url", + }), + }, + }); + + await expect( + host.trpc.project.setup.mutate({ + projectId: randomUUID(), + mode: { kind: "clone", parentDir: "/tmp/parent-does-not-matter" }, + }), + ).rejects.toThrow(/Could not parse GitHub remote/i); + }); + + test("rejects re-pointing existing project to a different path without allowRelocate", async () => { + const projectId = randomUUID(); + host = await createTestHost({ + apiOverrides: { + "v2Project.get.query": () => ({ + id: projectId, + repoCloneUrl: "https://github.com/octocat/hello.git", + }), + }, + }); + + // project already set up at repo.repoPath + host.db + .insert(projects) + .values({ id: projectId, repoPath: repo.repoPath }) + .run(); + + await expect( + host.trpc.project.setup.mutate({ + projectId, + mode: { kind: "clone", parentDir: "/tmp/some-other-parent" }, + }), + ).rejects.toThrow(/already set up on this device/i); + }); + + test("rejects setup with a non-uuid projectId at validation", async () => { + host = await createTestHost(); + await expect( + host.trpc.project.setup.mutate({ + projectId: "not-a-uuid", + mode: { kind: "import", repoPath: repo.repoPath }, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("create() with empty mode returns NOT_IMPLEMENTED", async () => { + host = await createTestHost(); + await expect( + host.trpc.project.create.mutate({ + name: "x", + mode: { + kind: "empty", + parentDir: "/tmp/x", + visibility: "private", + }, + }), + ).rejects.toThrow(/not implemented/i); + }); + + test("create() with template mode returns NOT_IMPLEMENTED", async () => { + host = await createTestHost(); + await expect( + host.trpc.project.create.mutate({ + name: "x", + mode: { + kind: "template", + parentDir: "/tmp/x", + templateId: "next-app", + visibility: "private", + }, + }), + ).rejects.toThrow(/not implemented/i); + }); + + test("remove() is idempotent when project doesn't exist", async () => { + host = await createTestHost(); + const result = await host.trpc.project.remove.mutate({ + projectId: randomUUID(), + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/host-service/test/integration/project.integration.test.ts b/packages/host-service/test/integration/project.integration.test.ts new file mode 100644 index 00000000000..44ed360e13f --- /dev/null +++ b/packages/host-service/test/integration/project.integration.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { TRPCClientError } from "@trpc/client"; +import { cloudOk } from "../helpers/cloud-fakes"; +import { createTestHost } from "../helpers/createTestHost"; +import { createGitFixture } from "../helpers/git-fixture"; +import { createProjectScenario } from "../helpers/scenarios"; +import { seedProject } from "../helpers/seed"; + +describe("project router integration", () => { + let dispose: (() => Promise) | undefined; + + afterEach(async () => { + if (dispose) { + await dispose(); + dispose = undefined; + } + }); + + test("list returns rows from db", async () => { + const host = await createTestHost(); + const repo = await createGitFixture(); + dispose = async () => { + await host.dispose(); + repo.dispose(); + }; + + const a = seedProject(host, { repoPath: repo.repoPath, repoName: "alpha" }); + const b = seedProject(host, { + repoPath: `${repo.repoPath}-other`, + repoName: "beta", + }); + + const result = await host.trpc.project.list.query(); + const ids = result.map((p) => p.id).sort(); + expect(ids).toEqual([a.id, b.id].sort()); + }); + + test("get returns project by id, null when missing", async () => { + const scenario = await createProjectScenario(); + dispose = scenario.dispose; + + const found = await scenario.host.trpc.project.get.query({ + projectId: scenario.projectId, + }); + expect(found?.id).toBe(scenario.projectId); + expect(found?.repoPath).toBe(scenario.repo.repoPath); + + const missing = await scenario.host.trpc.project.get.query({ + projectId: randomUUID(), + }); + expect(missing).toBeNull(); + }); + + test("get rejects non-uuid projectId via zod", async () => { + const scenario = await createProjectScenario(); + dispose = scenario.dispose; + + await expect( + scenario.host.trpc.project.get.query({ projectId: "not-a-uuid" }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("findBackfillConflict always returns conflict: null", async () => { + const scenario = await createProjectScenario(); + dispose = scenario.dispose; + + const result = await scenario.host.trpc.project.findBackfillConflict.query({ + projectId: randomUUID(), + repoPath: scenario.repo.repoPath, + }); + expect(result).toEqual({ conflict: null }); + }); + + test("findByPath returns local match without hitting cloud api", async () => { + const host = await createTestHost(); + const repo = await createGitFixture(); + dispose = async () => { + await host.dispose(); + repo.dispose(); + }; + + const { id } = seedProject(host, { + repoPath: repo.repoPath, + repoName: "local-name", + }); + + const result = await host.trpc.project.findByPath.query({ + repoPath: repo.repoPath, + }); + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]).toEqual({ id, name: "local-name" }); + expect( + host.apiCalls.some( + (c) => c.path === "v2Project.findByGitHubRemote.query", + ), + ).toBe(false); + }); + + test("findByPath returns empty candidates when repo has no parsed remote and no local project", async () => { + const host = await createTestHost(); + const repo = await createGitFixture(); + dispose = async () => { + await host.dispose(); + repo.dispose(); + }; + + const result = await host.trpc.project.findByPath.query({ + repoPath: repo.repoPath, + }); + expect(result.candidates).toEqual([]); + }); + + test("findByPath falls back to cloud when no local project + parseable remote", async () => { + const host = await createTestHost({ + apiOverrides: { + "v2Project.findByGitHubRemote.query": + cloudOk.v2ProjectFindByGitHubRemote([ + { id: "cloud-project-id", name: "octocat/hello" }, + ]), + }, + }); + const repo = await createGitFixture(); + await repo.git.addRemote("origin", "https://github.com/octocat/hello.git"); + dispose = async () => { + await host.dispose(); + repo.dispose(); + }; + + const result = await host.trpc.project.findByPath.query({ + repoPath: repo.repoPath, + }); + expect(result.candidates).toEqual([ + { id: "cloud-project-id", name: "octocat/hello" }, + ]); + expect( + host.apiCalls.some( + (c) => c.path === "v2Project.findByGitHubRemote.query", + ), + ).toBe(true); + }); +}); diff --git a/packages/host-service/test/integration/pull-requests.integration.test.ts b/packages/host-service/test/integration/pull-requests.integration.test.ts new file mode 100644 index 00000000000..d170af60bb4 --- /dev/null +++ b/packages/host-service/test/integration/pull-requests.integration.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; +import { seedPullRequest, seedWorkspace } from "../helpers/seed"; + +describe("pullRequests router integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("getByWorkspaces returns [] for empty input", async () => { + const result = await scenario.host.trpc.pullRequests.getByWorkspaces.query({ + workspaceIds: [], + }); + expect(result.workspaces).toEqual([]); + }); + + test("getByWorkspaces returns null pullRequest for workspace with no PR linked", async () => { + const { id: workspaceId } = seedWorkspace(scenario.host, { + projectId: scenario.projectId, + worktreePath: scenario.repo.repoPath, + branch: "feature/x", + }); + + const result = await scenario.host.trpc.pullRequests.getByWorkspaces.query({ + workspaceIds: [workspaceId], + }); + expect(result.workspaces).toHaveLength(1); + expect(result.workspaces[0].workspaceId).toBe(workspaceId); + expect(result.workspaces[0].pullRequest).toBeNull(); + }); + + test("getByWorkspaces hydrates linked pull request fields", async () => { + const { id: pullRequestId } = seedPullRequest(scenario.host, { + projectId: scenario.projectId, + prNumber: 42, + title: "do the thing", + headBranch: "feature/x", + }); + const { id: workspaceId } = seedWorkspace(scenario.host, { + projectId: scenario.projectId, + worktreePath: scenario.repo.repoPath, + branch: "feature/x", + pullRequestId, + }); + + const result = await scenario.host.trpc.pullRequests.getByWorkspaces.query({ + workspaceIds: [workspaceId], + }); + expect(result.workspaces[0].pullRequest).toMatchObject({ + number: 42, + title: "do the thing", + url: "https://github.com/octocat/hello/pull/42", + }); + }); + + test("refreshByWorkspaces is a no-op for empty input", async () => { + const result = + await scenario.host.trpc.pullRequests.refreshByWorkspaces.mutate({ + workspaceIds: [], + }); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/packages/host-service/test/integration/smoke.integration.test.ts b/packages/host-service/test/integration/smoke.integration.test.ts new file mode 100644 index 00000000000..b43c241ad9a --- /dev/null +++ b/packages/host-service/test/integration/smoke.integration.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("host-service smoke", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("health.check returns ok without auth", async () => { + const result = await host.unauthenticatedTrpc.health.check.query(); + expect(result).toEqual({ status: "ok" }); + }); + + test("health.check returns ok with auth", async () => { + const result = await host.trpc.health.check.query(); + expect(result).toEqual({ status: "ok" }); + }); + + test("protected procedure rejects requests without bearer token", async () => { + await expect( + host.unauthenticatedTrpc.host.info.query(), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("host.info round-trips through fake cloud api", async () => { + const orgId = "00000000-0000-0000-0000-0000000000aa"; + host = await replaceHost(host, { + organizationId: orgId, + apiOverrides: { + "organization.getByIdFromJwt.query": (input) => { + expect(input).toEqual({ id: orgId }); + return { id: orgId, name: "Test Org", slug: "test-org" }; + }, + }, + }); + + const info = await host.trpc.host.info.query(); + expect(info.organization).toEqual({ + id: orgId, + name: "Test Org", + slug: "test-org", + }); + expect(info.platform).toEqual(process.platform); + expect(typeof info.uptime).toBe("number"); + expect(host.apiCalls.map((c) => c.path)).toContain( + "organization.getByIdFromJwt.query", + ); + }); + + test("CORS preflight allows configured origin and rejects others", async () => { + const allowed = await host.fetch( + "http://host-service.test/trpc/health.check", + { + method: "OPTIONS", + headers: { + origin: "http://localhost:5173", + "access-control-request-method": "GET", + "access-control-request-headers": "content-type", + }, + }, + ); + expect(allowed.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + + const rejected = await host.fetch( + "http://host-service.test/trpc/health.check", + { + method: "OPTIONS", + headers: { + origin: "http://evil.example", + "access-control-request-method": "GET", + }, + }, + ); + // A misconfigured wildcard `*` would also satisfy `not.toBe("http://evil.example")` + // — assert the header is absent entirely, which is what Hono's CORS + // middleware does for a non-allowlisted origin. + expect(rejected.headers.get("access-control-allow-origin")).toBeNull(); + }); + + test("websocket routes reject unauthenticated upgrade attempts", async () => { + const res = await host.fetch("http://host-service.test/events"); + expect(res.status).toBe(401); + }); +}); + +async function replaceHost( + current: TestHost, + options: Parameters[0], +): Promise { + await current.dispose(); + return createTestHost(options); +} diff --git a/packages/host-service/test/integration/terminal.integration.test.ts b/packages/host-service/test/integration/terminal.integration.test.ts new file mode 100644 index 00000000000..dfa68de19f8 --- /dev/null +++ b/packages/host-service/test/integration/terminal.integration.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { TRPCClientError } from "@trpc/client"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; + +describe("terminal router integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("listSessions returns empty when no sessions exist", async () => { + const result = await scenario.host.trpc.terminal.listSessions.query({ + workspaceId: scenario.workspaceId, + }); + expect(result.sessions).toEqual([]); + }); + + test("killSession throws NOT_FOUND for unknown workspace", async () => { + await expect( + scenario.host.trpc.terminal.killSession.mutate({ + workspaceId: "no-such-ws", + terminalId: randomUUID(), + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("killSession throws NOT_FOUND for unknown terminal", async () => { + await expect( + scenario.host.trpc.terminal.killSession.mutate({ + workspaceId: scenario.workspaceId, + terminalId: randomUUID(), + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("listSessions requires authentication", async () => { + await expect( + scenario.host.unauthenticatedTrpc.terminal.listSessions.query({ + workspaceId: scenario.workspaceId, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts new file mode 100644 index 00000000000..a83b8dadc80 --- /dev/null +++ b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { TRPCClientError } from "@trpc/client"; +import { eq } from "drizzle-orm"; +import { workspaces } from "../../src/db/schema"; +import { cloudFlows, cloudOk } from "../helpers/cloud-fakes"; +import { createTestHost } from "../helpers/createTestHost"; +import { createGitFixture } from "../helpers/git-fixture"; +import { + createBasicScenario, + createFeatureWorktreeScenario, + type FeatureWorktreeScenario, +} from "../helpers/scenarios"; +import { seedProject, seedWorkspace } from "../helpers/seed"; + +describe("workspaceCleanup.destroy integration", () => { + let scenario: FeatureWorktreeScenario; + + beforeEach(async () => { + scenario = await createFeatureWorktreeScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceDeleteOk() }, + }); + }); + + afterEach(async () => { + await scenario.dispose(); + }); + + test("rejects deleting a main workspace (worktreePath === repoPath)", async () => { + // Use the main workspace (id), not the feature one — that's the row + // whose worktreePath equals the project's repoPath. + await expect( + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.workspaceId, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("rejects deleting a workspace flagged as main by cloud", async () => { + // Different scenario: cloud says type=main even though the path + // doesn't match repoPath. Build a fresh host with that mock. + await scenario.dispose(); + const host = await createTestHost({ + apiOverrides: { + "v2Workspace.getFromHost.query": cloudOk.workspaceGetFromHost({ + type: "main", + }), + }, + }); + const repo = await createGitFixture(); + const { id: projectId } = seedProject(host, { repoPath: repo.repoPath }); + const worktreePath = join(repo.repoPath, ".worktrees", "feature-cleanup"); + await repo.git.raw([ + "worktree", + "add", + "-b", + "feature/cleanup", + worktreePath, + ]); + const { id: workspaceId } = seedWorkspace(host, { + projectId, + worktreePath, + branch: "feature/cleanup", + }); + + try { + await expect( + host.trpc.workspaceCleanup.destroy.mutate({ workspaceId }), + ).rejects.toBeInstanceOf(TRPCClientError); + } finally { + await host.dispose(); + repo.dispose(); + } + }); + + test("blocks on dirty worktree with CONFLICT (no force)", async () => { + writeFileSync(join(scenario.worktreePath, "dirty.txt"), "uncommitted"); + + await expect( + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + }), + ).rejects.toThrow(/uncommitted changes/i); + + // Cloud delete should NOT have been called — we're past the dirty check. + expect( + scenario.host.apiCalls.some( + (c) => c.path === "v2Workspace.delete.mutate", + ), + ).toBe(false); + }); + + test("force=true skips preflight and runs cloud delete + db cleanup", async () => { + writeFileSync(join(scenario.worktreePath, "dirty.txt"), "uncommitted"); + + const result = await scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + force: true, + }); + expect(result.success).toBe(true); + expect(result.cloudDeleted).toBe(true); + + const remaining = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, scenario.featureWorkspaceId)) + .all(); + expect(remaining).toHaveLength(0); + expect( + scenario.host.apiCalls.some( + (c) => c.path === "v2Workspace.delete.mutate", + ), + ).toBe(true); + }); + + test("clean worktree destroys without force and removes db row", async () => { + const result = await scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + }); + expect(result.success).toBe(true); + expect(result.cloudDeleted).toBe(true); + + const remaining = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, scenario.featureWorkspaceId)) + .all(); + expect(remaining).toHaveLength(0); + }); + + test("deleteBranch=true also removes the branch after worktree teardown", async () => { + const result = await scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + deleteBranch: true, + }); + expect(result.branchDeleted).toBe(true); + + const branches = await scenario.repo.git.branchLocal(); + expect(branches.all).not.toContain(scenario.branch); + }); + + test("returns success when no local workspace row exists, still calls cloud delete", async () => { + await scenario.dispose(); + const fresh = await createBasicScenario({ + hostOptions: { + apiOverrides: { + "v2Workspace.getFromHost.query": () => null, + "v2Workspace.delete.mutate": cloudOk.workspaceDelete(), + }, + }, + }); + try { + const result = await fresh.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: randomUUID(), + }); + expect(result.success).toBe(true); + expect(result.cloudDeleted).toBe(true); + } finally { + await fresh.dispose(); + } + }); +}); diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts new file mode 100644 index 00000000000..afeca500899 --- /dev/null +++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { TRPCClientError } from "@trpc/client"; +import { eq } from "drizzle-orm"; +import { workspaces } from "../../src/db/schema"; +import { cloudFlows, cloudOk } from "../helpers/cloud-fakes"; +import { + createBasicScenario, + createFeatureWorktreeScenario, + createProjectScenario, +} from "../helpers/scenarios"; + +describe("workspace.create + workspace.delete integration", () => { + let dispose: (() => Promise) | undefined; + + afterEach(async () => { + if (dispose) { + await dispose(); + dispose = undefined; + } + }); + + test("create() adds a worktree, calls cloud, and persists workspace row", async () => { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + const result = await scenario.host.trpc.workspace.create.mutate({ + projectId: scenario.projectId, + name: "new ws", + branch: "feature/new", + }); + + expect(result?.branch).toBe("feature/new"); + const expectedWorktree = join( + scenario.repo.repoPath, + ".worktrees", + "feature/new", + ); + expect(existsSync(expectedWorktree)).toBe(true); + + const persisted = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result?.id ?? "")) + .get(); + expect(persisted?.branch).toBe("feature/new"); + expect(persisted?.worktreePath).toBe(expectedWorktree); + }); + + test("create() rolls back the worktree if cloud v2Workspace.create fails", async () => { + const scenario = await createProjectScenario({ + hostOptions: { + apiOverrides: { + "host.ensure.mutate": cloudOk.hostEnsure(), + "v2Workspace.create.mutate": () => { + throw new Error("cloud-down"); + }, + }, + }, + }); + dispose = scenario.dispose; + + await expect( + scenario.host.trpc.workspace.create.mutate({ + projectId: scenario.projectId, + name: "ws", + branch: "feature/rollback", + }), + ).rejects.toThrow(/cloud-down/); + + const expectedWorktree = join( + scenario.repo.repoPath, + ".worktrees", + "feature/rollback", + ); + expect(existsSync(expectedWorktree)).toBe(false); + + const rows = scenario.host.db.select().from(workspaces).all(); + expect(rows).toHaveLength(0); + }); + + test("delete() rejects deleting a main workspace by path equality", async () => { + const scenario = await createBasicScenario(); + dispose = scenario.dispose; + + await expect( + scenario.host.trpc.workspace.delete.mutate({ id: scenario.workspaceId }), + ).rejects.toThrow(/Main workspaces cannot be deleted/i); + }); + + test("delete() removes the worktree and the local row on success", async () => { + const scenario = await createFeatureWorktreeScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceDeleteOk() }, + }); + dispose = scenario.dispose; + + const result = await scenario.host.trpc.workspace.delete.mutate({ + id: scenario.featureWorkspaceId, + }); + expect(result).toEqual({ success: true }); + + expect(existsSync(scenario.worktreePath)).toBe(false); + const rows = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, scenario.featureWorkspaceId)) + .all(); + expect(rows).toHaveLength(0); + expect( + scenario.host.apiCalls.some( + (c) => c.path === "v2Workspace.delete.mutate", + ), + ).toBe(true); + }); + + test("delete() requires authentication", async () => { + const scenario = await createBasicScenario(); + dispose = scenario.dispose; + + await expect( + scenario.host.unauthenticatedTrpc.workspace.delete.mutate({ + id: randomUUID(), + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); +}); diff --git a/packages/host-service/test/integration/workspace-creation-adopt.integration.test.ts b/packages/host-service/test/integration/workspace-creation-adopt.integration.test.ts new file mode 100644 index 00000000000..dbc341cdd5f --- /dev/null +++ b/packages/host-service/test/integration/workspace-creation-adopt.integration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { workspaces } from "../../src/db/schema"; +import { cloudFlows } from "../helpers/cloud-fakes"; +import { + createBasicScenario, + createProjectScenario, +} from "../helpers/scenarios"; + +describe("workspaceCreation.adopt integration", () => { + let dispose: (() => Promise) | undefined; + + afterEach(async () => { + if (dispose) { + await dispose(); + dispose = undefined; + } + }); + + test("rejects with PROJECT_NOT_SETUP when project isn't in db", async () => { + const scenario = await createBasicScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + // Assert the specific PROJECT_NOT_SETUP cause structure (set by + // `requireLocalProject`'s `projectNotSetupError`) rather than just + // "any throw" — that way an unrelated regression that happens to + // throw doesn't pass this test. + await expect( + scenario.host.trpc.workspaceCreation.adopt.mutate({ + projectId: randomUUID(), + workspaceName: "x", + branch: "feature/x", + }), + ).rejects.toMatchObject({ + data: { code: "PRECONDITION_FAILED" }, + }); + }); + + test("rejects when no managed worktree exists for the branch", async () => { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + await expect( + scenario.host.trpc.workspaceCreation.adopt.mutate({ + projectId: scenario.projectId, + workspaceName: "x", + branch: "feature/missing", + }), + ).rejects.toThrow(/No existing worktree/); + }); + + test("rejects with NOT_FOUND when explicit worktreePath isn't a registered worktree", async () => { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + await expect( + scenario.host.trpc.workspaceCreation.adopt.mutate({ + projectId: scenario.projectId, + workspaceName: "x", + branch: "feature/x", + worktreePath: "/tmp/not-a-real-worktree", + }), + ).rejects.toThrow(/No git worktree registered/); + }); + + test("adopts a worktree at an explicit path, creates cloud row + local row", async () => { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + const worktreePath = join( + scenario.repo.repoPath, + ".worktrees", + "feature-adopt", + ); + await scenario.repo.git.raw([ + "worktree", + "add", + "-b", + "feature/adopt", + worktreePath, + ]); + + const result = await scenario.host.trpc.workspaceCreation.adopt.mutate({ + projectId: scenario.projectId, + workspaceName: "adopted", + branch: "feature/adopt", + worktreePath, + }); + + expect(result.workspace.branch).toBe("feature/adopt"); + expect(result.warnings).toEqual([]); + + const persisted = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get(); + expect(persisted?.worktreePath).toBe(worktreePath); + expect(persisted?.branch).toBe("feature/adopt"); + }); + + test("recordBaseBranch persists `branch..base` in git config", async () => { + const scenario = await createProjectScenario({ + hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() }, + }); + dispose = scenario.dispose; + + const worktreePath = join( + scenario.repo.repoPath, + ".worktrees", + "feature-base", + ); + await scenario.repo.git.raw([ + "worktree", + "add", + "-b", + "feature/base", + worktreePath, + ]); + + await scenario.host.trpc.workspaceCreation.adopt.mutate({ + projectId: scenario.projectId, + workspaceName: "base-test", + branch: "feature/base", + baseBranch: "main", + worktreePath, + }); + + const configured = ( + await scenario.repo.git.raw(["config", "branch.feature/base.base"]) + ).trim(); + expect(configured).toBe("main"); + }); +}); diff --git a/packages/host-service/test/integration/workspace-creation-github.integration.test.ts b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts new file mode 100644 index 00000000000..e08176e159c --- /dev/null +++ b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("workspaceCreation github procedures with mocked Octokit", () => { + let host: TestHost; + const calls: Array<{ method: string; args: unknown }> = []; + + const fakeOctokit = { + issues: { + get: async (args: unknown) => { + calls.push({ method: "issues.get", args }); + const a = args as { issue_number: number }; + return { + data: { + number: a.issue_number, + title: `Issue #${a.issue_number}`, + html_url: `https://github.com/octocat/hello/issues/${a.issue_number}`, + state: "open", + user: { login: "alice" }, + pull_request: undefined, + }, + }; + }, + }, + pulls: { + get: async (args: unknown) => { + calls.push({ method: "pulls.get", args }); + const a = args as { pull_number: number }; + return { + data: { + number: a.pull_number, + title: `PR #${a.pull_number}`, + html_url: `https://github.com/octocat/hello/pull/${a.pull_number}`, + state: "open", + user: { login: "bob" }, + head: { ref: "feature/x" }, + base: { ref: "main" }, + draft: false, + }, + }; + }, + }, + search: { + issuesAndPullRequests: async (args: unknown) => { + calls.push({ method: "search.issuesAndPullRequests", args }); + return { + data: { + items: [ + { + number: 7, + title: "search hit", + html_url: "https://github.com/octocat/hello/issues/7", + state: "open", + user: { login: "carol" }, + pull_request: undefined, + }, + ], + }, + }; + }, + }, + }; + + const projectId = randomUUID(); + + beforeEach(async () => { + calls.length = 0; + host = await createTestHost({ + githubFactory: async () => fakeOctokit, + apiOverrides: { + "v2Project.get.query": () => ({ + id: projectId, + githubRepository: { owner: "octocat", name: "hello" }, + }), + }, + }); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("searchGitHubIssues handles direct #123 lookup via issues.get", async () => { + const result = await host.trpc.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: "#42", + }); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].issueNumber).toBe(42); + expect(calls[0].method).toBe("issues.get"); + expect(calls[0].args).toMatchObject({ + owner: "octocat", + repo: "hello", + issue_number: 42, + }); + }); + + test("searchGitHubIssues falls through to search.issuesAndPullRequests for free-text", async () => { + const result = await host.trpc.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: "fix bug", + }); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].issueNumber).toBe(7); + expect(calls[0].method).toBe("search.issuesAndPullRequests"); + }); + + test("searchGitHubIssues returns repoMismatch for cross-repo URLs", async () => { + const result = await host.trpc.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: "https://github.com/other/repo/issues/1", + }); + expect(result.issues).toEqual([]); + expect(result.repoMismatch).toBe("octocat/hello"); + expect(calls).toHaveLength(0); + }); + + test("searchPullRequests handles direct #123 lookup via pulls.get", async () => { + const result = await host.trpc.workspaceCreation.searchPullRequests.query({ + projectId, + query: "#33", + }); + expect(result.pullRequests).toHaveLength(1); + expect(result.pullRequests[0].prNumber).toBe(33); + expect(calls[0].method).toBe("pulls.get"); + expect(calls[0].args).toMatchObject({ + owner: "octocat", + repo: "hello", + pull_number: 33, + }); + }); + + test("searchPullRequests filters search results to PRs only", async () => { + const result = await host.trpc.workspaceCreation.searchPullRequests.query({ + projectId, + query: "find me", + }); + // Our fake search returns one issue (no `pull_request`), so no PRs. + expect(result.pullRequests).toEqual([]); + expect(calls[0].method).toBe("search.issuesAndPullRequests"); + }); +}); diff --git a/packages/host-service/test/integration/workspace-creation-misc.integration.test.ts b/packages/host-service/test/integration/workspace-creation-misc.integration.test.ts new file mode 100644 index 00000000000..d5af79dff7f --- /dev/null +++ b/packages/host-service/test/integration/workspace-creation-misc.integration.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + clearProgress, + setProgress, +} from "../../src/trpc/router/workspace-creation/shared/progress-store"; +import { createTestHost } from "../helpers/createTestHost"; +import { + type BasicScenario, + createBasicScenario, + createProjectScenario, +} from "../helpers/scenarios"; + +describe("workspaceCreation misc procedures", () => { + let basic: BasicScenario; + + beforeEach(async () => { + basic = await createBasicScenario(); + }); + + afterEach(async () => { + await basic.dispose(); + }); + + test("getContext reports hasLocalRepo=false for unknown project", async () => { + const result = await basic.host.trpc.workspaceCreation.getContext.query({ + projectId: randomUUID(), + }); + expect(result.hasLocalRepo).toBe(false); + expect(result.defaultBranch).toBeNull(); + }); + + test("getContext returns defaultBranch when project exists locally", async () => { + const proj = await createProjectScenario(); + try { + const result = await proj.host.trpc.workspaceCreation.getContext.query({ + projectId: proj.projectId, + }); + expect(result.hasLocalRepo).toBe(true); + expect(result.defaultBranch).toBe("main"); + } finally { + await proj.dispose(); + } + }); + + test("getProgress returns null for unknown pendingId", async () => { + const host = await createTestHost(); + try { + const result = await host.trpc.workspaceCreation.getProgress.query({ + pendingId: "no-such-id", + }); + expect(result).toBeNull(); + } finally { + await host.dispose(); + } + }); + + test("getProgress reflects state set via the in-memory store", async () => { + const pendingId = randomUUID(); + // `progress-store` is a module-level Map, so any test entry has to + // be cleaned up explicitly — otherwise it leaks across suites and + // only `sweepStaleProgress` (every 5 min) clears it. + setProgress(pendingId, "creating_worktree"); + try { + const result = await basic.host.trpc.workspaceCreation.getProgress.query({ + pendingId, + }); + expect(result).not.toBeNull(); + const steps = result?.steps ?? []; + expect(steps.find((s) => s.id === "ensuring_repo")?.status).toBe("done"); + expect(steps.find((s) => s.id === "creating_worktree")?.status).toBe( + "active", + ); + expect(steps.find((s) => s.id === "registering")?.status).toBe("pending"); + } finally { + clearProgress(pendingId); + } + }); + + test("generateBranchName returns null for empty prompts (no AI call)", async () => { + const proj = await createProjectScenario(); + try { + const result = + await proj.host.trpc.workspaceCreation.generateBranchName.mutate({ + projectId: proj.projectId, + prompt: " ", + }); + expect(result.branchName).toBeNull(); + } finally { + await proj.dispose(); + } + }); + + test("generateBranchName returns null when project is unknown", async () => { + const result = + await basic.host.trpc.workspaceCreation.generateBranchName.mutate({ + projectId: randomUUID(), + prompt: "fix the bug", + }); + expect(result.branchName).toBeNull(); + }); +}); diff --git a/packages/host-service/test/integration/workspace-creation-validation.integration.test.ts b/packages/host-service/test/integration/workspace-creation-validation.integration.test.ts new file mode 100644 index 00000000000..0c22abbdc30 --- /dev/null +++ b/packages/host-service/test/integration/workspace-creation-validation.integration.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { TRPCClientError } from "@trpc/client"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("workspaceCreation create/checkout input validation", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("create rejects empty branchName at the procedure layer", async () => { + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + names: { workspaceName: "ws", branchName: "" }, + composer: {}, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("create rejects empty workspaceName at zod boundary", async () => { + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + names: { workspaceName: "", branchName: "feature/x" }, + composer: {}, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("create throws PROJECT_NOT_SETUP when project missing locally", async () => { + await expect( + host.trpc.workspaceCreation.create.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + names: { workspaceName: "ws", branchName: "feature/x" }, + composer: {}, + }), + ).rejects.toThrow(); + }); + + test("checkout requires exactly one of branch or pr (refine guard)", async () => { + // Neither + await expect( + host.trpc.workspaceCreation.checkout.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + workspaceName: "ws", + composer: {}, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + + // Both — also rejected + await expect( + host.trpc.workspaceCreation.checkout.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + workspaceName: "ws", + branch: "feature/x", + pr: { + number: 1, + url: "https://github.com/o/r/pull/1", + title: "t", + headRefName: "h", + baseRefName: "main", + headRepositoryOwner: "o", + isCrossRepository: false, + state: "open", + }, + composer: {}, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("checkout PR with negative number is rejected at zod boundary", async () => { + await expect( + host.trpc.workspaceCreation.checkout.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + workspaceName: "ws", + pr: { + number: -1, + url: "https://github.com/o/r/pull/1", + title: "t", + headRefName: "h", + baseRefName: "main", + headRepositoryOwner: "o", + isCrossRepository: false, + state: "open", + }, + composer: {}, + }), + ).rejects.toBeInstanceOf(TRPCClientError); + }); + + test("checkout throws PROJECT_NOT_SETUP for unknown projectId", async () => { + await expect( + host.trpc.workspaceCreation.checkout.mutate({ + pendingId: randomUUID(), + projectId: randomUUID(), + workspaceName: "ws", + branch: "feature/x", + composer: {}, + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/host-service/test/integration/workspace-creation.integration.test.ts b/packages/host-service/test/integration/workspace-creation.integration.test.ts new file mode 100644 index 00000000000..49b2c3ced79 --- /dev/null +++ b/packages/host-service/test/integration/workspace-creation.integration.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createProjectScenario } from "../helpers/scenarios"; +import { seedWorkspace } from "../helpers/seed"; + +describe("workspaceCreation.searchBranches integration", () => { + let scenario: Awaited>; + + beforeEach(async () => { + scenario = await createProjectScenario(); + }); + + afterEach(async () => { + await scenario.dispose(); + }); + + test("returns empty result when project is unknown", async () => { + const result = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: "no-such-project", + }); + expect(result).toEqual({ + defaultBranch: null, + items: [], + nextCursor: null, + }); + }); + + test("lists local branches sorted with default branch first", async () => { + await scenario.repo.git.checkoutLocalBranch("feature/alpha"); + await scenario.repo.commit("alpha work", { "alpha.txt": "alpha" }); + await scenario.repo.git.checkout("main"); + await scenario.repo.git.checkoutLocalBranch("feature/beta"); + await scenario.repo.commit("beta work", { "beta.txt": "beta" }); + await scenario.repo.git.checkout("main"); + + const result = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: scenario.projectId, + }); + + expect(result.defaultBranch).toBe("main"); + const names = result.items.map((b) => b.name); + expect(names[0]).toBe("main"); + expect(names).toContain("feature/alpha"); + expect(names).toContain("feature/beta"); + const main = result.items.find((b) => b.name === "main"); + expect(main?.isLocal).toBe(true); + expect(main?.isRemote).toBe(false); + expect(main?.hasWorkspace).toBe(false); + }); + + test("filters by query substring (case-insensitive)", async () => { + await scenario.repo.git.checkoutLocalBranch("Feature/Alpha"); + await scenario.repo.commit("a", { "a.txt": "a" }); + await scenario.repo.git.checkout("main"); + await scenario.repo.git.checkoutLocalBranch("bugfix/zeta"); + await scenario.repo.commit("z", { "z.txt": "z" }); + await scenario.repo.git.checkout("main"); + + const result = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: scenario.projectId, + query: "alpha", + }); + expect(result.items.map((b) => b.name)).toEqual(["Feature/Alpha"]); + }); + + test("respects limit and emits a cursor when more pages exist", async () => { + for (let i = 0; i < 5; i++) { + await scenario.repo.git.checkoutLocalBranch(`branch-${i}`); + await scenario.repo.commit(`commit ${i}`, { [`f${i}.txt`]: `${i}` }); + await scenario.repo.git.checkout("main"); + } + + const page1 = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: scenario.projectId, + limit: 2, + }); + expect(page1.items).toHaveLength(2); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: scenario.projectId, + limit: 2, + cursor: page1.nextCursor ?? undefined, + }); + expect(page2.items).toHaveLength(2); + const seen = new Set([ + ...page1.items.map((b) => b.name), + ...page2.items.map((b) => b.name), + ]); + expect(seen.size).toBe(4); + }); + + test("marks branches as having a workspace when a workspace row exists", async () => { + await scenario.repo.git.checkoutLocalBranch("with-workspace"); + await scenario.repo.commit("ws", { "ws.txt": "ws" }); + await scenario.repo.git.checkout("main"); + + seedWorkspace(scenario.host, { + projectId: scenario.projectId, + worktreePath: `${scenario.repo.repoPath}/.worktrees/with-workspace`, + branch: "with-workspace", + }); + + const result = + await scenario.host.trpc.workspaceCreation.searchBranches.query({ + projectId: scenario.projectId, + }); + const branch = result.items.find((b) => b.name === "with-workspace"); + expect(branch?.hasWorkspace).toBe(true); + }); +}); diff --git a/packages/host-service/test/integration/workspace.integration.test.ts b/packages/host-service/test/integration/workspace.integration.test.ts new file mode 100644 index 00000000000..749863d0e6a --- /dev/null +++ b/packages/host-service/test/integration/workspace.integration.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type BasicScenario, createBasicScenario } from "../helpers/scenarios"; + +describe("workspace router integration", () => { + let scenario: BasicScenario; + + beforeEach(async () => { + scenario = await createBasicScenario(); + }); + + afterEach(async () => { + await scenario?.dispose(); + }); + + test("get returns the workspace row", async () => { + const ws = await scenario.host.trpc.workspace.get.query({ + id: scenario.workspaceId, + }); + expect(ws.id).toBe(scenario.workspaceId); + expect(ws.branch).toBe("main"); + }); + + test("get throws NOT_FOUND for missing workspace", async () => { + await expect( + scenario.host.trpc.workspace.get.query({ id: "no-such-id" }), + ).rejects.toMatchObject({ data: { code: "NOT_FOUND" } }); + }); + + test("gitStatus reports clean repo with no changes", async () => { + const status = await scenario.host.trpc.workspace.gitStatus.query({ + id: scenario.workspaceId, + }); + expect(status.workspaceId).toBe(scenario.workspaceId); + expect(status.branch).toBe("main"); + expect(status.isClean).toBe(true); + expect(status.files).toEqual([]); + }); + + test("gitStatus reports modified files when worktree is dirty", async () => { + writeFileSync( + join(scenario.repo.repoPath, "README.md"), + "modified content", + ); + writeFileSync(join(scenario.repo.repoPath, "new.txt"), "new file"); + + const status = await scenario.host.trpc.workspace.gitStatus.query({ + id: scenario.workspaceId, + }); + expect(status.isClean).toBe(false); + const paths = status.files.map((f) => f.path).sort(); + expect(paths).toContain("README.md"); + expect(paths).toContain("new.txt"); + }); + + test("gitStatus throws NOT_FOUND for missing workspace", async () => { + await expect( + scenario.host.trpc.workspace.gitStatus.query({ id: "no-such-id" }), + ).rejects.toMatchObject({ data: { code: "NOT_FOUND" } }); + }); +}); diff --git a/packages/host-service/test/integration/ws-auth.integration.test.ts b/packages/host-service/test/integration/ws-auth.integration.test.ts new file mode 100644 index 00000000000..52e26c9052b --- /dev/null +++ b/packages/host-service/test/integration/ws-auth.integration.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +describe("websocket route auth", () => { + let host: TestHost; + + beforeEach(async () => { + host = await createTestHost(); + }); + + afterEach(async () => { + await host.dispose(); + }); + + test("/events rejects requests without auth header or token", async () => { + const res = await host.fetch("http://host-service.test/events"); + expect(res.status).toBe(401); + }); + + test("/events rejects requests with a wrong token query param", async () => { + const res = await host.fetch("http://host-service.test/events?token=wrong"); + expect(res.status).toBe(401); + }); + + test("/events with a valid token query param passes auth and falls through to the WS-upgrade handler", async () => { + const res = await host.fetch( + `http://host-service.test/events?token=${encodeURIComponent(host.psk)}`, + ); + // Without an `Upgrade: websocket` header Hono's WS handler doesn't + // 101-switch and the route falls through to the default 404. The + // point of this test: auth passed (no 401) AND we hit the WS + // route (no 5xx). Both 404 and 426 signal that — be explicit so a + // future change to a 5xx fails this test instead of silently + // passing. + expect([404, 426]).toContain(res.status); + }); + + test("/terminal/* rejects requests without auth", async () => { + const res = await host.fetch( + "http://host-service.test/terminal/some-id?workspaceId=ws-1", + ); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/host-service/test/pull-requests.test.ts b/packages/host-service/test/pull-requests.test.ts index 206fd050b44..428aadbe031 100644 --- a/packages/host-service/test/pull-requests.test.ts +++ b/packages/host-service/test/pull-requests.test.ts @@ -68,7 +68,9 @@ describe("PullRequestRuntimeManager branch sync", () => { upstreamRepo: null, upstreamBranch: null, }); - expect(refreshProjectMock).toHaveBeenCalledWith("project-1", true); + expect(refreshProjectMock).toHaveBeenCalledWith("project-1", { + bypassCache: true, + }); }); test("logs and skips unexpected HEAD lookup failures", async () => {