import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; import { downloadMSTeamsGraphMedia } from "./attachments/graph.js"; import { setMSTeamsRuntime } from "./runtime.js"; const GRAPH_HOST = "graph.microsoft.com"; const SHAREPOINT_HOST = "contoso.sharepoint.com"; const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`; const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`; const DEFAULT_MAX_BYTES = 1024 * 1024; const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST]; const DEFAULT_SHARE_REFERENCE_URL = `https://${SHAREPOINT_HOST}/site/file`; const CONTENT_TYPE_IMAGE_PNG = "image/png"; const CONTENT_TYPE_APPLICATION_PDF = "application/pdf"; const PNG_BUFFER = Buffer.from("png"); const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG); const saveMediaBufferMock = vi.fn(async () => ({ id: "saved.png", path: "/tmp/saved.png", size: Buffer.byteLength(PNG_BUFFER), contentType: CONTENT_TYPE_IMAGE_PNG, })); const readRemoteMediaResponse = async ( res: Response, params: { maxBytes?: number; filePathHint?: string }, ) => { 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 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, { redirect: "manual" }); return readRemoteMediaResponse(res, params); }, ); const runtimeStub = { media: { detectMime: detectMimeMock, }, channel: { media: { fetchRemoteMedia: fetchRemoteMediaMock, saveMediaBuffer: saveMediaBufferMock, }, }, } as unknown as PluginRuntime; type DownloadGraphMediaParams = Parameters[0]; type DownloadGraphMediaOverrides = Partial< Omit >; type FetchFn = typeof fetch; type LabeledCase = { label: string }; type GraphFetchMockOptions = { hostedContents?: unknown[]; attachments?: unknown[]; messageAttachments?: unknown[]; onShareRequest?: (url: string) => Response | Promise; onUnhandled?: (url: string) => Response | Promise | undefined; }; type GraphMediaDownloadResult = { fetchMock: ReturnType; media: Awaited>; }; type GraphMediaSuccessCase = LabeledCase & { buildOptions: () => GraphFetchMockOptions; expectedLength: number; assert?: (params: GraphMediaDownloadResult) => void; }; const withLabel = (label: string, fields: T): T & LabeledCase => ({ label, ...fields, }); const createTokenProvider = ( tokenOrResolver: string | ((scope: string) => string | Promise) = "token", ) => ({ getAccessToken: vi.fn(async (scope: string) => typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver, ), }); const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => { const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); return new Response(new Uint8Array(raw), { status, headers: { "content-type": contentType }, }); }; const createPdfResponse = (payload: Buffer | string = Buffer.from("pdf")) => createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF); const createJsonResponse = (payload: unknown, status = 200) => new Response(JSON.stringify(payload), { status }); const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value }); const createNotFoundResponse = () => new Response("not found", { status: 404 }); const createRedirectResponse = (location: string, status = 302) => new Response(null, { status, headers: { location } }); const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn; const expectAttachmentMediaLength = ( media: Awaited>["media"], expectedLength: number, ) => { expect(media).toHaveLength(expectedLength); }; const expectMediaBufferSaved = () => { expect(saveMediaBufferMock).toHaveBeenCalled(); }; const createHostedContentsWithType = (contentType: string, ...ids: string[]) => ids.map((id) => ({ id, contentType, contentBytes: PNG_BUFFER.toString("base64") })); const createHostedImageContents = (...ids: string[]) => createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids); const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({ id: "ref-1", contentType: "reference", contentUrl: shareUrl, name: "report.pdf", }); const buildShareReferenceGraphFetchOptions = (params: { referenceAttachment: ReturnType; onShareRequest?: GraphFetchMockOptions["onShareRequest"]; onUnhandled?: GraphFetchMockOptions["onUnhandled"]; }) => ({ attachments: [params.referenceAttachment], messageAttachments: [params.referenceAttachment], ...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}), ...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}), }); const buildDefaultShareReferenceGraphFetchOptions = ( params: Omit[0], "referenceAttachment">, ) => buildShareReferenceGraphFetchOptions({ referenceAttachment: createReferenceAttachment(), ...params, }); type GraphEndpointResponseHandler = { suffix: string; buildResponse: () => Response; }; const createGraphEndpointResponseHandlers = (params: { hostedContents: unknown[]; attachments: unknown[]; messageAttachments: unknown[]; }): GraphEndpointResponseHandler[] => [ { suffix: "/hostedContents", buildResponse: () => createGraphCollectionResponse(params.hostedContents), }, { suffix: "/attachments", buildResponse: () => createGraphCollectionResponse(params.attachments), }, { suffix: "/messages/123", buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }), }, ]; const resolveGraphEndpointResponse = ( url: string, handlers: GraphEndpointResponseHandler[], ): Response | undefined => { const handler = handlers.find((entry) => url.endsWith(entry.suffix)); return handler ? handler.buildResponse() : undefined; }; const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => { const hostedContents = options.hostedContents ?? []; const attachments = options.attachments ?? []; const messageAttachments = options.messageAttachments ?? []; const endpointHandlers = createGraphEndpointResponseHandlers({ hostedContents, attachments, messageAttachments, }); return vi.fn(async (url: string) => { const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers); if (endpointResponse) { return endpointResponse; } if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) { return options.onShareRequest(url); } const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined; return unhandled ?? createNotFoundResponse(); }); }; const downloadGraphMediaWithMockOptions = async ( options: GraphFetchMockOptions = {}, overrides: DownloadGraphMediaOverrides = {}, ): Promise => { const fetchMock = createGraphFetchMock(options); const media = await downloadMSTeamsGraphMedia({ messageUrl: DEFAULT_MESSAGE_URL, tokenProvider: createTokenProvider(), maxBytes: DEFAULT_MAX_BYTES, fetchFn: asFetchFn(fetchMock), ...overrides, }); return { fetchMock, media }; }; const runGraphMediaSuccessCase = async ({ buildOptions, expectedLength, assert, }: GraphMediaSuccessCase) => { const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions()); expectAttachmentMediaLength(media.media, expectedLength); assert?.({ fetchMock, media }); }; const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [ withLabel("downloads hostedContents images", { buildOptions: () => ({ hostedContents: createHostedImageContents("1") }), expectedLength: 1, assert: ({ fetchMock }) => { expect(fetchMock).toHaveBeenCalled(); expectMediaBufferSaved(); }, }), withLabel("merges SharePoint reference attachments with hosted content", { buildOptions: () => { return { hostedContents: createHostedImageContents("hosted-1"), ...buildDefaultShareReferenceGraphFetchOptions({ onShareRequest: () => createPdfResponse(), }), }; }, expectedLength: 2, }), ]; describe("msteams graph attachments", () => { beforeEach(() => { detectMimeMock.mockClear(); fetchRemoteMediaMock.mockClear(); saveMediaBufferMock.mockClear(); setMSTeamsRuntime(runtimeStub); }); it.each(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase); it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => { const tokenProvider = createTokenProvider("top-secret-token"); const escapedUrl = "https://example.com/collect"; const seen: Array<{ url: string; auth: string }> = []; const referenceAttachment = createReferenceAttachment(); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const auth = new Headers(init?.headers).get("Authorization") ?? ""; seen.push({ url, auth }); if (url === DEFAULT_MESSAGE_URL) { return createJsonResponse({ attachments: [referenceAttachment] }); } if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) { return createGraphCollectionResponse([]); } if (url === `${DEFAULT_MESSAGE_URL}/attachments`) { return createGraphCollectionResponse([referenceAttachment]); } if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) { return createRedirectResponse(escapedUrl); } if (url === escapedUrl) { return createPdfResponse(); } return createNotFoundResponse(); }); const media = await downloadMSTeamsGraphMedia({ messageUrl: DEFAULT_MESSAGE_URL, tokenProvider, maxBytes: DEFAULT_MAX_BYTES, allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"], authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, fetchFn: asFetchFn(fetchMock), }); expectAttachmentMediaLength(media.media, 1); const redirected = seen.find((entry) => entry.url === escapedUrl); expect(redirected).toBeDefined(); expect(redirected?.auth).toBe(""); }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { const escapedUrl = "https://evil.example/internal.pdf"; const { fetchMock, media } = await downloadGraphMediaWithMockOptions( { ...buildDefaultShareReferenceGraphFetchOptions({ onShareRequest: () => createRedirectResponse(escapedUrl), onUnhandled: (url) => { if (url === escapedUrl) { return createPdfResponse("should-not-be-fetched"); } return undefined; }, }), }, { allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, }, ); expectAttachmentMediaLength(media.media, 0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true); expect(calledUrls).not.toContain(escapedUrl); }); });