@openclaw/msteams 2026.2.22 → 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,38 +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
 
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
+
5
20
  /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
6
21
  const publicResolveFn = async () => ({ address: "13.107.136.10" });
7
-
8
- const detectMimeMock = vi.fn(async () => "image/png");
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);
9
57
  const saveMediaBufferMock = vi.fn(async () => ({
10
- path: "/tmp/saved.png",
11
- contentType: "image/png",
58
+ path: SAVED_PNG_PATH,
59
+ contentType: CONTENT_TYPE_IMAGE_PNG,
12
60
  }));
13
- const fetchRemoteMediaMock = vi.fn(
14
- async (params: {
15
- url: string;
16
- maxBytes?: number;
17
- filePathHint?: string;
18
- fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
19
- }) => {
20
- const fetchFn = params.fetchImpl ?? fetch;
21
- const res = await fetchFn(params.url);
22
- if (!res.ok) {
23
- throw new Error(`HTTP ${res.status}`);
24
- }
25
- const buffer = Buffer.from(await res.arrayBuffer());
26
- if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
27
- throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
28
- }
29
- return {
30
- buffer,
31
- contentType: res.headers.get("content-type") ?? undefined,
32
- fileName: params.filePathHint,
33
- };
34
- },
35
- );
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
+ });
36
83
 
