/** * Tests for attachment rendering, URL resolution, file downloads, * and content-type sanitization in attachments.ts. * * The sibling attachments-cache.test.ts covers cache invalidation flows. * This file covers the rendering paths and helper functions. */ import { beforeEach, describe, expect, it, vi } from "vitest"; const { fetchMock, saveMock, writeFileMock } = vi.hoisted(() => ({ fetchMock: vi.fn(), saveMock: vi.fn(), writeFileMock: vi.fn(), })); vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock, })); // The HTTP TOFU proxy resolves a server host to a fixed loopback origin so // server-bound fetches are cert-pinned; external URLs bypass it. vi.mock("@lib/httpProxy", () => ({ ensureHttpProxy: () => Promise.resolve("http://127.0.0.1:9999"), stopHttpProxy: () => Promise.resolve(), })); vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); vi.mock("@tauri-apps/plugin-dialog", () => ({ save: saveMock })); vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile: writeFileMock })); vi.mock("@lib/icons", () => ({ createIcon: () => document.createElement("span") })); vi.mock("@lib/media-visibility", () => ({ observeMedia: vi.fn() })); vi.mock("../../src/components/message-list/media", () => ({ openImageLightbox: vi.fn() })); // Provide a minimal indexedDB stub that returns null from idbGet // so renderAttachment always goes through the network fetch path. vi.stubGlobal("indexedDB", { open: () => { const db = { objectStoreNames: { contains: () => true }, createObjectStore: vi.fn(), close: vi.fn(), transaction: () => { const tx: Record = { oncomplete: null, onabort: null, onerror: null, objectStore: () => ({ get: () => { const req: Record = { onsuccess: null, onerror: null, result: undefined, }; Promise.resolve().then(() => { const fn = req.onsuccess as ((ev: Event) => void) | null; fn?.(new Event("success")); }); return req; }, put: vi.fn(), }), }; Promise.resolve().then(() => { const fn = tx.oncomplete as ((ev: Event) => void) | null; fn?.(new Event("complete")); }); return tx; }, }; const req: Record = { result: db, onsuccess: null, onerror: null, onupgradeneeded: null, }; Promise.resolve().then(() => { const upgrade = req.onupgradeneeded as ((ev: Event) => void) | null; upgrade?.(new Event("upgradeneeded")); const success = req.onsuccess as ((ev: Event) => void) | null; success?.(new Event("success")); }); return req; }, }); import { setServerHost, resolveServerUrl, formatFileSize, isImageMime, isSafeUrl, isTrustedServerUrl, clearAttachmentCaches, uint8ToBase64, renderAttachment, fetchImageAsDataUrl, } from "../../src/components/message-list/attachments"; describe("resolveServerUrl", () => { beforeEach(() => { // Reset server host to a known value setServerHost("myserver.local:8443"); }); it("returns absolute http URLs unchanged", () => { expect(resolveServerUrl("http://other.com/file.png")).toBe("http://other.com/file.png"); }); it("returns absolute https URLs unchanged", () => { expect(resolveServerUrl("https://cdn.example.com/file.png")).toBe( "https://cdn.example.com/file.png", ); }); it("prepends server host for relative paths", () => { expect(resolveServerUrl("/api/v1/attachments/1.png")).toBe( "https://myserver.local:8443/api/v1/attachments/1.png", ); }); it("returns the path as-is when no server host is set", () => { // Trick: create a module-level override by calling the function // We cannot unset _serverHost since it's module-level, but resolveServerUrl // always has a host set after the beforeEach above. Testing the fallback // path would require a separate module instance — but we can test that // absolute URLs pass through regardless. expect(resolveServerUrl("https://example.com/img.png")).toBe("https://example.com/img.png"); }); }); describe("formatFileSize", () => { it("formats bytes", () => { expect(formatFileSize(500)).toBe("500 B"); }); it("formats kilobytes", () => { expect(formatFileSize(2048)).toBe("2.0 KB"); }); it("formats megabytes", () => { expect(formatFileSize(5 * 1024 * 1024)).toBe("5.0 MB"); }); it("handles exact KB boundary", () => { expect(formatFileSize(1024)).toBe("1.0 KB"); }); it("handles sub-KB boundary", () => { expect(formatFileSize(1023)).toBe("1023 B"); }); }); describe("isImageMime", () => { it("returns true for image/png", () => { expect(isImageMime("image/png")).toBe(true); }); it("returns true for image/gif", () => { expect(isImageMime("image/gif")).toBe(true); }); // SVG can carry script, so it is deliberately not an inline image type. it("returns false for image/svg+xml", () => { expect(isImageMime("image/svg+xml")).toBe(false); }); it("returns false for application/pdf", () => { expect(isImageMime("application/pdf")).toBe(false); }); it("returns false for text/plain", () => { expect(isImageMime("text/plain")).toBe(false); }); }); describe("isSafeUrl", () => { it("allows http URLs", () => { expect(isSafeUrl("http://example.com/file.txt")).toBe(true); }); it("allows https URLs", () => { expect(isSafeUrl("https://example.com/file.txt")).toBe(true); }); it("rejects javascript: URLs", () => { expect(isSafeUrl("javascript:alert(1)")).toBe(false); }); it("rejects data: URLs", () => { expect(isSafeUrl("data:text/html,