import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent"; import { DEFAULT_ATTACHMENT_FOLDER, attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; vi.mock("@earendil-works/pi-coding-agent", () => ({ formatDimensionNote: vi.fn(), resizeImage: vi.fn(), })); let workspace: string; let externalDirectories: string[] = []; beforeEach(async () => { vi.mocked(formatDimensionNote).mockReset(); vi.mocked(resizeImage).mockReset(); workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-")); externalDirectories = []; }); afterEach(async () => { await Promise.all([ rm(workspace, { recursive: true, force: true }), ...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })), ]); }); const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const pngBase64 = pngBytes.toString("base64"); function resizedImage(overrides: Partial = {}): ResizedImage { return { data: "resized-data", mimeType: "image/png", originalWidth: 2400, originalHeight: 1200, width: 1200, height: 600, wasResized: true, ...overrides, }; } describe("attachmentsToInlineImages", () => { it("resizes images, drops unresizable images, and preserves dimension notes", async () => { const firstInput = Buffer.from("first image"); const droppedInput = Buffer.from("too large"); const thirdInput = Buffer.from("third image"); const firstResized = resizedImage({ data: "first-resized", mimeType: "image/webp" }); const thirdResized = resizedImage({ data: "third-resized", mimeType: "image/jpeg", originalWidth: 640, originalHeight: 480, width: 640, height: 480, wasResized: false, }); vi.mocked(resizeImage) .mockResolvedValueOnce(firstResized) .mockResolvedValueOnce(null) .mockResolvedValueOnce(thirdResized); vi.mocked(formatDimensionNote) .mockReturnValueOnce("[Image dimensions changed.]") .mockReturnValueOnce(undefined); await expect(attachmentsToInlineImages([ { kind: "image", mimeType: "image/png", data: firstInput.toString("base64"), name: "first.png" }, { kind: "image", mimeType: "image/png", data: droppedInput.toString("base64"), name: "huge.png" }, { kind: "image", mimeType: "image/jpeg", data: thirdInput.toString("base64"), name: "photo.jpg" }, ])).resolves.toEqual([ { image: { type: "image", data: "first-resized", mimeType: "image/webp" }, dimensionNote: "[Image dimensions changed.]", }, { image: { type: "image", data: "third-resized", mimeType: "image/jpeg" } }, ]); expect(resizeImage).toHaveBeenNthCalledWith(1, firstInput, "image/png"); expect(resizeImage).toHaveBeenNthCalledWith(2, droppedInput, "image/png"); expect(resizeImage).toHaveBeenNthCalledWith(3, thirdInput, "image/jpeg"); expect(formatDimensionNote).toHaveBeenNthCalledWith(1, firstResized); expect(formatDimensionNote).toHaveBeenNthCalledWith(2, thirdResized); }); }); describe("saveAttachmentsToWorkspace", () => { it("writes attachments into the default folder and returns relative paths", async () => { const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); const saved = await saveAttachmentsToWorkspace( workspace, [ { kind: "image", mimeType: "image/png", data: pngBase64, name: "a.png" }, { kind: "image", mimeType: "image/webp", data: pngBase64, name: "b.webp" }, ], { now: fixedNow }, ); expect(saved).toHaveLength(2); expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true); expect(saved[0]?.path.endsWith(".png")).toBe(true); expect(saved[1]?.path.endsWith(".webp")).toBe(true); expect(saved[0]?.size).toBe(pngBytes.byteLength); const folderEntries = await readdir(join(workspace, ".pi-web", "attachments")); expect(folderEntries).toHaveLength(2); const firstPath = saved[0]?.path ?? ""; const written = await readFile(join(workspace, firstPath)); expect(written.equals(pngBytes)).toBe(true); }); it("saves generic files with sanitized original filenames", async () => { const pdfBytes = Buffer.from("PDF bytes"); const saved = await saveAttachmentsToWorkspace( workspace, [ { kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" }, { kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }, ], { now: () => new Date("2026-06-13T12:05:01.123Z") }, ); expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true); expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true); expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength }); expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true); expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 }); expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true); expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0); }); it("falls back, strips controls, and truncates unsafe attachment names", async () => { const longStem = "a".repeat(140); const saved = await saveAttachmentsToWorkspace( workspace, [ { kind: "image", mimeType: "image/jpeg", data: pngBase64 }, { kind: "file", mimeType: "application/octet-stream", data: "QUJD", name: "\u0000\u001f\u007f" }, { kind: "file", mimeType: "text/plain", data: "REVG", name: "nested/bad\u0000\u007fname\n.txt" }, { kind: "file", mimeType: "application/pdf", data: "R0hJ", name: `${longStem}.pdf` }, ], { now: () => new Date(2026, 5, 13, 12, 5, 1, 123) }, ); expect(saved.map((attachment) => basename(attachment.path))).toEqual([ "attachment-20260613-120501-123-1-image.jpg", "attachment-20260613-120501-123-2-file.bin", "attachment-20260613-120501-123-3-badname.txt", `attachment-20260613-120501-123-4-${"a".repeat(92)}.pdf`, ]); }); it("does not overwrite an existing attachment name", async () => { const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); const first = await saveAttachmentsToWorkspace( workspace, [{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }], { now: fixedNow }, ); const second = await saveAttachmentsToWorkspace( workspace, [{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }], { now: fixedNow }, ); expect(second[0]?.path).not.toBe(first[0]?.path); expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true); expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC"); expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF"); }); it("rejects unsafe custom folders", async () => { await expect(saveAttachmentsToWorkspace( workspace, [{ kind: "image", mimeType: "image/png", data: pngBase64 }], { folder: "/tmp/uploads" }, )).rejects.toThrow(/Absolute paths/); await expect(saveAttachmentsToWorkspace( workspace, [{ kind: "image", mimeType: "image/png", data: pngBase64 }], { folder: "../uploads" }, )).rejects.toThrow(/Path traversal/); }); it("honors a custom folder", async () => { const saved = await saveAttachmentsToWorkspace( workspace, [{ kind: "image", mimeType: "image/png", data: pngBase64 }], { folder: "uploads/images" }, ); expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true); }); it("rejects attachment folders that resolve outside the workspace", async () => { const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-")); externalDirectories.push(outside); await mkdir(join(workspace, ".pi-web")); await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir"); await expect(saveAttachmentsToWorkspace( workspace, [{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }], )).rejects.toThrow(/Path escapes workspace/); await expect(readdir(outside)).resolves.toEqual([]); }); it("returns empty for no attachments", async () => { expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]); }); });