mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Issue, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
|
|
|
function seededIssue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">): Issue {
|
|
const now = new Date();
|
|
return {
|
|
workMode: "standard",
|
|
reviewPolicy: null,
|
|
responsibleUserId: null,
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: null,
|
|
identifier: null,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...input,
|
|
};
|
|
}
|
|
|
|
describe("plugin SDK test harness", () => {
|
|
it("rejects project snapshot requests that the host would deny", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-project-snapshots",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Project Snapshots",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["projects.read", "project.workspaces.read", "projects.instance-secrets"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
const projectId = "10000000-0000-4000-8000-000000000001";
|
|
await expect(harness.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
|
|
await expect(harness.ctx.projects.readSnapshots([projectId, projectId], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
|
|
await expect(harness.ctx.projects.readSnapshots([projectId], "company", "bad type")).rejects.toThrow("Invalid project snapshot scope");
|
|
const denied = createTestHarness({ manifest: { ...manifest, capabilities: [] } });
|
|
await expect(denied.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("missing required capability");
|
|
});
|
|
|
|
it("returns scoped execution workspace metadata with the read capability", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-execution-workspace-metadata",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Execution Workspace Metadata",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["execution.workspaces.read"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
executionWorkspaces: [{
|
|
id: "workspace-1",
|
|
companyId: "company-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "project-workspace-1",
|
|
path: "/tmp/paperclip-test",
|
|
cwd: "/tmp/paperclip-test",
|
|
repoUrl: "https://example.com/repo.git",
|
|
baseRef: "main",
|
|
branchName: "feature/test",
|
|
providerType: "git_worktree",
|
|
providerMetadata: { sandboxId: "sandbox-1" },
|
|
}],
|
|
});
|
|
|
|
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({
|
|
id: "workspace-1",
|
|
cwd: "/tmp/paperclip-test",
|
|
branchName: "feature/test",
|
|
providerMetadata: { sandboxId: "sandbox-1" },
|
|
});
|
|
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull();
|
|
});
|
|
|
|
it("requires execution.workspaces.read before returning workspace metadata", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-missing-execution-workspace-read",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Missing Workspace Read Capability",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
|
|
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow(
|
|
"missing required capability 'execution.workspaces.read'",
|
|
);
|
|
});
|
|
|
|
it("requires skills.managed capability before resetting a missing declaration", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-missing-managed-skill-capability",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Missing Managed Skill Capability",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
skills: [{
|
|
skillKey: "wiki-maintainer",
|
|
displayName: "Wiki Maintainer",
|
|
}],
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
|
|
await expect(harness.ctx.skills.managed.reset("unknown-skill", "company-1")).rejects.toThrow(
|
|
"missing required capability 'skills.managed'",
|
|
);
|
|
});
|
|
|
|
it("requires access and authorization capabilities for permission SDK calls", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-missing-access-authz-capability",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Missing Access Capability",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
|
|
await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow(
|
|
"missing required capability 'access.members.read'",
|
|
);
|
|
await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow(
|
|
"missing required capability 'authorization.grants.read'",
|
|
);
|
|
await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow(
|
|
"missing required capability 'authorization.audit.read'",
|
|
);
|
|
});
|
|
|
|
it("returns tombstone-safe deleted comments from the in-memory issue helper", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-comment-redaction",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Comment Redaction",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["issue.comments.read"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [seededIssue({
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
title: "Comment redaction",
|
|
status: "todo",
|
|
priority: "medium",
|
|
})],
|
|
issueComments: [{
|
|
id: "comment-1",
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
authorType: "user",
|
|
authorAgentId: null,
|
|
authorUserId: "user-1",
|
|
body: "secret plugin-visible body",
|
|
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
|
|
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret plugin metadata" }] }] },
|
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
|
deletedByType: "user",
|
|
deletedByUserId: "user-1",
|
|
createdAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-03T12:00:00.000Z"),
|
|
}],
|
|
});
|
|
|
|
const comments = await harness.ctx.issues.listComments("issue-1", "company-1");
|
|
|
|
expect(comments).toHaveLength(1);
|
|
expect(comments[0]).toMatchObject({
|
|
id: "comment-1",
|
|
body: "",
|
|
presentation: null,
|
|
metadata: null,
|
|
deletedByUserId: "user-1",
|
|
});
|
|
expect(JSON.stringify(comments)).not.toContain("secret plugin-visible body");
|
|
expect(JSON.stringify(comments)).not.toContain("secret plugin metadata");
|
|
});
|
|
|
|
it("rejects a human-attributed comment when the actorUserId is not an active member", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-human-attributed-comment-unverified",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Human-Attributed Comment (unverified)",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [seededIssue({
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
title: "Human attribution",
|
|
status: "todo",
|
|
priority: "medium",
|
|
})],
|
|
// A suspended member must not satisfy the active-human-member check —
|
|
// the harness mirrors the host's `requireActiveHumanMember` guard so a
|
|
// plugin test cannot pass an attribution production would reject.
|
|
accessMembers: [{
|
|
id: "member-suspended",
|
|
companyId: "company-1",
|
|
principalType: "user",
|
|
principalId: "user-1",
|
|
status: "suspended",
|
|
membershipRole: "member",
|
|
grants: [],
|
|
createdAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
}],
|
|
});
|
|
|
|
await expect(
|
|
harness.ctx.issues.createComment("issue-1", "relayed reply", "company-1", { actorUserId: "user-1" }),
|
|
).rejects.toThrow('actorUserId "user-1" is not an active human member of this company');
|
|
});
|
|
|
|
it("rejects a human-attributed comment when the actorUserId is a viewer-role (read-only) member", async () => {
|
|
// LOOA-648: the harness mirrors the host's viewer write-bar so a plugin's
|
|
// own test suite cannot pass an attribution production rejects. A viewer is
|
|
// an active member but read-only in the web app.
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-human-attributed-comment-viewer",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Human-Attributed Comment (viewer)",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [seededIssue({
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
title: "Human attribution",
|
|
status: "todo",
|
|
priority: "medium",
|
|
})],
|
|
accessMembers: [{
|
|
id: "member-viewer",
|
|
companyId: "company-1",
|
|
principalType: "user",
|
|
principalId: "user-1",
|
|
status: "active",
|
|
membershipRole: "viewer",
|
|
grants: [],
|
|
createdAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
}],
|
|
});
|
|
|
|
await expect(
|
|
harness.ctx.issues.createComment("issue-1", "relayed reply", "company-1", { actorUserId: "user-1" }),
|
|
).rejects.toThrow("viewer (read-only) access");
|
|
});
|
|
|
|
it("attributes a comment to an active human member for a verified actorUserId", async () => {
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "paperclip.test-human-attributed-comment-verified",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Human-Attributed Comment (verified)",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
};
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({
|
|
issues: [seededIssue({
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
title: "Human attribution",
|
|
status: "todo",
|
|
priority: "medium",
|
|
})],
|
|
accessMembers: [{
|
|
id: "member-active",
|
|
companyId: "company-1",
|
|
principalType: "user",
|
|
principalId: "user-1",
|
|
status: "active",
|
|
membershipRole: "member",
|
|
grants: [],
|
|
createdAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-03T11:00:00.000Z"),
|
|
}],
|
|
});
|
|
|
|
const comment = await harness.ctx.issues.createComment(
|
|
"issue-1",
|
|
"relayed reply",
|
|
"company-1",
|
|
{ actorUserId: "user-1" },
|
|
);
|
|
|
|
expect(comment).toMatchObject({
|
|
issueId: "issue-1",
|
|
authorType: "user",
|
|
authorUserId: "user-1",
|
|
authorAgentId: null,
|
|
body: "relayed reply",
|
|
});
|
|
});
|
|
});
|