@openclaw/msteams 2026.3.12 → 2026.5.1-beta.1

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.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +161 -9
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +174 -437
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +148 -14
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +258 -0
  78. package/src/graph-upload.ts +87 -8
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +522 -45
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +477 -174
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +301 -106
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +34 -40
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -101
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -1,17 +1,12 @@
1
- import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk/msteams";
2
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
4
- import {
5
- buildMSTeamsAttachmentPlaceholder,
6
- buildMSTeamsGraphMessageUrls,
7
- buildMSTeamsMediaPayload,
8
- downloadMSTeamsAttachments,
9
- downloadMSTeamsGraphMedia,
10
- } from "./attachments.js";
2
+ import type { PluginRuntime, SsrFPolicy } from "../runtime-api.js";
3
+ import { readRemoteMediaResponse } from "./attachments.test-helpers.js";
4
+ import { downloadMSTeamsAttachments } from "./attachments/download.js";
5
+ import { resolveRequestUrl } from "./attachments/shared.js";
11
6
  import { setMSTeamsRuntime } from "./runtime.js";
12
7
 
13
8
  const GRAPH_HOST = "graph.microsoft.com";
14
- const SHAREPOINT_HOST = "contoso.sharepoint.com";
9
+ const _SHAREPOINT_HOST = "contoso.sharepoint.com";
15
10
  const AZUREEDGE_HOST = "azureedge.net";
16
11
  const TEST_HOST = "x";
