Files
PaperClipAI/server/src/__tests__/issue-attachment-routes.test.ts
T
Devin FoleyandPaperclip 5f463dd625 test(server): reuse route modules across test cases (#16396)
## Thinking Path

> - Paperclip uses server routes to manage agents and their work.
> - Route tests check permissions, login sessions, skills, and file
downloads.
> - Four suites reload large module graphs before every test.
> - Their workers exhaust the heap and block dependency updates.
> - This pull request loads each route graph once per suite.
> - Each test still gets a fresh app and resets its fixture state.

## Linked Issues or Issue Description

Refs #16017. Related: #12987 addresses repeated logger transports
through a different production change.

**What happened?**

CI run 38293835699, attempt 2, aborted four server test workers. The
affected suites cover attachments, device login, agent skills, and setup
tokens. Three failed log tails show heap exhaustion. The separate
`db.transaction` warning came from the document-annotation suite, which
passed.

**Expected behavior**

All route cases complete within the worker heap limit and keep
independent test state.

**Steps to reproduce**

Run the unchanged agent-skills suite from master with
`NODE_OPTIONS=--max-old-space-size=1536` and `--logHeapUsage`. The
worker aborted after 66 of 90 cases, at a reported heap of 1,532 MB.

**Paperclip version**

The fix is based on master commit
`444d5854b0f53b19d138f060a267cfd10171c7b3`. The original dependency PR
head is `7b74c3f248731943154547385b1de5eb6562bf01`.

**Deployment mode**

Local tests and CI.

## What Changed

- Move module resets and mock registration from `beforeEach` to
`beforeAll` in four route suites.
- Load the agent-skills route modules during suite setup.
- Keep each test's app, fixture resets, and login cleanup.
- Remove two comments that describe the old reset behavior.

## Verification

All 193 tests in the four affected suites pass with a 1.5 GiB heap
limit:

```sh
NODE_OPTIONS=--max-old-space-size=1536 pnpm exec vitest run --project @paperclipai/server \
  server/src/__tests__/agent-skills-routes.test.ts \
  server/src/__tests__/agent-device-login-routes.test.ts \
  server/src/__tests__/issue-attachment-routes.test.ts \
  server/src/routes/setup-token-route.test.ts --reporter=verbose --logHeapUsage
```

- Local runtime: Node 25.9.0, Vitest 5.0.4, Supertest 7.3.1, Express
5.2.1.
- Master has an existing manifest/lockfile mismatch. Local installation
used `--no-lockfile`. Express was restored to its locked version in an
untracked test dependency directory. The tracked lockfile is unchanged.
- The agent-skills suite stays below 400 MB of reported heap after the
fix.
- `git diff --check` passes.
- Repository typecheck reaches the Runner Rust check and stops because
this environment has no `cargo`.
- The repository build and normal server typecheck also stop at the
missing Rust toolchain. A direct server `tsc --noEmit` attempt ends with
SIGKILL.
- [CI run
38686964537](https://github.com/paperclipai/paperclip/actions/runs/38686964537)
passes on `c744774bbada4e35ce3acf5991ff19cf50405f2f`, including all
general and serialized server shards, typecheck, build, Runner checks,
and e2e.
- GitHub reports 53 successful check runs, two skipped Storybook checks,
and a successful Snyk status. No checks are pending or failing.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/16396#issuecomment-6185194537)
gives 5/5 on the same commit, with no actionable issues and zero review
threads.
- The full repository test suite was verified in CI; the local container
has a 4 GiB memory limit.

## Risks

Module state now lasts for one suite. Each case still constructs its own
router and resets its mocks and fixtures. The affected suites pass
together locally, and all applicable CI gates pass on the published
commit.

The change contains four test files. It changes no production code,
workflow, manifest, or lockfile. Merge this separate fix before asking
Dependabot to rebase #16017.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The runtime does not expose a more specific model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 23:21:16 -07:00

891 lines
32 KiB
TypeScript

import { Readable } from "node:stream";
import type { IncomingMessage } from "node:http";
import express from "express";
import request from "supertest";
import { beforeAll, 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 mockAccessService = vi.hoisted(() => ({
decide: vi.fn(async () => ({
allowed: true,
explanation: "Allowed by test mock",
})),
canUser: vi.fn(),
hasPermission: 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: () => mockAccessService,
agentService: () => ({
getById: vi.fn(),
}),
companySkillService: () => ({}),
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,
};
}
function parseBinaryResponse(res: IncomingMessage, callback: (error: Error | null, body?: Buffer) => void) {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
res.on("end", () => callback(null, Buffer.concat(chunks)));
res.on("error", callback);
}
describe("MAX_ATTACHMENT_BYTES", () => {
it("reads the deployment-level attachment cap from the environment", async () => {
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
vi.resetModules();
try {
const { MAX_ATTACHMENT_BYTES } = await import("../attachment-types.js");
expect(MAX_ATTACHMENT_BYTES).toBe(5);
} 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", () => {
beforeAll(() => {
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();
});
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
explanation: "Allowed by test mock",
});
mockLogActivity.mockResolvedValue(undefined);
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
projectId: null,
parentId: null,
status: "todo",
assigneeAgentId: null,
assigneeUserId: null,
identifier: "PAP-1",
});
mockCompanyService.getById.mockResolvedValue({
id: "company-1",
});
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("removes a newly stored object when attachment registration is rejected", async () => {
const storage = createStorageService();
const { HttpError } = await vi.importActual<
typeof import("../errors.js")
>("../errors.js");
mockIssueService.createAttachment.mockRejectedValue(
new HttpError(422, "Attachment selection limit reached", {
code: "chat_attachment_selection_limit_exceeded",
}),
);
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("overflow"), {
filename: "overflow.txt",
contentType: "text/plain",
});
expect(res.status).toBe(422);
expect(res.body).toMatchObject({
error: "Attachment selection limit reached",
details: { code: "chat_attachment_selection_limit_exceeded" },
});
expect(storage.deleteObject).toHaveBeenCalledWith(
"company-1",
"issues/11111111-1111-4111-8111-111111111111/overflow.txt",
);
});
it("retains a stored object when attachment registration has an ambiguous server error", async () => {
const storage = createStorageService();
mockIssueService.createAttachment.mockRejectedValue(
new Error("connection lost after commit"),
);
const app = await createApp(storage);
await request(app)
.post(
"/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments",
)
.attach("file", Buffer.from("ambiguous"), {
filename: "ambiguous.txt",
contentType: "text/plain",
})
.expect(500);
expect(storage.deleteObject).not.toHaveBeenCalled();
});
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("accepts arbitrary upload content types while preserving the stored MIME type", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/x-msdownload", "payload.exe"));
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(201);
expect(storage.__calls.putFile).toMatchObject({
contentType: "application/x-msdownload",
originalFilename: "payload.exe",
});
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
contentType: "application/x-msdownload",
originalFilename: "payload.exe",
}),
);
expect(res.body.contentType).toBe("application/x-msdownload");
});
it("accepts Office uploads with official MIME types for issue attachments", async () => {
const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx"));
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("xlsx"), { filename: "raw-data.xlsx", contentType });
expect(res.status).toBe(201);
expect(storage.__calls.putFile).toMatchObject({
contentType,
originalFilename: "raw-data.xlsx",
});
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
contentType,
originalFilename: "raw-data.xlsx",
}),
);
});
it("infers Office MIME types for generic binary issue attachment uploads", async () => {
const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx"));
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("xlsx"), {
filename: "raw-data.xlsx",
contentType: "application/octet-stream",
});
expect(res.status).toBe(201);
expect(storage.__calls.putFile).toMatchObject({
contentType,
originalFilename: "raw-data.xlsx",
});
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
contentType,
originalFilename: "raw-data.xlsx",
}),
);
});
it("preserves generic binary uploads when the filename is not a known Office document", 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", "payload.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.from("bin"), { filename: "payload.bin", contentType: "application/octet-stream" });
expect(res.status).toBe(201);
expect(storage.__calls.putFile).toMatchObject({
contentType: "application/octet-stream",
originalFilename: "payload.bin",
});
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
contentType: "application/octet-stream",
originalFilename: "payload.bin",
}),
);
expect(res.body.contentType).toBe("application/octet-stream");
});
it("bounds an issue attachment by the deployment-level limit", 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 is larger than the 10 MB limit");
expect(storage.__calls.putFile).toBeUndefined();
// The deployment cap is the only limit left. The route no longer reads a
// per-company override, so it never loads the company to size an upload.
expect(mockCompanyService.getById).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")
.buffer(true)
.parse(parseBinaryResponse);
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("serves arbitrary binary attachments as downloads with nosniff", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("application/x-msdownload", "payload.exe"));
const app = await createApp(storage);
const res = await request(app)
.get("/api/attachments/attachment-1/content")
.buffer(true)
.parse(parseBinaryResponse);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("application/x-msdownload");
expect(res.headers["content-disposition"]).toBe('attachment; filename="payload.exe"');
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("declares utf-8 for inline markdown attachments", async () => {
const storage = createStorageService(Buffer.from("# Hello\n"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("text/markdown", "notes.md"),
byteSize: 8,
});
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/markdown; charset=utf-8");
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("keeps the charset declaration on forced markdown downloads", async () => {
const storage = createStorageService(Buffer.from("# Hello\n"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("text/markdown", "notes.md"),
byteSize: 8,
});
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-type"]).toBe("text/markdown; charset=utf-8");
expect(res.headers["content-disposition"]).toBe('attachment; filename="notes.md"');
});
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.each([
{
filename: '猫 "chart"; 100%.png',
contentType: "image/png",
filenameParameters: String.raw`filename="? \"chart\"; 100%.png"; filename*=UTF-8''%E7%8C%AB%20%22chart%22%3B%20100%25.png`,
},
{
filename: 'report "final"; 100%.pdf',
contentType: "application/pdf",
filenameParameters: String.raw`filename="report \"final\"; 100%.pdf"`,
},
].flatMap((file) => [false, true].flatMap((download) =>
[false, true].map((range) => ({ ...file, download, range })),
)))("preserves disposition filenames: $filename (download=$download, range=$range)", async ({
filename, contentType, filenameParameters, download, range,
}) => {
const bytes = Buffer.from([0, 255, 128, 10, 13, 42]);
const storage = createStorageService(bytes);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment(contentType, filename),
byteSize: bytes.length,
});
const app = await createApp(storage);
const downloadRequest = request(app)
.get(`/api/attachments/attachment-1/content${download ? "?download=1" : ""}`)
.buffer(true)
.parse(parseBinaryResponse);
if (range) downloadRequest.set("Range", "bytes=1-3");
const res = await downloadRequest;
expect(res.status).toBe(range ? 206 : 200);
expect(res.headers["content-disposition"]).toBe(`${download ? "attachment" : "inline"}; ${filenameParameters}`);
expect(res.headers["content-type"]).toBe(contentType);
expect(res.headers["content-length"]).toBe(String(range ? 3 : bytes.length));
expect(res.headers["accept-ranges"]).toBe("bytes");
expect(res.headers["content-range"]).toBe(range ? "bytes 1-3/6" : undefined);
expect(res.headers["x-content-type-options"]).toBe("nosniff");
expect(res.body).toEqual(range ? bytes.subarray(1, 4) : bytes);
});
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("serves mp4 attachments inline when stored with a generic binary content type", async () => {
const storage = createStorageService(Buffer.from("abcdef"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("application/octet-stream", "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["content-disposition"]).toBe('inline; filename="clip.mp4"');
expect(res.headers["content-range"]).toBe("bytes 1-3/6");
expect(Buffer.from(res.body).toString("utf8")).toBe("bcd");
});
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");
// Cross-tenant reads return 404 (not 403) so the status code cannot be
// used as an existence oracle for other tenants' attachment ids.
expect(res.status).toBe(404);
expect(res.body.error).toBe("Attachment not found");
expect(storage.getObject).not.toHaveBeenCalled();
});
it("rejects same-company attachment content reads outside the parent issue boundary", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4"));
mockAccessService.decide.mockResolvedValue({
allowed: false,
explanation: "Denied by test mock",
});
const app = await createApp(storage);
const res = await request(app).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Issue not found");
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",
},
}),
);
});
});