import { afterEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; 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", "companies") .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 image 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/assets/images") .field("namespace", "companies") .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 app = createApp(createStorageService()); createAssetMock.mockResolvedValue(createAsset()); const file = Buffer.alloc(100 * 1024 + 1, "a"); const res = await request(app) .post("/api/companies/company-1/assets/images") .field("namespace", "companies") .attach("file", file, "too-large.png"); expect(res.status).toBe(422); expect(res.body.error).toBe("Image exceeds 102400 bytes"); }); it("allows larger non-logo images within the general asset limit", async () => { const png = createStorageService("image/png"); const app = createApp(png); createAssetMock.mockResolvedValue({ ...createAsset(), contentType: "image/png", originalFilename: "goal.png", }); const file = Buffer.alloc(150 * 1024, "a"); const res = await request(app) .post("/api/companies/company-1/assets/images") .field("namespace", "goals") .attach("file", file, "goal.png"); expect(res.status).toBe(201); expect(createAssetMock).toHaveBeenCalledTimes(1); }); 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/assets/images") .field("namespace", "companies") .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/assets/images") .field("namespace", "companies") .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(); }); });