17
12
  const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`;
@@ -19,14 +14,14 @@ const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathS
19
14
  const SAVED_PNG_PATH = "/tmp/saved.png";
20
15
  const SAVED_PDF_PATH = "/tmp/saved.pdf";
21
16
  const TEST_URL_IMAGE = createTestUrl("img");
22
- const TEST_URL_IMAGE_PNG = createTestUrl("img.png");
23
- const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
24
- const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
25
- const TEST_URL_PDF = createTestUrl("x.pdf");
26
- const TEST_URL_PDF_1 = createTestUrl("1.pdf");
27
- const TEST_URL_PDF_2 = createTestUrl("2.pdf");
28
- const TEST_URL_HTML_A = createTestUrl("a.png");
29
- const TEST_URL_HTML_B = createTestUrl("b.png");
17
+ const _TEST_URL_IMAGE_PNG = createTestUrl("img.png");
18
+ const _TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
19
+ const _TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
20
+ const _TEST_URL_PDF = createTestUrl("x.pdf");
21
+ const _TEST_URL_PDF_1 = createTestUrl("1.pdf");
22
+ const _TEST_URL_PDF_2 = createTestUrl("2.pdf");
23
+ const _TEST_URL_HTML_A = createTestUrl("a.png");
24
+ const _TEST_URL_HTML_B = createTestUrl("b.png");
30
25
  const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png");
31
26
  const TEST_URL_DOC_PDF = createTestUrl("doc.pdf");
32
27
  const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl");
@@ -35,7 +30,7 @@ const CONTENT_TYPE_IMAGE_PNG = "image/png";
35
30
  const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
36
31
  const CONTENT_TYPE_TEXT_HTML = "text/html";
37
32
  const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
38
- const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
33
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
39
34
  const MAX_REDIRECT_HOPS = 5;
40
35
  type RemoteMediaFetchParams = {
41
36
  url: string;
@@ -52,24 +47,6 @@ const saveMediaBufferMock = vi.fn(async () => ({
52
47
  size: Buffer.byteLength(PNG_BUFFER),
53
48
  contentType: CONTENT_TYPE_IMAGE_PNG,
54
49
  }));
55
- const readRemoteMediaResponse = async (
56
- res: Response,
57
- params: Pick<RemoteMediaFetchParams, "maxBytes" | "filePathHint">,
58
- ) => {
59
- if (!res.ok) {
60
- throw new Error(`HTTP ${res.status}`);
61
- }
62
- const buffer = Buffer.from(await res.arrayBuffer());
63
- if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
64
- throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
65
- }
66
- return {
67
- buffer,
68
- contentType: res.headers.get("content-type") ?? undefined,
69
- fileName: params.filePathHint,
70
- };
71
- };
72
-
73
50
  function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
74
51
  if (pattern.startsWith("*.")) {
75
52
  const suffix = pattern.slice(2);
@@ -88,15 +65,18 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
88
65
  );
89
66
  }
90
67
 
91
- const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
68
+ async function fetchRemoteMediaWithRedirects(
69
+ params: RemoteMediaFetchParams,
70
+ requestInit?: RequestInit,
71
+ ) {
92
72
  const fetchFn = params.fetchImpl ?? fetch;
93
73
  let currentUrl = params.url;
94
74
  for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
95
75
  if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
96
76
  throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
97
77
  }
98
- const res = await fetchFn(currentUrl, { redirect: "manual" });
99
- if (REDIRECT_STATUS_CODES.includes(res.status)) {
78
+ const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit });
79
+ if (REDIRECT_STATUS_CODES.has(res.status)) {
100
80
  const location = res.headers.get("location");
101
81
  if (!location) {
102
82
  throw new Error("redirect missing location");
@@ -107,9 +87,13 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
107
87
  return readRemoteMediaResponse(res, params);
108
88
  }
109
89
  throw new Error("too many redirects");
90
+ }
91
+
92
+ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
93
+ return await fetchRemoteMediaWithRedirects(params);
110
94
  });
111
95
 
112
- const runtimeStub: PluginRuntime = createPluginRuntimeMock({
96
+ const runtimeStub = {
113
97
  media: {
114
98
  detectMime: detectMimeMock,
115
99
  },
@@ -119,12 +103,10 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
119
103
  saveMediaBuffer: saveMediaBufferMock,
120
104
  },
121
105
  },
122
- });
106
+ } as unknown as PluginRuntime;
123
107
 
124
108
  type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
125
- type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
126
109
  type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
127
- type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
128
110
  type DownloadAttachmentsBuildOverrides = Partial<
129
111
  Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
130
112
  > &
@@ -133,31 +115,17 @@ type DownloadAttachmentsNoFetchOverrides = Partial<
133
115
  Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
134
116
  > &
135
117
  Pick<DownloadAttachmentsParams, "allowHosts">;
136
- type DownloadGraphMediaOverrides = Partial<
137
- Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
138
- >;
139
118
  type FetchFn = typeof fetch;
140
119
  type MSTeamsAttachments = DownloadAttachmentsParams["attachments"];
141
- type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
142
- type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
143
120
  type LabeledCase = { label: string };
144
121
  type FetchCallExpectation = { expectFetchCalled?: boolean };
145
122
  type DownloadedMediaExpectation = { path?: string; placeholder?: string };
146
- type MSTeamsMediaPayloadExpectation = {
147
- firstPath: string;
148
- paths: string[];
149
- types: string[];
150
- };
151
123
 
152
- const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`;
153
- const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
154
124
  const DEFAULT_MAX_BYTES = 1024 * 1024;
155
125
  const DEFAULT_ALLOW_HOSTS = [TEST_HOST];
156
- const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST];
157
- const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file");
158
126
  const MEDIA_PLACEHOLDER_IMAGE = "<media:image>";
159
127
  const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>";
160
- const formatImagePlaceholder = (count: number) =>
128
+ const _formatImagePlaceholder = (count: number) =>
161
129
  count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE;
162
130
  const formatDocumentPlaceholder = (count: number) =>
163
131
  count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT;
@@ -201,19 +169,16 @@ const createTeamsFileDownloadInfoAttachments = (
201
169
  content: { downloadUrl, fileType },
202
170
  }),
203
171
  );
204
- const createMediaEntriesWithType = (contentType: string, ...paths: string[]) =>
205
- paths.map((path) => ({ path, contentType }));
206
172
  const createHostedContentsWithType = (contentType: string, ...ids: string[]) =>
207
173
  ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 }));
208
- const createImageMediaEntries = (...paths: string[]) =>
209
- createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
210
- const createHostedImageContents = (...ids: string[]) =>
174
+ const _createHostedImageContents = (...ids: string[]) =>
211
175
  createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids);
212
- const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => {
176
+ type BinaryPayload = Uint8Array | string;
177
+ const _createPdfResponse = (payload: BinaryPayload = PDF_BUFFER) => {
213
178
  return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF);
214
179
  };
