@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.
- package/package.json +1 -1
- package/src/attachments/payload.ts +3 -11
- package/src/attachments/shared.test.ts +4 -2
- package/src/attachments.test.ts +674 -486
- package/src/messenger.test.ts +30 -48
- package/src/monitor-handler/message-handler.ts +7 -0
- package/src/policy.test.ts +13 -1
- package/src/policy.ts +2 -0
package/src/attachments.test.ts
CHANGED
|
@@ -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
|
|
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:
|
|
11
|
-
contentType:
|
|
58
|
+
path: SAVED_PNG_PATH,
|
|
59
|
+
contentType: CONTENT_TYPE_IMAGE_PNG,
|
|
12
60
|
}));
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
280
|
-
|
|
667
|
+
expectSingleMedia(media);
|
|
668
|
+
expectMediaBufferSaved();
|
|
281
669
|
});
|
|
282
670
|
|
|
283
|
-
it(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
688
|
+
expectAttachmentMediaLength(media, 0);
|
|
327
689
|
});
|
|
328
690
|
});
|
|
329
691
|
|
|
330
692
|
describe("buildMSTeamsGraphMessageUrls", () => {
|
|
331
|
-
it("
|
|
332
|
-
const
|
|
333
|
-
|
|
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("
|
|
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 <
|
|
707
|
+
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
|
|
479
708
|
const res = await fetchFn(currentUrl, { redirect: "manual" });
|
|
480
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
});
|