import { Readable } from "node:stream"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; 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)); vi.mock("../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: () => ({}), })); function createStorageService(): StorageService { return { provider: "local_disk", putFile: vi.fn(async (input) => ({ 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(), }; } function createApp(storage: StorageService) { 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.clearAllMocks(); }); 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 res = await request(createApp(storage)) .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") .attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" }); expect(res.status).toBe(201); const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0]; 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 res = await request(createApp(storage)).get("/api/attachments/attachment-1/content"); expect(res.status).toBe(200); expect(res.headers["content-disposition"]).toBe('attachment; filename="report.html"'); 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 res = await request(createApp(storage)).get("/api/attachments/attachment-1/content"); expect(res.status).toBe(200); expect(res.headers["content-disposition"]).toBe('inline; filename="preview.png"'); }); });