forked from farhoodlabs/paperclip
e739a2d130
Add a `dryRun` option to the scan-projects endpoint. When true, the scan identifies which skills would be pruned and which agents would be affected, but does not delete anything or modify agent configs. The response now includes: - `pruned[]`: list of skills that would be (or were) removed, with affected agent names - `dryRun`: boolean echoed back so callers can distinguish preview results from live mutations This lets callers preview destructive prune operations before committing to them, addressing the review concern about silent deletion of production data. Co-Authored-By: Paperclip <noreply@paperclip.ing>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
canUser: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
}));
|
|
|
|
const mockCompanySkillService = vi.hoisted(() => ({
|
|
importFromSource: vi.fn(),
|
|
deleteSkill: vi.fn(),
|
|
scanProjectWorkspaces: vi.fn(),
|
|
}));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
|
|
|
function registerRouteMocks() {
|
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
|
trackSkillImported: mockTrackSkillImported,
|
|
trackErrorHandlerCrash: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../telemetry.js", () => ({
|
|
getTelemetryClient: mockGetTelemetryClient,
|
|
}));
|
|
|
|
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([
|
|
import("../routes/company-skills.js"),
|
|
import("../middleware/index.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", companySkillRoutes({} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("company skill mutation permissions", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
registerRouteMocks();
|
|
vi.clearAllMocks();
|
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
|
imported: [],
|
|
warnings: [],
|
|
});
|
|
mockCompanySkillService.deleteSkill.mockResolvedValue({
|
|
id: "skill-1",
|
|
slug: "find-skills",
|
|
name: "Find Skills",
|
|
});
|
|
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
|
scannedProjects: 1,
|
|
scannedWorkspaces: 2,
|
|
discovered: [],
|
|
imported: [],
|
|
updated: [],
|
|
skipped: [],
|
|
conflicts: [],
|
|
pruned: [],
|
|
warnings: [],
|
|
dryRun: false,
|
|
});
|
|
mockLogActivity.mockResolvedValue(undefined);
|
|
mockAccessService.canUser.mockResolvedValue(true);
|
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
|
});
|
|
|
|
it("allows local board operators to mutate company skills", async () => {
|
|
const res = await request(await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.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",
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("tracks public GitHub skill imports with an explicit skill reference", async () => {
|
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
|
imported: [
|
|
{
|
|
id: "skill-1",
|
|
companyId: "company-1",
|
|
key: "vercel-labs/agent-browser/find-skills",
|
|
slug: "find-skills",
|
|
name: "Find Skills",
|
|
description: null,
|
|
markdown: "# Find Skills",
|
|
sourceType: "github",
|
|
sourceLocator: "https://github.com/vercel-labs/agent-browser",
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [],
|
|
metadata: {
|
|
hostname: "github.com",
|
|
owner: "vercel-labs",
|
|
repo: "agent-browser",
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
|
|
|
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
|
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
|
sourceType: "github",
|
|
skillRef: "vercel-labs/agent-browser/find-skills",
|
|
});
|
|
});
|
|
|
|
it("does not expose a skill reference for non-public skill imports", async () => {
|
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
|
imported: [
|
|
{
|
|
id: "skill-1",
|
|
companyId: "company-1",
|
|
key: "private-skill",
|
|
slug: "private-skill",
|
|
name: "Private Skill",
|
|
description: null,
|
|
markdown: "# Private Skill",
|
|
sourceType: "github",
|
|
sourceLocator: "https://ghe.example.com/acme/private-skill",
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [],
|
|
metadata: {
|
|
hostname: "ghe.example.com",
|
|
owner: "acme",
|
|
repo: "private-skill",
|
|
},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.send({ source: "https://ghe.example.com/acme/private-skill" });
|
|
|
|
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
|
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
|
sourceType: "github",
|
|
skillRef: null,
|
|
});
|
|
});
|
|
|
|
it("does not expose a skill reference when GitHub metadata is missing", async () => {
|
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
|
imported: [
|
|
{
|
|
id: "skill-1",
|
|
companyId: "company-1",
|
|
key: "unknown/private-skill",
|
|
slug: "private-skill",
|
|
name: "Private Skill",
|
|
description: null,
|
|
markdown: "# Private Skill",
|
|
sourceType: "github",
|
|
sourceLocator: "https://github.com/acme/private-skill",
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [],
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.send({ source: "https://github.com/acme/private-skill" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
|
sourceType: "github",
|
|
skillRef: null,
|
|
});
|
|
});
|
|
|
|
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
permissions: {},
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
runId: "run-1",
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows agents with canCreateAgents to scan project workspaces", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
runId: "run-1",
|
|
}))
|
|
.post("/api/companies/company-1/skills/scan-projects")
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
|
|
});
|
|
|
|
it("returns warnings from scan when removed skills are still used by agents", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
|
|
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValueOnce({
|
|
scannedProjects: 1,
|
|
scannedWorkspaces: 1,
|
|
discovered: [],
|
|
imported: [],
|
|
updated: [],
|
|
skipped: [],
|
|
conflicts: [],
|
|
pruned: [
|
|
{ skillId: "skill-1", slug: "ghost-skill", key: "vercel-labs/agent-browser/ghost-skill", sourceLocator: "https://github.com/vercel-labs/agent-browser", affectedAgents: ["Builder"] },
|
|
],
|
|
warnings: [
|
|
'Skill "ghost-skill" was removed from https://github.com/vercel-labs/agent-browser and detached from Builder.',
|
|
],
|
|
dryRun: false,
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
runId: "run-1",
|
|
}))
|
|
.post("/api/companies/company-1/skills/scan-projects")
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
warnings: [expect.stringContaining("was removed from")],
|
|
});
|
|
});
|
|
|
|
it("allows agents with canCreateAgents to mutate company skills", async () => {
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
permissions: { canCreateAgents: true },
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
runId: "run-1",
|
|
}))
|
|
.post("/api/companies/company-1/skills/import")
|
|
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
|
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"https://github.com/vercel-labs/agent-browser",
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
|
|
const { unprocessable } = await import("../errors.js");
|
|
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
|
|
throw unprocessable(
|
|
'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
|
|
);
|
|
});
|
|
|
|
const res = await request(await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
}))
|
|
.delete("/api/companies/company-1/skills/skill-1");
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toEqual({
|
|
error: 'Cannot delete skill "Find Skills" while it is still used by Builder, Reviewer. Detach it from those agents first.',
|
|
});
|
|
expect(mockCompanySkillService.deleteSkill).toHaveBeenCalledWith("company-1", "skill-1");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
});
|