215
- const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => {
216
- const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
180
+ const createBufferResponse = (payload: BinaryPayload, contentType: string, status = 200) => {
181
+ const raw = typeof payload === "string" ? Buffer.from(payload) : payload;
217
182
  return new Response(new Uint8Array(raw), {
218
183
  status,
219
184
  headers: { "content-type": contentType },
@@ -222,13 +187,16 @@ const createBufferResponse = (payload: Buffer | string, contentType: string, sta
222
187
  const createJsonResponse = (payload: unknown, status = 200) =>
223
188
  new Response(JSON.stringify(payload), { status });
224
189
  const createTextResponse = (body: string, status = 200) => new Response(body, { status });
225
- const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
190
+ const _createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
226
191
  const createNotFoundResponse = () => new Response("not found", { status: 404 });
227
192
  const createRedirectResponse = (location: string, status = 302) =>
228
193
  new Response(null, { status, headers: { location } });
194
+ const publicResolve = async () => ({ address: "13.107.136.10" });
229
195
 
230
196
  const createOkFetchMock = (contentType: string, payload = "png") =>
231
- vi.fn(async () => createBufferResponse(payload, contentType));
197
+ vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
198
+ createBufferResponse(payload, contentType),
199
+ );
232
200
  const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn;
233
201
 
234
202
  const buildDownloadParams = (
@@ -239,6 +207,7 @@ const buildDownloadParams = (
239
207
  attachments,
240
208
  maxBytes: DEFAULT_MAX_BYTES,
241
209
  allowHosts: DEFAULT_ALLOW_HOSTS,
210
+ resolveFn: publicResolve,
242
211
  ...overrides,
243
212
  };
244
213
  };
@@ -276,25 +245,6 @@ const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => {
276
245
  }
277
246
  };
278
247
 
279
- const DEFAULT_CHANNEL_TEAM_ID = "team-id";
280
- const DEFAULT_CHANNEL_ID = "chan-id";
281
- const createChannelGraphMessageUrlParams = (params: {
282
- messageId: string;
283
- replyToId?: string;
284
- conversationId?: string;
285
- }) => ({
286
- conversationType: "channel" as const,
287
- ...params,
288
- channelData: {
289
- team: { id: DEFAULT_CHANNEL_TEAM_ID },
290
- channel: { id: DEFAULT_CHANNEL_ID },
291
- },
292
- });
293
- const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) =>
294
- params.replyToId
295
- ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}`
296
- : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`;
297
-
298
248
  const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
299
249
  expect(media).toHaveLength(expectedLength);
300
250
  };
@@ -314,25 +264,6 @@ const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpec
314
264
  expect(first?.placeholder).toBe(expected.placeholder);
315
265
  }
316
266
  };
