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) { 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( "", ), "logo.svg", ); expect(res.status).toBe(201); expect(svg.putFile).toHaveBeenCalledTimes(1); const stored = (svg.putFile as ReturnType).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(" { 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(); }); });