@kodelyth/msteams 2026.5.39 → 2026.5.42

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 (208) 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/dist/api.js +3 -0
  7. package/dist/channel-BvTXHuGs.js +1161 -0
  8. package/dist/channel-config-api.js +2 -0
  9. package/dist/channel-plugin-api.js +2 -0
  10. package/dist/channel.runtime-NssGKZm5.js +650 -0
  11. package/dist/config-schema-Btk-XCOd.js +43 -0
  12. package/dist/contract-api.js +2 -0
  13. package/dist/graph-users-D-gKCguI.js +1411 -0
  14. package/dist/index.js +22 -0
  15. package/dist/oauth-BUxlphX3.js +114 -0
  16. package/dist/oauth.token-ebId9946.js +116 -0
  17. package/dist/probe-Cj2KsAGF.js +2190 -0
  18. package/dist/runtime-api-BL4DOWXD.js +28 -0
  19. package/dist/runtime-api.js +2 -0
  20. package/dist/secret-contract-Bo7kdUrT.js +35 -0
  21. package/dist/secret-contract-api.js +2 -0
  22. package/dist/setup-entry.js +15 -0
  23. package/dist/setup-plugin-api.js +64 -0
  24. package/dist/setup-surface-COTQDcTQ.js +531 -0
  25. package/dist/src-tvpsGYPV.js +4226 -0
  26. package/dist/test-api.js +2 -0
  27. package/index.ts +20 -0
  28. package/klaw.plugin.json +2 -726
  29. package/package.json +4 -4
  30. package/runtime-api.ts +66 -0
  31. package/secret-contract-api.ts +5 -0
  32. package/setup-entry.ts +13 -0
  33. package/setup-plugin-api.ts +3 -0
  34. package/src/ai-entity.ts +7 -0
  35. package/src/approval-auth.ts +44 -0
  36. package/src/attachments/bot-framework.test.ts +506 -0
  37. package/src/attachments/bot-framework.ts +348 -0
  38. package/src/attachments/download.ts +328 -0
  39. package/src/attachments/graph.test.ts +441 -0
  40. package/src/attachments/graph.ts +489 -0
  41. package/src/attachments/html.ts +122 -0
  42. package/src/attachments/payload.ts +14 -0
  43. package/src/attachments/remote-media.test.ts +187 -0
  44. package/src/attachments/remote-media.ts +86 -0
  45. package/src/attachments/shared.test.ts +547 -0
  46. package/src/attachments/shared.ts +655 -0
  47. package/src/attachments/types.ts +47 -0
  48. package/src/attachments.graph.test.ts +414 -0
  49. package/src/attachments.helpers.test.ts +245 -0
  50. package/src/attachments.test-helpers.ts +17 -0
  51. package/src/attachments.test.ts +754 -0
  52. package/src/attachments.ts +18 -0
  53. package/src/block-streaming-config.test.ts +61 -0
  54. package/src/channel-api.ts +1 -0
  55. package/src/channel.actions.test.ts +797 -0
  56. package/src/channel.directory.test.ts +176 -0
  57. package/src/channel.message-adapter.test.ts +227 -0
  58. package/src/channel.runtime.ts +56 -0
  59. package/src/channel.setup.ts +77 -0
  60. package/src/channel.test.ts +136 -0
  61. package/src/channel.ts +1176 -0
  62. package/src/config-schema.ts +6 -0
  63. package/src/config-ui-hints.ts +40 -0
  64. package/src/conversation-store-fs.test.ts +81 -0
  65. package/src/conversation-store-fs.ts +149 -0
  66. package/src/conversation-store-helpers.test.ts +202 -0
  67. package/src/conversation-store-helpers.ts +105 -0
  68. package/src/conversation-store-memory.ts +51 -0
  69. package/src/conversation-store.shared.test.ts +260 -0
  70. package/src/conversation-store.ts +71 -0
  71. package/src/directory-live.test.ts +156 -0
  72. package/src/directory-live.ts +111 -0
  73. package/src/doctor.ts +27 -0
  74. package/src/errors.test.ts +154 -0
  75. package/src/errors.ts +270 -0
  76. package/src/feedback-reflection-prompt.ts +117 -0
  77. package/src/feedback-reflection-store.ts +113 -0
  78. package/src/feedback-reflection.test.ts +237 -0
  79. package/src/feedback-reflection.ts +268 -0
  80. package/src/file-consent-helpers.test.ts +328 -0
  81. package/src/file-consent-helpers.ts +115 -0
  82. package/src/file-consent-invoke.ts +150 -0
  83. package/src/file-consent.test.ts +378 -0
  84. package/src/file-consent.ts +223 -0
  85. package/src/graph-chat.ts +36 -0
  86. package/src/graph-group-management.test.ts +332 -0
  87. package/src/graph-group-management.ts +168 -0
  88. package/src/graph-members.test.ts +89 -0
  89. package/src/graph-members.ts +48 -0
  90. package/src/graph-messages.actions.test.ts +253 -0
  91. package/src/graph-messages.read.test.ts +391 -0
  92. package/src/graph-messages.search.test.ts +227 -0
  93. package/src/graph-messages.test-helpers.ts +50 -0
  94. package/src/graph-messages.ts +534 -0
  95. package/src/graph-teams.test.ts +222 -0
  96. package/src/graph-teams.ts +114 -0
  97. package/src/graph-thread.test.ts +252 -0
  98. package/src/graph-thread.ts +146 -0
  99. package/src/graph-upload.test.ts +253 -0
  100. package/src/graph-upload.ts +531 -0
  101. package/src/graph-users.ts +29 -0
  102. package/src/graph.test.ts +540 -0
  103. package/src/graph.ts +308 -0
  104. package/src/inbound.test.ts +221 -0
  105. package/src/inbound.ts +148 -0
  106. package/src/index.ts +4 -0
  107. package/src/media-helpers.test.ts +220 -0
  108. package/src/media-helpers.ts +105 -0
  109. package/src/mentions.test.ts +254 -0
  110. package/src/mentions.ts +114 -0
  111. package/src/messenger.test.ts +961 -0
  112. package/src/messenger.ts +608 -0
  113. package/src/monitor-handler/access.ts +136 -0
  114. package/src/monitor-handler/inbound-media.test.ts +314 -0
  115. package/src/monitor-handler/inbound-media.ts +180 -0
  116. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  117. package/src/monitor-handler/message-handler.authz.test.ts +739 -0
  118. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  119. package/src/monitor-handler/message-handler.test-support.ts +99 -0
  120. package/src/monitor-handler/message-handler.thread-parent.test.ts +225 -0
  121. package/src/monitor-handler/message-handler.thread-session.test.ts +132 -0
  122. package/src/monitor-handler/message-handler.ts +1003 -0
  123. package/src/monitor-handler/reaction-handler.test.ts +325 -0
  124. package/src/monitor-handler/reaction-handler.ts +122 -0
  125. package/src/monitor-handler/thread-session.ts +30 -0
  126. package/src/monitor-handler.adaptive-card.test.ts +158 -0
  127. package/src/monitor-handler.feedback-authz.test.ts +357 -0
  128. package/src/monitor-handler.file-consent.test.ts +443 -0
  129. package/src/monitor-handler.sso.test.ts +576 -0
  130. package/src/monitor-handler.test-helpers.ts +181 -0
  131. package/src/monitor-handler.ts +538 -0
  132. package/src/monitor-handler.types.ts +27 -0
  133. package/src/monitor-types.ts +6 -0
  134. package/src/monitor.lifecycle.test.ts +457 -0
  135. package/src/monitor.test.ts +119 -0
  136. package/src/monitor.ts +476 -0
  137. package/src/oauth.flow.ts +77 -0
  138. package/src/oauth.shared.ts +37 -0
  139. package/src/oauth.test.ts +350 -0
  140. package/src/oauth.token.ts +162 -0
  141. package/src/oauth.ts +130 -0
  142. package/src/outbound.test.ts +400 -0
  143. package/src/outbound.ts +198 -0
  144. package/src/pending-uploads-fs.test.ts +261 -0
  145. package/src/pending-uploads-fs.ts +235 -0
  146. package/src/pending-uploads.test.ts +186 -0
  147. package/src/pending-uploads.ts +121 -0
  148. package/src/policy.test.ts +156 -0
  149. package/src/policy.ts +245 -0
  150. package/src/polls-store-memory.ts +32 -0
  151. package/src/polls.test.ts +169 -0
  152. package/src/polls.ts +312 -0
  153. package/src/presentation.ts +93 -0
  154. package/src/probe.test.ts +79 -0
  155. package/src/probe.ts +132 -0
  156. package/src/reply-dispatcher.test.ts +543 -0
  157. package/src/reply-dispatcher.ts +523 -0
  158. package/src/reply-stream-controller.test.ts +424 -0
  159. package/src/reply-stream-controller.ts +334 -0
  160. package/src/resolve-allowlist.test.ts +253 -0
  161. package/src/resolve-allowlist.ts +309 -0
  162. package/src/revoked-context.ts +17 -0
  163. package/src/runtime.ts +12 -0
  164. package/src/sdk-types.ts +59 -0
  165. package/src/sdk.test.ts +727 -0
  166. package/src/sdk.ts +916 -0
  167. package/src/secret-contract.ts +49 -0
  168. package/src/secret-input.ts +7 -0
  169. package/src/send-context.test.ts +93 -0
  170. package/src/send-context.ts +269 -0
  171. package/src/send.test.ts +588 -0
  172. package/src/send.ts +697 -0
  173. package/src/sent-message-cache.test.ts +106 -0
  174. package/src/sent-message-cache.ts +174 -0
  175. package/src/session-route.ts +40 -0
  176. package/src/setup-core.ts +162 -0
  177. package/src/setup-surface.test.ts +175 -0
  178. package/src/setup-surface.ts +319 -0
  179. package/src/sso-token-store.test.ts +74 -0
  180. package/src/sso-token-store.ts +166 -0
  181. package/src/sso.ts +300 -0
  182. package/src/storage.ts +25 -0
  183. package/src/store-fs.ts +42 -0
  184. package/src/streaming-message.test.ts +323 -0
  185. package/src/streaming-message.ts +327 -0
  186. package/src/test-runtime.ts +16 -0
  187. package/src/thread-parent-context.test.ts +224 -0
  188. package/src/thread-parent-context.ts +159 -0
  189. package/src/token-response.ts +11 -0
  190. package/src/token.test.ts +268 -0
  191. package/src/token.ts +194 -0
  192. package/src/user-agent.test.ts +121 -0
  193. package/src/user-agent.ts +53 -0
  194. package/src/webhook-timeouts.ts +27 -0
  195. package/src/welcome-card.test.ts +104 -0
  196. package/src/welcome-card.ts +57 -0
  197. package/test-api.ts +1 -0
  198. package/tsconfig.json +16 -0
  199. package/api.js +0 -7
  200. package/channel-config-api.js +0 -7
  201. package/channel-plugin-api.js +0 -7
  202. package/contract-api.js +0 -7
  203. package/index.js +0 -7
  204. package/runtime-api.js +0 -7
  205. package/secret-contract-api.js +0 -7
  206. package/setup-entry.js +0 -7
  207. package/setup-plugin-api.js +0 -7
  208. package/test-api.js +0 -7
