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