Spaces:
Paused
Paused
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import express from "express"; | |
| import request from "supertest"; | |
| import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; | |
| import { assetRoutes } from "../routes/assets.js"; | |
| import type { StorageService } from "../storage/types.js"; | |
| const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({ | |
| createAssetMock: vi.fn(), | |
| getAssetByIdMock: vi.fn(), | |
| logActivityMock: vi.fn(), | |
| })); | |
| vi.mock("../services/index.js", () => ({ | |
| assetService: vi.fn(() => ({ | |
| create: createAssetMock, | |
| getById: getAssetByIdMock, | |
| })), | |
| logActivity: logActivityMock, | |
| })); | |
| function createAsset() { | |
| const now = new Date("2026-01-01T00:00:00.000Z"); | |
| return { | |
| id: "asset-1", | |
| companyId: "company-1", | |
| provider: "local", | |
| objectKey: "assets/abc", | |
| contentType: "image/png", | |
| byteSize: 40, | |
| sha256: "sha256-sample", | |
| originalFilename: "logo.png", | |
| createdByAgentId: null, | |
| createdByUserId: "user-1", | |
| createdAt: now, | |
| updatedAt: now, | |
| }; | |
| } | |
| function createStorageService(contentType = "image/png"): StorageService { | |
| const putFile: StorageService["putFile"] = vi.fn(async (input: { | |
| companyId: string; | |
| namespace: string; | |
| originalFilename: string | null; | |
| contentType: string; | |
| body: Buffer; | |
| }) => { | |
| return { | |
| provider: "local_disk" as const, | |
| objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, | |
| contentType: contentType || input.contentType, | |
| byteSize: input.body.length, | |
| sha256: "sha256-sample", | |
| originalFilename: input.originalFilename, | |
| }; | |
| }); | |
| return { | |
| provider: "local_disk" as const, | |
| putFile, | |
| getObject: vi.fn(), | |
| headObject: vi.fn(), | |
| deleteObject: vi.fn(), | |
| }; | |
| } | |
| function createApp(storage: ReturnType<typeof createStorageService>) { | |
| const app = express(); | |
| app.use((req, _res, next) => { | |
| req.actor = { | |
| type: "board", | |
| source: "local_implicit", | |
| userId: "user-1", | |
| }; | |
| next(); | |
| }); | |
| app.use("/api", assetRoutes({} as any, storage)); | |
| return app; | |
| } | |
| describe("POST /api/companies/:companyId/assets/images", () => { | |
| afterEach(() => { | |
| createAssetMock.mockReset(); | |
| getAssetByIdMock.mockReset(); | |
| logActivityMock.mockReset(); | |
| }); | |
| it("accepts PNG image uploads and returns an asset path", async () => { | |
| const png = createStorageService("image/png"); | |
| const app = createApp(png); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/assets/images") | |
| .field("namespace", "goals") | |
| .attach("file", Buffer.from("png"), "logo.png"); | |
| expect(res.status).toBe(201); | |
| expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); | |
| expect(createAssetMock).toHaveBeenCalledTimes(1); | |
| expect(png.putFile).toHaveBeenCalledWith({ | |
| companyId: "company-1", | |
| namespace: "assets/goals", | |
| originalFilename: "logo.png", | |
| contentType: "image/png", | |
| body: expect.any(Buffer), | |
| }); | |
| }); | |
| it("allows supported non-image attachments outside the company logo flow", async () => { | |
| const text = createStorageService("text/plain"); | |
| const app = createApp(text); | |
| createAssetMock.mockResolvedValue({ | |
| ...createAsset(), | |
| contentType: "text/plain", | |
| originalFilename: "note.txt", | |
| }); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/assets/images") | |
| .field("namespace", "issues/drafts") | |
| .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }); | |
| expect(res.status).toBe(201); | |
| expect(text.putFile).toHaveBeenCalledWith({ | |
| companyId: "company-1", | |
| namespace: "assets/issues/drafts", | |
| originalFilename: "note.txt", | |
| contentType: "text/plain", | |
| body: expect.any(Buffer), | |
| }); | |
| }); | |
| }); | |
| describe("POST /api/companies/:companyId/logo", () => { | |
| afterEach(() => { | |
| createAssetMock.mockReset(); | |
| getAssetByIdMock.mockReset(); | |
| logActivityMock.mockReset(); | |
| }); | |
| it("accepts PNG logo uploads and returns an asset path", async () => { | |
| const png = createStorageService("image/png"); | |
| const app = createApp(png); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach("file", Buffer.from("png"), "logo.png"); | |
| expect(res.status).toBe(201); | |
| expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); | |
| expect(createAssetMock).toHaveBeenCalledTimes(1); | |
| expect(png.putFile).toHaveBeenCalledWith({ | |
| companyId: "company-1", | |
| namespace: "assets/companies", | |
| originalFilename: "logo.png", | |
| contentType: "image/png", | |
| body: expect.any(Buffer), | |
| }); | |
| }); | |
| it("sanitizes SVG logo uploads before storing them", async () => { | |
| const svg = createStorageService("image/svg+xml"); | |
| const app = createApp(svg); | |
| createAssetMock.mockResolvedValue({ | |
| ...createAsset(), | |
| contentType: "image/svg+xml", | |
| originalFilename: "logo.svg", | |
| }); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach( | |
| "file", | |
| Buffer.from( | |
| "<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>", | |
| ), | |
| "logo.svg", | |
| ); | |
| expect(res.status).toBe(201); | |
| expect(svg.putFile).toHaveBeenCalledTimes(1); | |
| const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]; | |
| expect(stored.contentType).toBe("image/svg+xml"); | |
| expect(stored.originalFilename).toBe("logo.svg"); | |
| const body = stored.body.toString("utf8"); | |
| expect(body).toContain("<svg"); | |
| expect(body).toContain("<circle"); | |
| expect(body).not.toContain("<script"); | |
| expect(body).not.toContain("onload="); | |
| expect(body).not.toContain("https://evil.example/"); | |
| }); | |
| it("allows logo uploads within the general attachment limit", async () => { | |
| const png = createStorageService("image/png"); | |
| const app = createApp(png); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const file = Buffer.alloc(150 * 1024, "a"); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach("file", file, "within-limit.png"); | |
| expect(res.status).toBe(201); | |
| }); | |
| it("rejects logo files larger than the general attachment limit", async () => { | |
| const app = createApp(createStorageService()); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a"); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach("file", file, "too-large.png"); | |
| expect(res.status).toBe(422); | |
| expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`); | |
| }); | |
| it("rejects unsupported image types", async () => { | |
| const app = createApp(createStorageService("text/plain")); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach("file", Buffer.from("not an image"), "note.txt"); | |
| expect(res.status).toBe(422); | |
| expect(res.body.error).toBe("Unsupported image type: text/plain"); | |
| expect(createAssetMock).not.toHaveBeenCalled(); | |
| }); | |
| it("rejects SVG image uploads that cannot be sanitized", async () => { | |
| const app = createApp(createStorageService("image/svg+xml")); | |
| createAssetMock.mockResolvedValue(createAsset()); | |
| const res = await request(app) | |
| .post("/api/companies/company-1/logo") | |
| .attach("file", Buffer.from("not actually svg"), "logo.svg"); | |
| expect(res.status).toBe(422); | |
| expect(res.body.error).toBe("SVG could not be sanitized"); | |
| expect(createAssetMock).not.toHaveBeenCalled(); | |
| }); | |
| }); | |