@@ -0,0 +1,441 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages.
4
+ vi.mock("./shared.js", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("./shared.js")>();
6
+ return {
7
+ ...actual,
8
+ applyAuthorizationHeaderForUrl: vi.fn(),
9
+ GRAPH_ROOT: "https://graph.microsoft.com/v1.0",
10
+ inferPlaceholder: vi.fn(({ contentType }: { contentType?: string }) =>
11
+ contentType?.startsWith("image/") ? "[image]" : "[file]",
12
+ ),
13
+ isRecord: (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v),
14
+ isUrlAllowed: vi.fn(() => true),
15
+ normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined),
16
+ resolveMediaSsrfPolicy: vi.fn(() => undefined),
17
+ resolveAttachmentFetchPolicy: vi.fn(() => ({ allowHosts: ["*"], authAllowHosts: ["*"] })),
18
+ resolveRequestUrl: vi.fn((input: string) => input),
19
+ safeFetchWithPolicy: vi.fn(),
20
+ };
21
+ });
22
+
23
+ vi.mock("klaw/plugin-sdk/ssrf-runtime", () => ({
24
+ fetchWithSsrFGuard: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("../runtime.js", () => ({
28
+ getMSTeamsRuntime: vi.fn(() => ({
29
+ media: {
30
+ detectMime: vi.fn(async () => "image/png"),
31
+ },
32
+ channel: {
33
+ media: {
34
+ saveResponseMedia: vi.fn(
35
+ async (
36
+ response: Response,
37
+ options?: { fallbackContentType?: string; maxBytes?: number },
38
+ ) => {
39
+ const length = Number(response.headers.get("content-length"));
40
+ if (
41
+ Number.isFinite(length) &&
42
+ options?.maxBytes !== undefined &&
43
+ length > options.maxBytes
44
+ ) {
45
+ throw new Error("content length exceeds maxBytes");
46
+ }
47
+ return {
48
+ path: "/tmp/saved.png",
49
+ contentType: options?.fallbackContentType ?? "image/png",
50
+ };
51
+ },
52
+ ),
53
+ saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({
54
+ path: "/tmp/saved.png",
55
+ contentType: ct ?? "image/png",
56
+ })),
57
+ },
58
+ },
59
+ })),
60
+ }));
61
+
62
+ vi.mock("./download.js", () => ({
63
+ downloadMSTeamsAttachments: vi.fn(async () => []),
64
+ }));
65
+
66
+ vi.mock("./remote-media.js", () => ({
67
+ downloadAndStoreMSTeamsRemoteMedia: vi.fn(),
68
+ }));
69
+
70
+ import { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
71
+ import { downloadMSTeamsGraphMedia } from "./graph.js";
72
+ import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
73
+ import { safeFetchWithPolicy } from "./shared.js";
74
+
75
+ function mockFetchResponse(body: unknown, status = 200) {
76
+ const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
77
+ return new Response(bodyStr, { status, headers: { "content-type": "application/json" } });
78
+ }
79
+
80
+ function mockBinaryResponse(data: Uint8Array, status = 200) {
81
+ return new Response(Buffer.from(data) as BodyInit, { status });
82
+ }
83
+
84
+ type GuardedFetchParams = { url: string; init?: RequestInit };
85
+
86
+ function guardedFetchResult(params: GuardedFetchParams, response: Response) {
87
+ return {
88
+ response,
89
+ release: async () => {},
90
+ finalUrl: params.url,
91
+ };
92
+ }
93
+
94
+ function requireFirstMockCall<TArgs extends unknown[]>(
95
+ mock: { mock: { calls: TArgs[] } },
96
+ label: string,
97
+ ): TArgs {
98
+ const [call] = mock.mock.calls;
99
+ if (!call) {
100
+ throw new Error(`expected ${label}`);
101
+ }
102
+ return call;
103
+ }
104
+
105
+ function mockGraphMediaFetch(options: {
106
+ messageId: string;
107
+ messageResponse?: unknown;
108
+ hostedContents?: unknown[];
109
+ valueResponses?: Record<string, Response>;
110
+ fetchCalls?: string[];
111
+ }) {
112
+ vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
113
+ options.fetchCalls?.push(params.url);
114
+ const url = params.url;
115
+ if (url.endsWith(`/messages/${options.messageId}`) && !url.includes("hostedContents")) {
116
+ return guardedFetchResult(
117
+ params,
118
+ mockFetchResponse(options.messageResponse ?? { body: {}, attachments: [] }),
119
+ );
120
+ }
121
+ if (url.endsWith("/hostedContents")) {
122
+ return guardedFetchResult(params, mockFetchResponse({ value: options.hostedContents ?? [] }));
123
+ }
124
+ for (const [fragment, response] of Object.entries(options.valueResponses ?? {})) {
125
+ if (url.includes(fragment)) {
126
+ return guardedFetchResult(params, response);
127
+ }
128
+ }
129
+ return guardedFetchResult(params, mockFetchResponse({}, 404));
130
+ });
131
+ }
132
+
133
+ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
134
+ beforeEach(() => {
135
+ vi.clearAllMocks();
136
+ });
137
+
138
+ it("fetches $value endpoint when contentBytes is null but item.id exists", async () => {
139
+ const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
140
+
141
+ const fetchCalls: string[] = [];
142
+
143
+ mockGraphMediaFetch({
144
+ messageId: "msg-1",
145
+ hostedContents: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
146
+ valueResponses: {
147
+ "/hostedContents/hosted-123/$value": mockBinaryResponse(imageBytes),
148
+ },
149
+ fetchCalls,
150
+ });
151
+
152
+ const result = await downloadMSTeamsGraphMedia({
153
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-1",
154
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
155
+ maxBytes: 10 * 1024 * 1024,
156
+ });
157
+
158
+ // Verify the $value endpoint was fetched
159
+ expect(fetchCalls).toContain(
160
+ "https://graph.microsoft.com/v1.0/chats/c/messages/msg-1/hostedContents/hosted-123/$value",
161
+ );
162
+ expect(result.media.length).toBeGreaterThan(0);
163
+ expect(result.hostedCount).toBe(1);
164
+ });
165
+
166
+ it("skips hosted content when contentBytes is null and id is missing", async () => {
167
+ mockGraphMediaFetch({
168
+ messageId: "msg-2",
169
+ hostedContents: [{ contentType: "image/png", contentBytes: null }],
170
+ });
171
+
172
+ const result = await downloadMSTeamsGraphMedia({
173
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-2",
174
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
175
+ maxBytes: 10 * 1024 * 1024,
176
+ });
177
+
178
+ // No media because there's no id to fetch $value from and no contentBytes
179
+ expect(result.media).toHaveLength(0);
180
+ });
181
+
182
+ it("skips $value content when Content-Length exceeds maxBytes", async () => {
183
+ const fetchCalls: string[] = [];
184
+
185
+ mockGraphMediaFetch({
186
+ messageId: "msg-cl",
187
+ hostedContents: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
188
+ valueResponses: {
189
+ "/hostedContents/hosted-big/$value": new Response(
190
+ Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) as BodyInit,
191
+ {
192
+ status: 200,
193
+ headers: { "content-length": "999999999" },
194
+ },
195
+ ),
196
+ },
197
+ fetchCalls,
198
+ });
199
+
200
+ const result = await downloadMSTeamsGraphMedia({
201
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl",
202
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
203
+ maxBytes: 1024, // 1 KB limit
204
+ });
205
+
206
+ // $value was fetched but skipped due to Content-Length exceeding maxBytes
207
+ expect(fetchCalls).toContain(
208
+ "https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl/hostedContents/hosted-big/$value",
209
+ );
210
+ expect(result.media).toHaveLength(0);
211
+ });
212
+
213
+ it("uses inline contentBytes when available instead of $value", async () => {
214
+ const fetchCalls: string[] = [];
215
+ const base64Png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
216
+
217
+ mockGraphMediaFetch({
218
+ messageId: "msg-3",
219
+ hostedContents: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
220
+ fetchCalls,
221
+ });
222
+
223
+ const result = await downloadMSTeamsGraphMedia({
224
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-3",
225
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
226
+ maxBytes: 10 * 1024 * 1024,
227
+ });
228
+
229
+ // Should NOT have fetched $value since contentBytes was available
230
+ const valueCall = fetchCalls.find((u) => u.includes("/$value"));
231
+ expect(valueCall).toBeUndefined();
232
+ expect(result.media.length).toBeGreaterThan(0);
233
+ });
234
+
235
+ it("adds the Klaw User-Agent to guarded Graph attachment fetches", async () => {
236
+ mockGraphMediaFetch({ messageId: "msg-ua" });
237
+
238
+ await downloadMSTeamsGraphMedia({
239
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
240
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
241
+ maxBytes: 10 * 1024 * 1024,
242
+ });
243
+
244
+ const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
245
+ for (const [call] of guardCalls) {
246
+ const headers = call.init?.headers;
247
+ expect(headers).toBeInstanceOf(Headers);
248
+ expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
249
+ expect((headers as Headers).get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ Klaw\/.+$/);
250
+ }
251
+ });
252
+
253
+ it("adds the Klaw User-Agent to Graph shares downloads for reference attachments", async () => {
254
+ mockGraphMediaFetch({
255
+ messageId: "msg-share",
256
+ messageResponse: {
257
+ body: {},
258
+ attachments: [
259
+ {
260
+ contentType: "reference",
261
+ contentUrl: "https://tenant.sharepoint.com/file.docx",
262
+ name: "file.docx",
263
+ },
264
+ ],
265
+ },
266
+ });
267
+ vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
268
+ vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
269
+ if (params.fetchImpl) {
270
+ await params.fetchImpl(params.url, {});
271
+ }
272
+ return {
273
+ path: "/tmp/file.docx",
274
+ contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
275
+ placeholder: "[file]",
276
+ };
277
+ });
278
+
279
+ await downloadMSTeamsGraphMedia({
280
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share",
281
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
282
+ maxBytes: 10 * 1024 * 1024,
283
+ });
284
+
285
+ const [fetchParams] = requireFirstMockCall(
286
+ vi.mocked(safeFetchWithPolicy),
287
+ "safeFetchWithPolicy call",
288
+ );
289
+ expect(fetchParams.requestInit?.headers).toBeInstanceOf(Headers);
290
+ const requestInit = fetchParams.requestInit;
291
+ const headers = requestInit?.headers as Headers;
292
+ expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ Klaw\/.+$/);
293
+ });
294
+ });
295
+
296
+ describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () => {
297
+ beforeEach(() => {
298
+ vi.clearAllMocks();
299
+ });
300
+
301
+ it("does NOT call the nonexistent ${messageUrl}/attachments sub-resource", async () => {
302
+ // The Graph v1.0 API does not expose a `/attachments` sub-resource on
303
+ // channel or chat messages. Issue #58617 documented that the old code
304
+ // path called this endpoint and recorded a 404 in diagnostics. After
305
+ // this fix, the helper must source attachments from the main message
306
+ // resource's inline `attachments` array instead.
307
+ const fetchCalls: string[] = [];
308
+
309
+ mockGraphMediaFetch({
310
+ messageId: "msg-no-sub",
311
+ messageResponse: {
312
+ body: { content: "hi" },
313
+ attachments: [],
314
+ },
315
+ fetchCalls,
316
+ });
317
+
318
+ await downloadMSTeamsGraphMedia({
319
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-no-sub",
320
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
321
+ maxBytes: 10 * 1024 * 1024,
322
+ });
323
+
324
+ const calledSubResource = fetchCalls.some((u) =>
325
+ u.endsWith("/messages/msg-no-sub/attachments"),
326
+ );
327
+ expect(calledSubResource).toBe(false);
328
+ });
329
+
330
+ it("sources reference attachments from the message body's attachments array", async () => {
331
+ // Before the fix, the helper fetched `/attachments` and used that list.
332
+ // After the fix, it must use `msgData.attachments` from the main fetch.
333
+ mockGraphMediaFetch({
334
+ messageId: "msg-inline",
335
+ messageResponse: {
336
+ body: {},
337
+ attachments: [
338
+ {
339
+ contentType: "reference",
340
+ contentUrl: "https://tenant.sharepoint.com/inline.pdf",
341
+ name: "inline.pdf",
342
+ },
343
+ ],
344
+ },
345
+ });
346
+ vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
347
+ vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockResolvedValue({
348
+ path: "/tmp/inline.pdf",
349
+ contentType: "application/pdf",
350
+ placeholder: "[file]",
351
+ });
352
+
353
+ const result = await downloadMSTeamsGraphMedia({
354
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-inline",
355
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
356
+ maxBytes: 10 * 1024 * 1024,
357
+ });
358
+
359
+ expect(result.media).toHaveLength(1);
360
+ expect(result.media[0]?.path).toBe("/tmp/inline.pdf");
361
+ // Regression guard: attachmentCount now reflects real inline attachments,
362
+ // not the imaginary `/attachments` sub-resource count.
363
+ expect(result.attachmentCount).toBe(1);
364
+ });
365
+
366
+ it("logs a debug event when the message fetch throws instead of swallowing it", async () => {
367
+ // Regression test for #51749: empty `catch {}` blocks used to hide the
368
+ // real error, producing misleading `graph media fetch empty` diagnostics
369
+ // without surfacing the underlying cause.
370
+ vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
371
+ if (params.url.endsWith("/messages/msg-err")) {
372
+ throw new Error("network boom");
373
+ }
374
+ // hostedContents and any other paths succeed so the error branch under
375
+ // test is the only one that fires.
376
+ return guardedFetchResult(params, mockFetchResponse({ value: [] }));
377
+ });
378
+ const logger = { warn: vi.fn() };
379
+
380
+ const result = await downloadMSTeamsGraphMedia({
381
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-err",
382
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
383
+ maxBytes: 10 * 1024 * 1024,
384
+ logger,
385
+ });
386
+
387
+ expect(result.media).toHaveLength(0);
388
+ const [message, context] = requireFirstMockCall(logger.warn, "message fetch warning");
389
+ expect(message).toBe("msteams graph message fetch failed");
390
+ expect((context as { error?: unknown }).error).toBe("network boom");
391
+ });
392
+
393
+ it("logs a debug event when the message fetch returns non-ok", async () => {
394
+ // If the message endpoint returns 403/404, we want that recorded so
395
+ // operators can distinguish auth issues from empty result sets.
396
+ vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
397
+ const url = params.url;
398
+ if (url.endsWith("/hostedContents")) {
399
+ return guardedFetchResult(params, mockFetchResponse({ value: [] }));
400
+ }
401
+ return guardedFetchResult(params, mockFetchResponse({ error: "forbidden" }, 403));
402
+ });
403
+ const log = { debug: vi.fn() };
404
+
405
+ const result = await downloadMSTeamsGraphMedia({
406
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-403",
407
+ tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
408
+ maxBytes: 10 * 1024 * 1024,
409
+ log,
410
+ });
411
+
412
+ expect(result.media).toHaveLength(0);
413
+ expect(result.attachmentStatus).toBe(403);
414
+ const [message, context] = requireFirstMockCall(log.debug, "message fetch debug event");
415
+ expect(message).toBe("graph media message fetch not ok");
416
+ expect((context as { status?: unknown }).status).toBe(403);
417
+ });
418
+
419
+ it("logs a debug event when token acquisition fails", async () => {
420
+ vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) =>
421
+ guardedFetchResult(params, mockFetchResponse({})),
422
+ );
423
+ const logger = { warn: vi.fn() };
424
+
425
+ const result = await downloadMSTeamsGraphMedia({
426
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-token",
427
+ tokenProvider: {
428
+ getAccessToken: vi.fn(async () => {
429
+ throw new Error("token expired");
430
+ }),
431
+ },
432
+ maxBytes: 10 * 1024 * 1024,
433
+ logger,
434
+ });
435
+
436
+ expect(result.tokenError).toBe(true);
437
+ const [message, context] = requireFirstMockCall(logger.warn, "token acquisition warning");
438
+ expect(message).toBe("msteams graph token acquisition failed");
439
+ expect((context as { error?: unknown }).error).toBe("token expired");
440
+ });
441
+ });