317
- const expectMSTeamsMediaPayload = (
318
- payload: MSTeamsMediaPayload,
319
- expected: MSTeamsMediaPayloadExpectation,
320
- ) => {
321
- expect(payload.MediaPath).toBe(expected.firstPath);
322
- expect(payload.MediaUrl).toBe(expected.firstPath);
323
- expect(payload.MediaPaths).toEqual(expected.paths);
324
- expect(payload.MediaUrls).toEqual(expected.paths);
325
- expect(payload.MediaTypes).toEqual(expected.types);
326
- };
327
- type AttachmentPlaceholderCase = LabeledCase & {
328
- attachments: AttachmentPlaceholderInput;
329
- expected: string;
330
- };
331
- type CountedAttachmentPlaceholderCaseDef = LabeledCase & {
332
- attachments: AttachmentPlaceholderCase["attachments"];
333
- count: number;
334
- formatPlaceholder: (count: number) => string;
335
- };
336
267
  type AttachmentDownloadSuccessCase = LabeledCase & {
337
268
  attachments: MSTeamsAttachments;
338
269
  buildFetchFn?: () => unknown;
@@ -350,77 +281,6 @@ type AttachmentAuthRetryCase = LabeledCase & {
350
281
  expectedMediaLength: number;
351
282
  expectTokenFetch: boolean;
352
283
  };
353
- type GraphUrlExpectationCase = LabeledCase & {
354
- params: GraphMessageUrlParams;
355
- expectedPath: string;
356
- };
357
- type ChannelGraphUrlCaseParams = {
358
- messageId: string;
359
- replyToId?: string;
360
- conversationId?: string;
361
- };
362
- type GraphMediaDownloadResult = {
363
- fetchMock: ReturnType<typeof createGraphFetchMock>;
364
- media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>;
365
- };
366
- type GraphMediaSuccessCase = LabeledCase & {
367
- buildOptions: () => GraphFetchMockOptions;
368
- expectedLength: number;
369
- assert?: (params: GraphMediaDownloadResult) => void;
370
- };
371
- const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
372
- withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }),
373
- withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }),
374
- ];
375
- const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [
376
- withLabel("returns image placeholder for one image attachment", {
377
- attachments: createImageAttachments(TEST_URL_IMAGE_PNG),
378
- count: 1,
379
- formatPlaceholder: formatImagePlaceholder,
380
- }),
381
- withLabel("returns image placeholder with count for many image attachments", {
382
- attachments: [
383
- ...createImageAttachments(TEST_URL_IMAGE_1_PNG),
384
- { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG },
385
- ],
386
- count: 2,
387
- formatPlaceholder: formatImagePlaceholder,
388
- }),
389
- withLabel("treats Teams file.download.info image attachments as images", {
390
- attachments: createTeamsFileDownloadInfoAttachments(),
391
- count: 1,
392
- formatPlaceholder: formatImagePlaceholder,
393
- }),
394
- withLabel("returns document placeholder for non-image attachments", {
395
- attachments: createPdfAttachments(TEST_URL_PDF),
396
- count: 1,
397
- formatPlaceholder: formatDocumentPlaceholder,
398
- }),
399
- withLabel("returns document placeholder with count for many non-image attachments", {
400
- attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2),
401
- count: 2,
402
- formatPlaceholder: formatDocumentPlaceholder,
403
- }),
404
- withLabel("counts one inline image in html attachments", {
405
- attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"),
406
- count: 1,
407
- formatPlaceholder: formatImagePlaceholder,
408
- }),
409
- withLabel("counts many inline images in html attachments", {
410
- attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]),
411
- count: 2,
412
- formatPlaceholder: formatImagePlaceholder,
413
- }),
414
- ];
415
- const ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
416
- ...EMPTY_ATTACHMENT_PLACEHOLDER_CASES,
417
- ...COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS.map((testCase) =>
418
- withLabel(testCase.label, {
419
- attachments: testCase.attachments,
420
- expected: testCase.formatPlaceholder(testCase.count),
421
- }),
422
- ),
423
- ];
424
284
  const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
425
285
  withLabel("downloads and stores image contentUrl attachments", {
426
286
  attachments: asSingleItemArray(IMAGE_ATTACHMENT),
@@ -480,150 +340,6 @@ const ATTACHMENT_AUTH_RETRY_CASES: AttachmentAuthRetryCase[] = [
480
340
  expectTokenFetch: false,
481
341
  }),
482
342
  ];
