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 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/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), hasPermission: vi.fn(), }), agentService: () => ({ getById: vi.fn(), }), 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: () => ({}), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), workProductService: () => ({}), })); } type TestStorageService = StorageService & { __calls: { putFile?: { companyId: string; namespace: string; originalFilename?: string; contentType: string; body: Buffer; }; }; }; function createStorageService(): 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 () => ({ stream: Readable.from(Buffer.from("test")), contentLength: 4, })), headObject: vi.fn(), deleteObject: vi.fn(), }; } async function createApp(storage: StorageService) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/issues.js"), ]); const app = express(); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "local-board", companyIds: ["company-1"], 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("issue attachment routes", () => { beforeEach(() => { vi.resetModules(); registerRouteMocks(); vi.clearAllMocks(); mockLogActivity.mockResolvedValue(undefined); }); 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("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"]); }); });