mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
Allow arbitrary issue attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
517fe5093e
commit
e9c8bd4805
5 changed files with 242 additions and 13 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAllowedTypes,
|
||||
matchesContentType,
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
INLINE_ATTACHMENT_TYPES,
|
||||
isInlineAttachmentContentType,
|
||||
matchesContentType,
|
||||
normalizeContentType,
|
||||
parseAllowedTypes,
|
||||
} from "../attachment-types.js";
|
||||
|
||||
describe("parseAllowedTypes", () => {
|
||||
|
|
@ -95,3 +98,28 @@ describe("matchesContentType", () => {
|
|||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeContentType", () => {
|
||||
it("lowercases and trims explicit types", () => {
|
||||
expect(normalizeContentType(" Application/Zip ")).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream when the type is missing", () => {
|
||||
expect(normalizeContentType(undefined)).toBe("application/octet-stream");
|
||||
expect(normalizeContentType("")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInlineAttachmentContentType", () => {
|
||||
it("allows the configured inline-safe types", () => {
|
||||
for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) {
|
||||
expect(isInlineAttachmentContentType(contentType)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects potentially unsafe or binary download types", () => {
|
||||
expect(INLINE_ATTACHMENT_TYPES).not.toContain("text/html");
|
||||
expect(isInlineAttachmentContentType("text/html")).toBe(false);
|
||||
expect(isInlineAttachmentContentType("application/zip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
175
server/src/__tests__/issue-attachment-routes.test.ts
Normal file
175
server/src/__tests__/issue-attachment-routes.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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"');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue