import type { PluginRuntime } from "sophiaclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setMSTeamsRuntime } from "./runtime.js"; /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ const publicResolveFn = async () => ({ address: "13.107.136.10" }); const detectMimeMock = vi.fn(async () => "image/png"); const saveMediaBufferMock = vi.fn(async () => ({ path: "/tmp/saved.png", contentType: "image/png", })); const fetchRemoteMediaMock = vi.fn( async (params: { url: string; maxBytes?: number; filePathHint?: string; fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }) => { const fetchFn = params.fetchImpl ?? fetch; const res = await fetchFn(params.url); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const buffer = Buffer.from(await res.arrayBuffer()); if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); } return { buffer, contentType: res.headers.get("content-type") ?? undefined, fileName: params.filePathHint, }; }, ); const runtimeStub = { media: { detectMime: detectMimeMock as unknown as PluginRuntime["media"]["detectMime"], }, channel: { media: { fetchRemoteMedia: fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], saveMediaBuffer: saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], }, }, } as unknown as PluginRuntime; describe("msteams attachments", () => { const load = async () => { return await import("./attachments.js"); }; beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); fetchRemoteMediaMock.mockClear(); setMSTeamsRuntime(runtimeStub); }); describe("buildMSTeamsAttachmentPlaceholder", () => { it("returns empty string when no attachments", async () => { const { buildMSTeamsAttachmentPlaceholder } = await load(); expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); }); it("returns image placeholder for image attachments", async () => { const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "image/png", contentUrl: "https://x/img.png" }, ]), ).toBe(""); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "image/png", contentUrl: "https://x/1.png" }, { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" }, ]), ).toBe(" (2 images)"); }); it("treats Teams file.download.info image attachments as images", async () => { const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/vnd.microsoft.teams.file.download.info", content: { downloadUrl: "https://x/dl", fileType: "png" }, }, ]), ).toBe(""); }); it("returns document placeholder for non-image attachments", async () => { const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, ]), ).toBe(""); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/pdf", contentUrl: "https://x/1.pdf" }, { contentType: "application/pdf", contentUrl: "https://x/2.pdf" }, ]), ).toBe(" (2 files)"); }); it("counts inline images in text/html attachments", async () => { const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "text/html", content: '

hi