37
84
  const runtimeStub = {
38
85
  media: {
@@ -48,11 +95,546 @@ const runtimeStub = {
48
95
  },
49
96
  } as unknown as PluginRuntime;
50
97
 
51
- describe("msteams attachments", () => {
52
- const load = async () => {
53
- 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,
54
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
+ };
55
636
 
637
+ describe("msteams attachments", () => {
56
638
  beforeEach(() => {
57
639
  detectMimeMock.mockClear();
58
640
  saveMediaBufferMock.mockClear();
@@ -61,423 +643,70 @@ describe("msteams attachments", () => {
61
643
  });
62
644
 
63
645
  describe("buildMSTeamsAttachmentPlaceholder", () => {
64
- it("returns empty string when no attachments", async () => {
65
- const { buildMSTeamsAttachmentPlaceholder } = await load();
66
- expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
67
- expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
68
- });
69
-
70
- it("returns image placeholder for image attachments", async () => {
71
- const { buildMSTeamsAttachmentPlaceholder } = await load();
72
- expect(
73
- buildMSTeamsAttachmentPlaceholder([
74
- { contentType: "image/png", contentUrl: "https://x/img.png" },
75
- ]),
76
- ).toBe("<media:image>");
77
- expect(
78
- buildMSTeamsAttachmentPlaceholder([
79
- { contentType: "image/png", contentUrl: "https://x/1.png" },
80
- { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" },
81
- ]),
82
- ).toBe("<media:image> (2 images)");
83
- });
84
-
85
- it("treats Teams file.download.info image attachments as images", async () => {
86
- const { buildMSTeamsAttachmentPlaceholder } = await load();
87
- expect(
88
- buildMSTeamsAttachmentPlaceholder([
89
- {
90
- contentType: "application/vnd.microsoft.teams.file.download.info",
91
- content: { downloadUrl: "https://x/dl", fileType: "png" },
92
- },
93
- ]),
94
- ).toBe("<media:image>");
95
- });
96
-
97
- it("returns document placeholder for non-image attachments", async () => {
98
- const { buildMSTeamsAttachmentPlaceholder } = await load();
99
- expect(
100
- buildMSTeamsAttachmentPlaceholder([
101
- { contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
102
- ]),
103
- ).toBe("<media:document>");
104
- expect(
105
- buildMSTeamsAttachmentPlaceholder([
106
- { contentType: "application/pdf", contentUrl: "https://x/1.pdf" },
107
- { contentType: "application/pdf", contentUrl: "https://x/2.pdf" },
108
- ]),
109
- ).toBe("<media:document> (2 files)");
110
- });
111
-
112
- it("counts inline images in text/html attachments", async () => {
113
- const { buildMSTeamsAttachmentPlaceholder } = await load();
114
- expect(
115
- buildMSTeamsAttachmentPlaceholder([
116
- {
117
- contentType: "text/html",
118
- content: '<p>hi</p><img src="https://x/a.png" />',
119
- },
120
- ]),
121
- ).toBe("<media:image>");
122
- expect(
123
- buildMSTeamsAttachmentPlaceholder([
124
- {
125
- contentType: "text/html",
126
- content: '<img src="https://x/a.png" /><img src="https://x/b.png" />',
127
- },
128
- ]),
129
- ).toBe("<media:image> (2 images)");
130
- });
646
+ it.each<AttachmentPlaceholderCase>(ATTACHMENT_PLACEHOLDER_CASES)(
647
+ "$label",
648
+ ({ attachments, expected }) => {
649
+ expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
650
+ },
651
+ );
131
652
  });
132
653
 
133
654
  describe("downloadMSTeamsAttachments", () => {
134
- it("downloads and stores image contentUrl attachments", async () => {
135
- const { downloadMSTeamsAttachments } = await load();
136
- const fetchMock = vi.fn(async () => {
137
- return new Response(Buffer.from("png"), {
138
- status: 200,
139
- headers: { "content-type": "image/png" },
140
- });
141
- });
142
-
143
- const media = await downloadMSTeamsAttachments({
144
- attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
145
- maxBytes: 1024 * 1024,
146
- allowHosts: ["x"],
147
- fetchFn: fetchMock as unknown as typeof fetch,
148
- resolveFn: publicResolveFn,
149
- });
150
-
151
- expect(fetchMock).toHaveBeenCalled();
152
- expect(saveMediaBufferMock).toHaveBeenCalled();
153
- expect(media).toHaveLength(1);
154
- expect(media[0]?.path).toBe("/tmp/saved.png");
155
- });
156
-
157
- it("supports Teams file.download.info downloadUrl attachments", async () => {
158
- const { downloadMSTeamsAttachments } = await load();
159
- const fetchMock = vi.fn(async () => {
160
- return new Response(Buffer.from("png"), {
161
- status: 200,
162
- headers: { "content-type": "image/png" },
163
- });
164
- });
165
-
166
- const media = await downloadMSTeamsAttachments({
167
- attachments: [
168
- {
169
- contentType: "application/vnd.microsoft.teams.file.download.info",
170
- content: { downloadUrl: "https://x/dl", fileType: "png" },
171
- },
172
- ],
173
- maxBytes: 1024 * 1024,
174
- allowHosts: ["x"],
175
- fetchFn: fetchMock as unknown as typeof fetch,
176
- resolveFn: publicResolveFn,
177
- });
178
-
179
- expect(fetchMock).toHaveBeenCalled();
180
- expect(media).toHaveLength(1);
181
- });
182
-
183
- it("downloads non-image file attachments (PDF)", async () => {
184
- const { downloadMSTeamsAttachments } = await load();
185
- const fetchMock = vi.fn(async () => {
186
- return new Response(Buffer.from("pdf"), {
187
- status: 200,
188
- headers: { "content-type": "application/pdf" },
189
- });
190
- });
191
- detectMimeMock.mockResolvedValueOnce("application/pdf");
192
- saveMediaBufferMock.mockResolvedValueOnce({
193
- path: "/tmp/saved.pdf",
194
- contentType: "application/pdf",
195
- });
196
-
197
- const media = await downloadMSTeamsAttachments({
198
- attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
199
- maxBytes: 1024 * 1024,
200
- allowHosts: ["x"],
201
- fetchFn: fetchMock as unknown as typeof fetch,
202
- resolveFn: publicResolveFn,
203
- });
204
-
205
- expect(fetchMock).toHaveBeenCalled();
206
- expect(media).toHaveLength(1);
207
- expect(media[0]?.path).toBe("/tmp/saved.pdf");
208
- expect(media[0]?.placeholder).toBe("<media:document>");
209
- });
210
-
211
- it("downloads inline image URLs from html attachments", async () => {
212
- const { downloadMSTeamsAttachments } = await load();
213
- const fetchMock = vi.fn(async () => {
214
- return new Response(Buffer.from("png"), {
215
- status: 200,
216
- headers: { "content-type": "image/png" },
217
- });
218
- });
219
-
220
- const media = await downloadMSTeamsAttachments({
221
- attachments: [
222
- {
223
- contentType: "text/html",
224
- content: '<img src="https://x/inline.png" />',
225
- },
226
- ],
227
- maxBytes: 1024 * 1024,
228
- allowHosts: ["x"],
229
- fetchFn: fetchMock as unknown as typeof fetch,
230
- resolveFn: publicResolveFn,
231
- });
232
-
233
- expect(media).toHaveLength(1);
234
- expect(fetchMock).toHaveBeenCalled();
235
- });
655
+ it.each<AttachmentDownloadSuccessCase>(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)(
656
+ "$label",
657
+ runAttachmentDownloadSuccessCase,
658
+ );
236
659
 
237
660
  it("stores inline data:image base64 payloads", async () => {
238
- const { downloadMSTeamsAttachments } = await load();
239
- const base64 = Buffer.from("png").toString("base64");
240
- const media = await downloadMSTeamsAttachments({
241
- attachments: [
242
- {
243
- contentType: "text/html",
244
- content: `<img src="data:image/png;base64,${base64}" />`,
245
- },
246
- ],
247
- maxBytes: 1024 * 1024,
248
- allowHosts: ["x"],
249
- });
250
-
251
- expect(media).toHaveLength(1);
252
- expect(saveMediaBufferMock).toHaveBeenCalled();
253
- });
254
-
255
- it("retries with auth when the first request is unauthorized", async () => {
256
- const { downloadMSTeamsAttachments } = await load();
257
- const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
258
- const headers = new Headers(opts?.headers);
259
- const hasAuth = Boolean(headers.get("Authorization"));
260
- if (!hasAuth) {
261
- return new Response("unauthorized", { status: 401 });
262
- }
263
- return new Response(Buffer.from("png"), {
264
- status: 200,
265
- headers: { "content-type": "image/png" },
266
- });
267
- });
268
-
269
- const media = await downloadMSTeamsAttachments({
270
- attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
271
- maxBytes: 1024 * 1024,
272
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
273
- allowHosts: ["x"],
274
- authAllowHosts: ["x"],
275
- fetchFn: fetchMock as unknown as typeof fetch,
276
- resolveFn: publicResolveFn,
277
- });
661
+ const media = await downloadMSTeamsAttachments(
662
+ buildDownloadParams([
663
+ ...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]),
664
+ ]),
665
+ );
278
666
 
279
- expect(fetchMock).toHaveBeenCalled();
280
- expect(media).toHaveLength(1);
667
+ expectSingleMedia(media);
668
+ expectMediaBufferSaved();
281
669
  });
282
670
 
283
- it("skips auth retries when the host is not in auth allowlist", async () => {
284
- const { downloadMSTeamsAttachments } = await load();
285
- const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
286
- const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
287
- const headers = new Headers(opts?.headers);
288
- const hasAuth = Boolean(headers.get("Authorization"));
289
- if (!hasAuth) {
290
- return new Response("forbidden", { status: 403 });
291
- }
292
- return new Response(Buffer.from("png"), {
293
- status: 200,
294
- headers: { "content-type": "image/png" },
295
- });
296
- });
297
-
298
- const media = await downloadMSTeamsAttachments({
299
- attachments: [
300
- { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
301
- ],
302
- maxBytes: 1024 * 1024,
303
- tokenProvider,
304
- allowHosts: ["azureedge.net"],
305
- authAllowHosts: ["graph.microsoft.com"],
306
- fetchFn: fetchMock as unknown as typeof fetch,
307
- resolveFn: publicResolveFn,
308
- });
309
-
310
- expect(media).toHaveLength(0);
311
- expect(fetchMock).toHaveBeenCalled();
312
- expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
313
- });
671
+ it.each<AttachmentAuthRetryCase>(ATTACHMENT_AUTH_RETRY_CASES)(
672
+ "$label",
673
+ runAttachmentAuthRetryCase,
674
+ );
314
675
 
315
676
  it("skips urls outside the allowlist", async () => {
316
- const { downloadMSTeamsAttachments } = await load();
317
677
  const fetchMock = vi.fn();
318
- const media = await downloadMSTeamsAttachments({
319
- attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
320
- maxBytes: 1024 * 1024,
321
- allowHosts: ["graph.microsoft.com"],
322
- fetchFn: fetchMock as unknown as typeof fetch,
323
- });
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
+ );
324
687
 
325
- expect(media).toHaveLength(0);
326
- expect(fetchMock).not.toHaveBeenCalled();
688
+ expectAttachmentMediaLength(media, 0);
327
689
  });
328
690
  });
329
691
 
330
692
  describe("buildMSTeamsGraphMessageUrls", () => {
331
- it("builds channel message urls", async () => {
332
- const { buildMSTeamsGraphMessageUrls } = await load();
333
- const urls = buildMSTeamsGraphMessageUrls({
334
- conversationType: "channel",
335
- conversationId: "19:thread@thread.tacv2",
336
- messageId: "123",
337
- channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
338
- });
339
- expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123");
340
- });
341
-
342
- it("builds channel reply urls when replyToId is present", async () => {
343
- const { buildMSTeamsGraphMessageUrls } = await load();
344
- const urls = buildMSTeamsGraphMessageUrls({
345
- conversationType: "channel",
346
- messageId: "reply-id",
347
- replyToId: "root-id",
348
- channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
349
- });
350
- expect(urls[0]).toContain(
351
- "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id",
352
- );
353
- });
354
-
355
- it("builds chat message urls", async () => {
356
- const { buildMSTeamsGraphMessageUrls } = await load();
357
- const urls = buildMSTeamsGraphMessageUrls({
358
- conversationType: "groupChat",
359
- conversationId: "19:chat@thread.v2",
360
- messageId: "456",
361
- });
362
- 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);
363
696
  });
364
697
  });
365
698
 
366
699
  describe("downloadMSTeamsGraphMedia", () => {
367
- it("downloads hostedContents images", async () => {
368
- const { downloadMSTeamsGraphMedia } = await load();
369
- const base64 = Buffer.from("png").toString("base64");
370
- const fetchMock = vi.fn(async (url: string) => {
371
- if (url.endsWith("/hostedContents")) {
372
- return new Response(
373
- JSON.stringify({
374
- value: [
375
- {
376
- id: "1",
377
- contentType: "image/png",
378
- contentBytes: base64,
379
- },
380
- ],
381
- }),
382
- { status: 200 },
383
- );
384
- }
385
- if (url.endsWith("/attachments")) {
386
- return new Response(JSON.stringify({ value: [] }), { status: 200 });
387
- }
388
- return new Response("not found", { status: 404 });
389
- });
390
-
391
- const media = await downloadMSTeamsGraphMedia({
392
- messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
393
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
394
- maxBytes: 1024 * 1024,
395
- fetchFn: fetchMock as unknown as typeof fetch,
396
- });
397
-
398
- expect(media.media).toHaveLength(1);
399
- expect(fetchMock).toHaveBeenCalled();
400
- expect(saveMediaBufferMock).toHaveBeenCalled();
401
- });
402
-
403
- it("merges SharePoint reference attachments with hosted content", async () => {
404
- const { downloadMSTeamsGraphMedia } = await load();
405
- const hostedBase64 = Buffer.from("png").toString("base64");
406
- const shareUrl = "https://contoso.sharepoint.com/site/file";
407
- const fetchMock = vi.fn(async (url: string) => {
408
- if (url.endsWith("/hostedContents")) {
409
- return new Response(
410
- JSON.stringify({
411
- value: [
412
- {
413
- id: "hosted-1",
414
- contentType: "image/png",
415
- contentBytes: hostedBase64,
416
- },
417
- ],
418
- }),
419
- { status: 200 },
420
- );
421
- }
422
- if (url.endsWith("/attachments")) {
423
- return new Response(
424
- JSON.stringify({
425
- value: [
426
- {
427
- id: "ref-1",
428
- contentType: "reference",
429
- contentUrl: shareUrl,
430
- name: "report.pdf",
431
- },
432
- ],
433
- }),
434
- { status: 200 },
435
- );
436
- }
437
- if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
438
- return new Response(Buffer.from("pdf"), {
439
- status: 200,
440
- headers: { "content-type": "application/pdf" },
441
- });
442
- }
443
- if (url.endsWith("/messages/123")) {
444
- return new Response(
445
- JSON.stringify({
446
- attachments: [
447
- {
448
- id: "ref-1",
449
- contentType: "reference",
450
- contentUrl: shareUrl,
451
- name: "report.pdf",
452
- },
453
- ],
454
- }),
455
- { status: 200 },
456
- );
457
- }
458
- return new Response("not found", { status: 404 });
459
- });
460
-
461
- const media = await downloadMSTeamsGraphMedia({
462
- messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
463
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
464
- maxBytes: 1024 * 1024,
465
- fetchFn: fetchMock as unknown as typeof fetch,
466
- });
467
-
468
- expect(media.media).toHaveLength(2);
469
- });
700
+ it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
470
701
 
471
702
  it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
472
- const { downloadMSTeamsGraphMedia } = await load();
473
- const shareUrl = "https://contoso.sharepoint.com/site/file";
474
703
  const escapedUrl = "https://evil.example/internal.pdf";
475
704
  fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
476
705
  const fetchFn = params.fetchImpl ?? fetch;
477
706
  let currentUrl = params.url;
478
- for (let i = 0; i < 5; i += 1) {
707
+ for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
479
708
  const res = await fetchFn(currentUrl, { redirect: "manual" });
480
- if ([301, 302, 303, 307, 308].includes(res.status)) {
709
+ if (REDIRECT_STATUS_CODES.includes(res.status)) {
481
710
  const location = res.headers.get("location");
482
711
  if (!location) {
483
712
  throw new Error("redirect missing location");
@@ -485,84 +714,43 @@ describe("msteams attachments", () => {
485
714
  currentUrl = new URL(location, currentUrl).toString();
486
715
  continue;
487
716
  }
488
- if (!res.ok) {
489
- throw new Error(`HTTP ${res.status}`);
490
- }
491
- return {
492
- buffer: Buffer.from(await res.arrayBuffer()),
493
- contentType: res.headers.get("content-type") ?? undefined,
494
- fileName: params.filePathHint,
495
- };
717
+ return readRemoteMediaResponse(res, params);
496
718
  }
497
719
  throw new Error("too many redirects");
498
720
  });
499
721
 
500
- const fetchMock = vi.fn(async (url: string) => {
501
- if (url.endsWith("/hostedContents")) {
502
- return new Response(JSON.stringify({ value: [] }), { status: 200 });
503
- }
504
- if (url.endsWith("/attachments")) {
505
- return new Response(JSON.stringify({ value: [] }), { status: 200 });
506
- }
507
- if (url.endsWith("/messages/123")) {
508
- return new Response(
509
- JSON.stringify({
510
- attachments: [
511
- {
512
- id: "ref-1",
513
- contentType: "reference",
514
- contentUrl: shareUrl,
515
- name: "report.pdf",
516
- },
517
- ],
518
- }),
519
- { status: 200 },
520
- );
521
- }
522
- if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
523
- return new Response(null, {
524
- status: 302,
525
- headers: { location: escapedUrl },
526
- });
527
- }
528
- if (url === escapedUrl) {
529
- return new Response(Buffer.from("should-not-be-fetched"), {
530
- status: 200,
531
- headers: { "content-type": "application/pdf" },
532
- });
533
- }
534
- return new Response("not found", { status: 404 });
535
- });
536
-
537
- const media = await downloadMSTeamsGraphMedia({
538
- messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
539
- tokenProvider: { getAccessToken: vi.fn(async () => "token") },
540
- maxBytes: 1024 * 1024,
541
- allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
542
- fetchFn: fetchMock as unknown as typeof fetch,
543
- });
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
+ );
544
738
 
545
- expect(media.media).toHaveLength(0);
739
+ expectAttachmentMediaLength(media.media, 0);
546
740
  const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
547
- expect(
548
- calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")),
549
- ).toBe(true);
741
+ expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true);
550
742
  expect(calledUrls).not.toContain(escapedUrl);
551
743
  });
552
744
  });
553
745
 
554
746
  describe("buildMSTeamsMediaPayload", () => {
555
747
  it("returns single and multi-file fields", async () => {
556
- const { buildMSTeamsMediaPayload } = await load();
557
- const payload = buildMSTeamsMediaPayload([
558
- { path: "/tmp/a.png", contentType: "image/png" },
559
- { path: "/tmp/b.png", contentType: "image/png" },
560
- ]);
561
- expect(payload.MediaPath).toBe("/tmp/a.png");
562
- expect(payload.MediaUrl).toBe("/tmp/a.png");
563
- expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]);
564
- expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]);
565
- 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
+ });
566
754
  });
567
755
  });
568
756
  });