@openclaw/msteams 2026.2.21 → 2026.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,85 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ buildMSTeamsAttachmentPlaceholder,
5
+ buildMSTeamsGraphMessageUrls,
6
+ buildMSTeamsMediaPayload,
7
+ downloadMSTeamsAttachments,
8
+ downloadMSTeamsGraphMedia,
9
+ } from "./attachments.js";
3
10
  import { setMSTeamsRuntime } from "./runtime.js";
4
11
 
5
- const detectMimeMock = vi.fn(async () => "image/png");
12
+ vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
14
+ return {
15
+ ...actual,
16
+ isPrivateIpAddress: () => false,
17
+ };
18
+ });
19
+
20
+ /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
21
+ const publicResolveFn = async () => ({ address: "13.107.136.10" });
22
+ const GRAPH_HOST = "graph.microsoft.com";
23
+ const SHAREPOINT_HOST = "contoso.sharepoint.com";
24
+ const AZUREEDGE_HOST = "azureedge.net";
25
+ const TEST_HOST = "x";
26
+ const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`;
27
+ const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment);
28
+ const SAVED_PNG_PATH = "/tmp/saved.png";
29
+ const SAVED_PDF_PATH = "/tmp/saved.pdf";
30
+ const TEST_URL_IMAGE = createTestUrl("img");
31
+ const TEST_URL_IMAGE_PNG = createTestUrl("img.png");
32
+ const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
33
+ const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
34
+ const TEST_URL_PDF = createTestUrl("x.pdf");
35
+ const TEST_URL_PDF_1 = createTestUrl("1.pdf");
36
+ const TEST_URL_PDF_2 = createTestUrl("2.pdf");
37
+ const TEST_URL_HTML_A = createTestUrl("a.png");
38
+ const TEST_URL_HTML_B = createTestUrl("b.png");
39
+ const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png");
40
+ const TEST_URL_DOC_PDF = createTestUrl("doc.pdf");
41
+ const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl");
42
+ const TEST_URL_OUTSIDE_ALLOWLIST = "https://evil.test/img";
43
+ const CONTENT_TYPE_IMAGE_PNG = "image/png";
44
+ const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
45
+ const CONTENT_TYPE_TEXT_HTML = "text/html";
46
+ const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
47
+ const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
48
+ const MAX_REDIRECT_HOPS = 5;
49
+ type RemoteMediaFetchParams = {
50
+ url: string;
51
+ maxBytes?: number;
52
+ filePathHint?: string;
53
+ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
54
+ };
55
+
56
+ const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
6
57
  const saveMediaBufferMock = vi.fn(async () => ({
7
- path: "/tmp/saved.png",
8
- contentType: "image/png",
58
+ path: SAVED_PNG_PATH,
59
+ contentType: CONTENT_TYPE_IMAGE_PNG,
9
60
  }));
61
+ const readRemoteMediaResponse = async (
62
+ res: Response,
63
+ params: Pick<RemoteMediaFetchParams, "maxBytes" | "filePathHint">,
64
+ ) => {
65
+ if (!res.ok) {
66
+ throw new Error(`HTTP ${res.status}`);
67
+ }
68
+ const buffer = Buffer.from(await res.arrayBuffer());
69
+ if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
70
+ throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
71
+ }
72
+ return {
73
+ buffer,
74
+ contentType: res.headers.get("content-type") ?? undefined,
75
+ fileName: params.filePathHint,
76
+ };
77
+ };
78
+ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
79
+ const fetchFn = params.fetchImpl ?? fetch;
80
+ const res = await fetchFn(params.url);
81
+ return readRemoteMediaResponse(res, params);
82
+ });
10
83
 
11
84
  const runtimeStub = {
12
85
  media: {
@@ -14,447 +87,670 @@ const runtimeStub = {
14
87
  },
15
88
  channel: {
16
89
  media: {
90
+ fetchRemoteMedia:
91
+ fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
17
92
  saveMediaBuffer:
18
93
  saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
19
94
  },
20
95
  },
21
96
  } as unknown as PluginRuntime;
22
97
 
23
- describe("msteams attachments", () => {
24
- const load = async () => {
25
- return await import("./attachments.js");
98
+ type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
99
+ type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
100
+ type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
101
+ type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
102
+ type DownloadAttachmentsBuildOverrides = Partial<
103
+ Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
104
+ > &
105
+ Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
106
+ type DownloadAttachmentsNoFetchOverrides = Partial<
107
+ Omit<
108
+ DownloadAttachmentsParams,
109
+ "attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn"
110
+ >
111
+ > &
112
+ Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
113
+ type DownloadGraphMediaOverrides = Partial<
114
+ Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
115
+ >;
116
+ type FetchFn = typeof fetch;
117
+ type MSTeamsAttachments = DownloadAttachmentsParams["attachments"];
118
+ type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
119
+ type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
120
+ type LabeledCase = { label: string };
121
+ type FetchCallExpectation = { expectFetchCalled?: boolean };
122
+ type DownloadedMediaExpectation = { path?: string; placeholder?: string };
123
+ type MSTeamsMediaPayloadExpectation = {
124
+ firstPath: string;
125
+ paths: string[];
126
+ types: string[];
127
+ };
128
+
129
+ const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`;
130
+ const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
131
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
132
+ const DEFAULT_ALLOW_HOSTS = [TEST_HOST];
133
+ const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST];
134
+ const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file");
135
+ const MEDIA_PLACEHOLDER_IMAGE = "<media:image>";
136
+ const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>";
137
+ const formatImagePlaceholder = (count: number) =>
138
+ count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE;
139
+ const formatDocumentPlaceholder = (count: number) =>
140
+ count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT;
141
+ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST_URL_IMAGE };
142
+ const PNG_BUFFER = Buffer.from("png");
143
+ const PNG_BASE64 = PNG_BUFFER.toString("base64");
144
+ const PDF_BUFFER = Buffer.from("pdf");
145
+ const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") });
146
+ const asSingleItemArray = <T>(value: T) => [value];
147
+ const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
148
+ label,
149
+ ...fields,
150
+ });
151
+ const buildAttachment = <T extends Record<string, unknown>>(contentType: string, props: T) => ({
152
+ contentType,
153
+ ...props,
154
+ });
155
+ const createHtmlAttachment = (content: string) =>
156
+ buildAttachment(CONTENT_TYPE_TEXT_HTML, { content });
157
+ const buildHtmlImageTag = (src: string) => `<img src="${src}" />`;
158
+ const createHtmlImageAttachments = (sources: string[], prefix = "") =>
159
+ asSingleItemArray(createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`));
160
+ const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) =>
161
+ contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl }));
162
+ const createImageAttachments = (...contentUrls: string[]) =>
163
+ createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls);
164
+ const createPdfAttachments = (...contentUrls: string[]) =>
165
+ createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls);
166
+ const createTeamsFileDownloadInfoAttachments = (
167
+ downloadUrl = TEST_URL_FILE_DOWNLOAD,
168
+ fileType = "png",
169
+ ) =>
170
+ asSingleItemArray(
171
+ buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, {
172
+ content: { downloadUrl, fileType },
173
+ }),
174
+ );
175
+ const createMediaEntriesWithType = (contentType: string, ...paths: string[]) =>
176
+ paths.map((path) => ({ path, contentType }));
177
+ const createHostedContentsWithType = (contentType: string, ...ids: string[]) =>
178
+ ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 }));
179
+ const createImageMediaEntries = (...paths: string[]) =>
180
+ createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
181
+ const createHostedImageContents = (...ids: string[]) =>
182
+ createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids);
183
+ const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => {
184
+ return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF);
185
+ };
186
+ const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => {
187
+ const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
188
+ return new Response(new Uint8Array(raw), {
189
+ status,
190
+ headers: { "content-type": contentType },
191
+ });
192
+ };
193
+ const createJsonResponse = (payload: unknown, status = 200) =>
194
+ new Response(JSON.stringify(payload), { status });
195
+ const createTextResponse = (body: string, status = 200) => new Response(body, { status });
196
+ const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
197
+ const createNotFoundResponse = () => new Response("not found", { status: 404 });
198
+ const createRedirectResponse = (location: string, status = 302) =>
199
+ new Response(null, { status, headers: { location } });
200
+
201
+ const createOkFetchMock = (contentType: string, payload = "png") =>
202
+ vi.fn(async () => createBufferResponse(payload, contentType));
203
+ const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn;
204
+
205
+ const buildDownloadParams = (
206
+ attachments: MSTeamsAttachments,
207
+ overrides: DownloadAttachmentsBuildOverrides = {},
208
+ ): DownloadAttachmentsParams => {
209
+ return {
210
+ attachments,
211
+ maxBytes: DEFAULT_MAX_BYTES,
212
+ allowHosts: DEFAULT_ALLOW_HOSTS,
213
+ resolveFn: publicResolveFn,
214
+ ...overrides,
26
215
  };
216
+ };
217
+
218
+ const downloadAttachmentsWithFetch = async (
219
+ attachments: MSTeamsAttachments,
220
+ fetchFn: unknown,
221
+ overrides: DownloadAttachmentsNoFetchOverrides = {},
222
+ options: FetchCallExpectation = {},
223
+ ) => {
224
+ const media = await downloadMSTeamsAttachments(
225
+ buildDownloadParams(attachments, {
226
+ ...overrides,
227
+ fetchFn: asFetchFn(fetchFn),
228
+ }),
229
+ );
230
+ expectMockCallState(fetchFn, options.expectFetchCalled ?? true);
231
+ return media;
232
+ };
233
+
234
+ const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) =>
235
+ vi.fn(async (_url: string, opts?: RequestInit) => {
236
+ const headers = new Headers(opts?.headers);
237
+ const hasAuth = Boolean(headers.get("Authorization"));
238
+ if (!hasAuth) {
239
+ return createTextResponse(params.unauthBody, params.unauthStatus);
240
+ }
241
+ return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
242
+ });
243
+ const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => {
244
+ if (shouldCall) {
245
+ expect(mockFn).toHaveBeenCalled();
246
+ } else {
247
+ expect(mockFn).not.toHaveBeenCalled();
248
+ }
249
+ };
250
+
251
+ const DEFAULT_CHANNEL_TEAM_ID = "team-id";
252
+ const DEFAULT_CHANNEL_ID = "chan-id";
253
+ const createChannelGraphMessageUrlParams = (params: {
254
+ messageId: string;
255
+ replyToId?: string;
256
+ conversationId?: string;
257
+ }) => ({
258
+ conversationType: "channel" as const,
259
+ ...params,
260
+ channelData: {
261
+ team: { id: DEFAULT_CHANNEL_TEAM_ID },
262
+ channel: { id: DEFAULT_CHANNEL_ID },
263
+ },
264
+ });
265
+ const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) =>
266
+ params.replyToId
267
+ ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}`
268
+ : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`;
269
+
270
+ const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
271
+ expect(media).toHaveLength(expectedLength);
272
+ };
273
+ const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => {
274
+ expectAttachmentMediaLength(media, 1);
275
+ expectFirstMedia(media, expected);
276
+ };
277
+ const expectMediaBufferSaved = () => {
278
+ expect(saveMediaBufferMock).toHaveBeenCalled();
279
+ };
280
+ const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
281
+ const first = media[0];
282
+ if (expected.path !== undefined) {
283
+ expect(first?.path).toBe(expected.path);
284
+ }
285
+ if (expected.placeholder !== undefined) {
286
+ expect(first?.placeholder).toBe(expected.placeholder);
287
+ }
288
+ };
289
+ const expectMSTeamsMediaPayload = (
290
+ payload: MSTeamsMediaPayload,
291
+ expected: MSTeamsMediaPayloadExpectation,
292
+ ) => {
293
+ expect(payload.MediaPath).toBe(expected.firstPath);
294
+ expect(payload.MediaUrl).toBe(expected.firstPath);
295
+ expect(payload.MediaPaths).toEqual(expected.paths);
296
+ expect(payload.MediaUrls).toEqual(expected.paths);
297
+ expect(payload.MediaTypes).toEqual(expected.types);
298
+ };
299
+ type AttachmentPlaceholderCase = LabeledCase & {
300
+ attachments: AttachmentPlaceholderInput;
301
+ expected: string;
302
+ };
303
+ type CountedAttachmentPlaceholderCaseDef = LabeledCase & {
304
+ attachments: AttachmentPlaceholderCase["attachments"];
305
+ count: number;
306
+ formatPlaceholder: (count: number) => string;
307
+ };
308
+ type AttachmentDownloadSuccessCase = LabeledCase & {
309
+ attachments: MSTeamsAttachments;
310
+ buildFetchFn?: () => unknown;
311
+ beforeDownload?: () => void;
312
+ assert?: (media: DownloadedMedia) => void;
313
+ };
314
+ type AttachmentAuthRetryScenario = {
315
+ attachmentUrl: string;
316
+ unauthStatus: number;
317
+ unauthBody: string;
318
+ overrides?: Omit<DownloadAttachmentsNoFetchOverrides, "tokenProvider">;
319
+ };
320
+ type AttachmentAuthRetryCase = LabeledCase & {
321
+ scenario: AttachmentAuthRetryScenario;
322
+ expectedMediaLength: number;
323
+ expectTokenFetch: boolean;
324
+ };
325
+ type GraphUrlExpectationCase = LabeledCase & {
326
+ params: GraphMessageUrlParams;
327
+ expectedPath: string;
328
+ };
329
+ type ChannelGraphUrlCaseParams = {
330
+ messageId: string;
331
+ replyToId?: string;
332
+ conversationId?: string;
333
+ };
334
+ type GraphMediaDownloadResult = {
335
+ fetchMock: ReturnType<typeof createGraphFetchMock>;
336
+ media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>;
337
+ };
338
+ type GraphMediaSuccessCase = LabeledCase & {
339
+ buildOptions: () => GraphFetchMockOptions;
340
+ expectedLength: number;
341
+ assert?: (params: GraphMediaDownloadResult) => void;
342
+ };
343
+ const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
344
+ withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }),
345
+ withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }),
346
+ ];
347
+ const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [
348
+ withLabel("returns image placeholder for one image attachment", {
349
+ attachments: createImageAttachments(TEST_URL_IMAGE_PNG),
350
+ count: 1,
351
+ formatPlaceholder: formatImagePlaceholder,
352
+ }),
353
+ withLabel("returns image placeholder with count for many image attachments", {
354
+ attachments: [
355
+ ...createImageAttachments(TEST_URL_IMAGE_1_PNG),
356
+ { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG },
357
+ ],
358
+ count: 2,
359
+ formatPlaceholder: formatImagePlaceholder,
360
+ }),
361
+ withLabel("treats Teams file.download.info image attachments as images", {
362
+ attachments: createTeamsFileDownloadInfoAttachments(),
363
+ count: 1,
364
+ formatPlaceholder: formatImagePlaceholder,
365
+ }),
366
+ withLabel("returns document placeholder for non-image attachments", {
367
+ attachments: createPdfAttachments(TEST_URL_PDF),
368
+ count: 1,
369
+ formatPlaceholder: formatDocumentPlaceholder,
370
+ }),
371
+ withLabel("returns document placeholder with count for many non-image attachments", {
372
+ attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2),
373
+ count: 2,
374
+ formatPlaceholder: formatDocumentPlaceholder,
375
+ }),
376
+ withLabel("counts one inline image in html attachments", {
377
+ attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"),
378
+ count: 1,
379
+ formatPlaceholder: formatImagePlaceholder,
380
+ }),
381
+ withLabel("counts many inline images in html attachments", {
382
+ attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]),
383
+ count: 2,
384
+ formatPlaceholder: formatImagePlaceholder,
385
+ }),
386
+ ];
387
+ const ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
388
+ ...EMPTY_ATTACHMENT_PLACEHOLDER_CASES,
389
+ ...COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS.map((testCase) =>
390
+ withLabel(testCase.label, {
391
+ attachments: testCase.attachments,
392
+ expected: testCase.formatPlaceholder(testCase.count),
393
+ }),
394
+ ),
395
+ ];
396
+ const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
397
+ withLabel("downloads and stores image contentUrl attachments", {
398
+ attachments: asSingleItemArray(IMAGE_ATTACHMENT),
399
+ assert: (media) => {
400
+ expectFirstMedia(media, { path: SAVED_PNG_PATH });
401
+ expectMediaBufferSaved();
402
+ },
403
+ }),
404
+ withLabel("supports Teams file.download.info downloadUrl attachments", {
405
+ attachments: createTeamsFileDownloadInfoAttachments(),
406
+ }),
407
+ withLabel("downloads inline image URLs from html attachments", {
408
+ attachments: createHtmlImageAttachments([TEST_URL_INLINE_IMAGE]),
409
+ }),
410
+ withLabel("downloads non-image file attachments (PDF)", {
411
+ attachments: createPdfAttachments(TEST_URL_DOC_PDF),
412
+ buildFetchFn: () => createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf"),
413
+ beforeDownload: () => {
414
+ detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
415
+ saveMediaBufferMock.mockResolvedValueOnce({
416
+ path: SAVED_PDF_PATH,
417
+ contentType: CONTENT_TYPE_APPLICATION_PDF,
418
+ });
419
+ },
420
+ assert: (media) => {
421
+ expectSingleMedia(media, {
422
+ path: SAVED_PDF_PATH,
423
+ placeholder: formatDocumentPlaceholder(1),
424
+ });
425
+ },
426
+ }),
427
+ ];
428
+ const ATTACHMENT_AUTH_RETRY_CASES: AttachmentAuthRetryCase[] = [
429
+ withLabel("retries with auth when the first request is unauthorized", {
430
+ scenario: {
431
+ attachmentUrl: IMAGE_ATTACHMENT.contentUrl,
432
+ unauthStatus: 401,
433
+ unauthBody: "unauthorized",
434
+ overrides: { authAllowHosts: [TEST_HOST] },
435
+ },
436
+ expectedMediaLength: 1,
437
+ expectTokenFetch: true,
438
+ }),
439
+ withLabel("skips auth retries when the host is not in auth allowlist", {
440
+ scenario: {
441
+ attachmentUrl: createUrlForHost(AZUREEDGE_HOST, "img"),
442
+ unauthStatus: 403,
443
+ unauthBody: "forbidden",
444
+ overrides: {
445
+ allowHosts: [AZUREEDGE_HOST],
446
+ authAllowHosts: [GRAPH_HOST],
447
+ },
448
+ },
449
+ expectedMediaLength: 0,
450
+ expectTokenFetch: false,
451
+ }),
452
+ ];
453
+ const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
454
+ withLabel("downloads hostedContents images", {
455
+ buildOptions: () => ({ hostedContents: createHostedImageContents("1") }),
456
+ expectedLength: 1,
457
+ assert: ({ fetchMock }) => {
458
+ expect(fetchMock).toHaveBeenCalled();
459
+ expectMediaBufferSaved();
460
+ },
461
+ }),
462
+ withLabel("merges SharePoint reference attachments with hosted content", {
463
+ buildOptions: () => {
464
+ return {
465
+ hostedContents: createHostedImageContents("hosted-1"),
466
+ ...buildDefaultShareReferenceGraphFetchOptions({
467
+ onShareRequest: () => createPdfResponse(),
468
+ }),
469
+ };
470
+ },
471
+ expectedLength: 2,
472
+ }),
473
+ ];
474
+ const CHANNEL_GRAPH_URL_CASES: Array<LabeledCase & ChannelGraphUrlCaseParams> = [
475
+ withLabel("builds channel message urls", {
476
+ conversationId: "19:thread@thread.tacv2",
477
+ messageId: "123",
478
+ }),
479
+ withLabel("builds channel reply urls when replyToId is present", {
480
+ messageId: "reply-id",
481
+ replyToId: "root-id",
482
+ }),
483
+ ];
484
+ const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
485
+ ...CHANNEL_GRAPH_URL_CASES.map<GraphUrlExpectationCase>(({ label, ...params }) =>
486
+ withLabel(label, {
487
+ params: createChannelGraphMessageUrlParams(params),
488
+ expectedPath: buildExpectedChannelMessagePath(params),
489
+ }),
490
+ ),
491
+ withLabel("builds chat message urls", {
492
+ params: {
493
+ conversationType: "groupChat" as const,
494
+ conversationId: "19:chat@thread.v2",
495
+ messageId: "456",
496
+ },
497
+ expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
498
+ }),
499
+ ];
500
+
501
+ type GraphFetchMockOptions = {
502
+ hostedContents?: unknown[];
503
+ attachments?: unknown[];
504
+ messageAttachments?: unknown[];
505
+ onShareRequest?: (url: string) => Response | Promise<Response>;
506
+ onUnhandled?: (url: string) => Response | Promise<Response> | undefined;
507
+ };
508
+
509
+ const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({
510
+ id: "ref-1",
511
+ contentType: "reference",
512
+ contentUrl: shareUrl,
513
+ name: "report.pdf",
514
+ });
515
+ const buildShareReferenceGraphFetchOptions = (params: {
516
+ referenceAttachment: ReturnType<typeof createReferenceAttachment>;
517
+ onShareRequest?: GraphFetchMockOptions["onShareRequest"];
518
+ onUnhandled?: GraphFetchMockOptions["onUnhandled"];
519
+ }) => ({
520
+ attachments: [params.referenceAttachment],
521
+ messageAttachments: [params.referenceAttachment],
522
+ ...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}),
523
+ ...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}),
524
+ });
525
+ const buildDefaultShareReferenceGraphFetchOptions = (
526
+ params: Omit<Parameters<typeof buildShareReferenceGraphFetchOptions>[0], "referenceAttachment">,
527
+ ) =>
528
+ buildShareReferenceGraphFetchOptions({
529
+ referenceAttachment: createReferenceAttachment(),
530
+ ...params,
531
+ });
532
+ type GraphEndpointResponseHandler = {
533
+ suffix: string;
534
+ buildResponse: () => Response;
535
+ };
536
+ const createGraphEndpointResponseHandlers = (params: {
537
+ hostedContents: unknown[];
538
+ attachments: unknown[];
539
+ messageAttachments: unknown[];
540
+ }): GraphEndpointResponseHandler[] => [
541
+ {
542
+ suffix: "/hostedContents",
543
+ buildResponse: () => createGraphCollectionResponse(params.hostedContents),
544
+ },
545
+ {
546
+ suffix: "/attachments",
547
+ buildResponse: () => createGraphCollectionResponse(params.attachments),
548
+ },
549
+ {
550
+ suffix: "/messages/123",
551
+ buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }),
552
+ },
553
+ ];
554
+ const resolveGraphEndpointResponse = (
555
+ url: string,
556
+ handlers: GraphEndpointResponseHandler[],
557
+ ): Response | undefined => {
558
+ const handler = handlers.find((entry) => url.endsWith(entry.suffix));
559
+ return handler ? handler.buildResponse() : undefined;
560
+ };
561
+
562
+ const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => {
563
+ const hostedContents = options.hostedContents ?? [];
564
+ const attachments = options.attachments ?? [];
565
+ const messageAttachments = options.messageAttachments ?? [];
566
+ const endpointHandlers = createGraphEndpointResponseHandlers({
567
+ hostedContents,
568
+ attachments,
569
+ messageAttachments,
570
+ });
571
+ return vi.fn(async (url: string) => {
572
+ const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers);
573
+ if (endpointResponse) {
574
+ return endpointResponse;
575
+ }
576
+ if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) {
577
+ return options.onShareRequest(url);
578
+ }
579
+ const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined;
580
+ return unhandled ?? createNotFoundResponse();
581
+ });
582
+ };
583
+ const downloadGraphMediaWithMockOptions = async (
584
+ options: GraphFetchMockOptions = {},
585
+ overrides: DownloadGraphMediaOverrides = {},
586
+ ): Promise<GraphMediaDownloadResult> => {
587
+ const fetchMock = createGraphFetchMock(options);
588
+ const media = await downloadMSTeamsGraphMedia({
589
+ messageUrl: DEFAULT_MESSAGE_URL,
590
+ tokenProvider: createTokenProvider(),
591
+ maxBytes: DEFAULT_MAX_BYTES,
592
+ fetchFn: asFetchFn(fetchMock),
593
+ ...overrides,
594
+ });
595
+ return { fetchMock, media };
596
+ };
597
+ const runAttachmentDownloadSuccessCase = async ({
598
+ attachments,
599
+ buildFetchFn,
600
+ beforeDownload,
601
+ assert,
602
+ }: AttachmentDownloadSuccessCase) => {
603
+ const fetchFn = (buildFetchFn ?? (() => createOkFetchMock(CONTENT_TYPE_IMAGE_PNG)))();
604
+ beforeDownload?.();
605
+ const media = await downloadAttachmentsWithFetch(attachments, fetchFn);
606
+ expectSingleMedia(media);
607
+ assert?.(media);
608
+ };
609
+ const runAttachmentAuthRetryCase = async ({
610
+ scenario,
611
+ expectedMediaLength,
612
+ expectTokenFetch,
613
+ }: AttachmentAuthRetryCase) => {
614
+ const tokenProvider = createTokenProvider();
615
+ const fetchMock = createAuthAwareImageFetchMock({
616
+ unauthStatus: scenario.unauthStatus,
617
+ unauthBody: scenario.unauthBody,
618
+ });
619
+ const media = await downloadAttachmentsWithFetch(
620
+ createImageAttachments(scenario.attachmentUrl),
621
+ fetchMock,
622
+ { tokenProvider, ...scenario.overrides },
623
+ );
624
+ expectAttachmentMediaLength(media, expectedMediaLength);
625
+ expectMockCallState(tokenProvider.getAccessToken, expectTokenFetch);
626
+ };
627
+ const runGraphMediaSuccessCase = async ({
628
+ buildOptions,
629
+ expectedLength,
630
+ assert,
631
+ }: GraphMediaSuccessCase) => {
632
+ const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions());
633
+ expectAttachmentMediaLength(media.media, expectedLength);
634
+ assert?.({ fetchMock, media });
635
+ };
27
636
 
637
+ describe("msteams attachments", () => {
28
638
  beforeEach(() => {
29
639
  detectMimeMock.mockClear();
30
640
  saveMediaBufferMock.mockClear();
641
+ fetchRemoteMediaMock.mockClear();
31
642
  setMSTeamsRuntime(runtimeStub);
32
643
  });
33
644
 
34
645
  describe("buildMSTeamsAttachmentPlaceholder", () => {
35
- it("returns empty string when no attachments", async () => {
36
- const { buildMSTeamsAttachmentPlaceholder } = await load();
37
- expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
38
- expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
39
- });
40
-
41
- it("returns image placeholder for image attachments", async () => {
42
- const { buildMSTeamsAttachmentPlaceholder } = await load();
43
- expect(
44
- buildMSTeamsAttachmentPlaceholder([
45
- { contentType: "image/png", contentUrl: "https://x/img.png" },
46
- ]),
47
- ).toBe("<media:image>");
48
- expect(
49
- buildMSTeamsAttachmentPlaceholder([
50
- { contentType: "image/png", contentUrl: "https://x/1.png" },
51
- { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" },
52
- ]),
53
- ).toBe("<media:image> (2 images)");
54
- });
55
-
56
- it("treats Teams file.download.info image attachments as images", async () => {
57
- const { buildMSTeamsAttachmentPlaceholder } = await load();
58
- expect(
59
- buildMSTeamsAttachmentPlaceholder([
60
- {
61
- contentType: "application/vnd.microsoft.teams.file.download.info",
62
- content: { downloadUrl: "https://x/dl", fileType: "png" },
63
- },
64
- ]),
65
- ).toBe("<media:image>");
66
- });
67
-
68
- it("returns document placeholder for non-image attachments", async () => {
69
- const { buildMSTeamsAttachmentPlaceholder } = await load();
70
- expect(
71
- buildMSTeamsAttachmentPlaceholder([
72
- { contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
73
- ]),
74
- ).toBe("<media:document>");
75
- expect(
76
- buildMSTeamsAttachmentPlaceholder([
77
- { contentType: "application/pdf", contentUrl: "https://x/1.pdf" },
78
- { contentType: "application/pdf", contentUrl: "https://x/2.pdf" },
79
- ]),
80
- ).toBe("<media:document> (2 files)");
81
- });
82
-
83
- it("counts inline images in text/html attachments", async () => {
84
- const { buildMSTeamsAttachmentPlaceholder } = await load();
85
- expect(
86
- buildMSTeamsAttachmentPlaceholder([
87
- {
88
- contentType: "text/html",
89
- content: '<p>hi</p><img src="https://x/a.png" />',
90
- },
91
- ]),
92
- ).toBe("<media:image>");
93
- expect(
94
- buildMSTeamsAttachmentPlaceholder([
95
- {
96
- contentType: "text/html",
97
- content: '<img src="https://x/a.png" /><img src="https://x/b.png" />',
98
- },
99
- ]),
100
- ).toBe("<media:image> (2 images)");
101
- });
646
+ it.each<AttachmentPlaceholderCase>(ATTACHMENT_PLACEHOLDER_CASES)(
647
+ "$label",
648
+ ({ attachments, expected }) => {
649
+ expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
650
+ },
651
+ );
102
652
  });
103
653
 
104
654
  describe("downloadMSTeamsAttachments", () => {
105
- it("downloads and stores image contentUrl attachments", async () => {
106
- const { downloadMSTeamsAttachments } = await load();
107
- const fetchMock = vi.fn(async () => {
108
- return new Response(Buffer.from("png"), {
109
- status: 200,
110
- headers: { "content-type": "image/png" },
111
- });
112
- });
113
-
114
- const media = await downloadMSTeamsAttachments({
115
- attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
116
- maxBytes: 1024 * 1024,
117
- allowHosts: ["x"],
118
- fetchFn: fetchMock as unknown as typeof fetch,
119
- });
120
-
121
- expect(fetchMock).toHaveBeenCalledWith("https://x/img");
122
- expect(saveMediaBufferMock).toHaveBeenCalled();
123
- expect(media).toHaveLength(1);
124
- expect(media[0]?.path).toBe("/tmp/saved.png");
125
- });
126
-
127
- it("supports Teams file.download.info downloadUrl attachments", async () => {
128
- const { downloadMSTeamsAttachments } = await load();
129
- const fetchMock = vi.fn(async () => {
130
- return new Response(Buffer.from("png"), {
131
- status: 200,
132
- headers: { "content-type": "image/png" },
133
- });
134
- });
135
-
136
- const media = await downloadMSTeamsAttachments({
137
- attachments: [
138
- {
139
- contentType: "application/vnd.microsoft.teams.file.download.info",
140
- content: { downloadUrl: "https://x/dl", fileType: "png" },
141
- },
142
- ],
143
- maxBytes: 1024 * 1024,
144
- allowHosts: ["x"],
145
- fetchFn: fetchMock as unknown as typeof fetch,
146
- });
147
-
148
- expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
149
- expect(media).toHaveLength(1);
150
- });
151
-
152
- it("downloads non-image file attachments (PDF)", async () => {
153
- const { downloadMSTeamsAttachments } = await load();
154
- const fetchMock = vi.fn(async () => {
155
- return new Response(Buffer.from("pdf"), {
156
- status: 200,
157
- headers: { "content-type": "application/pdf" },
158
- });
159
- });
160
- detectMimeMock.mockResolvedValueOnce("application/pdf");
161
- saveMediaBufferMock.mockResolvedValueOnce({
162
- path: "/tmp/saved.pdf",
163
- contentType: "application/pdf",
164
- });
165
-
166
- const media = await downloadMSTeamsAttachments({
167
- attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
168
- maxBytes: 1024 * 1024,
169
- allowHosts: ["x"],
170
- fetchFn: fetchMock as unknown as typeof fetch,
171
- });
172
-
173
- expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
174
- expect(media).toHaveLength(1);
175
- expect(media[0]?.path).toBe("/tmp/saved.pdf");
176
- expect(media[0]?.placeholder).toBe("<media:document>");
177
- });
178
-
179
- it("downloads inline image URLs from html attachments", async () => {
180
- const { downloadMSTeamsAttachments } = await load();
181
- const fetchMock = vi.fn(async () => {
182
- return new Response(Buffer.from("png"), {
183
- status: 200,
184
- headers: { "content-type": "image/png" },
185
- });
186
- });
187
-
188
- const media = await downloadMSTeamsAttachments({
189
- attachments: [
190
- {
191
- contentType: "text/html",
192
- content: '<img src="https://x/inline.png" />',
193
- },
194
- ],
195
- maxBytes: 1024 * 1024,
196
- allowHosts: ["x"],
197
- fetchFn: fetchMock as unknown as typeof fetch,
198
- });
199
-
200
- expect(media).toHaveLength(1);
201
- expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png");
202
- });
655
+ it.each<AttachmentDownloadSuccessCase>(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)(
656
+ "$label",
657
+ runAttachmentDownloadSuccessCase,
658
+ );
203
659
 
204
660
  it("stores inline data:image base64 payloads", async () => {
205
- const { downloadMSTeamsAttachments } = await load();
206
- const base64 = Buffer.from("png").toString("base64");
207
- const media = await downloadMSTeamsAttachments({
208
- attachments: [
209
- {
210
- contentType: "text/html",
211
- content: `<img src="data:image/png;base64,${base64}" />`,
212
- },
213
- ],
214
- maxBytes: 1024 * 1024,
215
- allowHosts: ["x"],
216
- });
217
-
218
- expect(media).toHaveLength(1);
219
- expect(saveMediaBufferMock).toHaveBeenCalled();
220
- });
221
-
222
- it("retries with auth when the first request is unauthorized", async () => {
223
- const { downloadMSTeamsAttachments } = await load();
224
- const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
225
- const hasAuth = Boolean(
226
- opts &&
227
- typeof opts === "object" &&
228
- "headers" in opts &&
229
- (opts.headers as Record<string, string>)?.Authorization,
230
- );
231
- if (!hasAuth) {
232
- return new Response("unauthorized", { status: 401 });
233
- }
234
- return new Response(Buffer.from("png"), {
235
- status: 200,
236
- headers: { "content-type": "image/png" },
237
- });
238
- });
239
-
240
- const media = await downloadMSTeamsAttachments({
241
- attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
242
- maxBytes: 1024 * 1024,
243
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
244
- allowHosts: ["x"],
245
- authAllowHosts: ["x"],
246
- fetchFn: fetchMock as unknown as typeof fetch,
247
- });
661
+ const media = await downloadMSTeamsAttachments(
662
+ buildDownloadParams([
663
+ ...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]),
664
+ ]),
665
+ );
248
666
 
249
- expect(fetchMock).toHaveBeenCalled();
250
- expect(media).toHaveLength(1);
251
- expect(fetchMock).toHaveBeenCalledTimes(2);
667
+ expectSingleMedia(media);
668
+ expectMediaBufferSaved();
252
669
  });
253
670
 
254
- it("skips auth retries when the host is not in auth allowlist", async () => {
255
- const { downloadMSTeamsAttachments } = await load();
256
- const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
257
- const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
258
- const hasAuth = Boolean(
259
- opts &&
260
- typeof opts === "object" &&
261
- "headers" in opts &&
262
- (opts.headers as Record<string, string>)?.Authorization,
263
- );
264
- if (!hasAuth) {
265
- return new Response("forbidden", { status: 403 });
266
- }
267
- return new Response(Buffer.from("png"), {
268
- status: 200,
269
- headers: { "content-type": "image/png" },
270
- });
271
- });
272
-
273
- const media = await downloadMSTeamsAttachments({
274
- attachments: [
275
- { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
276
- ],
277
- maxBytes: 1024 * 1024,
278
- tokenProvider,
279
- allowHosts: ["azureedge.net"],
280
- authAllowHosts: ["graph.microsoft.com"],
281
- fetchFn: fetchMock as unknown as typeof fetch,
282
- });
283
-
284
- expect(media).toHaveLength(0);
285
- expect(fetchMock).toHaveBeenCalledTimes(1);
286
- expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
287
- });
671
+ it.each<AttachmentAuthRetryCase>(ATTACHMENT_AUTH_RETRY_CASES)(
672
+ "$label",
673
+ runAttachmentAuthRetryCase,
674
+ );
288
675
 
289
676
  it("skips urls outside the allowlist", async () => {
290
- const { downloadMSTeamsAttachments } = await load();
291
677
  const fetchMock = vi.fn();
292
- const media = await downloadMSTeamsAttachments({
293
- attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
294
- maxBytes: 1024 * 1024,
295
- allowHosts: ["graph.microsoft.com"],
296
- fetchFn: fetchMock as unknown as typeof fetch,
297
- });
678
+ const media = await downloadAttachmentsWithFetch(
679
+ createImageAttachments(TEST_URL_OUTSIDE_ALLOWLIST),
680
+ fetchMock,
681
+ {
682
+ allowHosts: [GRAPH_HOST],
683
+ resolveFn: undefined,
684
+ },
685
+ { expectFetchCalled: false },
686
+ );
298
687
 
299
- expect(media).toHaveLength(0);
300
- expect(fetchMock).not.toHaveBeenCalled();
688
+ expectAttachmentMediaLength(media, 0);
301
689
  });
302
690
  });
303
691
 
304
692
  describe("buildMSTeamsGraphMessageUrls", () => {
305
- it("builds channel message urls", async () => {
306
- const { buildMSTeamsGraphMessageUrls } = await load();
307
- const urls = buildMSTeamsGraphMessageUrls({
308
- conversationType: "channel",
309
- conversationId: "19:thread@thread.tacv2",
310
- messageId: "123",
311
- channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
312
- });
313
- expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123");
314
- });
315
-
316
- it("builds channel reply urls when replyToId is present", async () => {
317
- const { buildMSTeamsGraphMessageUrls } = await load();
318
- const urls = buildMSTeamsGraphMessageUrls({
319
- conversationType: "channel",
320
- messageId: "reply-id",
321
- replyToId: "root-id",
322
- channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
323
- });
324
- expect(urls[0]).toContain(
325
- "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id",
326
- );
327
- });
328
-
329
- it("builds chat message urls", async () => {
330
- const { buildMSTeamsGraphMessageUrls } = await load();
331
- const urls = buildMSTeamsGraphMessageUrls({
332
- conversationType: "groupChat",
333
- conversationId: "19:chat@thread.v2",
334
- messageId: "456",
335
- });
336
- expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456");
693
+ it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
694
+ const urls = buildMSTeamsGraphMessageUrls(params);
695
+ expect(urls[0]).toContain(expectedPath);
337
696
  });
338
697
  });
339
698
 
340
699
  describe("downloadMSTeamsGraphMedia", () => {
341
- it("downloads hostedContents images", async () => {
342
- const { downloadMSTeamsGraphMedia } = await load();
343
- const base64 = Buffer.from("png").toString("base64");
344
- const fetchMock = vi.fn(async (url: string) => {
345
- if (url.endsWith("/hostedContents")) {
346
- return new Response(
347
- JSON.stringify({
348
- value: [
349
- {
350
- id: "1",
351
- contentType: "image/png",
352
- contentBytes: base64,
353
- },
354
- ],
355
- }),
356
- { status: 200 },
357
- );
358
- }
359
- if (url.endsWith("/attachments")) {
360
- return new Response(JSON.stringify({ value: [] }), { status: 200 });
700
+ it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
701
+
702
+ it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
703
+ const escapedUrl = "https://evil.example/internal.pdf";
704
+ fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
705
+ const fetchFn = params.fetchImpl ?? fetch;
706
+ let currentUrl = params.url;
707
+ for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
708
+ const res = await fetchFn(currentUrl, { redirect: "manual" });
709
+ if (REDIRECT_STATUS_CODES.includes(res.status)) {
710
+ const location = res.headers.get("location");
711
+ if (!location) {
712
+ throw new Error("redirect missing location");
713
+ }
714
+ currentUrl = new URL(location, currentUrl).toString();
715
+ continue;
716
+ }
717
+ return readRemoteMediaResponse(res, params);
361
718
  }
362
- return new Response("not found", { status: 404 });
719
+ throw new Error("too many redirects");
363
720
  });
364
721
 
365
- const media = await downloadMSTeamsGraphMedia({
366
- messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
367
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
368
- maxBytes: 1024 * 1024,
369
- fetchFn: fetchMock as unknown as typeof fetch,
370
- });
371
-
372
- expect(media.media).toHaveLength(1);
373
- expect(fetchMock).toHaveBeenCalled();
374
- expect(saveMediaBufferMock).toHaveBeenCalled();
375
- });
376
-
377
- it("merges SharePoint reference attachments with hosted content", async () => {
378
- const { downloadMSTeamsGraphMedia } = await load();
379
- const hostedBase64 = Buffer.from("png").toString("base64");
380
- const shareUrl = "https://contoso.sharepoint.com/site/file";
381
- const fetchMock = vi.fn(async (url: string) => {
382
- if (url.endsWith("/hostedContents")) {
383
- return new Response(
384
- JSON.stringify({
385
- value: [
386
- {
387
- id: "hosted-1",
388
- contentType: "image/png",
389
- contentBytes: hostedBase64,
390
- },
391
- ],
392
- }),
393
- { status: 200 },
394
- );
395
- }
396
- if (url.endsWith("/attachments")) {
397
- return new Response(
398
- JSON.stringify({
399
- value: [
400
- {
401
- id: "ref-1",
402
- contentType: "reference",
403
- contentUrl: shareUrl,
404
- name: "report.pdf",
405
- },
406
- ],
407
- }),
408
- { status: 200 },
409
- );
410
- }
411
- if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
412
- return new Response(Buffer.from("pdf"), {
413
- status: 200,
414
- headers: { "content-type": "application/pdf" },
415
- });
416
- }
417
- if (url.endsWith("/messages/123")) {
418
- return new Response(
419
- JSON.stringify({
420
- attachments: [
421
- {
422
- id: "ref-1",
423
- contentType: "reference",
424
- contentUrl: shareUrl,
425
- name: "report.pdf",
426
- },
427
- ],
428
- }),
429
- { status: 200 },
430
- );
431
- }
432
- return new Response("not found", { status: 404 });
433
- });
434
-
435
- const media = await downloadMSTeamsGraphMedia({
436
- messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
437
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
438
- maxBytes: 1024 * 1024,
439
- fetchFn: fetchMock as unknown as typeof fetch,
440
- });
722
+ const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
723
+ {
724
+ ...buildDefaultShareReferenceGraphFetchOptions({
725
+ onShareRequest: () => createRedirectResponse(escapedUrl),
726
+ onUnhandled: (url) => {
727
+ if (url === escapedUrl) {
728
+ return createPdfResponse("should-not-be-fetched");
729
+ }
730
+ return undefined;
731
+ },
732
+ }),
733
+ },
734
+ {
735
+ allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
736
+ },
737
+ );
441
738
 
442
- expect(media.media).toHaveLength(2);
739
+ expectAttachmentMediaLength(media.media, 0);
740
+ const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
741
+ expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true);
742
+ expect(calledUrls).not.toContain(escapedUrl);
443
743
  });
444
744
  });
445
745
 
446
746
  describe("buildMSTeamsMediaPayload", () => {
447
747
  it("returns single and multi-file fields", async () => {
448
- const { buildMSTeamsMediaPayload } = await load();
449
- const payload = buildMSTeamsMediaPayload([
450
- { path: "/tmp/a.png", contentType: "image/png" },
451
- { path: "/tmp/b.png", contentType: "image/png" },
452
- ]);
453
- expect(payload.MediaPath).toBe("/tmp/a.png");
454
- expect(payload.MediaUrl).toBe("/tmp/a.png");
455
- expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]);
456
- expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]);
457
- expect(payload.MediaTypes).toEqual(["image/png", "image/png"]);
748
+ const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png"));
749
+ expectMSTeamsMediaPayload(payload, {
750
+ firstPath: "/tmp/a.png",
751
+ paths: ["/tmp/a.png", "/tmp/b.png"],
752
+ types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG],
753
+ });
458
754
  });
459
755
  });
460
756
  });