diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 42470e370b6..71cddb08d62 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,5 +1,12 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, + buildMSTeamsMediaPayload, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, +} from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; vi.mock("openclaw/plugin-sdk", () => ({ @@ -52,13 +59,47 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -type AttachmentsModule = typeof import("./attachments.js"); -type DownloadAttachmentsParams = Parameters[0]; -type DownloadGraphMediaParams = Parameters[0]; +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; +type DownloadedMedia = Awaited>; +type DownloadAttachmentsBuildOverrides = Partial< + Omit +> & + Pick; +type DownloadAttachmentsNoFetchOverrides = Partial< + Omit< + DownloadAttachmentsParams, + "attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn" + > +> & + Pick; 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 IMAGE_ATTACHMENT = { contentType: "image/png", contentUrl: "https://x/img" }; +const PNG_BUFFER = Buffer.from("png"); +const PNG_BASE64 = PNG_BUFFER.toString("base64"); +const PDF_BUFFER = Buffer.from("pdf"); +const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") }); +const buildAttachment = >(contentType: string, props: T) => ({ + contentType, + ...props, +}); +const createHtmlAttachment = (content: string) => buildAttachment("text/html", { content }); +const createImageAttachment = (contentUrl: string) => buildAttachment("image/png", { contentUrl }); +const createPdfAttachment = (contentUrl: string) => + buildAttachment("application/pdf", { contentUrl }); +const createTeamsFileDownloadInfoAttachment = (downloadUrl = "https://x/dl", fileType = "png") => + buildAttachment("application/vnd.microsoft.teams.file.download.info", { + content: { downloadUrl, fileType }, + }); +const createImageMediaEntry = (path: string) => ({ path, contentType: "image/png" }); +const createHostedImageContent = (id: string) => ({ + id, + contentType: "image/png", + contentBytes: PNG_BASE64, +}); const createOkFetchMock = (contentType: string, payload = "png") => vi.fn(async () => { @@ -70,10 +111,7 @@ const createOkFetchMock = (contentType: string, payload = "png") => const buildDownloadParams = ( attachments: DownloadAttachmentsParams["attachments"], - overrides: Partial< - Omit - > & - Pick = {}, + overrides: DownloadAttachmentsBuildOverrides = {}, ): DownloadAttachmentsParams => { return { attachments, @@ -84,26 +122,188 @@ const buildDownloadParams = ( }; }; +const buildDownloadParamsWithFetch = ( + attachments: DownloadAttachmentsParams["attachments"], + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, +): DownloadAttachmentsParams => { + return buildDownloadParams(attachments, { + ...overrides, + fetchFn: fetchFn as unknown as typeof fetch, + }); +}; + +const downloadAttachmentsWithFetch = async ( + attachments: DownloadAttachmentsParams["attachments"], + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: { expectFetchCalled?: boolean } = {}, +) => { + const media = await downloadMSTeamsAttachments( + buildDownloadParamsWithFetch(attachments, fetchFn, overrides), + ); + if (options.expectFetchCalled ?? true) { + expect(fetchFn).toHaveBeenCalled(); + } else { + expect(fetchFn).not.toHaveBeenCalled(); + } + return media; +}; +const downloadAttachmentsWithOkImageFetch = ( + attachments: DownloadAttachmentsParams["attachments"], + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: { expectFetchCalled?: boolean } = {}, +) => { + return downloadAttachmentsWithFetch( + attachments, + createOkFetchMock("image/png"), + overrides, + options, + ); +}; + +const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) => + vi.fn(async (_url: string, opts?: RequestInit) => { + const headers = new Headers(opts?.headers); + const hasAuth = Boolean(headers.get("Authorization")); + if (!hasAuth) { + return new Response(params.unauthBody, { status: params.unauthStatus }); + } + return new Response(PNG_BUFFER, { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + const buildDownloadGraphParams = ( - fetchFn: typeof fetch, + fetchFn: unknown, overrides: Partial< Omit > = {}, ): DownloadGraphMediaParams => { return { messageUrl: DEFAULT_MESSAGE_URL, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + tokenProvider: createTokenProvider(), maxBytes: DEFAULT_MAX_BYTES, - fetchFn, + fetchFn: fetchFn as unknown as typeof fetch, ...overrides, }; }; -describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); - }; +const downloadGraphMediaWithFetch = ( + fetchFn: unknown, + overrides: Partial< + Omit + > = {}, +) => { + return downloadMSTeamsGraphMedia(buildDownloadGraphParams(fetchFn, overrides)); +}; +const expectFirstGraphUrlContains = ( + params: Parameters[0], + expectedPath: string, +) => { + const urls = buildMSTeamsGraphMessageUrls(params); + expect(urls[0]).toContain(expectedPath); +}; +const expectAttachmentPlaceholder = ( + attachments: Parameters[0], + expected: string, +) => { + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); +}; +type AttachmentPlaceholderCase = { + label: string; + attachments: Parameters[0]; + expected: string; +}; +type AttachmentDownloadSuccessCase = { + label: string; + attachments: DownloadAttachmentsParams["attachments"]; + assert?: (media: DownloadedMedia) => void; +}; +type AttachmentAuthRetryScenario = { + attachmentUrl: string; + unauthStatus: number; + unauthBody: string; + overrides?: Omit; +}; +type AttachmentAuthRetryCase = { + label: string; + scenario: AttachmentAuthRetryScenario; + expectedMediaLength: number; + expectTokenFetch: boolean; +}; +type GraphUrlExpectationCase = { + label: string; + params: Parameters[0]; + expectedPath: string; +}; +type GraphFetchMockOptions = { + hostedContents?: unknown[]; + attachments?: unknown[]; + messageAttachments?: unknown[]; + onShareRequest?: (url: string) => Response | Promise; + onUnhandled?: (url: string) => Response | Promise | undefined; +}; + +const createReferenceAttachment = (shareUrl: string) => ({ + id: "ref-1", + contentType: "reference", + contentUrl: shareUrl, + name: "report.pdf", +}); +const createShareReferenceFixture = (shareUrl = "https://contoso.sharepoint.com/site/file") => ({ + shareUrl, + referenceAttachment: createReferenceAttachment(shareUrl), +}); + +const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => { + const hostedContents = options.hostedContents ?? []; + const attachments = options.attachments ?? []; + const messageAttachments = options.messageAttachments ?? []; + return vi.fn(async (url: string) => { + if (url.endsWith("/hostedContents")) { + return new Response(JSON.stringify({ value: hostedContents }), { status: 200 }); + } + if (url.endsWith("/attachments")) { + return new Response(JSON.stringify({ value: attachments }), { status: 200 }); + } + if (url.endsWith("/messages/123")) { + return new Response(JSON.stringify({ attachments: messageAttachments }), { status: 200 }); + } + if (url.startsWith("https://graph.microsoft.com/v1.0/shares/") && options.onShareRequest) { + return options.onShareRequest(url); + } + const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined; + return unhandled ?? new Response("not found", { status: 404 }); + }); +}; +const downloadGraphMediaWithMockOptions = async ( + options: GraphFetchMockOptions = {}, + overrides: Partial< + Omit + > = {}, +) => { + const fetchMock = createGraphFetchMock(options); + const media = await downloadGraphMediaWithFetch(fetchMock, overrides); + return { fetchMock, media }; +}; +const runAttachmentAuthRetryScenario = async (scenario: AttachmentAuthRetryScenario) => { + const tokenProvider = createTokenProvider(); + const fetchMock = createAuthAwareImageFetchMock({ + unauthStatus: scenario.unauthStatus, + unauthBody: scenario.unauthBody, + }); + const media = await downloadAttachmentsWithFetch( + [createImageAttachment(scenario.attachmentUrl)], + fetchMock, + { tokenProvider, ...scenario.overrides }, + ); + return { tokenProvider, media }; +}; + +describe("msteams attachments", () => { beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -112,112 +312,82 @@ 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" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "image/png", contentUrl: "https://x/1.png" }, + it.each([ + { label: "returns empty string when no attachments", attachments: undefined, expected: "" }, + { label: "returns empty string when attachments are empty", attachments: [], expected: "" }, + { + label: "returns image placeholder for one image attachment", + attachments: [createImageAttachment("https://x/img.png")], + expected: "", + }, + { + label: "returns image placeholder with count for many image attachments", + attachments: [ + createImageAttachment("https://x/1.png"), { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" }, - ]), - ).toBe(" (2 images)"); - }); - - it("treats Teams file.download.info image attachments as images", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ]), - ).toBe(""); - }); - - it("returns document placeholder for non-image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/1.pdf" }, - { contentType: "application/pdf", contentUrl: "https://x/2.pdf" }, - ]), - ).toBe(" (2 files)"); - }); - - it("counts inline images in text/html attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '

hi

', - }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '', - }, - ]), - ).toBe(" (2 images)"); + ], + expected: " (2 images)", + }, + { + label: "treats Teams file.download.info image attachments as images", + attachments: [createTeamsFileDownloadInfoAttachment()], + expected: "", + }, + { + label: "returns document placeholder for non-image attachments", + attachments: [createPdfAttachment("https://x/x.pdf")], + expected: "", + }, + { + label: "returns document placeholder with count for many non-image attachments", + attachments: [ + createPdfAttachment("https://x/1.pdf"), + createPdfAttachment("https://x/2.pdf"), + ], + expected: " (2 files)", + }, + { + label: "counts one inline image in html attachments", + attachments: [createHtmlAttachment('

hi

')], + expected: "", + }, + { + label: "counts many inline images in html attachments", + attachments: [ + createHtmlAttachment(''), + ], + expected: " (2 images)", + }, + ])("$label", ({ attachments, expected }) => { + expectAttachmentPlaceholder(attachments, expected); }); }); describe("downloadMSTeamsAttachments", () => { - it("downloads and stores image contentUrl attachments", async () => { - 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(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.png"); - }); - - it("supports Teams file.download.info downloadUrl attachments", async () => { - 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(); + it.each([ + { + label: "downloads and stores image contentUrl attachments", + attachments: [IMAGE_ATTACHMENT], + assert: (media) => { + expect(saveMediaBufferMock).toHaveBeenCalled(); + expect(media[0]?.path).toBe("/tmp/saved.png"); + }, + }, + { + label: "supports Teams file.download.info downloadUrl attachments", + attachments: [createTeamsFileDownloadInfoAttachment()], + }, + { + label: "downloads inline image URLs from html attachments", + attachments: [createHtmlAttachment('')], + }, + ])("$label", async ({ attachments, assert }) => { + const media = await downloadAttachmentsWithOkImageFetch(attachments); expect(media).toHaveLength(1); + assert?.(media); }); it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = createOkFetchMock("application/pdf", "pdf"); detectMimeMock.mockResolvedValueOnce("application/pdf"); saveMediaBufferMock.mockResolvedValueOnce({ @@ -225,46 +395,20 @@ describe("msteams attachments", () => { contentType: "application/pdf", }); - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], { - fetchFn: fetchMock as unknown as typeof fetch, - }), + const media = await downloadAttachmentsWithFetch( + [createPdfAttachment("https://x/doc.pdf")], + fetchMock, ); - expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); expect(media[0]?.path).toBe("/tmp/saved.pdf"); expect(media[0]?.placeholder).toBe(""); }); - it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = createOkFetchMock("image/png"); - const media = await downloadMSTeamsAttachments( - buildDownloadParams( - [ - { - contentType: "text/html", - content: '', - }, - ], - { 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( buildDownloadParams([ - { - contentType: "text/html", - content: ``, - }, + createHtmlAttachment(``), ]), ); @@ -272,218 +416,125 @@ describe("msteams attachments", () => { 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")); - if (!hasAuth) { - return new Response("unauthorized", { status: 401 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - 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); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("forbidden", { status: 403 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments( - buildDownloadParams( - [{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }], - { - tokenProvider, + it.each([ + { + label: "retries with auth when the first request is unauthorized", + scenario: { + attachmentUrl: IMAGE_ATTACHMENT.contentUrl, + unauthStatus: 401, + unauthBody: "unauthorized", + overrides: { authAllowHosts: ["x"] }, + }, + expectedMediaLength: 1, + expectTokenFetch: true, + }, + { + label: "skips auth retries when the host is not in auth allowlist", + scenario: { + attachmentUrl: "https://attacker.azureedge.net/img", + unauthStatus: 403, + unauthBody: "forbidden", + overrides: { allowHosts: ["azureedge.net"], authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, }, - ), - ); - - expect(media).toHaveLength(0); - expect(fetchMock).toHaveBeenCalled(); - expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); + }, + expectedMediaLength: 0, + expectTokenFetch: false, + }, + ])("$label", async ({ scenario, expectedMediaLength, expectTokenFetch }) => { + const { tokenProvider, media } = await runAttachmentAuthRetryScenario(scenario); + expect(media).toHaveLength(expectedMediaLength); + if (expectTokenFetch) { + expect(tokenProvider.getAccessToken).toHaveBeenCalled(); + } else { + expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); + } }); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], { + const media = await downloadAttachmentsWithFetch( + [createImageAttachment("https://evil.test/img")], + fetchMock, + { allowHosts: ["graph.microsoft.com"], resolveFn: undefined, - fetchFn: fetchMock as unknown as typeof fetch, - }), + }, + { expectFetchCalled: false }, ); expect(media).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("buildMSTeamsGraphMessageUrls", () => { - it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - conversationId: "19:thread@thread.tacv2", - messageId: "123", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); - }); + const cases: GraphUrlExpectationCase[] = [ + { + label: "builds channel message urls", + params: { + conversationType: "channel" as const, + conversationId: "19:thread@thread.tacv2", + messageId: "123", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }, + expectedPath: "/teams/team-id/channels/chan-id/messages/123", + }, + { + label: "builds channel reply urls when replyToId is present", + params: { + conversationType: "channel" as const, + messageId: "reply-id", + replyToId: "root-id", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }, + expectedPath: "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", + }, + { + label: "builds chat message urls", + params: { + conversationType: "groupChat" as const, + conversationId: "19:chat@thread.v2", + messageId: "456", + }, + expectedPath: "/chats/19%3Achat%40thread.v2/messages/456", + }, + ]; - it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - messageId: "reply-id", - replyToId: "root-id", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain( - "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", - ); - }); - - it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "groupChat", - conversationId: "19:chat@thread.v2", - messageId: "456", - }); - expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + it.each(cases)("$label", ({ params, expectedPath }) => { + expectFirstGraphUrlContains(params, expectedPath); }); }); 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")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "1", - contentType: "image/png", - contentBytes: base64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - return new Response("not found", { status: 404 }); + const { fetchMock, media } = await downloadGraphMediaWithMockOptions({ + hostedContents: [createHostedImageContent("1")], }); - const media = await downloadMSTeamsGraphMedia( - buildDownloadGraphParams(fetchMock as unknown as typeof fetch), - ); - expect(media.media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); }); 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) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "hosted-1", - contentType: "image/png", - contentBytes: hostedBase64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(Buffer.from("pdf"), { + const { referenceAttachment } = createShareReferenceFixture(); + const { media } = await downloadGraphMediaWithMockOptions({ + hostedContents: [createHostedImageContent("hosted-1")], + attachments: [referenceAttachment], + messageAttachments: [referenceAttachment], + onShareRequest: () => + new Response(PDF_BUFFER, { status: 200, headers: { "content-type": "application/pdf" }, - }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); + }), }); - 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 { referenceAttachment } = createShareReferenceFixture(); const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { const fetchFn = params.fetchImpl ?? fetch; @@ -510,47 +561,27 @@ describe("msteams attachments", () => { throw new Error("too many redirects"); }); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], + const { fetchMock, media } = await downloadGraphMediaWithMockOptions( + { + messageAttachments: [referenceAttachment], + onShareRequest: () => + new Response(null, { + status: 302, + headers: { location: escapedUrl }, }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(null, { - status: 302, - headers: { location: escapedUrl }, - }); - } - if (url === escapedUrl) { - return new Response(Buffer.from("should-not-be-fetched"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia( - buildDownloadGraphParams(fetchMock as unknown as typeof fetch, { + onUnhandled: (url) => { + if (url === escapedUrl) { + return new Response(Buffer.from("should-not-be-fetched"), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + } + return undefined; + }, + }, + { allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - }), + }, ); expect(media.media).toHaveLength(0); @@ -564,10 +595,9 @@ 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" }, + createImageMediaEntry("/tmp/a.png"), + createImageMediaEntry("/tmp/b.png"), ]); expect(payload.MediaPath).toBe("/tmp/a.png"); expect(payload.MediaUrl).toBe("/tmp/a.png"); diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 14f6f5fffcf..5006d8e8611 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -15,10 +15,26 @@ const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 16" : "sleep 0.016"; const longDelayCmd = isWin ? "Start-Sleep -Milliseconds 72" : "sleep 0.072"; const POLL_INTERVAL_MS = 15; +const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200; +const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000; const TEST_EXEC_DEFAULTS = { security: "full" as const, ask: "off" as const }; +const DEFAULT_NOTIFY_SESSION_KEY = "agent:main:main"; +type ExecToolConfig = Exclude[0], undefined>; const createTestExecTool = ( defaults?: Parameters[0], ): ReturnType => createExecTool({ ...TEST_EXEC_DEFAULTS, ...defaults }); +const createNotifyOnExitExecTool = (overrides: Partial = {}) => + createTestExecTool({ + allowBackground: true, + backgroundMs: 0, + notifyOnExit: true, + sessionKey: DEFAULT_NOTIFY_SESSION_KEY, + ...overrides, + }); +const createScopedToolSet = (scopeKey: string) => ({ + exec: createTestExecTool({ backgroundMs: 10, scopeKey }), + process: createProcessTool({ scopeKey }), +}); const execTool = createTestExecTool(); const processTool = createProcessTool(); // Both PowerShell and bash use ; for command separation @@ -33,13 +49,36 @@ const normalizeText = (value?: string) => .map((line) => line.replace(/\s+$/u, "")) .join("\n") .trim(); +type ToolTextContent = Array<{ type: string; text?: string }>; +const readTextContent = (content: ToolTextContent) => + content.find((part) => part.type === "text")?.text; +const readNormalizedTextContent = (content: ToolTextContent) => + normalizeText(readTextContent(content)); +const readTrimmedLines = (content: ToolTextContent) => + (readTextContent(content) ?? "").split("\n").map((line) => line.trim()); +const readTotalLines = (details: unknown) => (details as { totalLines?: number }).totalLines; -function captureShellEnv() { - const envSnapshot = captureEnv(["SHELL"]); +function applyDefaultShellEnv() { if (!isWin && defaultShell) { process.env.SHELL = defaultShell; } - return envSnapshot; +} + +function useCapturedEnv(keys: string[], afterCapture?: () => void) { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(keys); + afterCapture?.(); + }); + + afterEach(() => { + envSnapshot.restore(); + }); +} + +function useCapturedShellEnv() { + useCapturedEnv(["SHELL"], applyDefaultShellEnv); } async function waitForCompletion(sessionId: string) { @@ -54,18 +93,42 @@ async function waitForCompletion(sessionId: string) { status = (poll.details as { status: string }).status; return status; }, - { timeout: process.platform === "win32" ? 8000 : 1200, interval: POLL_INTERVAL_MS }, + { timeout: BACKGROUND_POLL_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .not.toBe("running"); return status; } -async function runBackgroundEchoLines(lines: string[]) { - const result = await execTool.execute("call1", { - command: echoLines(lines), +function requireSessionId(details: { sessionId?: string }): string { + if (!details.sessionId) { + throw new Error("expected sessionId in exec result details"); + } + return details.sessionId; +} + +function hasNotifyEventForPrefix(prefix: string): boolean { + return peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY).some((event) => event.includes(prefix)); +} + +async function startBackgroundSession(params: { + tool: ReturnType; + callId: string; + command: string; +}) { + const result = await params.tool.execute(params.callId, { + command: params.command, background: true, }); - const sessionId = (result.details as { sessionId: string }).sessionId; + expect(result.details.status).toBe("running"); + return requireSessionId(result.details as { sessionId?: string }); +} + +async function runBackgroundEchoLines(lines: string[]) { + const sessionId = await startBackgroundSession({ + tool: execTool, + callId: "call1", + command: echoLines(lines), + }); await waitForCompletion(sessionId); return sessionId; } @@ -81,18 +144,32 @@ async function readProcessLog( }); } +type ProcessLogResult = Awaited>; +const readLogSnapshot = (log: ProcessLogResult) => ({ + text: readTextContent(log.content) ?? "", + lines: readTrimmedLines(log.content), + totalLines: readTotalLines(log.details), +}); +const createNumberedLines = (count: number) => + Array.from({ length: count }, (_value, index) => `line-${index + 1}`); +const LONG_LOG_LINE_COUNT = 201; + +async function runBackgroundAndReadProcessLog( + lines: string[], + options: { offset?: number; limit?: number } = {}, +) { + const sessionId = await runBackgroundEchoLines(lines); + return readProcessLog(sessionId, options); +} +const readLongProcessLog = (options: { offset?: number; limit?: number } = {}) => + runBackgroundAndReadProcessLog(createNumberedLines(LONG_LOG_LINE_COUNT), options); + async function runBackgroundAndWaitForCompletion(params: { tool: ReturnType; callId: string; command: string; }) { - const result = await params.tool.execute(params.callId, { - command: params.command, - background: true, - }); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; + const sessionId = await startBackgroundSession(params); const status = await waitForCompletion(sessionId); expect(status).toBe("completed"); return { sessionId }; @@ -104,15 +181,7 @@ beforeEach(() => { }); describe("exec tool backgrounding", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureShellEnv(); - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedShellEnv(); it( "backgrounds after yield and can be polled", @@ -122,8 +191,15 @@ describe("exec tool backgrounding", () => { yieldMs: 10, }); + // Timing can race here: command may already be complete before the first response. + if (result.details.status === "completed") { + const text = readTextContent(result.content) ?? ""; + expect(text).toContain("done"); + return; + } + expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; + const sessionId = requireSessionId(result.details as { sessionId?: string }); let output = ""; await expect @@ -134,11 +210,10 @@ describe("exec tool backgrounding", () => { sessionId, }); const status = (poll.details as { status: string }).status; - const textBlock = poll.content.find((c) => c.type === "text"); - output = textBlock?.text ?? ""; + output = readTextContent(poll.content) ?? ""; return status; }, - { timeout: process.platform === "win32" ? 8000 : 1200, interval: POLL_INTERVAL_MS }, + { timeout: BACKGROUND_POLL_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .toBe("completed"); @@ -148,14 +223,12 @@ describe("exec tool backgrounding", () => { ); it("supports explicit background and derives session name from the command", async () => { - const result = await execTool.execute("call1", { + const sessionId = await startBackgroundSession({ + tool: execTool, + callId: "call1", command: "echo hello", - background: true, }); - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - const list = await processTool.execute("call2", { action: "list" }); const sessions = (list.details as { sessions: Array<{ sessionId: string; name?: string }> }) .sessions; @@ -180,7 +253,7 @@ describe("exec tool backgrounding", () => { const customBash = createTestExecTool({ elevated: { enabled: true, allowed: false, defaultLevel: "off" }, messageProvider: "telegram", - sessionKey: "agent:main:main", + sessionKey: DEFAULT_NOTIFY_SESSION_KEY, }); await expect( @@ -201,99 +274,66 @@ describe("exec tool backgrounding", () => { const result = await customBash.execute("call1", { command: "echo hi", }); - const text = result.content.find((c) => c.type === "text")?.text ?? ""; + const text = readTextContent(result.content) ?? ""; expect(text).toContain("hi"); }); it("logs line-based slices and defaults to last lines", async () => { - const result = await execTool.execute("call1", { + const { sessionId } = await runBackgroundAndWaitForCompletion({ + tool: execTool, + callId: "call1", command: echoLines(["one", "two", "three"]), - background: true, }); - const sessionId = (result.details as { sessionId: string }).sessionId; - const status = await waitForCompletion(sessionId); - - const log = await processTool.execute("call3", { - action: "log", - sessionId, - limit: 2, - }); - const textBlock = log.content.find((c) => c.type === "text"); - expect(normalizeText(textBlock?.text)).toBe("two\nthree"); - expect((log.details as { totalLines?: number }).totalLines).toBe(3); - expect(status).toBe("completed"); + const log = await readProcessLog(sessionId, { limit: 2 }); + expect(readNormalizedTextContent(log.content)).toBe("two\nthree"); + expect(readTotalLines(log.details)).toBe(3); }); it("applies default tail only when no explicit log window is provided", async () => { - const lines = Array.from({ length: 201 }, (_value, index) => `line-${index + 1}`); - const sessionId = await runBackgroundEchoLines(lines); - - const log = await readProcessLog(sessionId); - const textBlock = log.content.find((c) => c.type === "text")?.text ?? ""; - const firstLine = textBlock.split("\n")[0]?.trim(); - expect(textBlock).toContain("showing last 200 of 201 lines"); - expect(firstLine).toBe("line-2"); - expect(textBlock).toContain("line-2"); - expect(textBlock).toContain("line-201"); - expect((log.details as { totalLines?: number }).totalLines).toBe(201); + const snapshot = readLogSnapshot(await readLongProcessLog()); + expect(snapshot.text).toContain("showing last 200 of 201 lines"); + expect(snapshot.lines[0]).toBe("line-2"); + expect(snapshot.text).toContain("line-2"); + expect(snapshot.text).toContain("line-201"); + expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT); }); it("supports line offsets for log slices", async () => { - const result = await execTool.execute("call1", { - command: echoLines(["alpha", "beta", "gamma"]), - background: true, - }); - const sessionId = (result.details as { sessionId: string }).sessionId; - await waitForCompletion(sessionId); + const sessionId = await runBackgroundEchoLines(["alpha", "beta", "gamma"]); - const log = await processTool.execute("call2", { - action: "log", - sessionId, - offset: 1, - limit: 1, - }); - const textBlock = log.content.find((c) => c.type === "text"); - expect(normalizeText(textBlock?.text)).toBe("beta"); + const log = await readProcessLog(sessionId, { offset: 1, limit: 1 }); + expect(readNormalizedTextContent(log.content)).toBe("beta"); }); it("keeps offset-only log requests unbounded by default tail mode", async () => { - const lines = Array.from({ length: 201 }, (_value, index) => `line-${index + 1}`); - const sessionId = await runBackgroundEchoLines(lines); - - const log = await readProcessLog(sessionId, { offset: 30 }); - - const textBlock = log.content.find((c) => c.type === "text")?.text ?? ""; - const renderedLines = textBlock.split("\n"); - expect(renderedLines[0]?.trim()).toBe("line-31"); - expect(renderedLines[renderedLines.length - 1]?.trim()).toBe("line-201"); - expect(textBlock).not.toContain("showing last 200"); - expect((log.details as { totalLines?: number }).totalLines).toBe(201); + const snapshot = readLogSnapshot(await readLongProcessLog({ offset: 30 })); + expect(snapshot.lines[0]).toBe("line-31"); + expect(snapshot.lines[snapshot.lines.length - 1]).toBe("line-201"); + expect(snapshot.text).not.toContain("showing last 200"); + expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT); }); it("scopes process sessions by scopeKey", async () => { - const bashA = createTestExecTool({ backgroundMs: 10, scopeKey: "agent:alpha" }); - const processA = createProcessTool({ scopeKey: "agent:alpha" }); - const bashB = createTestExecTool({ backgroundMs: 10, scopeKey: "agent:beta" }); - const processB = createProcessTool({ scopeKey: "agent:beta" }); + const alphaTools = createScopedToolSet("agent:alpha"); + const betaTools = createScopedToolSet("agent:beta"); - const resultA = await bashA.execute("call1", { + const sessionA = await startBackgroundSession({ + tool: alphaTools.exec, + callId: "call1", command: shortDelayCmd, - background: true, }); - const resultB = await bashB.execute("call2", { + const sessionB = await startBackgroundSession({ + tool: betaTools.exec, + callId: "call2", command: shortDelayCmd, - background: true, }); - const sessionA = (resultA.details as { sessionId: string }).sessionId; - const sessionB = (resultB.details as { sessionId: string }).sessionId; - - const listA = await processA.execute("call3", { action: "list" }); + const listA = await alphaTools.process.execute("call3", { action: "list" }); const sessionsA = (listA.details as { sessions: Array<{ sessionId: string }> }).sessions; expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true); expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false); - const pollB = await processB.execute("call4", { + const pollB = await betaTools.process.execute("call4", { action: "poll", sessionId: sessionA, }); @@ -303,15 +343,7 @@ describe("exec tool backgrounding", () => { }); describe("exec exit codes", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureShellEnv(); - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedShellEnv(); it("treats non-zero exits as completed and appends exit code", async () => { const command = isWin @@ -322,7 +354,7 @@ describe("exec exit codes", () => { expect(resultDetails.status).toBe("completed"); expect(resultDetails.exitCode).toBe(1); - const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + const text = readNormalizedTextContent(result.content); expect(text).toContain("nope"); expect(text).toContain("Command exited with code 1"); }); @@ -330,39 +362,32 @@ describe("exec exit codes", () => { describe("exec notifyOnExit", () => { it("enqueues a system event when a backgrounded exec exits", async () => { - const tool = createTestExecTool({ - allowBackground: true, - backgroundMs: 0, - notifyOnExit: true, - sessionKey: "agent:main:main", - }); + const tool = createNotifyOnExitExecTool(); - const result = await tool.execute("call1", { + const sessionId = await startBackgroundSession({ + tool, + callId: "call1", command: echoAfterDelay("notify"), - background: true, }); - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - const prefix = sessionId.slice(0, 8); let finished = getFinishedSession(sessionId); - let hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + let hasEvent = hasNotifyEventForPrefix(prefix); await expect .poll( () => { finished = getFinishedSession(sessionId); - hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + hasEvent = hasNotifyEventForPrefix(prefix); return Boolean(finished && hasEvent); }, - { timeout: isWin ? 12_000 : 5_000, interval: POLL_INTERVAL_MS }, + { timeout: NOTIFY_EVENT_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .toBe(true); if (!finished) { finished = getFinishedSession(sessionId); } if (!hasEvent) { - hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + hasEvent = hasNotifyEventForPrefix(prefix); } expect(finished).toBeTruthy(); @@ -381,20 +406,16 @@ describe("exec notifyOnExit", () => { }, ]) { resetSystemEventsForTest(); - const tool = createTestExecTool({ - allowBackground: true, - backgroundMs: 0, - notifyOnExit: true, - ...(testCase.notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {}), - sessionKey: "agent:main:main", - }); + const tool = createNotifyOnExitExecTool( + testCase.notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {}, + ); await runBackgroundAndWaitForCompletion({ tool, callId: "call-noop", command: shortDelayCmd, }); - const events = peekSystemEvents("agent:main:main"); + const events = peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY); if (!testCase.notifyOnExitEmptySuccess) { expect(events, testCase.label).toEqual([]); } else { @@ -409,18 +430,7 @@ describe("exec notifyOnExit", () => { }); describe("exec PATH handling", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv(["PATH", "SHELL"]); - if (!isWin && defaultShell) { - process.env.SHELL = defaultShell; - } - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedEnv(["PATH", "SHELL"], applyDefaultShellEnv); it("prepends configured path entries", async () => { const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin"; @@ -432,7 +442,7 @@ describe("exec PATH handling", () => { command: isWin ? "Write-Output $env:PATH" : "echo $PATH", }); - const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); expect(entries.slice(0, prepend.length)).toEqual(prepend); expect(entries).toContain(basePath);