paperclip/server/src/__tests__/issue-attachment-routes.test.ts
2026-05-30 20:51:46 +00:00

606 lines
21 KiB
TypeScript

import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StorageService } from "../storage/types.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
createAttachment: vi.fn(),
getAttachmentById: vi.fn(),
}));
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockWorkProductService = vi.hoisted(() => ({
createForIssue: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
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(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
companyService: () => mockCompanyService,
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
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,
}),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => mockWorkProductService,
}));
}
type TestStorageService = StorageService & {
__calls: {
putFile?: {
companyId: string;
namespace: string;
originalFilename?: string;
contentType: string;
body: Buffer;
};
};
};
function createStorageService(body = Buffer.from("test")): TestStorageService {
const calls: TestStorageService["__calls"] = {};
return {
provider: "local_disk",
__calls: calls,
putFile: async (input) => {
calls.putFile = input;
return {
provider: "local_disk",
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
contentType: input.contentType,
byteSize: input.body.length,
sha256: "sha256-sample",
originalFilename: input.originalFilename,
};
},
getObject: vi.fn(async (_companyId, _objectKey, options) => {
const range = options?.range;
const streamBody = range ? body.subarray(range.start, range.end + 1) : body;
return {
stream: Readable.from(streamBody),
contentLength: streamBody.length,
};
}),
headObject: vi.fn(),
deleteObject: vi.fn(),
};
}
async function createApp(storage: StorageService, options?: { companyIds?: string[]; source?: string }) {
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) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: options?.companyIds ?? ["company-1"],
source: options?.source ?? "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, storage));
app.use(errorHandler);
return app;
}
function makeAttachment(contentType: string, originalFilename: string) {
const now = new Date("2026-01-01T00:00:00.000Z");
return {
id: "attachment-1",
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: `issues/issue-1/${originalFilename}`,
contentType,
byteSize: 4,
sha256: "sha256-sample",
originalFilename,
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: now,
updatedAt: now,
};
}
describe("normalizeIssueAttachmentMaxBytes", () => {
it("keeps the process-level attachment cap as the final cap", async () => {
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
vi.resetModules();
try {
const { normalizeIssueAttachmentMaxBytes } = await import("../attachment-types.js");
expect(normalizeIssueAttachmentMaxBytes(null)).toBe(5);
expect(normalizeIssueAttachmentMaxBytes(10)).toBe(5);
expect(normalizeIssueAttachmentMaxBytes(3)).toBe(3);
} finally {
if (previous === undefined) {
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
} else {
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = previous;
}
vi.resetModules();
}
});
});
describe("issue attachment routes", () => {
beforeEach(() => {
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");
registerRouteMocks();
vi.clearAllMocks();
mockLogActivity.mockResolvedValue(undefined);
mockCompanyService.getById.mockResolvedValue({
id: "company-1",
attachmentMaxBytes: 1024 * 1024 * 1024,
});
mockWorkProductService.createForIssue.mockReset();
mockWorkProductService.getById.mockReset();
mockWorkProductService.update.mockReset();
});
it("accepts zip uploads for issue attachments", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/zip", "bundle.zip"));
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
expect([200, 201]).toContain(res.status);
const putFileCall = storage.__calls.putFile;
expect(putFileCall).toMatchObject({
companyId: "company-1",
namespace: "issues/11111111-1111-4111-8111-111111111111",
originalFilename: "bundle.zip",
contentType: "application/zip",
});
expect(Buffer.isBuffer(putFileCall?.body)).toBe(true);
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
contentType: "application/zip",
originalFilename: "bundle.zip",
}),
);
expect(res.body.contentType).toBe("application/zip");
});
it("accepts default video uploads for issue attachments", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4"));
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("mp4"), { filename: "clip.mp4", contentType: "video/mp4" });
expect(res.status).toBe(201);
expect(storage.__calls.putFile).toMatchObject({
contentType: "video/mp4",
originalFilename: "clip.mp4",
});
expect(res.body).toMatchObject({
contentType: "video/mp4",
contentPath: "/api/attachments/attachment-1/content",
openPath: "/api/attachments/attachment-1/content",
downloadPath: "/api/attachments/attachment-1/content?download=1",
});
});
it("rejects unsupported upload content types before storing the file", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("exe"), { filename: "payload.exe", contentType: "application/x-msdownload" });
expect(res.status).toBe(422);
expect(res.body.error).toBe("Unsupported attachment content type: application/x-msdownload");
expect(storage.__calls.putFile).toBeUndefined();
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
});
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
filename: "large.bin",
contentType: "application/octet-stream",
});
expect(res.status).toBe(422);
expect(res.body.error).toBe("Attachment exceeds 10485760 bytes");
expect(storage.__calls.putFile).toBeUndefined();
});
it("enforces the configured per-company issue attachment limit", async () => {
const storage = createStorageService();
mockCompanyService.getById.mockResolvedValue({
id: "company-1",
attachmentMaxBytes: 4,
});
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
expect(res.status).toBe(422);
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
});
it("serves html attachments as downloads with nosniff", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect([
undefined,
'attachment; filename="report.html"',
]).toContain(res.headers["content-disposition"]);
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("keeps image attachments inline for previews", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect([
undefined,
'inline; filename="preview.png"',
]).toContain(res.headers["content-disposition"]);
});
it("serves video attachments inline with byte-range support", async () => {
const storage = createStorageService(Buffer.from("abcdef"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/mp4", "clip.mp4"),
byteSize: 6,
});
const app = await createApp(storage);
const res = await request(app)
.get("/api/attachments/attachment-1/content")
.set("Range", "bytes=1-3");
expect(res.status).toBe(206);
expect(res.headers["content-type"]).toContain("video/mp4");
expect(res.headers["accept-ranges"]).toBe("bytes");
expect(res.headers["content-range"]).toBe("bytes 1-3/6");
expect(res.headers["content-length"]).toBe("3");
expect(res.headers["content-disposition"]).toBe('inline; filename="clip.mp4"');
expect(Buffer.from(res.body).toString("utf8")).toBe("bcd");
expect(storage.getObject).toHaveBeenCalledWith(
"company-1",
"issues/issue-1/clip.mp4",
{ range: { start: 1, end: 3 } },
);
});
it("forces video downloads when the download path is requested", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm"));
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content?download=1");
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toBe('attachment; filename="clip.webm"');
});
it("rejects invalid byte ranges without streaming the object", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4"));
const app = await createApp(storage);
const res = await request(app)
.get("/api/attachments/attachment-1/content")
.set("Range", "bytes=99-100");
expect(res.status).toBe(416);
expect(res.headers["content-range"]).toBe("bytes */4");
expect(storage.getObject).not.toHaveBeenCalled();
});
it("rejects cross-company attachment content reads", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4"));
const app = await createApp(storage, { companyIds: ["company-2"], source: "session" });
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(403);
expect(storage.getObject).not.toHaveBeenCalled();
});
it("canonicalizes paperclip artifact metadata before creating a work product", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/mp4", "clip.mp4"),
id: "22222222-2222-4222-8222-222222222222",
byteSize: 6,
issueId: issue.id,
});
mockWorkProductService.createForIssue.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
const app = await createApp(storage);
const res = await request(app)
.post(`/api/issues/${issue.id}/work-products`)
.send({
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/mp4",
byteSize: 6,
contentPath: "https://evil.example/clip.mp4",
openPath: "javascript:alert(1)",
downloadPath: "javascript:alert(2)",
originalFilename: "clip.mp4",
},
});
expect(res.status).toBe(201);
expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith(
issue.id,
issue.companyId,
expect.objectContaining({
type: "artifact",
provider: "paperclip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/mp4",
byteSize: 6,
contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1",
originalFilename: "clip.mp4",
},
}),
);
});
it("rejects paperclip artifact metadata that references another issue's attachment", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/mp4", "clip.mp4"),
id: "22222222-2222-4222-8222-222222222222",
issueId: "different-issue",
});
const app = await createApp(storage);
const res = await request(app)
.post(`/api/issues/${issue.id}/work-products`)
.send({
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toBe("Attachment artifact must reference an attachment on the same issue");
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
});
it("canonicalizes paperclip artifact metadata on work product updates", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockWorkProductService.getById.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/webm", "clip.webm"),
id: "22222222-2222-4222-8222-222222222222",
issueId: issue.id,
byteSize: 8,
});
mockWorkProductService.update.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
const app = await createApp(storage);
const res = await request(app)
.patch("/api/work-products/work-product-1")
.send({
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
openPath: "javascript:alert(1)",
},
});
expect(res.status).toBe(200);
expect(mockWorkProductService.update).toHaveBeenCalledWith(
"work-product-1",
expect.objectContaining({
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/webm",
byteSize: 8,
contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1",
originalFilename: "clip.webm",
},
}),
);
});
});