Stabilize serialized route tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-24 17:18:19 -05:00
parent 5a0c1979cf
commit cfda3df766
5 changed files with 205 additions and 689 deletions

View File

@@ -5,6 +5,7 @@ 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",
@@ -63,6 +64,10 @@ 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;
@@ -99,10 +104,13 @@ function runVitest(args, label) {
const routeTests = walk(serverTestsDir)
.filter((file) => isRouteOrAuthzTest(toRepoPath(file)))
.map((file) => ({ repoPath: 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.repoPath]);
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]);
for (const project of nonServerProjects) {
runVitest(["--project", project], `non-server project ${project}`);
}

View File

@@ -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,25 +91,13 @@ 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 mockEnvironmentService = vi.hoisted(() => ({}));
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<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
return {
...actual,
ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable,
};
});
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
@@ -219,44 +201,53 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } =
};
}
let sharedServer: Server | null = null;
async function closeSharedServer() {
if (!sharedServer) return;
const server = sharedServer;
sharedServer = null;
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
async function createApp(actor: Record<string, unknown>, 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<typeof import("../middleware/index.js")>,
import("../routes/agents.js") as Promise<typeof import("../routes/agents.js")>,
]);
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<void>((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<typeof import("node:http")>("node:http");
const server = createServer(app);
try {
await new Promise<void>((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<void>((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");
@@ -316,7 +307,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);
@@ -348,7 +338,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<string, unknown>, files: Record<string, string>) => ({
bundle: null,
@@ -371,9 +360,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);
});
@@ -388,7 +374,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({});
@@ -406,7 +392,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([
@@ -429,9 +415,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);
});
@@ -447,9 +433,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);
});
@@ -465,9 +451,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);
});
@@ -481,7 +467,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: {
@@ -490,7 +476,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");
@@ -506,14 +492,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");
@@ -529,9 +515,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");
@@ -549,7 +535,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",
@@ -559,7 +545,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");
@@ -578,14 +564,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");
@@ -604,16 +590,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({
@@ -642,14 +628,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");
@@ -667,14 +653,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(
@@ -703,9 +689,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");
@@ -721,7 +707,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",
@@ -733,7 +719,7 @@ describe.sequential("agent permission routes", () => {
intervalSec: 3600,
},
},
});
}));
expect([200, 201]).toContain(res.status);
expect(mockAgentService.create).toHaveBeenCalledWith(
@@ -759,7 +745,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",
@@ -771,7 +757,7 @@ describe.sequential("agent permission routes", () => {
intervalSec: 3600,
},
},
});
}));
expect(res.status).toBe(201);
expect(mockAgentService.create).toHaveBeenCalledWith(
@@ -811,9 +797,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);
@@ -837,9 +823,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();
@@ -848,524 +834,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([
{
@@ -1389,7 +857,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);
@@ -1410,9 +878,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(
@@ -1445,9 +913,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([
@@ -1482,7 +950,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();

View File

@@ -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(),
@@ -32,72 +34,86 @@ 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,
environmentService: () => ({}),
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(),
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
}
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
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 () => []),
}));
const mockEnvironmentService = vi.hoisted(() => ({}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => mockFeedbackService,
goalService: () => mockGoalService,
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
routineService: () => mockRoutineService,
workProductService: () => mockWorkProductService,
}));
vi.mock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -147,16 +163,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("../services/execution-workspaces.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: [] });
@@ -213,7 +222,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);
@@ -231,7 +240,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",
);
@@ -257,7 +266,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",
);
@@ -288,7 +297,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",
);
@@ -323,7 +332,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",
);

View File

@@ -56,9 +56,37 @@ vi.mock("../routes/workspace-runtime-service-authz.js", () => ({
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
}));
function registerWorkspaceRouteMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
cleanupExecutionWorkspaceArtifacts: vi.fn(),
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForExecutionWorkspace: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
}));
}
let appImportCounter = 0;
async function createProjectApp(actor: Record<string, unknown>) {
registerWorkspaceRouteMocks();
appImportCounter += 1;
const routeModulePath = `../routes/projects.js?workspace-runtime-routes-authz-${appImportCounter}`;
const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`;
@@ -78,6 +106,7 @@ async function createProjectApp(actor: Record<string, unknown>) {
}
async function createExecutionWorkspaceApp(actor: Record<string, unknown>) {
registerWorkspaceRouteMocks();
appImportCounter += 1;
const routeModulePath = `../routes/execution-workspaces.js?workspace-runtime-routes-authz-${appImportCounter}`;
const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`;

View File

@@ -5,6 +5,8 @@ export default defineConfig({
environment: "node",
isolate: true,
maxConcurrency: 1,
maxWorkers: 1,
minWorkers: 1,
pool: "forks",
poolOptions: {
forks: {