test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -1,9 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { downloadMSTeamsAttachments } from "./attachments/download.js";
import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js";
import { buildMSTeamsMediaPayload } from "./attachments/payload.js";
import { setMSTeamsRuntime } from "./runtime.js";
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
@@ -52,7 +48,58 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
type AttachmentsModule = typeof import("./attachments.js");
type DownloadAttachmentsParams = Parameters<AttachmentsModule["downloadMSTeamsAttachments"]>[0];
type DownloadGraphMediaParams = Parameters<AttachmentsModule["downloadMSTeamsGraphMedia"]>[0];
const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123";
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_ALLOW_HOSTS = ["x"];
const createOkFetchMock = (contentType: string, payload = "png") =>
vi.fn(async () => {
return new Response(Buffer.from(payload), {
status: 200,
headers: { "content-type": contentType },
});
});
const buildDownloadParams = (
attachments: DownloadAttachmentsParams["attachments"],
overrides: Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn"> = {},
): DownloadAttachmentsParams => {
return {
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
const buildDownloadGraphParams = (
fetchFn: typeof fetch,
overrides: Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
> = {},
): DownloadGraphMediaParams => {
return {
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: DEFAULT_MAX_BYTES,
fetchFn,
...overrides,
};
};
describe("msteams attachments", () => {
const load = async () => {
return await import("./attachments.js");
};
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
@@ -62,11 +109,13 @@ describe("msteams attachments", () => {
describe("buildMSTeamsAttachmentPlaceholder", () => {
it("returns empty string when no attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
});
it("returns image placeholder for image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "image/png", contentUrl: "https://x/img.png" },
@@ -81,6 +130,7 @@ describe("msteams attachments", () => {
});
it("treats Teams file.download.info image attachments as images", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -92,6 +142,7 @@ describe("msteams attachments", () => {
});
it("returns document placeholder for non-image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
@@ -106,6 +157,7 @@ describe("msteams attachments", () => {
});
it("counts inline images in text/html attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -127,20 +179,13 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
@@ -149,50 +194,38 @@ describe("msteams attachments", () => {
});
it("supports Teams file.download.info downloadUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("downloads non-image file attachments (PDF)", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("application/pdf", "pdf");
detectMimeMock.mockResolvedValueOnce("application/pdf");
saveMediaBufferMock.mockResolvedValueOnce({
path: "/tmp/saved.pdf",
contentType: "application/pdf",
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
@@ -201,48 +234,42 @@ describe("msteams attachments", () => {
});
it("downloads inline image URLs from html attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
});
it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsAttachments } = await load();
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsAttachments({
attachments: [
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${base64}" />`,
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
});
]),
);
expect(media).toHaveLength(1);
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("retries with auth when the first request is unauthorized", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
const hasAuth = Boolean(headers.get("Authorization"));
@@ -255,21 +282,20 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
allowHosts: ["x"],
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("skips auth retries when the host is not in auth allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
@@ -283,17 +309,17 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
],
maxBytes: 1024 * 1024,
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }],
{
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
},
),
);
expect(media).toHaveLength(0);
expect(fetchMock).toHaveBeenCalled();
@@ -301,13 +327,15 @@ describe("msteams attachments", () => {
});
it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], {
allowHosts: ["graph.microsoft.com"],
resolveFn: undefined,
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
@@ -316,6 +344,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsGraphMessageUrls", () => {
it("builds channel message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
conversationId: "19:thread@thread.tacv2",
@@ -326,6 +355,7 @@ describe("msteams attachments", () => {
});
it("builds channel reply urls when replyToId is present", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
messageId: "reply-id",
@@ -338,6 +368,7 @@ describe("msteams attachments", () => {
});
it("builds chat message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "groupChat",
conversationId: "19:chat@thread.v2",
@@ -349,6 +380,7 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsGraphMedia", () => {
it("downloads hostedContents images", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const base64 = Buffer.from("png").toString("base64");
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
@@ -371,12 +403,9 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
@@ -384,6 +413,7 @@ describe("msteams attachments", () => {
});
it("merges SharePoint reference attachments with hosted content", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const hostedBase64 = Buffer.from("png").toString("base64");
const shareUrl = "https://contoso.sharepoint.com/site/file";
const fetchMock = vi.fn(async (url: string) => {
@@ -440,17 +470,15 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(2);
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const shareUrl = "https://contoso.sharepoint.com/site/file";
const escapedUrl = "https://evil.example/internal.pdf";
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
@@ -515,13 +543,11 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch, {
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
}),
);
expect(media.media).toHaveLength(0);
const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
@@ -534,6 +560,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", async () => {
const { buildMSTeamsMediaPayload } = await load();
const payload = buildMSTeamsMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },

View File

@@ -49,6 +49,28 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
const createNoopAdapter = (): MSTeamsAdapter => ({
continueConversation: async () => {},
process: async () => {},
});
const createRecordedSendActivity = (
sink: string[],
failFirstWithStatusCode?: number,
): ((activity: unknown) => Promise<{ id: string }>) => {
let attempts = 0;
return async (activity: unknown) => {
const { text } = activity as { text?: string };
const content = text ?? "";
sink.push(content);
attempts += 1;
if (failFirstWithStatusCode !== undefined && attempts === 1) {
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
}
return { id: `id:${content}` };
};
};
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
@@ -117,17 +139,9 @@ describe("msteams messenger", () => {
it("sends thread messages via the provided context", async () => {
const sent: string[] = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
sent.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(sent),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -149,11 +163,7 @@ describe("msteams messenger", () => {
continueConversation: async (_appId, reference, logic) => {
seen.reference = reference;
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
seen.texts.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
sendActivity: createRecordedSendActivity(seen.texts),
});
},
process: async () => {},
@@ -192,10 +202,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -242,20 +249,9 @@ describe("msteams messenger", () => {
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("throttled"), { statusCode: 429 });
}
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(attempts, 429),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -280,10 +276,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
await expect(
sendMSTeamsMessages({
@@ -303,18 +296,7 @@ describe("msteams messenger", () => {
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("server error"), {
statusCode: 503,
});
}
return { id: `id:${text ?? ""}` };
},
});
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
},
process: async () => {},
};