', }, ]), ).toBe(""); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "text/html", content: '', }, ]), ).toBe(" (2 images)"); }); }); describe("downloadMSTeamsAttachments", () => { it("downloads and stores image contentUrl attachments", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, headers: { "content-type": "image/png" }, }); }); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], maxBytes: 1024 * 1024, allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); expect(media).toHaveLength(1); expect(media[0]?.path).toBe("/tmp/saved.png"); }); it("supports Teams file.download.info downloadUrl attachments", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, headers: { "content-type": "image/png" }, }); }); const media = await downloadMSTeamsAttachments({ attachments: [ { contentType: "application/vnd.microsoft.teams.file.download.info", content: { downloadUrl: "https://x/dl", fileType: "png" }, }, ], maxBytes: 1024 * 1024, allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); }); it("downloads non-image file attachments (PDF)", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("pdf"), { status: 200, headers: { "content-type": "application/pdf" }, }); }); detectMimeMock.mockResolvedValueOnce("application/pdf"); saveMediaBufferMock.mockResolvedValueOnce({ path: "/tmp/saved.pdf", contentType: "application/pdf", }); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], maxBytes: 1024 * 1024, allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); expect(media[0]?.path).toBe("/tmp/saved.pdf"); expect(media[0]?.placeholder).toBe(""); }); it("downloads inline image URLs from html attachments", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async () => { return new Response(Buffer.from("png"), { status: 200, headers: { "content-type": "image/png" }, }); }); const media = await downloadMSTeamsAttachments({ attachments: [ { contentType: "text/html", content: '', }, ], maxBytes: 1024 * 1024, allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); }); it("stores inline data:image base64 payloads", async () => { const { downloadMSTeamsAttachments } = await load(); const base64 = Buffer.from("png").toString("base64"); const media = await downloadMSTeamsAttachments({ attachments: [ { contentType: "text/html", content: ``, }, ], maxBytes: 1024 * 1024, allowHosts: ["x"], }); expect(media).toHaveLength(1); expect(saveMediaBufferMock).toHaveBeenCalled(); }); it("retries with auth when the first request is unauthorized", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); const hasAuth = Boolean(headers.get("Authorization")); if (!hasAuth) { return new Response("unauthorized", { status: 401 }); } return new Response(Buffer.from("png"), { status: 200, headers: { "content-type": "image/png" }, }); }); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], maxBytes: 1024 * 1024, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, allowHosts: ["x"], authAllowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); }); it("skips auth retries when the host is not in auth allowlist", async () => { const { downloadMSTeamsAttachments } = await load(); const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); const hasAuth = Boolean(headers.get("Authorization")); if (!hasAuth) { return new Response("forbidden", { status: 403 }); } return new Response(Buffer.from("png"), { status: 200, headers: { "content-type": "image/png" }, }); }); const media = await downloadMSTeamsAttachments({ attachments: [ { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, ], maxBytes: 1024 * 1024, tokenProvider, allowHosts: ["azureedge.net"], authAllowHosts: ["graph.microsoft.com"], fetchFn: fetchMock as unknown as typeof fetch, resolveFn: publicResolveFn, }); expect(media).toHaveLength(0); expect(fetchMock).toHaveBeenCalled(); expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); }); it("skips urls outside the allowlist", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); const media = await downloadMSTeamsAttachments({ attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], maxBytes: 1024 * 1024, allowHosts: ["graph.microsoft.com"], fetchFn: fetchMock as unknown as typeof fetch, }); expect(media).toHaveLength(0); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("buildMSTeamsGraphMessageUrls", () => { it("builds channel message urls", async () => { const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", conversationId: "19:thread@thread.tacv2", messageId: "123", channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, }); expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); }); it("builds channel reply urls when replyToId is present", async () => { const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", messageId: "reply-id", replyToId: "root-id", channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, }); expect(urls[0]).toContain( "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", ); }); it("builds chat message urls", async () => { const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "groupChat", conversationId: "19:chat@thread.v2", messageId: "456", }); expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); }); }); describe("downloadMSTeamsGraphMedia", () => { it("downloads hostedContents images", async () => { const { downloadMSTeamsGraphMedia } = await load(); const base64 = Buffer.from("png").toString("base64"); const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { return new Response( JSON.stringify({ value: [ { id: "1", contentType: "image/png", contentBytes: base64, }, ], }), { status: 200 }, ); } if (url.endsWith("/attachments")) { return new Response(JSON.stringify({ value: [] }), { status: 200 }); } return new Response("not found", { status: 404 }); }); const media = await downloadMSTeamsGraphMedia({ messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", tokenProvider: { getAccessToken: vi.fn(async () => "token") }, maxBytes: 1024 * 1024, fetchFn: fetchMock as unknown as typeof fetch, }); expect(media.media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); }); it("merges SharePoint reference attachments with hosted content", async () => { const { downloadMSTeamsGraphMedia } = await load(); const hostedBase64 = Buffer.from("png").toString("base64"); const shareUrl = "https://contoso.sharepoint.com/site/file"; const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { return new Response( JSON.stringify({ value: [ { id: "hosted-1", contentType: "image/png", contentBytes: hostedBase64, }, ], }), { status: 200 }, ); } if (url.endsWith("/attachments")) { return new Response( JSON.stringify({ value: [ { id: "ref-1", contentType: "reference", contentUrl: shareUrl, name: "report.pdf", }, ], }), { status: 200 }, ); } if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { return new Response(Buffer.from("pdf"), { status: 200, headers: { "content-type": "application/pdf" }, }); } if (url.endsWith("/messages/123")) { return new Response( JSON.stringify({ attachments: [ { id: "ref-1", contentType: "reference", contentUrl: shareUrl, name: "report.pdf", }, ], }), { status: 200 }, ); } return new Response("not found", { status: 404 }); }); const media = await downloadMSTeamsGraphMedia({ messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", tokenProvider: { getAccessToken: vi.fn(async () => "token") }, maxBytes: 1024 * 1024, fetchFn: fetchMock as unknown as typeof fetch, }); expect(media.media).toHaveLength(2); }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { const { downloadMSTeamsGraphMedia } = await load(); const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { const fetchFn = params.fetchImpl ?? fetch; let currentUrl = params.url; for (let i = 0; i < 5; i += 1) { const res = await fetchFn(currentUrl, { redirect: "manual" }); if ([301, 302, 303, 307, 308].includes(res.status)) { const location = res.headers.get("location"); if (!location) { throw new Error("redirect missing location"); } currentUrl = new URL(location, currentUrl).toString(); continue; } if (!res.ok) { throw new Error(`HTTP ${res.status}`); } return { buffer: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get("content-type") ?? undefined, fileName: params.filePathHint, }; } throw new Error("too many redirects"); }); const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { return new Response(JSON.stringify({ value: [] }), { status: 200 }); } if (url.endsWith("/attachments")) { return new Response(JSON.stringify({ value: [] }), { status: 200 }); } if (url.endsWith("/messages/123")) { return new Response( JSON.stringify({ attachments: [ { id: "ref-1", contentType: "reference", contentUrl: shareUrl, name: "report.pdf", }, ], }), { status: 200 }, ); } if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { return new Response(null, { status: 302, headers: { location: escapedUrl }, }); } if (url === escapedUrl) { return new Response(Buffer.from("should-not-be-fetched"), { status: 200, headers: { "content-type": "application/pdf" }, }); } return new Response("not found", { status: 404 }); }); const media = await downloadMSTeamsGraphMedia({ messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", tokenProvider: { getAccessToken: vi.fn(async () => "token") }, maxBytes: 1024 * 1024, allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], fetchFn: fetchMock as unknown as typeof fetch, }); expect(media.media).toHaveLength(0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); expect( calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")), ).toBe(true); expect(calledUrls).not.toContain(escapedUrl); }); }); describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { const { buildMSTeamsMediaPayload } = await load(); const payload = buildMSTeamsMediaPayload([ { path: "/tmp/a.png", contentType: "image/png" }, { path: "/tmp/b.png", contentType: "image/png" }, ]); expect(payload.MediaPath).toBe("/tmp/a.png"); expect(payload.MediaUrl).toBe("/tmp/a.png"); expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]); expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]); expect(payload.MediaTypes).toEqual(["image/png", "image/png"]); }); }); });