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(), installFromCatalog: vi.fn(), deleteSkill: vi.fn(), })); const mockCatalogService = vi.hoisted(() => ({ listCatalogSkills: vi.fn(), getCatalogSkillOrThrow: vi.fn(), readCatalogSkillFile: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = 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", () => ({ trackSkillImported: mockTrackSkillImported, trackErrorHandlerCrash: vi.fn(), })); 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/skills-catalog.js", () => mockCatalogService); vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, companySkillService: () => mockCompanySkillService, logActivity: mockLogActivity, })); } async function createApp(actor: Record) { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/company-skills.js"), vi.importActual("../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(); 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/skills-catalog.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/company-skills.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ imported: [], warnings: [], }); mockCompanySkillService.installFromCatalog.mockResolvedValue({ action: "created", skill: { id: "skill-1", companyId: "company-1", key: "paperclipai/bundled/software-development/review", slug: "review", name: "review", description: "Review code", markdown: "# Review", sourceType: "catalog", sourceLocator: "/tmp/review", sourceRef: "sha256:abc", trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "catalog", catalogId: "paperclipai:bundled:software-development:review", originHash: "sha256:abc", }, createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), }, catalogSkill: { id: "paperclipai:bundled:software-development:review", key: "paperclipai/bundled/software-development/review", kind: "bundled", category: "software-development", slug: "review", name: "review", description: "Review code", path: "catalog/bundled/software-development/review", entrypoint: "SKILL.md", trustLevel: "markdown_only", compatibility: "compatible", defaultInstall: false, recommendedForRoles: ["engineer"], requires: [], tags: ["review"], files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], contentHash: "sha256:abc", }, warnings: [], }); mockCompanySkillService.deleteSkill.mockResolvedValue({ id: "skill-1", slug: "find-skills", name: "Find Skills", }); mockCatalogService.listCatalogSkills.mockReturnValue([]); mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ id: "paperclipai:bundled:software-development:review", key: "paperclipai/bundled/software-development/review", kind: "bundled", category: "software-development", slug: "review", name: "review", description: "Review code", path: "catalog/bundled/software-development/review", entrypoint: "SKILL.md", trustLevel: "markdown_only", compatibility: "compatible", defaultInstall: false, recommendedForRoles: ["engineer"], requires: [], tags: ["review"], files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], contentHash: "sha256:abc", }); mockCatalogService.readCatalogSkillFile.mockResolvedValue({ catalogSkillId: "paperclipai:bundled:software-development:review", path: "SKILL.md", kind: "skill", content: "# Review", language: "markdown", markdown: true, }); 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(res.body).toEqual({ imported: [], warnings: [], }); }); it("serves catalog listing without mutating company skills", async () => { mockCatalogService.listCatalogSkills.mockReturnValue([ { id: "paperclipai:bundled:software-development:review", key: "paperclipai/bundled/software-development/review", kind: "bundled", category: "software-development", slug: "review", name: "review", description: "Review code", path: "catalog/bundled/software-development/review", entrypoint: "SKILL.md", trustLevel: "markdown_only", compatibility: "compatible", defaultInstall: false, recommendedForRoles: ["engineer"], requires: [], tags: ["review"], files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], contentHash: "sha256:abc", }, ]); const res = await request(await createApp({ type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, })) .get("/api/skills/catalog?kind=bundled&q=review"); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockCatalogService.listCatalogSkills).toHaveBeenCalledWith({ kind: "bundled", q: "review" }); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); }); it("requires authentication for catalog read routes", async () => { const app = await createApp({ type: "none" }); const list = await request(app).get("/api/skills/catalog"); const detail = await request(app).get("/api/skills/catalog/review"); const file = await request(app).get("/api/skills/catalog/review/files?path=SKILL.md"); expect(list.status, JSON.stringify(list.body)).toBe(401); expect(detail.status, JSON.stringify(detail.body)).toBe(401); expect(file.status, JSON.stringify(file.body)).toBe(401); expect(mockCatalogService.listCatalogSkills).not.toHaveBeenCalled(); expect(mockCatalogService.getCatalogSkillOrThrow).not.toHaveBeenCalled(); expect(mockCatalogService.readCatalogSkillFile).not.toHaveBeenCalled(); }); it("serves catalog detail and files by catalog reference", async () => { const app = await createApp({ type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }); const detail = await request(app) .get("/api/skills/catalog/review"); const file = await request(app) .get("/api/skills/catalog/review/files?path=SKILL.md"); expect(detail.status, JSON.stringify(detail.body)).toBe(200); expect(file.status, JSON.stringify(file.body)).toBe(200); expect(mockCatalogService.getCatalogSkillOrThrow).toHaveBeenCalledWith("review"); expect(mockCatalogService.readCatalogSkillFile).toHaveBeenCalledWith("review", "SKILL.md"); expect(mockLogActivity).not.toHaveBeenCalled(); }); it("installs catalog skills with mutation permissions and logs provenance", 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/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review", slug: "review", }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockCompanySkillService.installFromCatalog).toHaveBeenCalledWith("company-1", { catalogSkillId: "paperclipai:bundled:software-development:review", slug: "review", }); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ companyId: "company-1", action: "company.skill_catalog_installed", entityType: "company_skill", entityId: "skill-1", details: expect.objectContaining({ catalogId: "paperclipai:bundled:software-development:review", catalogKey: "paperclipai/bundled/software-development/review", originHash: "sha256:abc", }), })); }); 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([200, 201], JSON.stringify(res.body)).toContain(res.status); 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("blocks agent catalog installs for other companies", 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-2/skills/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled(); }); 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", ); }); 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(); }); });