483
- const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
484
- withLabel("downloads hostedContents images", {
485
- buildOptions: () => ({ hostedContents: createHostedImageContents("1") }),
486
- expectedLength: 1,
487
- assert: ({ fetchMock }) => {
488
- expect(fetchMock).toHaveBeenCalled();
489
- expectMediaBufferSaved();
490
- },
491
- }),
492
- withLabel("merges SharePoint reference attachments with hosted content", {
493
- buildOptions: () => {
494
- return {
495
- hostedContents: createHostedImageContents("hosted-1"),
496
- ...buildDefaultShareReferenceGraphFetchOptions({
497
- onShareRequest: () => createPdfResponse(),
498
- }),
499
- };
500
- },
501
- expectedLength: 2,
502
- }),
503
- ];
504
- const CHANNEL_GRAPH_URL_CASES: Array<LabeledCase & ChannelGraphUrlCaseParams> = [
505
- withLabel("builds channel message urls", {
506
- conversationId: "19:thread@thread.tacv2",
507
- messageId: "123",
508
- }),
509
- withLabel("builds channel reply urls when replyToId is present", {
510
- messageId: "reply-id",
511
- replyToId: "root-id",
512
- }),
513
- ];
514
- const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
515
- ...CHANNEL_GRAPH_URL_CASES.map<GraphUrlExpectationCase>(({ label, ...params }) =>
516
- withLabel(label, {
517
- params: createChannelGraphMessageUrlParams(params),
518
- expectedPath: buildExpectedChannelMessagePath(params),
519
- }),
520
- ),
521
- withLabel("builds chat message urls", {
522
- params: {
523
- conversationType: "groupChat" as const,
524
- conversationId: "19:chat@thread.v2",
525
- messageId: "456",
526
- },
527
- expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
528
- }),
529
- ];
530
-
531
- type GraphFetchMockOptions = {
532
- hostedContents?: unknown[];
533
- attachments?: unknown[];
534
- messageAttachments?: unknown[];
535
- onShareRequest?: (url: string) => Response | Promise<Response>;
536
- onUnhandled?: (url: string) => Response | Promise<Response> | undefined;
537
- };
538
-
539
- const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({
540
- id: "ref-1",
541
- contentType: "reference",
542
- contentUrl: shareUrl,
543
- name: "report.pdf",
544
- });
545
- const buildShareReferenceGraphFetchOptions = (params: {
546
- referenceAttachment: ReturnType<typeof createReferenceAttachment>;
547
- onShareRequest?: GraphFetchMockOptions["onShareRequest"];
548
- onUnhandled?: GraphFetchMockOptions["onUnhandled"];
549
- }) => ({
550
- attachments: [params.referenceAttachment],
551
- messageAttachments: [params.referenceAttachment],
552
- ...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}),
553
- ...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}),
554
- });
555
- const buildDefaultShareReferenceGraphFetchOptions = (
556
- params: Omit<Parameters<typeof buildShareReferenceGraphFetchOptions>[0], "referenceAttachment">,
557
- ) =>
558
- buildShareReferenceGraphFetchOptions({
559
- referenceAttachment: createReferenceAttachment(),
560
- ...params,
561
- });
562
- type GraphEndpointResponseHandler = {
563
- suffix: string;
564
- buildResponse: () => Response;
565
- };
566
- const createGraphEndpointResponseHandlers = (params: {
567
- hostedContents: unknown[];
568
- attachments: unknown[];
569
- messageAttachments: unknown[];
570
- }): GraphEndpointResponseHandler[] => [
571
- {
572
- suffix: "/hostedContents",
573
- buildResponse: () => createGraphCollectionResponse(params.hostedContents),
574
- },
575
- {
576
- suffix: "/attachments",
577
- buildResponse: () => createGraphCollectionResponse(params.attachments),
578
- },
579
- {
580
- suffix: "/messages/123",
581
- buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }),
582
- },
583
- ];
584
- const resolveGraphEndpointResponse = (
585
- url: string,
586
- handlers: GraphEndpointResponseHandler[],
587
- ): Response | undefined => {
588
- const handler = handlers.find((entry) => url.endsWith(entry.suffix));
589
- return handler ? handler.buildResponse() : undefined;
590
- };
591
-
592
- const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => {
593
- const hostedContents = options.hostedContents ?? [];
594
- const attachments = options.attachments ?? [];
595
- const messageAttachments = options.messageAttachments ?? [];
596
- const endpointHandlers = createGraphEndpointResponseHandlers({
597
- hostedContents,
598
- attachments,
599
- messageAttachments,
600
- });
601
- return vi.fn(async (url: string) => {
602
- const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers);
603
- if (endpointResponse) {
604
- return endpointResponse;
605
- }
606
- if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) {
607
- return options.onShareRequest(url);
608
- }
609
- const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined;
610
- return unhandled ?? createNotFoundResponse();
611
- });
612
- };
613
- const downloadGraphMediaWithMockOptions = async (
614
- options: GraphFetchMockOptions = {},
615
- overrides: DownloadGraphMediaOverrides = {},
616
- ): Promise<GraphMediaDownloadResult> => {
617
- const fetchMock = createGraphFetchMock(options);
618
- const media = await downloadMSTeamsGraphMedia({
619
- messageUrl: DEFAULT_MESSAGE_URL,
620
- tokenProvider: createTokenProvider(),
621
- maxBytes: DEFAULT_MAX_BYTES,
622
- fetchFn: asFetchFn(fetchMock),
623
- ...overrides,
624
- });
625
- return { fetchMock, media };
626
- };
627
343
  const runAttachmentDownloadSuccessCase = async ({
628
344
  attachments,
629
345
  buildFetchFn,
@@ -654,15 +370,6 @@ const runAttachmentAuthRetryCase = async ({
654
370
  expectAttachmentMediaLength(media, expectedMediaLength);
655
371
  expectMockCallState(tokenProvider.getAccessToken, expectTokenFetch);
656
372
  };
657
- const runGraphMediaSuccessCase = async ({
658
- buildOptions,
659
- expectedLength,
660
- assert,
661
- }: GraphMediaSuccessCase) => {
662
- const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions());
663
- expectAttachmentMediaLength(media.media, expectedLength);
664
- assert?.({ fetchMock, media });
665
- };
666
373
 
667
374
  describe("msteams attachments", () => {
668
375
  beforeEach(() => {
@@ -672,15 +379,6 @@ describe("msteams attachments", () => {
672
379
  setMSTeamsRuntime(runtimeStub);
673
380
  });
674
381
 
675
- describe("buildMSTeamsAttachmentPlaceholder", () => {
676
- it.each<AttachmentPlaceholderCase>(ATTACHMENT_PLACEHOLDER_CASES)(
677
- "$label",
678
- ({ attachments, expected }) => {
679
- expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
680
- },
681
- );
682
- });
683
-
684
382
  describe("downloadMSTeamsAttachments", () => {
685
383
  it.each<AttachmentDownloadSuccessCase>(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)(
686
384
  "$label",
@@ -720,24 +418,9 @@ describe("msteams attachments", () => {
720
418
  });
721
419
 
722
420
  fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
723
- const fetchFn = params.fetchImpl ?? fetch;
724
- let currentUrl = params.url;
725
- for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
726
- const res = await fetchFn(currentUrl, {
727
- redirect: "manual",
728
- dispatcher: {},
729
- } as RequestInit);
730
- if (REDIRECT_STATUS_CODES.includes(res.status)) {
731
- const location = res.headers.get("location");
732
- if (!location) {
733
- throw new Error("redirect missing location");
734
- }
735
- currentUrl = new URL(location, currentUrl).toString();
736
- continue;
737
- }
738
- return readRemoteMediaResponse(res, params);
739
- }
740
- throw new Error("too many redirects");
421
+ return await fetchRemoteMediaWithRedirects(params, {
422
+ dispatcher: {},
423
+ } as RequestInit);
741
424
  });
742
425
 
743
426
  const media = await downloadAttachmentsWithFetch(
@@ -748,7 +431,7 @@ describe("msteams attachments", () => {
748
431
 
749
432
  expectAttachmentMediaLength(media, 1);
750
433
  expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
751
- expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
434
+ expect(fetchMock.mock.calls.map(([calledUrl]) => calledUrl)).toContain(redirectedUrl);
752
435
  });
753
436
 
754
437
  it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
@@ -835,7 +518,7 @@ describe("msteams attachments", () => {
835
518
  it("blocks redirects to non-https URLs", async () => {
836
519
  const insecureUrl = "http://x/insecure.png";
837
520
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
838
- const url = typeof input === "string" ? input : input.toString();
521
+ const url = resolveRequestUrl(input);
839
522
  if (url === TEST_URL_IMAGE) {
840
523
  return createRedirectResponse(insecureUrl);
841
524
  }
@@ -856,94 +539,148 @@ describe("msteams attachments", () => {
856
539
  expectAttachmentMediaLength(media, 0);
857
540
  expect(fetchMock).toHaveBeenCalledTimes(1);
858
541
  });
859
- });
860
542
 
861
- describe("buildMSTeamsGraphMessageUrls", () => {
862
- it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
863
- const urls = buildMSTeamsGraphMessageUrls(params);
864
- expect(urls[0]).toContain(expectedPath);
865
- });
866
- });
867
-
868
- describe("downloadMSTeamsGraphMedia", () => {
869
- it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
870
-
871
- it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
872
- const tokenProvider = createTokenProvider("top-secret-token");
873
- const escapedUrl = "https://example.com/collect";
874
- const seen: Array<{ url: string; auth: string }> = [];
875
- const referenceAttachment = createReferenceAttachment();
876
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
877
- const url = String(input);
878
- const auth = new Headers(init?.headers).get("Authorization") ?? "";
879
- seen.push({ url, auth });
543
+ describe("OneDrive/SharePoint shared links", () => {
544
+ const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
545
+ const DEFAULT_GRAPH_ALLOW_HOSTS = [GRAPH_HOST];
546
+ const PDF_PAYLOAD = Buffer.from("pdf-bytes");
547
+
548
+ const createGraphSharesFetchMock = () =>
549
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
550
+ const url = resolveRequestUrl(input);
551
+ const auth = new Headers(init?.headers).get("Authorization");
552
+ if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
553
+ if (!auth) {
554
+ return createTextResponse("unauthorized", 401);
555
+ }
556
+ return createBufferResponse(PDF_PAYLOAD, CONTENT_TYPE_APPLICATION_PDF);
557
+ }
558
+ return createNotFoundResponse();
559
+ });
880
560
 
881
- if (url === DEFAULT_MESSAGE_URL) {
882
- return createJsonResponse({ attachments: [referenceAttachment] });
883
- }
884
- if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
885
- return createGraphCollectionResponse([]);
886
- }
887
- if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
888
- return createGraphCollectionResponse([referenceAttachment]);
889
- }
890
- if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
891
- return createRedirectResponse(escapedUrl);
892
- }
893
- if (url === escapedUrl) {
894
- return createPdfResponse();
561
+ it.each([
562
+ {
563
+ label: "SharePoint URL",
564
+ contentUrl: "https://contoso.sharepoint.com/personal/user/Documents/report.pdf",
565
+ },
566
+ {
567
+ label: "OneDrive 1drv.ms URL",
568
+ contentUrl: "https://1drv.ms/b/s!AkxYabcdefg",
569
+ },
570
+ {
571
+ label: "OneDrive onedrive.live.com URL",
572
+ contentUrl: "https://onedrive.live.com/share/file",
573
+ },
574
+ ])("routes $label through Graph shares endpoint", async ({ contentUrl }) => {
575
+ const tokenProvider = createTokenProvider();
576
+ const fetchMock = createGraphSharesFetchMock();
577
+ detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
578
+ saveMediaBufferMock.mockResolvedValueOnce({
579
+ id: "saved.pdf",
580
+ path: SAVED_PDF_PATH,
581
+ size: Buffer.byteLength(PDF_PAYLOAD),
582
+ contentType: CONTENT_TYPE_APPLICATION_PDF,
583
+ });
584
+
585
+ const media = await downloadMSTeamsAttachments(
586
+ buildDownloadParams(
587
+ [
588
+ {
589
+ contentType: "reference",
590
+ contentUrl,
591
+ name: "report.pdf",
592
+ },
593
+ ],
594
+ {
595
+ tokenProvider,
596
+ allowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
597
+ authAllowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
598
+ fetchFn: asFetchFn(fetchMock),
599
+ },
600
+ ),
601
+ );
602
+
603
+ expectAttachmentMediaLength(media, 1);
604
+ expect(media[0]?.path).toBe(SAVED_PDF_PATH);
605
+ // The only host that should be fetched is graph.microsoft.com.
606
+ const calledUrls = (fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>).map(
607
+ ([input]) => resolveRequestUrl(input),
608
+ );
609
+ expect(calledUrls.length).toBeGreaterThan(0);
610
+ for (const url of calledUrls) {
611
+ expect(url.startsWith(GRAPH_SHARES_URL_PREFIX)).toBe(true);
895
612
  }
896
- return createNotFoundResponse();
613
+ // Graph scope token was acquired for the shares fetch.
614
+ expect(tokenProvider.getAccessToken).toHaveBeenCalled();
897
615
  });
898
616
 
899
- const media = await downloadMSTeamsGraphMedia({
900
- messageUrl: DEFAULT_MESSAGE_URL,
901
- tokenProvider,
902
- maxBytes: DEFAULT_MAX_BYTES,
903
- allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
904
- authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
905
- fetchFn: asFetchFn(fetchMock),
617
+ it("falls through to direct fetch for non-shared-link URLs", async () => {
618
+ const directUrl = createTestUrl("direct.pdf");
619
+ const fetchMock = createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf");
620
+ detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
621
+ saveMediaBufferMock.mockResolvedValueOnce({
622
+ id: "saved.pdf",
623
+ path: SAVED_PDF_PATH,
624
+ size: Buffer.byteLength(PDF_BUFFER),
625
+ contentType: CONTENT_TYPE_APPLICATION_PDF,
626
+ });
627
+
628
+ const media = await downloadAttachmentsWithFetch(
629
+ createPdfAttachments(directUrl),
630
+ fetchMock,
631
+ );
632
+
633
+ expectAttachmentMediaLength(media, 1);
634
+ const calledUrls = (fetchMock.mock.calls as unknown[]).map((call) => {
635
+ const input = (call as [RequestInfo | URL])[0];
636
+ return resolveRequestUrl(input);
637
+ });
638
+ // Should have hit the original host, NOT graph shares.
639
+ expect(calledUrls.some((url) => url === directUrl)).toBe(true);
640
+ expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
906
641
  });
907
-
908
- expectAttachmentMediaLength(media.media, 1);
909
- const redirected = seen.find((entry) => entry.url === escapedUrl);
910
- expect(redirected).toBeDefined();
911
- expect(redirected?.auth).toBe("");
912
642
  });
913
643
 
914
- it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
915
- const escapedUrl = "https://evil.example/internal.pdf";
916
- const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
917
- {
918
- ...buildDefaultShareReferenceGraphFetchOptions({
919
- onShareRequest: () => createRedirectResponse(escapedUrl),
920
- onUnhandled: (url) => {
921
- if (url === escapedUrl) {
922
- return createPdfResponse("should-not-be-fetched");
923
- }
924
- return undefined;
925
- },
644
+ describe("error logging (issue #63396)", () => {
645
+ // Before this fix, fetch failures were swallowed by empty `catch {}`
646
+ // blocks, leaving operators with no signal that SharePoint downloads
647
+ // were silently failing on Node 24+. These tests pin the logger contract
648
+ // so the regression cannot return.
649
+ it("invokes logger.warn when a remote media download fails", async () => {
650
+ const logger = { warn: vi.fn(), error: vi.fn() };
651
+ const fetchMock = vi.fn(async () => createTextResponse("server error", 500));
652
+
653
+ const media = await downloadMSTeamsAttachments(
654
+ buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
655
+ fetchFn: asFetchFn(fetchMock),
656
+ logger,
926
657
  }),
927
- },
928
- {
929
- allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
930
- },
931
- );
658
+ );
659
+
660
+ expectAttachmentMediaLength(media, 0);
661
+ expect(logger.warn).toHaveBeenCalledWith(
662
+ "msteams attachment download failed",
663
+ expect.objectContaining({
664
+ error: expect.stringContaining("HTTP 500"),
665
+ host: expect.any(String),
666
+ }),
667
+ );
668
+ });
932
669
 
933
- expectAttachmentMediaLength(media.media, 0);
934
- const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
935
- expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true);
936
- expect(calledUrls).not.toContain(escapedUrl);
937
- });
938
- });
670
+ it("does not log when downloads succeed", async () => {
671
+ const logger = { warn: vi.fn(), error: vi.fn() };
672
+ const fetchMock = createOkFetchMock(CONTENT_TYPE_IMAGE_PNG);
673
+
674
+ const media = await downloadMSTeamsAttachments(
675
+ buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
676
+ fetchFn: asFetchFn(fetchMock),
677
+ logger,
678
+ }),
679
+ );
939
680
 
940
- describe("buildMSTeamsMediaPayload", () => {
941
- it("returns single and multi-file fields", async () => {
942
- const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png"));
943
- expectMSTeamsMediaPayload(payload, {
944
- firstPath: "/tmp/a.png",
945
- paths: ["/tmp/a.png", "/tmp/b.png"],
946
- types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG],
681
+ expectAttachmentMediaLength(media, 1);
682
+ expect(logger.warn).not.toHaveBeenCalled();
683
+ expect(logger.error).not.toHaveBeenCalled();
947
684
  });
948
685
  });
949
686
  });