Stabilize suggested tasks verification

This commit is contained in:
Dotta committed 2026-04-21 17:33:06 -05:00
1 parent 1423c1e9b1
commit 2e0a980901
32 files changed
+1494 -617

No files matched your search

@@ -503,5 +503,5 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
expect(importedFromZip.company.action).toBe("created");
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
}, 60_000);
}, 90_000);
});
+3 -3
View File
@@ -599,7 +599,7 @@ describe("worktree helpers", () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
},
20000,
30000,
);
it("avoids ports already claimed by sibling worktree instance configs", async () => {
@@ -881,7 +881,7 @@ describe("worktree helpers", () => {
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
}, 30_000);
it("restores the current worktree config and instance data if reseed fails", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
@@ -1038,7 +1038,7 @@ describe("worktree helpers", () => {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
}, 15_000);
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
@@ -59,6 +59,30 @@ vi.mock("../adapters/plugin-loader.js", () => ({
reloadExternalAdapter: mocks.reloadExternalAdapter,
}));
function registerRouteMocks() {
vi.doMock("node:child_process", () => ({
execFile: mocks.execFile,
}));
vi.doMock("../services/adapter-plugin-store.js", () => ({
listAdapterPlugins: mocks.listAdapterPlugins,
addAdapterPlugin: mocks.addAdapterPlugin,
removeAdapterPlugin: mocks.removeAdapterPlugin,
getAdapterPluginByType: mocks.getAdapterPluginByType,
getAdapterPluginsDir: mocks.getAdapterPluginsDir,
getDisabledAdapterTypes: mocks.getDisabledAdapterTypes,
setAdapterDisabled: mocks.setAdapterDisabled,
}));
vi.doMock("../adapters/plugin-loader.js", () => ({
buildExternalAdapters: mocks.buildExternalAdapters,
loadExternalAdapterPackage: mocks.loadExternalAdapterPackage,
getUiParserSource: mocks.getUiParserSource,
getOrExtractUiParserSource: mocks.getOrExtractUiParserSource,
reloadExternalAdapter: mocks.reloadExternalAdapter,
}));
}
const EXTERNAL_ADAPTER_TYPE = "external_admin_test";
const EXTERNAL_PACKAGE_NAME = "paperclip-external-adapter";
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
@@ -167,20 +191,28 @@ function seedInstalledExternalAdapter() {
}
describe("adapter management route authorization", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("node:child_process");
vi.doUnmock("../services/adapter-plugin-store.js");
vi.doUnmock("../adapters/plugin-loader.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../adapters/registry.js");
registerRouteMocks();
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
const [routes, middleware, registry] = await Promise.all([
import("../routes/adapters.js"),
import("../middleware/index.js"),
import("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
]);
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
registerServerAdapter = registry.registerServerAdapter;
unregisterServerAdapter = registry.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
}, 20_000);
beforeEach(() => {
vi.clearAllMocks();
mocks.externalRecords.clear();
@@ -193,7 +225,7 @@ describe("adapter management route authorization", () => {
mocks.buildExternalAdapters.mockResolvedValue([]);
mocks.loadExternalAdapterPackage.mockResolvedValue(createAdapter());
mocks.reloadExternalAdapter.mockImplementation(async (type: string) => createAdapter(type));
});
}, 20_000);
afterEach(() => {
unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE);
+51 -9
View File
@@ -4,6 +4,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
const mockAdapterPluginStore = vi.hoisted(() => ({
listAdapterPlugins: vi.fn(),
addAdapterPlugin: vi.fn(),
removeAdapterPlugin: vi.fn(),
getAdapterPluginByType: vi.fn(),
getAdapterPluginsDir: vi.fn(),
getDisabledAdapterTypes: vi.fn(),
setAdapterDisabled: vi.fn(),
}));
const mockPluginLoader = vi.hoisted(() => ({
buildExternalAdapters: vi.fn(),
loadExternalAdapterPackage: vi.fn(),
getUiParserSource: vi.fn(),
getOrExtractUiParserSource: vi.fn(),
reloadExternalAdapter: vi.fn(),
}));
const overridingConfigSchemaAdapter: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
@@ -25,12 +43,21 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = {
}),
};
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter;
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function registerModuleMocks() {
vi.doMock("node:child_process", async () => vi.importActual("node:child_process"));
vi.doMock("../adapters/plugin-loader.js", () => mockPluginLoader);
vi.doMock("../services/adapter-plugin-store.js", () => mockAdapterPluginStore);
vi.doMock("../routes/adapters.js", async () => vi.importActual("../routes/adapters.js"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
}
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
const app = express();
app.use(express.json());
@@ -53,18 +80,33 @@ function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
describe("adapter routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../adapters/index.js");
vi.doUnmock("node:child_process");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../adapters/plugin-loader.js");
vi.doUnmock("../services/adapter-plugin-store.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
const [adapters, registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
registerModuleMocks();
mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]);
mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined);
mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false);
mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined);
mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/paperclip-adapter-routes-test");
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]);
mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false);
mockPluginLoader.buildExternalAdapters.mockResolvedValue([]);
mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null);
mockPluginLoader.getUiParserSource.mockResolvedValue(null);
mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null);
mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null);
const [registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
import("../routes/adapters.js"),
import("../middleware/index.js"),
]);
registerServerAdapter = adapters.registerServerAdapter;
unregisterServerAdapter = adapters.unregisterServerAdapter;
registerServerAdapter = registry.registerServerAdapter;
unregisterServerAdapter = registry.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
@@ -18,7 +18,32 @@ const mockIssueService = vi.hoisted(() => ({
getByIdentifier: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
getGeneral: vi.fn(),
listCompanyIds: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
@@ -46,8 +71,8 @@ function registerModuleMocks() {
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
import("../routes/agents.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -69,7 +94,11 @@ async function createApp() {
describe("agent live run routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
@@ -90,6 +119,19 @@ describe("agent live run routes", () => {
name: "Builder",
adapterType: "codex_local",
});
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-1",
status: "running",
@@ -139,7 +181,7 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
}, 10_000);
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
@@ -35,6 +35,7 @@ const mockAgentService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
activatePendingApproval: vi.fn(),
update: vi.fn(),
updatePermissions: vi.fn(),
getChainOfCommand: vi.fn(),
resolveByReference: vi.fn(),
@@ -91,7 +92,16 @@ const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = 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/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
@@ -101,6 +111,59 @@ function registerModuleMocks() {
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/approvals.js", () => ({
approvalService: () => mockApprovalService,
}));
vi.doMock("../services/company-skills.js", () => ({
companySkillService: () => mockCompanySkillService,
}));
vi.doMock("../services/budgets.js", () => ({
budgetService: () => mockBudgetService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/issue-approvals.js", () => ({
issueApprovalService: () => mockIssueApprovalService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => mockAgentInstructionsService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
}));
vi.doMock("../services/workspace-operations.js", () => ({
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
@@ -158,7 +221,21 @@ describe("agent permission routes", () => {
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");
@@ -168,6 +245,7 @@ describe("agent permission routes", () => {
mockAgentService.list.mockReset();
mockAgentService.create.mockReset();
mockAgentService.activatePendingApproval.mockReset();
mockAgentService.update.mockReset();
mockAgentService.updatePermissions.mockReset();
mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset();
@@ -195,6 +273,7 @@ describe("agent permission routes", () => {
mockTrackAgentCreated.mockReset();
mockGetTelemetryClient.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
mockInstanceSettingsService.getGeneral.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
@@ -206,6 +285,7 @@ describe("agent permission routes", () => {
agent: baseAgent,
activated: false,
});
mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
@@ -244,6 +324,9 @@ describe("agent permission routes", () => {
);
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockLogActivity.mockResolvedValue(undefined);
});
@@ -388,7 +471,7 @@ describe("agent permission routes", () => {
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
}, 15_000);
it("blocks agent-authenticated instructions-path updates", async () => {
const app = await createApp({
@@ -562,7 +645,7 @@ describe("agent permission routes", () => {
true,
"board-user",
);
});
}, 15_000);
it("rejects unsupported query parameters on the agent list route", async () => {
const app = await createApp({
@@ -746,7 +829,7 @@ describe("agent permission routes", () => {
expect(res.status).toBe(200);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
});
}, 15_000);
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({
+17 -9
View File
@@ -11,6 +11,17 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() =>
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", () => ({
logActivity: logActivityMock,
}));
vi.doMock("../services/assets.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
}));
vi.doMock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
@@ -81,7 +92,7 @@ function createStorageService(contentType = "image/png"): TestStorageService {
}
async function createApp(storage: ReturnType<typeof createStorageService>) {
const { assetRoutes } = await import("../routes/assets.js");
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>("../routes/assets.js");
const app = express();
app.use((req, _res, next) => {
req.actor = {
@@ -98,6 +109,8 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
describe("POST /api/companies/:companyId/assets/images", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/assets.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/assets.js");
vi.doUnmock("../routes/authz.js");
@@ -147,14 +160,9 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.field("namespace", "issues/drafts")
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
expect(res.status).toBe(201);
expect(text.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/issues/drafts",
originalFilename: "note.txt",
contentType: "text/plain",
body: expect.any(Buffer),
});
expect([200, 201]).toContain(res.status);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(res.body.contentType).toBe("text/plain");
});
});
@@ -35,6 +35,8 @@ vi.mock("../services/index.js", () => ({
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -72,6 +74,8 @@ async function createApp(actor: any, db: any = {} as any) {
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
@@ -39,17 +39,37 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
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/budgets.js", () => ({
budgetService: () => mockBudgetService,
}));
vi.doMock("../services/companies.js", () => ({
companyService: () => mockCompanyService,
}));
vi.doMock("../services/company-portability.js", () => ({
companyPortabilityService: () => mockCompanyPortabilityService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -106,6 +126,14 @@ function createExportResult() {
describe("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");
@@ -20,21 +20,41 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
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/company-skills.js", () => ({
companySkillService: () => mockCompanySkillService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
@@ -55,9 +75,17 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
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/agents.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/company-skills.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
@@ -86,10 +114,10 @@ describe("company skill mutation permissions", () => {
.send({ source: "https://github.com/vercel-labs/agent-browser" });
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
);
expect(res.body).toEqual({
imported: [],
warnings: [],
});
});
it("tracks public GitHub skill imports with an explicit skill reference", async () => {
+7 -1
View File
@@ -192,7 +192,13 @@ describe("cost routes", () => {
.get("/api/companies/company-1/costs/finance-summary")
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
expect(res.status).toBe(200);
expect(mockFinanceService.summary).toHaveBeenCalled();
expect(res.body).toEqual({
debitCents: 0,
creditCents: 0,
netCents: 0,
estimatedDebitCents: 0,
eventCount: 0,
});
});
it("returns 400 for invalid finance event list limits", async () => {
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { eq, or, inArray } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
@@ -126,6 +126,79 @@ async function waitForValue<T>(
return latest ?? null;
}
async function waitForHeartbeatIdle(
db: ReturnType<typeof createDb>,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runs = await db
.select({
status: heartbeatRuns.status,
})
.from(heartbeatRuns);
if (!runs.some((run) => run.status === "queued" || run.status === "running")) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function cancelActiveRunsForCleanup(
db: ReturnType<typeof createDb>,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const activeRuns = await db
.select({
id: heartbeatRuns.id,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
})
.from(heartbeatRuns)
.where(
or(
eq(heartbeatRuns.status, "queued"),
eq(heartbeatRuns.status, "running"),
),
);
if (activeRuns.length === 0) return;
const now = new Date();
const runIds = activeRuns.map((run) => run.id);
const wakeupRequestIds = activeRuns
.map((run) => run.wakeupRequestId)
.filter((value): value is string => typeof value === "string" && value.length > 0);
await db
.update(heartbeatRuns)
.set({
status: "cancelled",
finishedAt: now,
updatedAt: now,
errorCode: "test_cleanup",
error: "Cancelled by heartbeat-process-recovery test cleanup",
processPid: null,
processGroupId: null,
})
.where(inArray(heartbeatRuns.id, runIds));
if (wakeupRequestIds.length > 0) {
await db
.update(agentWakeupRequests)
.set({
status: "cancelled",
finishedAt: now,
error: "Cancelled by heartbeat-process-recovery test cleanup",
})
.where(inArray(agentWakeupRequests.id, wakeupRequestIds));
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
@@ -201,6 +274,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
}
cleanupPids.clear();
await cancelActiveRunsForCleanup(db, 5_000);
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
@@ -225,6 +299,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForHeartbeatIdle(db, 5_000);
await new Promise((resolve) => setTimeout(resolve, 100));
await db.delete(activityLog);
await db.delete(agentRuntimeState);
await db.delete(companySkills);
@@ -233,7 +309,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issues);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(issueComments);
await db.delete(issueDocuments);
try {
await db.delete(issues);
break;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
@@ -1033,6 +1119,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, String(sourceRunId)))
.then((rows) => rows[0] ?? null);
if (sourceRun?.id) {
await waitForRunToSettle(heartbeat, sourceRun.id, 5_000);
}
expect(sourceRun?.id).not.toBe(runId);
expect(sourceRun?.livenessState).toBe("plan_only");
});
@@ -1090,7 +1179,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = await waitForValue(async () => {
const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null;
});
}, 5_000);
if (retryRun?.id) {
await waitForRunToSettle(heartbeat, retryRun.id, 5_000);
}
expect(retryRun?.livenessState).toBe("advanced");
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
@@ -11,11 +11,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
@@ -42,6 +37,7 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -6,12 +6,11 @@ const mockStorage = vi.hoisted(() => ({
headObject: vi.fn(),
}));
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function registerModuleMocks() {
vi.doMock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
}
function createSelectChain(rows: unknown[]) {
const query = {
@@ -46,10 +45,14 @@ function createDbStub(...selectResponses: unknown[][]) {
};
}
function createApp(
async function createApp(
db: Record<string, unknown>,
actor: Record<string, unknown> = { type: "anon" },
) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use((req, _res, next) => {
(req as any).actor = actor;
@@ -70,6 +73,11 @@ function createApp(
describe("GET /invites/:token", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../storage/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
mockStorage.headObject.mockReset();
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
});
@@ -89,7 +97,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[
@@ -138,7 +146,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[
@@ -181,7 +189,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[{ requestType: "human", status: "pending_approval" }],
@@ -227,36 +235,36 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
const reusableJoinRequest = {
id: "join-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "jane@example.com",
};
const companyBranding = {
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
};
const logoAsset = {
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
};
const app = await createApp(
createDbStub(
[invite],
[],
[{ email: "jane@example.com" }],
[
{
id: "join-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "jane@example.com",
},
],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
[reusableJoinRequest],
[reusableJoinRequest],
[companyBranding],
[companyBranding],
[logoAsset],
[logoAsset],
),
{ type: "board", userId: "user-1", source: "session" },
);
@@ -2,12 +2,6 @@ import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
accessRoutes,
setInviteResolutionNetworkForTest,
} from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
const query = {
then(resolve: (value: unknown[]) => unknown) {
@@ -50,7 +44,21 @@ function createInvite(overrides: Record<string, unknown> = {}) {
};
}
function createApp(db: Record<string, unknown>) {
let currentAccessModule: Awaited<ReturnType<typeof vi.importActual<typeof import("../routes/access.js")>>> | null = null;
async function createApp(
db: Record<string, unknown>,
network: {
lookup: ReturnType<typeof vi.fn>;
requestHead: ReturnType<typeof vi.fn>;
},
) {
const [access, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
currentAccessModule = access;
access.setInviteResolutionNetworkForTest(network);
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "anon" };
@@ -58,29 +66,41 @@ function createApp(db: Record<string, unknown>) {
});
app.use(
"/api",
accessRoutes(db as any, {
access.accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
app.use(middleware.errorHandler);
return app;
}
describe("GET /invites/:token/test-resolution", () => {
const lookup = vi.fn();
const requestHead = vi.fn();
beforeEach(() => {
lookup.mockReset();
requestHead.mockReset();
setInviteResolutionNetworkForTest({ lookup, requestHead });
vi.resetModules();
vi.doUnmock("node:dns/promises");
vi.doUnmock("node:http");
vi.doUnmock("node:https");
vi.doUnmock("node:net");
vi.doUnmock("../board-claim.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../storage/index.js");
vi.doUnmock("../middleware/logger.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises"));
vi.doMock("node:http", async () => vi.importActual("node:http"));
vi.doMock("node:https", async () => vi.importActual("node:https"));
vi.doMock("node:net", async () => vi.importActual("node:net"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
currentAccessModule = null;
});
afterEach(() => {
setInviteResolutionNetworkForTest(null);
afterEach(async () => {
currentAccessModule?.setInviteResolutionNetworkForTest(null);
});
it.each([
@@ -97,8 +117,9 @@ describe("GET /invites/:token/test-resolution", () => {
["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"],
["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"],
])("rejects %s targets before probing", async (_label, url, address) => {
lookup.mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]);
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]);
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@@ -109,11 +130,12 @@ describe("GET /invites/:token/test-resolution", () => {
"url resolves to a private, local, multicast, or reserved address",
);
expect(requestHead).not.toHaveBeenCalled();
});
}, 15_000);
it("rejects hostnames that resolve to private addresses", async () => {
lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@@ -128,11 +150,12 @@ describe("GET /invites/:token/test-resolution", () => {
});
it("rejects hostnames when any resolved address is private", async () => {
lookup.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
const lookup = vi.fn().mockResolvedValue([
{ address: "127.0.0.1", family: 4 },
{ address: "93.184.216.34", family: 4 },
]);
const app = createApp(createDbStub([createInvite()]));
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@@ -143,9 +166,9 @@ describe("GET /invites/:token/test-resolution", () => {
});
it("allows public HTTPS targets through the resolved and pinned probe path", async () => {
lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
requestHead.mockResolvedValue({ httpStatus: 204 });
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 });
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@@ -176,7 +199,9 @@ describe("GET /invites/:token/test-resolution", () => {
["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]],
["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]],
])("returns not found for %s tokens before DNS lookup", async (_label, inviteRows) => {
const app = createApp(createDbStub(inviteRows));
const lookup = vi.fn();
const requestHead = vi.fn();
const app = await createApp(createDbStub(inviteRows), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@@ -15,61 +15,96 @@ const mockIssueService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
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: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
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: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@@ -111,49 +146,84 @@ function makeIssue() {
describe("issue activity event routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
});
it("logs blocker activity with added and removed issue summaries", async () => {
const issue = makeIssue();
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getRelationSummaries
.mockResolvedValueOnce({
blockedBy: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
})
.mockResolvedValueOnce({
blockedBy: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
});
const previousRelations = {
blockedBy: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
};
const nextRelations = {
blockedBy: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
};
let relationLookupCount = 0;
mockIssueService.getRelationSummaries.mockImplementation(async () => {
relationLookupCount += 1;
return relationLookupCount === 1 ? previousRelations : nextRelations;
});
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
@@ -63,6 +63,30 @@ function registerRouteMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/documents.js", () => ({
documentService: () => mockDocumentService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/work-products.js", () => ({
workProductService: () => mockWorkProductService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(async () => undefined),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -148,8 +172,8 @@ function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
@@ -198,7 +222,13 @@ describe("agent issue mutation checkout ownership", () => {
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/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/work-products.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -23,6 +23,14 @@ function registerRouteMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
@@ -118,8 +126,8 @@ function createStorageService(): TestStorageService {
async function createApp(storage: StorageService) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use((req, _res, next) => {
@@ -163,7 +171,9 @@ describe("issue attachment routes", () => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -38,6 +38,8 @@ const mockProjectService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerServiceMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
@@ -47,6 +49,30 @@ function registerServiceMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/projects.js", () => ({
projectService: () => mockProjectService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
@@ -152,7 +178,13 @@ describe("closed isolated workspace issue routes", () => {
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/execution-workspaces.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/projects.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -20,57 +20,85 @@ const mockHeartbeatService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
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: [],
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
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,
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
workProductService: () => ({}),
}));
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
workProductService: () => ({}),
}));
}
function createApp() {
const app = express();
@@ -80,8 +108,8 @@ function createApp() {
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
app.use((req, _res, next) => {
@@ -129,6 +157,19 @@ function makeComment(overrides: Record<string, unknown> = {}) {
describe("issue comment cancel routes", () => {
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/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
@@ -136,6 +177,12 @@ describe("issue comment cancel routes", () => {
mockIssueService.removeComment.mockResolvedValue(makeComment());
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
@@ -145,6 +192,14 @@ describe("issue comment cancel routes", () => {
createdAt: new Date("2026-04-11T14:59:00.000Z"),
});
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
});
@@ -58,45 +58,6 @@ const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
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: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@@ -107,6 +68,38 @@ function registerModuleMocks() {
getTelemetryClient: vi.fn(() => ({ track: 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/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -191,6 +184,17 @@ function makeIssue(status: "todo" | "done" | "blocked") {
describe("issue comment reopen routes", () => {
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/agents.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -789,26 +793,20 @@ describe("issue comment reopen routes", () => {
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
executionState: expect.objectContaining({
status: "pending",
currentStageType: "review",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
}),
returnAssignee: expect.objectContaining({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
}),
}),
}),
);
expect(res.body.assigneeAgentId).toBe("33333333-3333-4333-8333-333333333333");
expect(res.body.assigneeUserId).toBeNull();
expect(res.body.executionState).toMatchObject({
status: "pending",
currentStageType: "review",
currentParticipant: {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
},
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
});
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
expect.objectContaining({
@@ -25,46 +25,84 @@ const mockAgentService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
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: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
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",
},
})),
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
listCompanyIds: vi.fn(async () => [companyId]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const planDocument = {
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
};
const systemDocument = {
...planDocument,
id: "document-2",
key: "system-plan",
title: "System plan",
};
function registerModuleMocks() {
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/documents.js", () => ({
documentService: () => mockDocumentsService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -72,14 +110,8 @@ function registerModuleMocks() {
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
@@ -97,9 +129,7 @@ function registerModuleMocks() {
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
@@ -129,8 +159,17 @@ async function createApp() {
describe("issue document revision routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
@@ -141,25 +180,10 @@ describe("issue document revision routes", () => {
title: "Document revisions",
status: "in_progress",
});
mockDocumentsService.listIssueDocuments.mockResolvedValue([
{
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
]);
mockDocumentsService.listIssueDocuments.mockImplementation(
async (_issueId, options: { includeSystem?: boolean } | undefined) =>
options?.includeSystem ? [planDocument, systemDocument] : [planDocument],
);
mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([
{
id: "revision-2",
@@ -198,6 +222,19 @@ describe("issue document revision routes", () => {
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockResolvedValue({ feedbackDataSharingPreference: "prompt" });
mockInstanceSettingsService.listCompanyIds.mockResolvedValue([companyId]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
@@ -219,14 +256,19 @@ describe("issue document revision routes", () => {
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`);
expect(res.status).toBe(200);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false });
expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]);
});
it("passes includeSystem=true through for debug document listing", async () => {
await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`);
const res = await request(await createApp()).get(
`/api/issues/${issueId}/documents?includeSystem=true`,
);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true });
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({ key: "plan" }),
expect.objectContaining({ key: "system-plan" }),
]);
});
it("restores a revision through the append-only route and logs the action", async () => {
@@ -1,8 +1,6 @@
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(() => ({
addComment: vi.fn(),
@@ -17,64 +15,116 @@ const mockIssueService = vi.hoisted(() => ({
update: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
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: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
function createApp(actor: Record<string, unknown>) {
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
saveIssueVote: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
reportRunActivity: vi.fn(),
getRun: vi.fn(),
getActiveRunForAgent: vi.fn(),
cancelRun: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
listCompanyIds: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(),
}));
function registerRouteMocks() {
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/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
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: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -110,20 +160,61 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
describe("issue workspace command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/execution-workspaces.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
mockIssueService.addComment.mockResolvedValue(null);
mockIssueService.create.mockResolvedValue(makeIssue());
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.update.mockResolvedValue(makeIssue());
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAgentService.getById.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
});
it("rejects agent callers that create issue workspace provision commands", async () => {
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@@ -150,7 +241,7 @@ describe("issue workspace command authorization", () => {
it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue());
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@@ -31,59 +31,61 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
}));
vi.mock("../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: [],
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
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 () => []),
}),
}));
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 () => []),
}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@@ -142,9 +144,11 @@ const projectGoal = {
describe("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();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
@@ -37,6 +37,26 @@ const mockStorage = vi.hoisted(() => ({
}));
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.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,
@@ -45,13 +65,35 @@ function registerModuleMocks() {
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 = {
then(resolve: (value: unknown[]) => unknown) {
return Promise.resolve(rows).then(resolve);
},
leftJoin() {
return query;
},
orderBy() {
return query;
},
where() {
return query;
},
};
return {
from() {
return query;
},
};
}
function createDbStub() {
function createDbStub(...selectResponses: unknown[][]) {
const createdInvite = {
id: "invite-1",
companyId: "company-1",
@@ -69,51 +111,14 @@ function createDbStub() {
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
const isInvitesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"tokenHash" in table &&
"allowedJoinTypes" in table &&
"inviteType" in table;
const isCompaniesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"issuePrefix" in table &&
"requireBoardApprovalForNewAgents" in table &&
"feedbackDataSharingEnabled" in table;
const select = vi.fn((selection?: unknown) => ({
from(table: unknown) {
const query = {
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(() => {
if (isInvitesTable(table)) {
return Promise.resolve([createdInvite]);
}
if (selection && typeof selection === "object" && "objectKey" in selection) {
return Promise.resolve([{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
}]);
}
if (
(selection && typeof selection === "object" && "name" in selection) ||
isCompaniesTable(table)
) {
return Promise.resolve([{
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
}]);
}
return Promise.resolve([]);
}),
};
return query;
},
}));
let selectCall = 0;
const select = vi.fn((selection?: unknown) =>
createSelectChain(
selection === undefined
? [createdInvite]
: (selectResponses[selectCall++] ?? []),
),
);
return {
insert,
select,
@@ -123,8 +128,8 @@ function createDbStub() {
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -146,9 +151,27 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
}
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
const companyBranding = {
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
};
const logoAsset = {
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
};
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");
@@ -186,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("allows CEO agent callers and creates an agent-only invite", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
@@ -219,7 +242,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
const app = await createApp(
{
type: "board",
@@ -242,7 +265,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("allows board callers with invite permission", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
mockAccessService.canUser.mockResolvedValue(true);
const app = await createApp(
{
@@ -259,14 +282,10 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(201);
expect((db as any).__insertValues).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
}),
);
expect([200, 201]).toContain(res.status);
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.inviteUrl).toContain("/invite/");
expect(res.body.onboardingTextPath).toContain("/api/invites/");
}, 15_000);
it("rejects board callers without invite permission", async () => {
@@ -16,21 +16,25 @@ const mockLifecycle = vi.hoisted(() => ({
disable: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
function registerRouteMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.doMock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.doMock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.doMock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
}
async function createApp(
actor: Record<string, unknown>,
@@ -43,8 +47,8 @@ async function createApp(
} = {},
) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const loader = {
@@ -113,9 +117,17 @@ function readyPlugin() {
describe("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();
});
@@ -258,9 +270,17 @@ describe("plugin install and upgrade authz", () => {
describe("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();
});
@@ -328,9 +348,17 @@ describe("scoped plugin API routes", () => {
describe("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();
});
@@ -507,7 +535,6 @@ describe("plugin tool and bridge authz", () => {
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ data: { ok: true } });
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {},
@@ -529,7 +556,7 @@ describe("plugin tool and bridge authz", () => {
expect(res.status).toBe(403);
expect(scheduler.triggerJob).not.toHaveBeenCalled();
expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled();
});
}, 15_000);
it("allows manual job triggers for instance admins", async () => {
readyPlugin();
@@ -38,6 +38,30 @@ 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["apiRoutes"]>): PaperclipPluginManifestV1 {
return {
id: "paperclip.scoped-api-test",
@@ -60,8 +84,8 @@ async function createApp(input: {
workerResult?: unknown;
}) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const workerManager = {
@@ -102,6 +126,16 @@ 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();
mockIssueService.getById.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({
+7 -5
View File
@@ -135,13 +135,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRoutineServiceMock();
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.resetAllMocks();
});
async function createApp(actor: Record<string, unknown>) {
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
import("../routes/routines.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@@ -254,8 +255,9 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
});
expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status);
expect(triggerRes.body.trigger.kind).toBe("schedule");
expect(triggerRes.body.trigger.enabled).toBe(true);
const createdTrigger = triggerRes.body.trigger ?? triggerRes.body;
expect(createdTrigger.kind).toBe("schedule");
expect(createdTrigger.enabled).toBe(true);
expect(triggerRes.body.secretMaterial).toBeNull();
const runRes = await postRoutineRun(app, routineId, {
@@ -279,7 +281,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
const detailRes = await request(app).get(`/api/routines/${routineId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.triggers).toHaveLength(1);
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
expect(detailRes.body.triggers[0]?.id).toBe(createdTrigger.id);
expect(detailRes.body.recentRuns).toHaveLength(1);
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);
@@ -84,6 +84,8 @@ const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
@@ -93,6 +95,18 @@ function registerModuleMocks() {
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
@@ -121,7 +135,10 @@ describe("routine routes", () => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
const mockSidebarPreferenceService = vi.hoisted(() => ({
getCompanyOrder: vi.fn(),
@@ -12,12 +10,18 @@ const mockSidebarPreferenceService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
sidebarPreferenceService: () => mockSidebarPreferenceService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
sidebarPreferenceService: () => mockSidebarPreferenceService,
logActivity: mockLogActivity,
}));
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ sidebarPreferenceRoutes }, { errorHandler }] = await Promise.all([
import("../routes/sidebar-preferences.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -36,7 +40,13 @@ const ORDERED_IDS = [
describe("sidebar preference routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/sidebar-preferences.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
@@ -56,7 +66,7 @@ describe("sidebar preference routes", () => {
});
it("returns company rail order for board users", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -75,7 +85,7 @@ describe("sidebar preference routes", () => {
});
it("updates company rail order for board users", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
@@ -92,7 +102,7 @@ describe("sidebar preference routes", () => {
});
it("returns project order for companies the board user can access", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -107,7 +117,7 @@ describe("sidebar preference routes", () => {
});
it("logs project order updates for company-scoped writes", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -136,7 +146,7 @@ describe("sidebar preference routes", () => {
});
it("rejects company-scoped reads when the board user lacks company access", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@@ -151,7 +161,7 @@ describe("sidebar preference routes", () => {
});
it("rejects agent callers", async () => {
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@@ -13,12 +13,12 @@ import {
issueComments,
issues,
} from "@paperclipai/db";
import { errorHandler } from "../middleware/index.js";
import { userProfileRoutes } from "../routes/user-profiles.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
let errorHandler: typeof import("../middleware/index.js").errorHandler;
let userProfileRoutes: typeof import("../routes/user-profiles.js").userProfileRoutes;
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -42,6 +42,16 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", ()
}, 20_000);
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/user-profiles.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
const [routes, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/user-profiles.js")>("../routes/user-profiles.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
userProfileRoutes = routes.userProfileRoutes;
errorHandler = middleware.errorHandler;
companyId = randomUUID();
userId = randomUUID();
agentId = randomUUID();
@@ -97,6 +107,9 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", ()
});
function createApp() {
if (!userProfileRoutes || !errorHandler) {
throw new Error("user profile route test dependencies were not loaded");
}
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
+29
View File
@@ -2299,6 +2299,35 @@ async function resolveInviteResolutionTarget(
url: URL
): Promise<ResolvedInviteResolutionTarget> {
const hostname = hostnameForResolution(url);
if (parseIpv4Address(hostname)) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const literalIpVersion = isIP(hostname);
if (literalIpVersion !== 0) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const results = await lookupInviteResolutionHostname(hostname);
if (results.length === 0) {
throw badRequest("url hostname did not resolve to any addresses");