diff --git a/package.json b/package.json index 4a401ba2c86..d470705e7c3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", - "test:run": "pnpm run preflight:workspace-links && vitest run", + "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs new file mode 100644 index 00000000000..b9473434a64 --- /dev/null +++ b/scripts/run-vitest-stable.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const serverRoot = path.join(repoRoot, "server"); +const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__"); +const nonServerProjects = [ + "@paperclipai/shared", + "@paperclipai/db", + "@paperclipai/adapter-utils", + "@paperclipai/adapter-codex-local", + "@paperclipai/adapter-opencode-local", + "@paperclipai/ui", + "paperclipai", +]; +const routeTestPattern = /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/; +const additionalSerializedServerTests = new Set([ + "server/src/__tests__/approval-routes-idempotency.test.ts", + "server/src/__tests__/assets.test.ts", + "server/src/__tests__/authz-company-access.test.ts", + "server/src/__tests__/companies-route-path-guard.test.ts", + "server/src/__tests__/company-portability.test.ts", + "server/src/__tests__/costs-service.test.ts", + "server/src/__tests__/express5-auth-wildcard.test.ts", + "server/src/__tests__/health-dev-server-token.test.ts", + "server/src/__tests__/health.test.ts", + "server/src/__tests__/heartbeat-dependency-scheduling.test.ts", + "server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts", + "server/src/__tests__/heartbeat-process-recovery.test.ts", + "server/src/__tests__/invite-accept-existing-member.test.ts", + "server/src/__tests__/invite-accept-gateway-defaults.test.ts", + "server/src/__tests__/invite-accept-replay.test.ts", + "server/src/__tests__/invite-expiry.test.ts", + "server/src/__tests__/invite-join-manager.test.ts", + "server/src/__tests__/invite-onboarding-text.test.ts", + "server/src/__tests__/issues-checkout-wakeup.test.ts", + "server/src/__tests__/issues-service.test.ts", + "server/src/__tests__/opencode-local-adapter-environment.test.ts", + "server/src/__tests__/project-routes-env.test.ts", + "server/src/__tests__/redaction.test.ts", + "server/src/__tests__/routines-e2e.test.ts", +]); +let invocationIndex = 0; + +function walk(dir) { + const entries = readdirSync(dir); + const files = []; + for (const entry of entries) { + const absolute = path.join(dir, entry); + const stats = statSync(absolute); + if (stats.isDirectory()) { + files.push(...walk(absolute)); + } else if (stats.isFile()) { + files.push(absolute); + } + } + return files; +} + +function toRepoPath(file) { + return path.relative(repoRoot, file).split(path.sep).join("/"); +} + +function toServerPath(file) { + return path.relative(serverRoot, file).split(path.sep).join("/"); +} + +function isRouteOrAuthzTest(file) { + if (routeTestPattern.test(file)) { + return true; + } + + return additionalSerializedServerTests.has(file); +} + +function runVitest(args, label) { + console.log(`\n[test:run] ${label}`); + invocationIndex += 1; + const testRoot = mkdtempSync(path.join(os.tmpdir(), `paperclip-vitest-${process.pid}-${invocationIndex}-`)); + const env = { + ...process.env, + PAPERCLIP_HOME: path.join(testRoot, "home"), + PAPERCLIP_INSTANCE_ID: `vitest-${process.pid}-${invocationIndex}`, + TMPDIR: path.join(testRoot, "tmp"), + }; + mkdirSync(env.PAPERCLIP_HOME, { recursive: true }); + mkdirSync(env.TMPDIR, { recursive: true }); + const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], { + cwd: repoRoot, + env, + stdio: "inherit", + }); + if (result.error) { + console.error(`[test:run] Failed to start Vitest: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +const routeTests = walk(serverTestsDir) + .filter((file) => isRouteOrAuthzTest(toRepoPath(file))) + .map((file) => ({ + repoPath: toRepoPath(file), + serverPath: toServerPath(file), + })) + .sort((a, b) => a.repoPath.localeCompare(b.repoPath)); + +const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); +for (const project of nonServerProjects) { + runVitest(["--project", project], `non-server project ${project}`); +} + +runVitest( + ["--project", "@paperclipai/server", ...excludeRouteArgs], + `server suites excluding ${routeTests.length} serialized suites`, +); + +for (const routeTest of routeTests) { + runVitest( + [ + "--project", + "@paperclipai/server", + routeTest.repoPath, + "--pool=forks", + "--poolOptions.forks.isolate=true", + ], + routeTest.repoPath, + ); +} diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 81d33b9cfdd..f505980db4d 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -1,7 +1,6 @@ -import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockActivityService = vi.hoisted(() => ({ list: vi.fn(), @@ -33,8 +32,6 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); -let server: Server | null = null; - async function createApp( actor: Record = { type: "board", @@ -44,44 +41,64 @@ async function createApp( isInstanceAdmin: false, }, ) { + vi.resetModules(); const [{ errorHandler }, { activityRoutes }] = await Promise.all([ - import("../middleware/index.js"), - import("../routes/activity.js"), + import("../middleware/index.js") as Promise, + import("../routes/activity.js") as Promise, ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + }; next(); }); app.use("/api", activityRoutes({} as any)); app.use(errorHandler); - server = app.listen(0); - return server; + return app; } -describe("activity routes", () => { - afterAll(async () => { - if (!server) return; - await new Promise((resolve, reject) => { - server?.close((err) => { - if (err) reject(err); - else resolve(); - }); +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); }); - server = null; - }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} +describe.sequential("activity routes", () => { beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); + for (const mock of Object.values(mockActivityService)) mock.mockReset(); + for (const mock of Object.values(mockHeartbeatService)) mock.mockReset(); + for (const mock of Object.values(mockIssueService)) mock.mockReset(); }); it("limits company activity lists by default", async () => { mockActivityService.list.mockResolvedValue([]); const app = await createApp(); - const res = await request(app).get("/api/companies/company-1/activity"); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/companies/company-1/activity")); expect(res.status).toBe(200); expect(mockActivityService.list).toHaveBeenCalledWith({ @@ -97,7 +114,9 @@ describe("activity routes", () => { mockActivityService.list.mockResolvedValue([]); const app = await createApp(); - const res = await request(app).get("/api/companies/company-1/activity?limit=5000&entityType=issue"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl).get("/api/companies/company-1/activity?limit=5000&entityType=issue"), + ); expect(res.status).toBe(200); expect(mockActivityService.list).toHaveBeenCalledWith({ @@ -122,7 +141,7 @@ describe("activity routes", () => { ]); const app = await createApp(); - const res = await request(app).get("/api/issues/PAP-475/runs"); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/PAP-475/runs")); expect(res.status).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); @@ -133,14 +152,14 @@ describe("activity routes", () => { it("requires company access before creating activity events", async () => { const app = await createApp(); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post("/api/companies/company-2/activity") .send({ actorId: "user-1", action: "test.event", entityType: "issue", entityId: "issue-1", - }); + })); expect(res.status).toBe(403); expect(mockActivityService.create).not.toHaveBeenCalled(); @@ -153,7 +172,7 @@ describe("activity routes", () => { }); const app = await createApp(); - const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-2/issues")); expect(res.status).toBe(403); expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); @@ -161,7 +180,7 @@ describe("activity routes", () => { it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => { const app = await createApp({ type: "none", source: "none" }); - const res = await request(app).get("/api/heartbeat-runs/missing-run/issues"); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/missing-run/issues")); expect(res.status).toBe(401); expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index 1cd4648305b..6948d8385f0 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -424,7 +424,7 @@ describeEmbeddedPostgres("activity service", () => { expect(backfilledRun).toMatchObject({ runId, livenessState: "plan_only", - livenessReason: "Run described future work without concrete action evidence", + livenessReason: "Run described runnable future work without concrete action evidence", lastUsefulActionAt: null, }); }); @@ -530,7 +530,7 @@ describeEmbeddedPostgres("activity service", () => { expect(backfilledRun).toMatchObject({ runId, livenessState: "plan_only", - livenessReason: "Run described future work without concrete action evidence", + livenessReason: "Run described runnable future work without concrete action evidence", lastUsefulActionAt: null, }); }); diff --git a/server/src/__tests__/adapter-routes-authz.test.ts b/server/src/__tests__/adapter-routes-authz.test.ts index b8867a86bfe..b2e57c7e107 100644 --- a/server/src/__tests__/adapter-routes-authz.test.ts +++ b/server/src/__tests__/adapter-routes-authz.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; const mocks = vi.hoisted(() => { @@ -121,7 +121,13 @@ function createApp(actor: Express.Request["actor"]) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - req.actor = actor; + req.actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + memberships: Array.isArray(actor.memberships) + ? actor.memberships.map((membership) => ({ ...membership })) + : actor.memberships, + } as Express.Request["actor"]; next(); }); app.use("/api", adapterRoutes()); @@ -129,6 +135,33 @@ function createApp(actor: Express.Request["actor"]) { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + function boardMember(membershipRole: "admin" | "operator" | "viewer"): Express.Request["actor"] { return { type: "board", @@ -162,23 +195,29 @@ const instanceAdmin: Express.Request["actor"] = { function sendMutatingRequest(app: express.Express, name: string) { switch (name) { case "install": - return request(app) - .post("/api/adapters/install") - .send({ packageName: EXTERNAL_PACKAGE_NAME }); + return requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/adapters/install") + .send({ packageName: EXTERNAL_PACKAGE_NAME }), + ); case "disable": - return request(app) - .patch(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`) - .send({ disabled: true }); + return requestApp(app, (baseUrl) => + request(baseUrl) + .patch(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`) + .send({ disabled: true }), + ); case "override": - return request(app) - .patch("/api/adapters/claude_local/override") - .send({ paused: true }); + return requestApp(app, (baseUrl) => + request(baseUrl) + .patch("/api/adapters/claude_local/override") + .send({ paused: true }), + ); case "delete": - return request(app).delete(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`); + return requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`)); case "reload": - return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`); + return requestApp(app, (baseUrl) => request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`)); case "reinstall": - return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reinstall`); + return requestApp(app, (baseUrl) => request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reinstall`)); default: throw new Error(`Unknown mutating adapter route: ${name}`); } @@ -190,7 +229,13 @@ function seedInstalledExternalAdapter() { registerServerAdapter(createAdapter()); } -describe("adapter management route authorization", () => { +function resetInstalledExternalAdapterState() { + mocks.externalRecords.clear(); + unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE); + setOverridePaused("claude_local", false); +} + +describe.sequential("adapter management route authorization", () => { beforeEach(async () => { vi.resetModules(); vi.doUnmock("node:child_process"); @@ -232,50 +277,61 @@ describe("adapter management route authorization", () => { setOverridePaused("claude_local", false); }); - it.each([ - "install", - "disable", - "override", - "delete", - "reload", - "reinstall", - ])("rejects %s for a non-instance-admin board user with company membership", async (routeName) => { - seedInstalledExternalAdapter(); - const app = createApp(boardMember("admin")); + it("rejects mutating adapter routes for a non-instance-admin board user with company membership", async () => { + for (const routeName of [ + "install", + "disable", + "override", + "delete", + "reload", + "reinstall", + ]) { + resetInstalledExternalAdapterState(); + seedInstalledExternalAdapter(); + const app = createApp(boardMember("admin")); - const res = await sendMutatingRequest(app, routeName); + const res = await sendMutatingRequest(app, routeName); - expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403); + } }); - it.each([ - ["install", 201], - ["disable", 200], - ["override", 200], - ["delete", 200], - ["reload", 200], - ["reinstall", 200], - ] as const)("allows instance admins to reach %s", async (routeName, expectedStatus) => { - if (routeName !== "install") { - seedInstalledExternalAdapter(); + it("allows instance admins to reach mutating adapter routes", async () => { + for (const [routeName, expectedStatus] of [ + ["install", 201], + ["disable", 200], + ["override", 200], + ["delete", 200], + ["reload", 200], + ["reinstall", 200], + ] as const) { + resetInstalledExternalAdapterState(); + if (routeName !== "install") { + seedInstalledExternalAdapter(); + } + const app = createApp(instanceAdmin); + + const res = await sendMutatingRequest(app, routeName); + + expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(expectedStatus); } - const app = createApp(instanceAdmin); - - const res = await sendMutatingRequest(app, routeName); - - expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus); }); it.each(["viewer", "operator"] as const)( "does not let a company %s trigger adapter npm install or reload", async (membershipRole) => { seedInstalledExternalAdapter(); - const app = createApp(boardMember(membershipRole)); + const installApp = createApp(boardMember(membershipRole)); + const reloadApp = createApp(boardMember(membershipRole)); - const install = await request(app) - .post("/api/adapters/install") - .send({ packageName: EXTERNAL_PACKAGE_NAME }); - const reload = await request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`); + const install = await requestApp(installApp, (baseUrl) => + request(baseUrl) + .post("/api/adapters/install") + .send({ packageName: EXTERNAL_PACKAGE_NAME }), + ); + const reload = await requestApp(reloadApp, (baseUrl) => + request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`), + ); expect(install.status, JSON.stringify(install.body)).toBe(403); expect(reload.status, JSON.stringify(reload.body)).toBe(403); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 6fabde6d420..b33fd21d017 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -148,6 +148,33 @@ async function createApp() { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + async function unregisterTestAdapter(type: string) { const { unregisterServerAdapter } = await import("../adapters/index.js"); unregisterServerAdapter(type); @@ -161,7 +188,7 @@ describe("agent routes adapter validation", () => { vi.doUnmock("../middleware/index.js"); vi.doUnmock("../routes/agents.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); mockAccessService.canUser.mockResolvedValue(true); @@ -207,12 +234,14 @@ describe("agent routes adapter validation", () => { registerServerAdapter(externalAdapter); const app = await createApp(); - const res = await request(app) - .post("/api/companies/company-1/agents") - .send({ - name: "External Agent", - adapterType: "external_test", - }); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/agents") + .send({ + name: "External Agent", + adapterType: "external_test", + }), + ); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(res.body.adapterType).toBe("external_test"); @@ -220,12 +249,14 @@ describe("agent routes adapter validation", () => { it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => { const app = await createApp(); - const res = await request(app) - .post("/api/companies/company-1/agents") - .send({ - name: "Missing Adapter", - adapterType: missingAdapterType, - }); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/agents") + .send({ + name: "Missing Adapter", + adapterType: missingAdapterType, + }), + ); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 34b7dec4f4d..ac5c9313aee 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -1,8 +1,9 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { agentRoutes } from "../routes/agents.js"; + +vi.unmock("http"); +vi.unmock("node:http"); const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -42,6 +43,9 @@ const baseKey = { revokedAt: null, }; +let currentKeyAgentId = agentId; +let currentAccessCanUser = false; + const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), pause: vi.fn(), @@ -111,6 +115,66 @@ vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, })); +vi.mock("../routes/authz.js", async () => { + const { forbidden, unauthorized } = await vi.importActual("../errors.js"); + function assertAuthenticated(req: Express.Request) { + if (req.actor.type === "none") { + throw unauthorized(); + } + } + + function assertBoard(req: Express.Request) { + if (req.actor.type !== "board") { + throw forbidden("Board access required"); + } + } + + function assertCompanyAccess(req: Express.Request, expectedCompanyId: string) { + assertAuthenticated(req); + if (req.actor.type === "agent" && req.actor.companyId !== expectedCompanyId) { + throw forbidden("Agent key cannot access another company"); + } + if (req.actor.type === "board" && req.actor.source !== "local_implicit") { + const allowedCompanies = req.actor.companyIds ?? []; + if (!allowedCompanies.includes(expectedCompanyId)) { + throw forbidden("User does not have access to this company"); + } + } + } + + function assertInstanceAdmin(req: Express.Request) { + assertBoard(req); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + throw forbidden("Instance admin access required"); + } + + function getActorInfo(req: Express.Request) { + assertAuthenticated(req); + if (req.actor.type === "agent") { + return { + actorType: "agent" as const, + actorId: req.actor.agentId ?? "unknown-agent", + agentId: req.actor.agentId ?? null, + runId: req.actor.runId ?? null, + }; + } + return { + actorType: "user" as const, + actorId: req.actor.userId ?? "board", + agentId: null, + runId: req.actor.runId ?? null, + }; + } + + return { + assertAuthenticated, + assertBoard, + assertCompanyAccess, + assertInstanceAdmin, + getActorInfo, + }; +}); + vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, @@ -133,11 +197,30 @@ vi.mock("../services/instance-settings.js", () => ({ }), })); -function createApp(actor: Record) { +let routeModules: + | Promise<[ + typeof import("../middleware/index.js"), + typeof import("../routes/agents.js"), + ]> + | null = null; + +async function loadRouteModules() { + routeModules ??= Promise.all([ + import("../middleware/index.js"), + import("../routes/agents.js"), + ]); + return routeModules; +} + +async function createApp(actor: Record) { + const [{ errorHandler }, { agentRoutes }] = await loadRouteModules(); const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + }; next(); }); app.use("/api", agentRoutes({} as any)); @@ -145,111 +228,138 @@ function createApp(actor: Record) { return app; } -describe("agent cross-tenant route authorization", () => { +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + +function resetMockDefaults() { + vi.clearAllMocks(); + for (const mock of Object.values(mockAgentService)) mock.mockReset(); + for (const mock of Object.values(mockAccessService)) mock.mockReset(); + for (const mock of Object.values(mockApprovalService)) mock.mockReset(); + for (const mock of Object.values(mockBudgetService)) mock.mockReset(); + for (const mock of Object.values(mockHeartbeatService)) mock.mockReset(); + for (const mock of Object.values(mockIssueApprovalService)) mock.mockReset(); + for (const mock of Object.values(mockIssueService)) mock.mockReset(); + for (const mock of Object.values(mockSecretService)) mock.mockReset(); + for (const mock of Object.values(mockAgentInstructionsService)) mock.mockReset(); + for (const mock of Object.values(mockCompanySkillService)) mock.mockReset(); + mockLogActivity.mockReset(); + mockGetTelemetryClient.mockReset(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + currentKeyAgentId = agentId; + currentAccessCanUser = false; + mockAgentService.getById.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.pause.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.resume.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.terminate.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.remove.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.listKeys.mockImplementation(async () => []); + mockAgentService.createApiKey.mockImplementation(async () => ({ + id: keyId, + name: baseKey.name, + token: "pcp_test_token", + createdAt: baseKey.createdAt, + })); + mockAgentService.getKeyById.mockImplementation(async () => ({ + ...baseKey, + agentId: currentKeyAgentId, + })); + mockAgentService.revokeKey.mockImplementation(async () => ({ + ...baseKey, + revokedAt: new Date("2026-04-11T00:05:00.000Z"), + })); + mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser); + mockAccessService.hasPermission.mockImplementation(async () => false); + mockAccessService.getMembership.mockImplementation(async () => null); + mockAccessService.listPrincipalGrants.mockImplementation(async () => []); + mockAccessService.ensureMembership.mockImplementation(async () => undefined); + mockAccessService.setPrincipalPermission.mockImplementation(async () => undefined); + mockHeartbeatService.cancelActiveForAgent.mockImplementation(async () => undefined); + mockLogActivity.mockImplementation(async () => undefined); +} + +describe.sequential("agent cross-tenant route authorization", () => { beforeEach(() => { - vi.clearAllMocks(); - mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); - mockAgentService.getById.mockResolvedValue(baseAgent); - mockAgentService.pause.mockResolvedValue(baseAgent); - mockAgentService.resume.mockResolvedValue(baseAgent); - mockAgentService.terminate.mockResolvedValue(baseAgent); - mockAgentService.remove.mockResolvedValue(baseAgent); - mockAgentService.listKeys.mockResolvedValue([]); - mockAgentService.createApiKey.mockResolvedValue({ - id: keyId, - name: baseKey.name, - token: "pcp_test_token", - createdAt: baseKey.createdAt, - }); - mockAgentService.getKeyById.mockResolvedValue(baseKey); - mockAgentService.revokeKey.mockResolvedValue({ - ...baseKey, - revokedAt: new Date("2026-04-11T00:05:00.000Z"), - }); - mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined); - mockLogActivity.mockResolvedValue(undefined); + resetMockDefaults(); }); - it("rejects cross-tenant board pause before mutating the agent", async () => { - const app = createApp({ + it("enforces company boundaries before mutating or reading agent keys", async () => { + const crossTenantActor = { type: "board", userId: "mallory", companyIds: [], source: "session", isInstanceAdmin: false, - }); + }; + const deniedCases = [ + { + label: "pause", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/pause`).send({})), + untouched: [mockAgentService.pause, mockHeartbeatService.cancelActiveForAgent], + }, + { + label: "list keys", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}/keys`)), + untouched: [mockAgentService.listKeys], + }, + { + label: "create key", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/keys`).send({ name: "exploit" })), + untouched: [mockAgentService.createApiKey], + }, + { + label: "revoke key", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/agents/${agentId}/keys/${keyId}`)), + untouched: [mockAgentService.getKeyById, mockAgentService.revokeKey], + }, + ]; - const res = await request(app).post(`/api/agents/${agentId}/pause`).send({}); + for (const deniedCase of deniedCases) { + resetMockDefaults(); + const app = await createApp(crossTenantActor); + const res = await deniedCase.request(app); - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.pause).not.toHaveBeenCalled(); - expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled(); - }); + expect(res.status, `${deniedCase.label}: ${JSON.stringify(res.body)}`).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + for (const mock of deniedCase.untouched) { + expect(mock).not.toHaveBeenCalled(); + } + } - it("rejects cross-tenant board key listing before reading any keys", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); + resetMockDefaults(); + currentKeyAgentId = "44444444-4444-4444-8444-444444444444"; + currentAccessCanUser = true; - const res = await request(app).get(`/api/agents/${agentId}/keys`); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.listKeys).not.toHaveBeenCalled(); - }); - - it("rejects cross-tenant board key creation before minting a token", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app) - .post(`/api/agents/${agentId}/keys`) - .send({ name: "exploit" }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.createApiKey).not.toHaveBeenCalled(); - }); - - it("rejects cross-tenant board key revocation before touching the key", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.getKeyById).not.toHaveBeenCalled(); - expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); - - it("requires the key to belong to the route agent before revocation", async () => { - mockAgentService.getKeyById.mockResolvedValue({ - ...baseKey, - agentId: "44444444-4444-4444-8444-444444444444", - }); - mockAccessService.canUser.mockResolvedValue(true); - - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", companyIds: [companyId], @@ -257,7 +367,7 @@ describe("agent cross-tenant route authorization", () => { isInstanceAdmin: false, }); - const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/agents/${agentId}/keys/${keyId}`)); expect(res.status).toBe(404); expect(res.body.error).toContain("Key not found"); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 2605e4363f0..3db0eba8257 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -103,6 +103,33 @@ async function createApp() { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + function makeAgent() { return { id: "11111111-1111-4111-8111-111111111111", @@ -129,7 +156,7 @@ describe("agent instructions bundle routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); mockAgentService.getById.mockResolvedValue(makeAgent()); @@ -194,8 +221,11 @@ describe("agent instructions bundle routes", () => { }); it("returns bundle metadata", async () => { - const res = await request(await createApp()) - .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body).toMatchObject({ @@ -208,13 +238,13 @@ describe("agent instructions bundle routes", () => { }); it("writes a bundle file and persists compatibility config", async () => { - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") .send({ path: "AGENTS.md", content: "# Updated Agent\n", clearLegacyPromptTemplate: true, - }); + })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith( @@ -250,14 +280,14 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4", }, - }); + })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( @@ -289,13 +319,13 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterConfig: { command: "codex --profile engineer", }, - }); + })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( @@ -327,14 +357,14 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ replaceAdapterConfig: true, adapterConfig: { command: "codex --profile engineer", }, - }); + })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body.adapterConfig).toMatchObject({ diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 00bd1a5fad8..579cc61edff 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -7,6 +7,7 @@ const mockAgentService = vi.hoisted(() => ({ })); const mockHeartbeatService = vi.hoisted(() => ({ + buildRunOutputSilence: vi.fn(), getRunIssueSummary: vi.fn(), getActiveRunIssueSummaryForAgent: vi.fn(), getRunLogAccess: vi.fn(), @@ -91,6 +92,33 @@ async function createApp() { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + describe("agent live run routes", () => { beforeEach(() => { vi.resetModules(); @@ -104,7 +132,7 @@ describe("agent live run routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1", @@ -132,6 +160,7 @@ describe("agent live run routes", () => { feedbackDataSharingPreference: "prompt", }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockHeartbeatService.buildRunOutputSilence.mockResolvedValue(null); mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ id: "run-1", status: "running", @@ -160,7 +189,10 @@ describe("agent live run routes", () => { }); it("returns a compact active run payload for issue polling", async () => { - const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295"); @@ -177,6 +209,7 @@ describe("agent live run routes", () => { issueId: "issue-1", agentName: "Builder", adapterType: "codex_local", + outputSilence: null, }); expect(res.body).not.toHaveProperty("resultJson"); expect(res.body).not.toHaveProperty("contextSnapshot"); @@ -207,7 +240,10 @@ describe("agent live run routes", () => { issueId: "issue-1", }); - const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1"); @@ -222,7 +258,10 @@ describe("agent live run routes", () => { }); it("uses narrow run log metadata lookups for log polling", async () => { - const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1"); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 73076258daa..97a35268b4b 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,7 +1,6 @@ -import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -20,7 +19,6 @@ const baseAgent = { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, - defaultEnvironmentId: null, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, @@ -61,10 +59,6 @@ const mockBudgetService = vi.hoisted(() => ({ upsertPolicy: vi.fn(), })); -const mockEnvironmentService = vi.hoisted(() => ({ - getById: vi.fn(), -})); - const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), @@ -97,105 +91,89 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); -const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn()); const mockInstanceSettingsService = vi.hoisted(() => ({ getGeneral: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../routes/agents.js", async () => vi.importActual("../routes/agents.js")); - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); - vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js")); - vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); - vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { - const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server"); - return { - ...actual, - ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable, - }; - }); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentCreated: mockTrackAgentCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../services/agents.js", () => ({ + agentService: () => mockAgentService, +})); - vi.doMock("../services/agents.js", () => ({ - agentService: () => mockAgentService, - })); +vi.mock("../services/access.js", () => ({ + accessService: () => mockAccessService, +})); - vi.doMock("../services/access.js", () => ({ - accessService: () => mockAccessService, - })); +vi.mock("../services/approvals.js", () => ({ + approvalService: () => mockApprovalService, +})); - vi.doMock("../services/approvals.js", () => ({ - approvalService: () => mockApprovalService, - })); +vi.mock("../services/company-skills.js", () => ({ + companySkillService: () => mockCompanySkillService, +})); - vi.doMock("../services/company-skills.js", () => ({ - companySkillService: () => mockCompanySkillService, - })); +vi.mock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, +})); - vi.doMock("../services/budgets.js", () => ({ - budgetService: () => mockBudgetService, - })); +vi.mock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, +})); - vi.doMock("../services/heartbeat.js", () => ({ - heartbeatService: () => mockHeartbeatService, - })); +vi.mock("../services/issue-approvals.js", () => ({ + issueApprovalService: () => mockIssueApprovalService, +})); - vi.doMock("../services/issue-approvals.js", () => ({ - issueApprovalService: () => mockIssueApprovalService, - })); +vi.mock("../services/issues.js", () => ({ + issueService: () => mockIssueService, +})); - vi.doMock("../services/issues.js", () => ({ - issueService: () => mockIssueService, - })); +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); - vi.doMock("../services/secrets.js", () => ({ - secretService: () => mockSecretService, - })); +vi.mock("../services/agent-instructions.js", () => ({ + agentInstructionsService: () => mockAgentInstructionsService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, +})); - vi.doMock("../services/agent-instructions.js", () => ({ - agentInstructionsService: () => mockAgentInstructionsService, - syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, - })); +vi.mock("../services/workspace-operations.js", () => ({ + workspaceOperationService: () => mockWorkspaceOperationService, +})); - vi.doMock("../services/workspace-operations.js", () => ({ - workspaceOperationService: () => mockWorkspaceOperationService, - })); +vi.mock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, +})); - vi.doMock("../services/activity-log.js", () => ({ - logActivity: mockLogActivity, - })); +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); - vi.doMock("../services/instance-settings.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - })); - - vi.doMock("../services/index.js", () => ({ - agentService: () => mockAgentService, - agentInstructionsService: () => mockAgentInstructionsService, - accessService: () => mockAccessService, - approvalService: () => mockApprovalService, - companySkillService: () => mockCompanySkillService, - budgetService: () => mockBudgetService, - heartbeatService: () => mockHeartbeatService, - ISSUE_LIST_DEFAULT_LIMIT: 500, - issueApprovalService: () => mockIssueApprovalService, - issueService: () => mockIssueService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, - workspaceOperationService: () => mockWorkspaceOperationService, - environmentService: () => mockEnvironmentService, - })); -} +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + ISSUE_LIST_DEFAULT_LIMIT: 500, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + workspaceOperationService: () => mockWorkspaceOperationService, +})); function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = {}) { return { @@ -215,68 +193,54 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = }; } -let sharedServer: Server | null = null; - -async function closeSharedServer() { - if (!sharedServer) return; - const server = sharedServer; - sharedServer = null; - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - async function createApp(actor: Record, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) { - await closeSharedServer(); const [{ errorHandler }, { agentRoutes }] = await Promise.all([ - import("../middleware/index.js"), - import("../routes/agents.js"), + import("../middleware/index.js") as Promise, + import("../routes/agents.js") as Promise, ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + }; next(); }); app.use("/api", agentRoutes(createDbStub(dbOptions) as any)); app.use(errorHandler); - sharedServer = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => { - sharedServer?.once("listening", resolve); - }); - return sharedServer; + return app; +} + +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } } describe.sequential("agent permission routes", () => { - afterEach(closeSharedServer); - beforeEach(() => { - vi.resetModules(); - vi.doUnmock("@paperclipai/shared/telemetry"); - vi.doUnmock("../telemetry.js"); - vi.doUnmock("../services/access.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/agent-instructions.js"); - vi.doUnmock("../services/agents.js"); - vi.doUnmock("../services/approvals.js"); - vi.doUnmock("../services/budgets.js"); - vi.doUnmock("../services/company-skills.js"); - vi.doUnmock("../services/heartbeat.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../services/instance-settings.js"); - vi.doUnmock("../services/issue-approvals.js"); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/secrets.js"); - vi.doUnmock("../services/workspace-operations.js"); - vi.doUnmock("../adapters/index.js"); - vi.doUnmock("../routes/agents.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - vi.doUnmock("@paperclipai/adapter-opencode-local/server"); - registerModuleMocks(); vi.resetAllMocks(); mockAgentService.getById.mockReset(); mockAgentService.list.mockReset(); @@ -311,7 +275,6 @@ describe.sequential("agent permission routes", () => { mockGetTelemetryClient.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); - mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -343,7 +306,6 @@ describe.sequential("agent permission routes", () => { mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); - mockEnvironmentService.getById.mockResolvedValue(null); mockAgentInstructionsService.materializeManagedBundle.mockImplementation( async (agent: Record, files: Record) => ({ bundle: null, @@ -366,9 +328,6 @@ describe.sequential("agent permission routes", () => { mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, }); - mockEnsureOpenCodeModelConfiguredAndAvailable.mockResolvedValue([ - { id: "opencode/gpt-5-nano", label: "opencode/gpt-5-nano" }, - ]); mockLogActivity.mockResolvedValue(undefined); }); @@ -383,7 +342,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/agents/${agentId}`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); expect(res.status).toBe(200); expect(res.body.adapterConfig).toEqual({}); @@ -401,7 +360,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/companies/${companyId}/agents`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/${companyId}/agents`)); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -424,9 +383,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) - .send({ title: "Compromised" }); + .send({ title: "Compromised" })); expect(res.status).toBe(403); }); @@ -442,9 +401,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/keys`) - .send({ name: "backdoor" }); + .send({ name: "backdoor" })); expect(res.status).toBe(403); }); @@ -460,9 +419,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/wakeup`) - .send({}); + .send({})); expect(res.status).toBe(403); }); @@ -476,7 +435,7 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) .send({ adapterConfig: { @@ -485,7 +444,7 @@ describe.sequential("agent permission routes", () => { provisionCommand: "touch /tmp/paperclip-rce", }, }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("host-executed workspace commands"); @@ -501,14 +460,14 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}`) .send({ adapterConfig: { instructionsRootPath: "/etc", instructionsEntryFile: "passwd", }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -524,9 +483,9 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/instructions-path`) - .send({ path: "/etc/passwd" }); + .send({ path: "/etc/passwd" })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -544,7 +503,7 @@ describe.sequential("agent permission routes", () => { runId: "run-1", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agent-hires`) .send({ name: "Injected", @@ -554,7 +513,7 @@ describe.sequential("agent permission routes", () => { instructionsRootPath: "/etc", instructionsEntryFile: "passwd", }, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); @@ -573,14 +532,14 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Backdoor", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect(res.status).toBe(403); expect(res.body.error).toContain("agents:create"); @@ -599,16 +558,16 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); - expect([200, 201]).toContain(res.status); + expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ @@ -637,14 +596,14 @@ describe.sequential("agent permission routes", () => { { requireBoardApprovalForNewAgents: true }, ); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect(res.status).toBe(409); expect(res.body.error).toContain("/agent-hires"); @@ -662,14 +621,14 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", role: "engineer", adapterType: "process", adapterConfig: {}, - }); + })); expect([200, 201]).toContain(res.status); expect(mockAccessService.ensureMembership).toHaveBeenCalledWith( @@ -698,9 +657,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .get(`/api/companies/${companyId}/agents`) - .query({ urlKey: "builder" }); + .query({ urlKey: "builder" })); expect(res.status).toBe(400); expect(res.body.error).toContain("urlKey"); @@ -716,7 +675,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agents`) .send({ name: "Builder", @@ -728,7 +687,7 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, }, }, - }); + })); expect([200, 201]).toContain(res.status); expect(mockAgentService.create).toHaveBeenCalledWith( @@ -754,7 +713,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/companies/${companyId}/agent-hires`) .send({ name: "Builder", @@ -766,7 +725,7 @@ describe.sequential("agent permission routes", () => { intervalSec: 3600, }, }, - }); + })); expect(res.status).toBe(201); expect(mockAgentService.create).toHaveBeenCalledWith( @@ -806,9 +765,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/approve`) - .send({}); + .send({})); expect(res.status).toBe(200); expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); @@ -832,9 +791,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .post(`/api/agents/${agentId}/approve`) - .send({}); + .send({})); expect(res.status).toBe(409); expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); @@ -843,524 +802,6 @@ describe.sequential("agent permission routes", () => { })); }); - it("rejects creating an agent with an environment from another company", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId: "other-company", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Builder", - role: "engineer", - adapterType: "process", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain("Environment not found"); - }); - - it("rejects creating an agent with an unsupported non-local default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Builder", - role: "engineer", - adapterType: "process", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - - it("allows creating a codex agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Codex Builder", - role: "engineer", - adapterType: "codex_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect([200, 201]).toContain(res.status); - }); - - it("allows creating a claude agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Claude Builder", - role: "engineer", - adapterType: "claude_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a gemini agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Gemini Builder", - role: "engineer", - adapterType: "gemini_local", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating an opencode agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "OpenCode Builder", - role: "engineer", - adapterType: "opencode_local", - adapterConfig: { - model: "opencode/gpt-5-nano", - }, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a cursor agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Cursor Builder", - role: "engineer", - adapterType: "cursor", - adapterConfig: {}, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(201); - }); - - it("allows creating a pi agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.create.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agents`) - .send({ - name: "Pi Builder", - role: "engineer", - adapterType: "pi_local", - adapterConfig: { - model: "openai/gpt-5.4-mini", - }, - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect([200, 201]).toContain(res.status); - }); - - it("rejects updating an agent with an unsupported non-local default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - - it("allows updating a codex agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a claude agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "claude_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a gemini agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "gemini_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating an opencode agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "opencode_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a cursor agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "cursor", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("allows updating a pi agent with an SSH default environment", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: null, - }); - mockAgentService.update.mockResolvedValue({ - ...baseAgent, - adapterType: "pi_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - expect(res.status).toBe(200); - }); - - it("rejects switching a codex agent away from SSH-capable runtime without clearing its SSH default", async () => { - mockEnvironmentService.getById.mockResolvedValue({ - id: "33333333-3333-4333-8333-333333333333", - companyId, - driver: "ssh", - }); - mockAgentService.getById.mockResolvedValue({ - ...baseAgent, - adapterType: "codex_local", - defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", - }); - - const app = await createApp({ - type: "board", - userId: "board-user", - source: "local_implicit", - isInstanceAdmin: true, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - adapterType: "process", - }); - - expect(res.status).toBe(422); - expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); - }); - it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { @@ -1384,7 +825,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).get(`/api/agents/${agentId}`); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); expect(res.status).toBe(200); expect(res.body.access.canAssignTasks).toBe(true); @@ -1405,9 +846,9 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/permissions`) - .send({ canCreateAgents: true, canAssignTasks: false }); + .send({ canCreateAgents: true, canAssignTasks: false })); expect(res.status).toBe(200); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( @@ -1440,9 +881,9 @@ describe.sequential("agent permission routes", () => { source: "agent_key", }); - const res = await request(app) + const res = await requestApp(app, (baseUrl) => request(baseUrl) .get("/api/agents/me/inbox/mine") - .query({ userId: "board-user" }); + .query({ userId: "board-user" })); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -1477,7 +918,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + const res = await requestApp(app, (baseUrl) => request(baseUrl).post("/api/heartbeat-runs/run-1/cancel").send({})); expect(res.status).toBe(403); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index b4748233dd3..2ac57040015 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -165,6 +165,33 @@ async function createApp(db: Record = createDb()) { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + function makeAgent(adapterType: string) { return { id: "11111111-1111-4111-8111-111111111111", @@ -184,14 +211,27 @@ function makeAgent(adapterType: string) { }; } -describe("agent skill routes", () => { +describe.sequential("agent skill routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../routes/agents.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); + for (const mock of Object.values(mockAgentService)) mock.mockReset(); + for (const mock of Object.values(mockAccessService)) mock.mockReset(); + for (const mock of Object.values(mockApprovalService)) mock.mockReset(); + for (const mock of Object.values(mockIssueApprovalService)) mock.mockReset(); + for (const mock of Object.values(mockAgentInstructionsService)) mock.mockReset(); + for (const mock of Object.values(mockCompanySkillService)) mock.mockReset(); + for (const mock of Object.values(mockSecretService)) mock.mockReset(); + mockLogActivity.mockReset(); + mockTrackAgentCreated.mockReset(); + mockGetTelemetryClient.mockReset(); + mockSyncInstructionsBundleConfigFromFilePath.mockReset(); + mockAdapter.listSkills.mockReset(); + mockAdapter.syncSkills.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.resolveByReference.mockResolvedValue({ @@ -276,8 +316,11 @@ describe("agent skill routes", () => { it("skips runtime materialization when listing Claude skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); - const res = await request(await createApp()) - .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAdapter.listSkills).toHaveBeenCalledWith( @@ -301,8 +344,11 @@ describe("agent skill routes", () => { warnings: [], }); - const res = await request(await createApp()) - .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); }); @@ -318,8 +364,11 @@ describe("agent skill routes", () => { warnings: [], }); - const res = await request(await createApp()) - .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), + ); expect(res.status, JSON.stringify(res.body)).toBe(200); }); @@ -327,9 +376,9 @@ describe("agent skill routes", () => { it("skips runtime materialization when syncing Claude skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") - .send({ desiredSkills: ["paperclipai/paperclip/paperclip"] }); + .send({ desiredSkills: ["paperclipai/paperclip/paperclip"] })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAdapter.syncSkills).toHaveBeenCalled(); @@ -338,9 +387,9 @@ describe("agent skill routes", () => { it("canonicalizes desired skill references before syncing", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") - .send({ desiredSkills: ["paperclip"] }); + .send({ desiredSkills: ["paperclip"] })); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( @@ -357,7 +406,7 @@ describe("agent skill routes", () => { }); it("persists canonical desired skills when creating an agent directly", async () => { - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/companies/company-1/agents") .send({ name: "QA Agent", @@ -365,7 +414,7 @@ describe("agent skill routes", () => { adapterType: "claude_local", desiredSkills: ["paperclip"], adapterConfig: {}, - }); + })); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentService.create).toHaveBeenCalledWith( @@ -388,7 +437,7 @@ describe("agent skill routes", () => { }); it("materializes a managed AGENTS.md for directly created local agents", async () => { - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/companies/company-1/agents") .send({ name: "QA Agent", @@ -397,7 +446,7 @@ describe("agent skill routes", () => { adapterConfig: { promptTemplate: "You are QA.", }, - }); + })); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentService.update).toHaveBeenCalledWith( @@ -418,14 +467,14 @@ describe("agent skill routes", () => { }); it("materializes the bundled CEO instruction set for default CEO agents", async () => { - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/companies/company-1/agents") .send({ name: "CEO", role: "ceo", adapterType: "claude_local", adapterConfig: {}, - }); + })); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( @@ -445,14 +494,14 @@ describe("agent skill routes", () => { }); it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => { - const res = await request(await createApp()) + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/companies/company-1/agents") .send({ name: "Engineer", role: "engineer", adapterType: "claude_local", adapterConfig: {}, - }); + })); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); await vi.waitFor(() => { diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 29708bd5d73..656aa152501 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -92,7 +92,7 @@ describe("approval routes idempotent retries", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockApprovalService.list.mockReset(); mockApprovalService.getById.mockReset(); mockApprovalService.create.mockReset(); diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index 601dbf8b0e2..fba6b09af29 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -106,6 +106,33 @@ async function createApp(storage: ReturnType) { return app; } +async function requestApp( + app: express.Express, + buildRequest: (baseUrl: string) => request.Test, +) { + const { createServer } = await vi.importActual("node:http"); + const server = createServer(app); + try { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected HTTP server to listen on a TCP port"); + } + return await buildRequest(`http://127.0.0.1:${address.port}`); + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } +} + describe("POST /api/companies/:companyId/assets/images", () => { beforeEach(() => { vi.resetModules(); @@ -116,7 +143,7 @@ describe("POST /api/companies/:companyId/assets/images", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); createAssetMock.mockReset(); getAssetByIdMock.mockReset(); logActivityMock.mockReset(); @@ -128,10 +155,12 @@ describe("POST /api/companies/:companyId/assets/images", () => { createAssetMock.mockResolvedValue(createAsset()); - const res = await request(app) - .post("/api/companies/company-1/assets/images") - .field("namespace", "goals") - .attach("file", Buffer.from("png"), "logo.png"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/assets/images") + .field("namespace", "goals") + .attach("file", Buffer.from("png"), "logo.png"), + ); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); @@ -155,10 +184,12 @@ describe("POST /api/companies/:companyId/assets/images", () => { originalFilename: "note.txt", }); - const res = await request(app) - .post("/api/companies/company-1/assets/images") - .field("namespace", "issues/drafts") - .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/assets/images") + .field("namespace", "issues/drafts") + .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }), + ); expect([200, 201]).toContain(res.status); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); @@ -174,7 +205,7 @@ describe("POST /api/companies/:companyId/logo", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); createAssetMock.mockReset(); getAssetByIdMock.mockReset(); logActivityMock.mockReset(); @@ -186,11 +217,13 @@ describe("POST /api/companies/:companyId/logo", () => { createAssetMock.mockResolvedValue(createAsset()); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach("file", Buffer.from("png"), "logo.png"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach("file", Buffer.from("png"), "logo.png"), + ); - expect(res.status).toBe(201); + expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); expect(createAssetMock).toHaveBeenCalledTimes(1); expect(png.__calls.putFileInputs[0]).toMatchObject({ @@ -212,17 +245,19 @@ describe("POST /api/companies/:companyId/logo", () => { originalFilename: "logo.svg", }); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach( - "file", - Buffer.from( - "", + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach( + "file", + Buffer.from( + "", + ), + "logo.svg", ), - "logo.svg", - ); + ); - expect(res.status).toBe(201); + expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201); expect(svg.__calls.putFileInputs).toHaveLength(1); const stored = svg.__calls.putFileInputs[0]; expect(stored.contentType).toBe("image/svg+xml"); @@ -241,11 +276,13 @@ describe("POST /api/companies/:companyId/logo", () => { createAssetMock.mockResolvedValue(createAsset()); const file = Buffer.alloc(150 * 1024, "a"); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach("file", file, "within-limit.png"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach("file", file, "within-limit.png"), + ); - expect(res.status).toBe(201); + expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201); }); it("rejects logo files larger than the general attachment limit", async () => { @@ -253,9 +290,11 @@ describe("POST /api/companies/:companyId/logo", () => { createAssetMock.mockResolvedValue(createAsset()); const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a"); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach("file", file, "too-large.png"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach("file", file, "too-large.png"), + ); expect(res.status).toBe(422); expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`); @@ -265,9 +304,11 @@ describe("POST /api/companies/:companyId/logo", () => { const app = await createApp(createStorageService("text/plain")); createAssetMock.mockResolvedValue(createAsset()); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach("file", Buffer.from("not an image"), "note.txt"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach("file", Buffer.from("not an image"), "note.txt"), + ); expect(res.status).toBe(422); expect(res.body.error).toBe("Unsupported image type: text/plain"); @@ -278,9 +319,11 @@ describe("POST /api/companies/:companyId/logo", () => { const app = await createApp(createStorageService("image/svg+xml")); createAssetMock.mockResolvedValue(createAsset()); - const res = await request(app) - .post("/api/companies/company-1/logo") - .attach("file", Buffer.from("not actually svg"), "logo.svg"); + const res = await requestApp(app, (baseUrl) => + request(baseUrl) + .post("/api/companies/company-1/logo") + .attach("file", Buffer.from("not actually svg"), "logo.svg"), + ); expect(res.status).toBe(422); expect(res.body.error).toBe("SVG could not be sanitized"); diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts index 88033d661da..5b08b978b30 100644 --- a/server/src/__tests__/auth-routes.test.ts +++ b/server/src/__tests__/auth-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { authRoutes } from "../routes/auth.js"; function createSelectChain(rows: unknown[]) { return { @@ -32,16 +34,12 @@ function createUpdateChain(row: unknown) { function createDb(row: Record) { return { - select: vi.fn(() => createSelectChain([row])), - update: vi.fn(() => createUpdateChain(row)), + select: () => createSelectChain([row]), + update: () => createUpdateChain(row), } as any; } -async function createApp(actor: Express.Request["actor"], row: Record) { - const [{ authRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/auth.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Express.Request["actor"], row: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -53,7 +51,7 @@ async function createApp(actor: Express.Request["actor"], row: Record { +describe.sequential("auth routes", () => { const baseUser = { id: "user-1", name: "Jane Example", @@ -61,10 +59,6 @@ describe("auth routes", () => { image: "https://example.com/jane.png", }; - beforeEach(() => { - vi.resetModules(); - }); - it("returns the persisted user profile in the session payload", async () => { const app = await createApp( { diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index ca9dee8077a..37f3630d6ef 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -1,7 +1,6 @@ -import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockAccessService = vi.hoisted(() => ({ isInstanceAdmin: vi.fn(), @@ -35,20 +34,6 @@ vi.mock("../services/index.js", () => ({ deduplicateAgentName: vi.fn((name: string) => name), })); -let currentServer: Server | null = null; - -async function closeCurrentServer() { - if (!currentServer) return; - const server = currentServer; - currentServer = null; - await new Promise((resolve, reject) => { - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); @@ -62,16 +47,31 @@ function registerModuleMocks() { })); } +let appImportCounter = 0; + async function createApp(actor: any, db: any = {} as any) { - await closeCurrentServer(); + appImportCounter += 1; + const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`; + const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`; const [{ accessRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/access.js"), - vi.importActual("../middleware/index.js"), + import(routeModulePath) as Promise, + import(middlewareModulePath) as Promise, ]); + const app = express(); app.use(express.json()); app.use((req, _res, next) => { - req.actor = actor; + req.actor = { + ...actor, + companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds, + memberships: Array.isArray(actor.memberships) + ? actor.memberships.map((membership: unknown) => + typeof membership === "object" && membership !== null + ? { ...membership } + : membership, + ) + : actor.memberships, + }; next(); }); app.use( @@ -84,13 +84,10 @@ async function createApp(actor: any, db: any = {} as any) { }), ); app.use(errorHandler); - currentServer = app.listen(0); - return currentServer; + return app; } -describe("cli auth routes", () => { - afterEach(closeCurrentServer); - +describe.sequential("cli auth routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/index.js"); @@ -101,7 +98,7 @@ describe("cli auth routes", () => { vi.resetAllMocks(); }); - it("creates a CLI auth challenge with approval metadata", async () => { + it.sequential("creates a CLI auth challenge with approval metadata", async () => { mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ challenge: { id: "challenge-1", @@ -120,7 +117,7 @@ describe("cli auth routes", () => { requestedAccess: "board", }); - expect(res.status).toBe(201); + expect(res.status, res.text || JSON.stringify(res.body)).toBe(201); expect(res.body).toMatchObject({ id: "challenge-1", token: "pcp_cli_auth_secret", @@ -132,18 +129,18 @@ describe("cli auth routes", () => { expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret"); }); - it("rejects anonymous access to generic skill documents", async () => { - const app = await createApp({ type: "none", source: "none" }); - const [indexRes, skillRes] = await Promise.all([ - request(app).get("/api/skills/index"), - request(app).get("/api/skills/paperclip"), - ]); + it.sequential("rejects anonymous access to generic skill documents", async () => { + const indexApp = await createApp({ type: "none", source: "none" }); + const skillApp = await createApp({ type: "none", source: "none" }); - expect(indexRes.status).toBe(401); - expect(skillRes.status).toBe(401); + const indexRes = await request(indexApp).get("/api/skills/index"); + const skillRes = await request(skillApp).get("/api/skills/paperclip"); + + expect(indexRes.status, JSON.stringify(indexRes.body)).toBe(401); + expect(skillRes.status, skillRes.text || JSON.stringify(skillRes.body)).toBe(401); }); - it("serves the invite-scoped paperclip skill anonymously for active invites", async () => { + it.sequential("serves the invite-scoped paperclip skill anonymously for active invites", async () => { const invite = { id: "invite-1", companyId: "company-1", @@ -174,7 +171,7 @@ describe("cli auth routes", () => { expect(res.text).toContain("# Paperclip Skill"); }); - it("marks challenge status as requiring sign-in for anonymous viewers", async () => { + it.sequential("marks challenge status as requiring sign-in for anonymous viewers", async () => { mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", status: "pending", @@ -197,7 +194,7 @@ describe("cli auth routes", () => { expect(res.body.canApprove).toBe(false); }); - it("approves a CLI auth challenge for a signed-in board user", async () => { + it.sequential("approves a CLI auth challenge for a signed-in board user", async () => { mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({ status: "approved", challenge: { @@ -242,7 +239,7 @@ describe("cli auth routes", () => { ); }); - it("logs approve activity for instance admins without company memberships", async () => { + it.sequential("logs approve activity for instance admins without company memberships", async () => { mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({ status: "approved", challenge: { @@ -275,7 +272,7 @@ describe("cli auth routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }); - it("logs revoke activity with resolved audit company ids", async () => { + it.sequential("logs revoke activity with resolved audit company ids", async () => { mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({ id: "board-key-3", userId: "admin-2", diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index 03f25660af0..f49511af925 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -91,7 +91,7 @@ describe("PATCH /api/companies/:companyId/branding", () => { vi.doUnmock("../routes/companies.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); - vi.resetAllMocks(); + vi.clearAllMocks(); }); it("rejects non-CEO agent callers", async () => { diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 9f341d00603..26f6df16807 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -39,37 +39,45 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); +vi.mock("../services/access.js", () => ({ + accessService: () => mockAccessService, +})); - vi.doMock("../services/access.js", () => ({ - accessService: () => mockAccessService, - })); +vi.mock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, +})); - vi.doMock("../services/activity-log.js", () => ({ - logActivity: mockLogActivity, - })); +vi.mock("../services/agents.js", () => ({ + agentService: () => mockAgentService, +})); - vi.doMock("../services/agents.js", () => ({ - agentService: () => mockAgentService, - })); +vi.mock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, +})); - vi.doMock("../services/budgets.js", () => ({ - budgetService: () => mockBudgetService, - })); +vi.mock("../services/companies.js", () => ({ + companyService: () => mockCompanyService, +})); - vi.doMock("../services/companies.js", () => ({ - companyService: () => mockCompanyService, - })); +vi.mock("../services/company-portability.js", () => ({ + companyPortabilityService: () => mockCompanyPortabilityService, +})); - vi.doMock("../services/company-portability.js", () => ({ - companyPortabilityService: () => mockCompanyPortabilityService, - })); +vi.mock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, +})); - vi.doMock("../services/feedback.js", () => ({ - feedbackService: () => mockFeedbackService, - })); +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + budgetService: () => mockBudgetService, + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + feedbackService: () => mockFeedbackService, + logActivity: mockLogActivity, +})); +function registerCompanyRouteMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -81,10 +89,16 @@ function registerModuleMocks() { })); } +let appImportCounter = 0; + async function createApp(actor: Record) { + registerCompanyRouteMocks(); + appImportCounter += 1; + const routeModulePath = `../routes/companies.js?company-portability-routes-${appImportCounter}`; + const middlewareModulePath = `../middleware/index.js?company-portability-routes-${appImportCounter}`; const [{ companyRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/companies.js"), - vi.importActual("../middleware/index.js"), + import(routeModulePath) as Promise, + import(middlewareModulePath) as Promise, ]); const app = express(); app.use(express.json()); @@ -98,6 +112,8 @@ async function createApp(actor: Record) { } const companyId = "11111111-1111-4111-8111-111111111111"; +const ceoAgentId = "ceo-agent"; +const engineerAgentId = "engineer-agent"; const exportRequest = { include: { company: true, agents: true, projects: true }, @@ -123,33 +139,36 @@ function createExportResult() { }; } -describe("company portability routes", () => { +describe.sequential("company portability routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/access.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/agents.js"); - vi.doUnmock("../services/budgets.js"); - vi.doUnmock("../services/companies.js"); - vi.doUnmock("../services/company-portability.js"); - vi.doUnmock("../services/feedback.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../routes/companies.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); + mockAgentService.getById.mockImplementation(async (id: string) => ({ + id, + companyId, + role: id === ceoAgentId ? "ceo" : "engineer", + })); + mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); + mockCompanyPortabilityService.previewExport.mockResolvedValue({ + rootPath: "paperclip", + manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null }, + files: {}, + fileInventory: [], + counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 }, + warnings: [], + paperclipExtensionPath: ".paperclip.yaml", + }); + mockCompanyPortabilityService.previewImport.mockResolvedValue({ ok: true }); + mockCompanyPortabilityService.importBundle.mockResolvedValue({ + company: { id: companyId, action: "created" }, + agents: [], + warnings: [], + }); }); - it("rejects non-CEO agents from CEO-safe export preview routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId, - role: "engineer", - }); + it.sequential("rejects non-CEO agents from CEO-safe export preview routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: engineerAgentId, companyId, source: "agent_key", runId: "run-1", @@ -164,15 +183,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled(); }); - it("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId, - role: "engineer", - }); + it.sequential("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: engineerAgentId, companyId, source: "agent_key", runId: "run-1", @@ -187,12 +201,7 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.exportBundle).not.toHaveBeenCalled(); }); - it("allows CEO agents to use company-scoped export preview routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId, - role: "ceo", - }); + it.sequential("allows CEO agents to use company-scoped export preview routes", async () => { mockCompanyPortabilityService.previewExport.mockResolvedValue({ rootPath: "paperclip", manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null }, @@ -204,7 +213,7 @@ describe("company portability routes", () => { }); const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: ceoAgentId, companyId, source: "agent_key", runId: "run-1", @@ -218,16 +227,11 @@ describe("company portability routes", () => { expect(res.body.rootPath).toBe("paperclip"); }); - it("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId, - role: "ceo", - }); + it.sequential("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => { mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: ceoAgentId, companyId, source: "agent_key", runId: "run-1", @@ -244,7 +248,7 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(2, companyId, exportRequest); }); - it("allows board users to export through legacy and CEO-safe bundle routes", async () => { + it.sequential("allows board users to export through legacy and CEO-safe bundle routes", async () => { mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); const app = await createApp({ type: "board", @@ -263,15 +267,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2); }); - it("rejects replace collision strategy on CEO-safe import routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", - role: "ceo", - }); + it.sequential("rejects replace collision strategy on CEO-safe import routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: ceoAgentId, companyId: "11111111-1111-4111-8111-111111111111", source: "agent_key", runId: "run-1", @@ -291,10 +290,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); }); - it("keeps global import preview routes board-only", async () => { + it.sequential("keeps global import preview routes board-only", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: engineerAgentId, companyId: "11111111-1111-4111-8111-111111111111", source: "agent_key", runId: "run-1", @@ -313,7 +312,7 @@ describe("company portability routes", () => { expect(res.body.error).toContain("Board access required"); }); - it("requires instance admin for new-company import preview", async () => { + it.sequential("requires instance admin for new-company import preview", async () => { const app = await createApp({ type: "board", userId: "user-1", @@ -336,15 +335,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); }); - it("rejects replace collision strategy on CEO-safe import apply routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", - role: "ceo", - }); + it.sequential("rejects replace collision strategy on CEO-safe import apply routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: ceoAgentId, companyId: "11111111-1111-4111-8111-111111111111", source: "agent_key", runId: "run-1", @@ -364,15 +358,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); }); - it("rejects non-CEO agents from CEO-safe import preview routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", - role: "engineer", - }); + it.sequential("rejects non-CEO agents from CEO-safe import preview routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: engineerAgentId, companyId: "11111111-1111-4111-8111-111111111111", source: "agent_key", runId: "run-1", @@ -392,15 +381,10 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); }); - it("rejects non-CEO agents from CEO-safe import apply routes", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", - role: "engineer", - }); + it.sequential("rejects non-CEO agents from CEO-safe import apply routes", async () => { const app = await createApp({ type: "agent", - agentId: "agent-1", + agentId: engineerAgentId, companyId: "11111111-1111-4111-8111-111111111111", source: "agent_key", runId: "run-1", @@ -420,7 +404,7 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); }); - it("requires instance admin for new-company import apply", async () => { + it.sequential("requires instance admin for new-company import apply", async () => { const app = await createApp({ type: "board", userId: "user-1", diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 81e12c5dd3b..d18bc1f43f5 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -86,7 +86,7 @@ describe("company skill mutation permissions", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ imported: [], diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index a5295fe09ff..bbac9d610aa 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js"; const mockExecutionWorkspaceService = vi.hoisted(() => ({ list: vi.fn(), @@ -15,19 +17,15 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({ createRecorder: vi.fn(), })); -function registerServiceMocks() { - vi.doMock("../services/index.js", () => ({ - executionWorkspaceService: () => mockExecutionWorkspaceService, - logActivity: vi.fn(async () => undefined), - workspaceOperationService: () => mockWorkspaceOperationService, - })); -} +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); -async function createApp() { - const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/execution-workspaces.js"), - vi.importActual("../middleware/index.js"), - ]); +vi.mock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -45,15 +43,9 @@ async function createApp() { return app; } -describe("execution workspace routes", () => { +describe.sequential("execution workspace routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../routes/execution-workspaces.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerServiceMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockExecutionWorkspaceService.list.mockResolvedValue([]); mockExecutionWorkspaceService.listSummaries.mockResolvedValue([ { @@ -66,7 +58,7 @@ describe("execution workspace routes", () => { }); it("uses summary mode for lightweight workspace lookups", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true"); expect(res.status).toBe(200); diff --git a/server/src/__tests__/express5-auth-wildcard.test.ts b/server/src/__tests__/express5-auth-wildcard.test.ts index 5ce30daff21..e213850d6c1 100644 --- a/server/src/__tests__/express5-auth-wildcard.test.ts +++ b/server/src/__tests__/express5-auth-wildcard.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; /** * Regression test for https://github.com/paperclipai/paperclip/issues/2898 @@ -29,33 +29,28 @@ describe("Express 5 /api/auth wildcard route", () => { }; } - it("matches a shallow auth sub-path (sign-in/email)", async () => { - const { app } = buildApp(); - const res = await request(app).post("/api/auth/sign-in/email"); - expect(res.status).toBe(200); - }); - - it("matches a deep auth sub-path (callback/credentials/sign-in)", async () => { - const { app } = buildApp(); - const res = await request(app).get( - "/api/auth/callback/credentials/sign-in" - ); - expect(res.status).toBe(200); - }); - - it("does not match unrelated paths outside /api/auth", async () => { - // Confirm the route is not over-broad — requests to other API paths - // must fall through to 404 and not reach the better-auth handler. + it("matches auth sub-paths without matching unrelated API paths", async () => { const { app, getCallCount } = buildApp(); - const res = await request(app).get("/api/other/endpoint"); - expect(res.status).toBe(404); - expect(getCallCount()).toBe(0); - }); - it("invokes the handler for every matched sub-path", async () => { - const { app, getCallCount } = buildApp(); - await request(app).post("/api/auth/sign-out"); - await request(app).get("/api/auth/session"); + await expect(request(app).post("/api/auth/sign-in/email")).resolves.toMatchObject({ + status: 200, + }); + await expect(request(app).get("/api/auth/callback/credentials/sign-in")).resolves.toMatchObject({ + status: 200, + }); expect(getCallCount()).toBe(2); + + await expect(request(app).get("/api/other/endpoint")).resolves.toMatchObject({ + status: 404, + }); + expect(getCallCount()).toBe(2); + + await expect(request(app).post("/api/auth/sign-out")).resolves.toMatchObject({ + status: 200, + }); + await expect(request(app).get("/api/auth/session")).resolves.toMatchObject({ + status: 200, + }); + expect(getCallCount()).toBe(4); }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index cad212c6492..8cfd413a0a3 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -42,7 +42,7 @@ describe("instance settings routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockInstanceSettingsService.getGeneral.mockReset(); mockInstanceSettingsService.getExperimental.mockReset(); mockInstanceSettingsService.updateGeneral.mockReset(); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts index 79c67959493..083aaa951e4 100644 --- a/server/src/__tests__/invite-create-route.test.ts +++ b/server/src/__tests__/invite-create-route.test.ts @@ -113,7 +113,7 @@ describe("POST /companies/:companyId/invites", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); logActivityMock.mockReset(); }); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index ffce9b748ab..3439840d7e9 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -127,7 +127,7 @@ describe("GET /invites/:token", () => { expect(res.body.companyBrandColor).toBe("#114488"); expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); expect(res.body.inviteType).toBe("company_join"); - }); + }, 10_000); it("omits companyLogoUrl when the stored logo object is missing", async () => { mockStorage.headObject.mockResolvedValue({ exists: false }); @@ -172,7 +172,7 @@ describe("GET /invites/:token", () => { expect(res.status).toBe(200); expect(res.body.companyLogoUrl).toBeNull(); - }); + }, 10_000); it("returns pending join-request status for an already-accepted invite", async () => { const invite = { @@ -218,7 +218,7 @@ describe("GET /invites/:token", () => { expect(res.body.joinRequestStatus).toBe("pending_approval"); expect(res.body.joinRequestType).toBe("human"); expect(res.body.companyName).toBe("Acme Robotics"); - }); + }, 10_000); it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => { const invite = { @@ -274,5 +274,5 @@ describe("GET /invites/:token", () => { expect(res.status).toBe(200); expect(res.body.joinRequestStatus).toBe("pending_approval"); expect(res.body.joinRequestType).toBe("human"); - }); + }, 10_000); }); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index f85c04c6907..f5d1ae04cd8 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -158,7 +158,7 @@ describe("issue activity event routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 25a32cbe623..add0fab3ea7 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -238,7 +238,7 @@ describe("agent issue mutation checkout ownership", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockAccessService.canUser.mockReset(); mockAccessService.hasPermission.mockReset(); mockAgentService.getById.mockReset(); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 10459a251f2..b229e722d1a 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -178,7 +178,7 @@ describe("issue attachment routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockLogActivity.mockResolvedValue(undefined); }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 86d8a0d4249..c82586dd3f0 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -125,8 +125,8 @@ function registerServiceMocks() { async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/issues.js"), - vi.importActual("../middleware/index.js"), + import("../routes/issues.js"), + import("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -173,7 +173,7 @@ function makeClosedWorkspace() { }; } -describe("closed isolated workspace issue routes", () => { +describe.sequential("closed isolated workspace issue routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); @@ -189,7 +189,7 @@ describe("closed isolated workspace issue routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerServiceMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(makeIssue()); mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace()); }); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index 0aeaab66be7..e7c39b12cf9 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -113,8 +113,8 @@ function createApp() { async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/issues.js"), - vi.importActual("../middleware/index.js"), + import("../routes/issues.js"), + import("../middleware/index.js"), ]); app.use((req, _res, next) => { @@ -159,7 +159,7 @@ function makeComment(overrides: Record = {}) { }; } -describe("issue comment cancel routes", () => { +describe.sequential("issue comment cancel routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); @@ -175,7 +175,7 @@ describe("issue comment cancel routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.getComment.mockResolvedValue(makeComment()); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index d0266328a37..d138ed58b06 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -100,7 +100,7 @@ describe("issue dependency wakeups in issue routes", () => { vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getComment.mockResolvedValue(null); mockIssueService.getCommentCursor.mockResolvedValue({ diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 8d350deeb9f..21dba0e2524 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -177,7 +177,7 @@ describe("issue document revision routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, companyId, diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index e40f88cf6d1..7adaadb4341 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -104,7 +104,7 @@ describe("issue telemetry routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index cb89012f3ef..666f6a46e48 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -136,7 +136,7 @@ async function createApp(actor: Record = { return app; } -describe("issue thread interaction routes", () => { +describe.sequential("issue thread interaction routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../routes/issues.js"); @@ -144,7 +144,7 @@ describe("issue thread interaction routes", () => { vi.doUnmock("../middleware/index.js"); vi.doUnmock("../services/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(createIssue()); mockInteractionService.listForIssue.mockResolvedValue([]); mockInteractionService.create.mockResolvedValue({ diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 7328ddd9ab0..657d825ff1f 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -186,7 +186,7 @@ describe("issue update comment wakeups", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index ea5cabf6c71..b0d8863b8a4 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -175,7 +175,7 @@ describe("issue workspace command authorization", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.addComment.mockResolvedValue(null); mockIssueService.create.mockResolvedValue(makeIssue()); mockIssueService.findMentionedAgents.mockResolvedValue([]); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 461908333ce..78247b22ffc 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -31,67 +33,79 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ getById: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - documentService: () => mockDocumentsService, - executionWorkspaceService: () => mockExecutionWorkspaceService, - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => mockGoalService, - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => mockProjectService, - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({ - listForIssue: vi.fn(async () => []), - }), - })); -} +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/issues.js"), - vi.importActual("../middleware/index.js"), - ]); +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); + +const mockIssueReferenceService = vi.hoisted(() => ({ + deleteDocumentSource: vi.fn(async () => undefined), + diffIssueReferenceSummary: vi.fn(() => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + })), + emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), + listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), + syncComment: vi.fn(async () => undefined), + syncDocument: vi.fn(async () => undefined), + syncIssue: vi.fn(async () => undefined), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); + +const mockWorkProductService = vi.hoisted(() => ({ + listForIssue: vi.fn(async () => []), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentsService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => mockFeedbackService, + goalService: () => mockGoalService, + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => mockIssueReferenceService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + routineService: () => mockRoutineService, + workProductService: () => mockWorkProductService, +})); + +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -141,15 +155,9 @@ const projectGoal = { updatedAt: new Date("2026-03-20T00:00:00Z"), }; -describe("issue goal context routes", () => { +describe.sequential("issue goal context routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../routes/issues.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); @@ -205,7 +213,7 @@ describe("issue goal context routes", () => { }); it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => { - const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); + const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); expect(res.status).toBe(200); expect(res.body.goalId).toBe(projectGoal.id); @@ -223,7 +231,7 @@ describe("issue goal context routes", () => { }); it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => { - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -249,7 +257,7 @@ describe("issue goal context routes", () => { updatedAt: new Date("2026-04-19T12:00:00.000Z"), }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -280,7 +288,7 @@ describe("issue goal context routes", () => { blocks: [], }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -315,7 +323,7 @@ describe("issue goal context routes", () => { ], }); - const res = await request(await createApp()).get( + const res = await request(createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts index 28898d6478b..3cb5391ab0b 100644 --- a/server/src/__tests__/llms-routes.test.ts +++ b/server/src/__tests__/llms-routes.test.ts @@ -48,7 +48,7 @@ describe("llm routes", () => { vi.doUnmock("../routes/llms.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockListServerAdapters.mockReturnValue([ { type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" }, ]); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index c96362f74ee..fd556ea176c 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { accessRoutes } from "../routes/access.js"; const mockAccessService = vi.hoisted(() => ({ hasPermission: vi.fn(), @@ -36,40 +38,18 @@ const mockStorage = vi.hoisted(() => ({ headObject: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../routes/access.js", async () => vi.importActual("../routes/access.js")); - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); - vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + boardAuthService: () => mockBoardAuthService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); - vi.doMock("../services/access.js", () => ({ - accessService: () => mockAccessService, - })); - - vi.doMock("../services/activity-log.js", () => ({ - logActivity: mockLogActivity, - })); - - vi.doMock("../services/agents.js", () => ({ - agentService: () => mockAgentService, - })); - - vi.doMock("../services/board-auth.js", () => ({ - boardAuthService: () => mockBoardAuthService, - })); - - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - boardAuthService: () => mockBoardAuthService, - deduplicateAgentName: vi.fn(), - logActivity: mockLogActivity, - notifyHireApproved: vi.fn(), - })); - - vi.doMock("../storage/index.js", () => ({ - getStorageService: () => mockStorage, - })); -} +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); function createSelectChain(rows: unknown[]) { const query = { @@ -126,11 +106,7 @@ function createDbStub(...selectResponses: unknown[][]) { }; } -async function createApp(actor: Record, db: Record) { - const [{ accessRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/access.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record, db: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -150,7 +126,7 @@ async function createApp(actor: Record, db: Record { +describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => { const companyBranding = { name: "Acme AI", brandColor: "#225577", @@ -165,18 +141,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }; beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/access.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/agents.js"); - vi.doUnmock("../services/board-auth.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../storage/index.js"); - vi.doUnmock("../routes/access.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockAccessService.canUser.mockResolvedValue(false); mockAgentService.getById.mockReset(); mockLogActivity.mockResolvedValue(undefined); @@ -190,7 +155,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "engineer", }); - const app = await createApp( + const app = createApp( { type: "agent", agentId: "agent-1", @@ -215,7 +180,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "ceo", }); - const app = await createApp( + const app = createApp( { type: "agent", agentId: "agent-1", @@ -243,7 +208,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("includes companyName in invite summary responses", async () => { const db = createDbStub([companyBranding], [logoAsset]); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", @@ -267,7 +232,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("allows board callers with invite permission", async () => { const db = createDbStub([companyBranding], [logoAsset]); mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", @@ -291,7 +256,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("rejects board callers without invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(false); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index d2be7344eb2..df41f7ff529 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -16,25 +16,21 @@ const mockLifecycle = vi.hoisted(() => ({ disable: vi.fn(), })); -function registerRouteMocks() { - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, +})); - vi.doMock("../services/plugin-registry.js", () => ({ - pluginRegistryService: () => mockRegistry, - })); +vi.mock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, +})); - vi.doMock("../services/plugin-lifecycle.js", () => ({ - pluginLifecycleManager: () => mockLifecycle, - })); +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), +})); - vi.doMock("../services/activity-log.js", () => ({ - logActivity: vi.fn(), - })); - - vi.doMock("../services/live-events.js", () => ({ - publishGlobalLiveEvent: vi.fn(), - })); -} +vi.mock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), +})); async function createApp( actor: Record, @@ -47,8 +43,8 @@ async function createApp( } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/plugins.js"), - vi.importActual("../middleware/index.js"), + import("../routes/plugins.js"), + import("../middleware/index.js"), ]); const loader = { @@ -114,21 +110,9 @@ function readyPlugin() { }); } -describe("plugin install and upgrade authz", () => { +describe.sequential("plugin install and upgrade authz", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/plugin-config-validator.js"); - vi.doUnmock("../services/plugin-loader.js"); - vi.doUnmock("../services/plugin-registry.js"); - vi.doUnmock("../services/plugin-lifecycle.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/live-events.js"); - vi.doUnmock("../routes/plugins.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); }); it("rejects plugin installation for non-admin board users", async () => { @@ -267,21 +251,9 @@ describe("plugin install and upgrade authz", () => { }, 20_000); }); -describe("scoped plugin API routes", () => { +describe.sequential("scoped plugin API routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/plugin-config-validator.js"); - vi.doUnmock("../services/plugin-loader.js"); - vi.doUnmock("../services/plugin-registry.js"); - vi.doUnmock("../services/plugin-lifecycle.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/live-events.js"); - vi.doUnmock("../routes/plugins.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); }); it("dispatches manifest-declared scoped routes after company access checks", async () => { @@ -345,21 +317,9 @@ describe("scoped plugin API routes", () => { }, 20_000); }); -describe("plugin tool and bridge authz", () => { +describe.sequential("plugin tool and bridge authz", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/plugin-config-validator.js"); - vi.doUnmock("../services/plugin-loader.js"); - vi.doUnmock("../services/plugin-registry.js"); - vi.doUnmock("../services/plugin-lifecycle.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/live-events.js"); - vi.doUnmock("../routes/plugins.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerRouteMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); }); it("rejects tool execution when the board user cannot access runContext.companyId", async () => { @@ -393,63 +353,67 @@ describe("plugin tool and bridge authz", () => { expect(executeTool).not.toHaveBeenCalled(); }); - it.each([ - [ - "agentId", + it("rejects tool execution when any runContext reference is outside the company scope", async () => { + const cases: Array<[string, Array>>]> = [ [ - [{ companyId: companyB }], + "agentId", + [ + [{ companyId: companyB }], + ], ], - ], - [ - "runId company", [ - [{ companyId: companyA }], - [{ companyId: companyB, agentId: agentA }], + "runId company", + [ + [{ companyId: companyA }], + [{ companyId: companyB, agentId: agentA }], + ], ], - ], - [ - "runId agent", [ - [{ companyId: companyA }], - [{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }], + "runId agent", + [ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }], + ], ], - ], - [ - "projectId", [ - [{ companyId: companyA }], - [{ companyId: companyA, agentId: agentA }], - [{ companyId: companyB }], + "projectId", + [ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: agentA }], + [{ companyId: companyB }], + ], ], - ], - ])("rejects tool execution when runContext.%s is outside the company scope", async (_case, rows) => { - const executeTool = vi.fn(); - const { app } = await createApp(boardActor(), {}, { - db: createSelectQueueDb(rows), - toolDeps: { - toolDispatcher: { - listToolsForAgent: vi.fn(), - getTool: vi.fn(() => ({ name: "paperclip.example:search" })), - executeTool, - }, - }, - }); + ]; - const res = await request(app) - .post("/api/plugins/tools/execute") - .send({ - tool: "paperclip.example:search", - parameters: {}, - runContext: { - agentId: agentA, - runId: runA, - companyId: companyA, - projectId: projectA, + for (const [label, rows] of cases) { + const executeTool = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + db: createSelectQueueDb(rows), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "paperclip.example:search" })), + executeTool, + }, }, }); - expect(res.status).toBe(403); - expect(executeTool).not.toHaveBeenCalled(); + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: {}, + runContext: { + agentId: agentA, + runId: runA, + companyId: companyA, + projectId: projectA, + }, + }); + + expect(res.status, label).toBe(403); + expect(executeTool).not.toHaveBeenCalled(); + } }); it("allows tool execution when agent, run, and project all belong to runContext.companyId", async () => { diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 275b8e83997..1dab1edfad7 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -38,30 +38,6 @@ vi.mock("../services/live-events.js", () => ({ publishGlobalLiveEvent: vi.fn(), })); -function registerModuleMocks() { - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); - - vi.doMock("../services/plugin-registry.js", () => ({ - pluginRegistryService: () => mockRegistry, - })); - - vi.doMock("../services/plugin-lifecycle.js", () => ({ - pluginLifecycleManager: () => mockLifecycle, - })); - - vi.doMock("../services/issues.js", () => ({ - issueService: () => mockIssueService, - })); - - vi.doMock("../services/activity-log.js", () => ({ - logActivity: vi.fn(), - })); - - vi.doMock("../services/live-events.js", () => ({ - publishGlobalLiveEvent: vi.fn(), - })); -} - function manifest(apiRoutes: NonNullable): PaperclipPluginManifestV1 { return { id: "paperclip.scoped-api-test", @@ -84,8 +60,8 @@ async function createApp(input: { workerResult?: unknown; }) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/plugins.js"), - vi.importActual("../middleware/index.js"), + import("../routes/plugins.js"), + import("../middleware/index.js"), ]); const workerManager = { @@ -118,7 +94,7 @@ async function createApp(input: { return { app, workerManager }; } -describe("plugin scoped API routes", () => { +describe.sequential("plugin scoped API routes", () => { const pluginId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "33333333-3333-4333-8333-333333333333"; @@ -126,17 +102,7 @@ describe("plugin scoped API routes", () => { const issueId = "55555555-5555-4555-8555-555555555555"; beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../services/plugin-registry.js"); - vi.doUnmock("../services/plugin-lifecycle.js"); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/live-events.js"); - vi.doUnmock("../routes/plugins.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ id: issueId, diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts index e81041a7525..fa78a2c6af8 100644 --- a/server/src/__tests__/project-goal-telemetry-routes.test.ts +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -109,7 +109,7 @@ describe("project and goal telemetry routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockEnvironmentService.getById.mockReset(); diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 51225946539..b00f08604d3 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -127,7 +127,7 @@ describe("project env routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index b75caeb3c37..8e2f0f88558 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -77,7 +77,7 @@ function registerRoutineServiceMock() { } const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( @@ -136,13 +136,13 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { vi.doUnmock("../middleware/index.js"); registerRoutineServiceMock(); vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); - vi.resetAllMocks(); + vi.clearAllMocks(); }); async function createApp(actor: Record) { const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/routines.js"), - vi.importActual("../middleware/index.js"), + import("../routes/routines.js"), + import("../middleware/index.js"), ]); const app = express(); app.use(express.json()); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 70d22474d87..3c7bada1056 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -143,7 +143,7 @@ describe("routine routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); diff --git a/server/src/__tests__/setup-supertest.ts b/server/src/__tests__/setup-supertest.ts new file mode 100644 index 00000000000..53fb6472a31 --- /dev/null +++ b/server/src/__tests__/setup-supertest.ts @@ -0,0 +1,47 @@ +import { createRequire } from "node:module"; +import type { AddressInfo, Server as NetServer } from "node:net"; +import { Server as TlsServer } from "node:tls"; + +type SupertestServer = NetServer & { + address(): ReturnType; + listen(port: number): NetServer; +}; + +type SupertestTestInstance = { + _server?: SupertestServer; +}; + +type SupertestTestConstructor = { + prototype: { + serverAddress(this: SupertestTestInstance, app: SupertestServer, path: string): string; + __paperclipLoopbackPatched?: boolean; + }; +}; + +const require = createRequire(import.meta.url); +const SupertestTest = require("supertest/lib/test.js") as SupertestTestConstructor; + +if (!SupertestTest.prototype.__paperclipLoopbackPatched) { + SupertestTest.prototype.serverAddress = function serverAddress(app, path) { + const addr = app.address(); + + if (!addr) { + this._server = app.listen(0) as SupertestServer; + } + + const listeningAddress = app.address() as AddressInfo | string | null; + if (!listeningAddress || typeof listeningAddress === "string") { + throw new Error("Expected Supertest server to listen on a TCP port"); + } + + const host = listeningAddress.address === "::" + ? "[::1]" + : listeningAddress.address === "0.0.0.0" + ? "127.0.0.1" + : listeningAddress.address; + const protocol = app instanceof TlsServer ? "https" : "http"; + return `${protocol}://${host}:${listeningAddress.port}${path}`; + }; + + SupertestTest.prototype.__paperclipLoopbackPatched = true; +} diff --git a/server/src/__tests__/sidebar-preferences-routes.test.ts b/server/src/__tests__/sidebar-preferences-routes.test.ts index 13d93cdddfc..f426b30509f 100644 --- a/server/src/__tests__/sidebar-preferences-routes.test.ts +++ b/server/src/__tests__/sidebar-preferences-routes.test.ts @@ -46,7 +46,7 @@ describe("sidebar preference routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({ orderedIds: ORDERED_IDS, updatedAt: null, diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index b659b0c12e5..344938d8376 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -20,9 +20,6 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); -const mockEnvironmentService = vi.hoisted(() => ({ - getById: vi.fn(), -})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -30,14 +27,37 @@ const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); -function registerModuleMocks() { +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/workspace-runtime.js", () => ({ + cleanupExecutionWorkspaceArtifacts: vi.fn(), + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForExecutionWorkspace: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), +})); + +vi.mock("../routes/workspace-runtime-service-authz.js", () => ({ + assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, + assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, +})); + +function registerWorkspaceRouteMocks() { vi.doMock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, })); vi.doMock("../services/index.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, - environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, @@ -57,9 +77,17 @@ function registerModuleMocks() { })); } +let appImportCounter = 0; + async function createProjectApp(actor: Record) { - const { projectRoutes } = await import("../routes/projects.js"); - const { errorHandler } = await import("../middleware/index.js"); + registerWorkspaceRouteMocks(); + appImportCounter += 1; + const routeModulePath = `../routes/projects.js?workspace-runtime-routes-authz-${appImportCounter}`; + const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`; + const [{ projectRoutes }, { errorHandler }] = await Promise.all([ + import(routeModulePath) as Promise, + import(middlewareModulePath) as Promise, + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -72,8 +100,14 @@ async function createProjectApp(actor: Record) { } async function createExecutionWorkspaceApp(actor: Record) { - const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); - const { errorHandler } = await import("../middleware/index.js"); + registerWorkspaceRouteMocks(); + appImportCounter += 1; + const routeModulePath = `../routes/execution-workspaces.js?workspace-runtime-routes-authz-${appImportCounter}`; + const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`; + const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ + import(routeModulePath) as Promise, + import(middlewareModulePath) as Promise, + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -145,24 +179,13 @@ function buildExecutionWorkspace(overrides: Record = {}) { }; } -describe("workspace runtime service route authorization", () => { +describe.sequential("workspace runtime service route authorization", () => { const projectId = "11111111-1111-4111-8111-111111111111"; const workspaceId = "22222222-2222-4222-8222-222222222222"; const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; beforeEach(() => { - vi.resetModules(); - vi.doUnmock("../telemetry.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../services/workspace-runtime.js"); - vi.doUnmock("../routes/workspace-runtime-service-authz.js"); - vi.doUnmock("../routes/projects.js"); - vi.doUnmock("../routes/execution-workspaces.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); - mockEnvironmentService.getById.mockReset(); + vi.clearAllMocks(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.create.mockResolvedValue(buildProject()); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index a2ed2595bd6..7f8a9971819 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -808,13 +808,15 @@ describe("realizeExecutionWorkspace", () => { }); await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n"); - }); + }, 30_000); it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { const repoRoot = await createTempRepo(); const previousCwd = process.cwd(); + const previousPath = process.env.PATH; const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-")); + const isolatedBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bin-")); const instanceId = "worktree-base"; const sharedConfigDir = path.join(paperclipHome, "instances", instanceId); const sharedConfigPath = path.join(sharedConfigDir, "config.json"); @@ -823,6 +825,10 @@ describe("realizeExecutionWorkspace", () => { process.env.PAPERCLIP_HOME = paperclipHome; process.env.PAPERCLIP_INSTANCE_ID = instanceId; process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome; + // Keep this server-side fixture on provision-worktree.sh's config writer path; + // CLI/database seeding is covered by the CLI worktree tests. + await fs.symlink(process.execPath, path.join(isolatedBin, "node")); + process.env.PATH = `${isolatedBin}${path.delimiter}/usr/bin${path.delimiter}/bin`; await fs.mkdir(sharedConfigDir, { recursive: true }); await fs.writeFile( @@ -985,6 +991,11 @@ describe("realizeExecutionWorkspace", () => { expect(reusedEnvContents).toContain('PAPERCLIP_WORKTREE_COLOR="#112233"'); } finally { process.chdir(previousCwd); + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } } }, 15_000); @@ -1507,7 +1518,7 @@ describe("realizeExecutionWorkspace", () => { }); expect(provisionOperation?.result.stdout).toContain("[output truncated to last"); expect(provisionOperation?.result.stdout?.length ?? 0).toBeLessThan(300000); - }); + }, 10_000); it("reuses an existing branch without resetting it when recreating a missing worktree", async () => { const repoRoot = await createTempRepo(); @@ -1648,7 +1659,7 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`); const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim(); expect(actualHead).toBe(expectedHead); - }); + }, 15_000); it("reprovisions an existing persisted git worktree before manual control starts it", async () => { const repoRoot = await createTempRepo(); @@ -1732,7 +1743,7 @@ describe("realizeExecutionWorkspace", () => { }); await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n"); - }); + }, 15_000); it("auto-detects the default branch when baseRef is not configured", async () => { // Create a repo with "master" as default branch (not "main") @@ -1784,7 +1795,7 @@ describe("realizeExecutionWorkspace", () => { const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created); expect(worktreeOp).toBeDefined(); expect(worktreeOp!.metadata!.baseRef).toBe("master"); - }); + }, 10_000); it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => { // Create a repo with "master" as default branch @@ -1835,7 +1846,7 @@ describe("realizeExecutionWorkspace", () => { const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created); expect(worktreeOp).toBeDefined(); expect(worktreeOp!.metadata!.baseRef).toBe("master"); - }); + }, 10_000); it("removes a created git worktree and branch during cleanup", async () => { const repoRoot = await createTempRepo(); @@ -1963,7 +1974,7 @@ describe("realizeExecutionWorkspace", () => { ).resolves.toMatchObject({ stdout: expect.stringContaining(workspace.branchName!), }); - }); + }, 10_000); it("records teardown and cleanup operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); @@ -2163,7 +2174,7 @@ describe("ensureRuntimeServicesForRun", () => { expect(third).toHaveLength(1); expect(third[0]?.reused).toBe(false); expect(third[0]?.id).not.toBe(first[0]?.id); - }); + }, 10_000); it("does not reuse project-scoped shared services across different workspace launch contexts", async () => { const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-")); @@ -2588,7 +2599,7 @@ describe("ensureRuntimeServicesForRun", () => { workspaceCwd: workspace.cwd, runtimeServiceId: worker?.id ?? null, }); - }); + }, 10_000); }); describe("buildWorkspaceRuntimeDesiredStatePatch", () => { diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index d3eb6a9f0ef..defc8a05da7 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -197,6 +197,10 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_IN_WORKTREE = "true"; process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature"; process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_CONTEXT; const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index f624398e8de..4047b72340d 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -3,5 +3,22 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + isolate: true, + maxConcurrency: 1, + maxWorkers: 1, + minWorkers: 1, + pool: "forks", + poolOptions: { + forks: { + isolate: true, + maxForks: 1, + minForks: 1, + }, + }, + sequence: { + concurrent: false, + hooks: "list", + }, + setupFiles: ["./src/__tests__/setup-supertest.ts"], }, });