diff --git a/__test__/hostOnlyListeners.test.ts b/__test__/hostOnlyListeners.test.ts new file mode 100644 index 00000000..563bc30d --- /dev/null +++ b/__test__/hostOnlyListeners.test.ts @@ -0,0 +1,63 @@ +import postRobot from "post-robot"; + +import Asset from "../src/stack/api/asset"; +import { IAppConfigInitData, LocationType } from "../src/types"; +import UiLocation from "../src/uiLocation"; + +jest.mock("post-robot"); + +// MKT-22128: the host (app-extension-component) sends extensionEvent and +// uploadReady_ from the window that holds the app iframe, i.e. +// window.parent. A listener without a window filter also accepts the same +// messages from sibling app frames, so both must be scoped to window.parent. +describe("host-only post-robot listeners (MKT-22128)", () => { + // jsdom's window.parent is window itself; a distinct parent proves the + // listener is bound to the parent and not to the app's own window. + const fakeParent = {} as Window; + + beforeEach(() => { + jest.spyOn(window, "parent", "get").mockReturnValue(fakeParent); + (postRobot as any).on.mockReturnValue({ cancel: jest.fn() }); + (postRobot as any).sendToParent.mockResolvedValue({ data: {} }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + window["postRobot"] = undefined; + }); + + it("listens for extensionEvent from window.parent only", () => { + new UiLocation({ + type: LocationType.APP_CONFIG_WIDGET, + app_id: "app_id", + installation_uid: "installation_uid", + extension_uid: "extension_uid", + region: "NA", + endpoints: { CMA: "", APP: "", DEVELOPER_HUB: "" }, + stack: {} as any, + user: {} as any, + currentBranch: "main", + manifest: {} as any, + } as IAppConfigInitData); + + const call = (postRobot as any).on.mock.calls.find( + ([name]) => name === "extensionEvent" + ); + expect(call).toBeDefined(); + // compare by identity: printing a Window on failure exhausts memory + expect(call[1]?.window === fakeParent).toBe(true); + expect(typeof call[2]).toBe("function"); + }); + + it("listens for uploadReady from window.parent only", async () => { + await Asset({}).uploadAsset([new Blob(["x"])]); + + const call = (postRobot as any).on.mock.calls.find(([name]) => + String(name).startsWith("uploadReady_") + ); + expect(call).toBeDefined(); + expect(call[1]?.window === fakeParent).toBe(true); + expect(typeof call[2]).toBe("function"); + }); +}); diff --git a/src/stack/api/asset/index.ts b/src/stack/api/asset/index.ts index 653b52bf..a0ce4c99 100755 --- a/src/stack/api/asset/index.ts +++ b/src/stack/api/asset/index.ts @@ -274,6 +274,7 @@ class Asset extends Base { try { const uploadReadyListener = postRobot.on( `uploadReady_${uid}`, + { window: window.parent }, function () { window.parent.postMessage( { diff --git a/src/types/post-robot.d.ts b/src/types/post-robot.d.ts index 00cbc6df..80770a29 100644 --- a/src/types/post-robot.d.ts +++ b/src/types/post-robot.d.ts @@ -23,7 +23,11 @@ declare module "post-robot" { cancel: () => void; } - export function on(name: string, handler?: HandlerType): CancelableType; + export function on( + name: string, + options?: ServerOptionsType | HandlerType, + handler?: HandlerType + ): CancelableType; export function once( name: string, diff --git a/src/uiLocation.ts b/src/uiLocation.ts index b84d68f7..bb27d01b 100755 --- a/src/uiLocation.ts +++ b/src/uiLocation.ts @@ -348,7 +348,9 @@ class UiLocation { } try { - postRobot.on("extensionEvent", async (event) => { + // Only the host (the window holding this iframe) sends events; ignore sibling frames. + const fromHost = { window: window.parent }; + postRobot.on("extensionEvent", fromHost, async (event) => { if (event.data.name === "entrySave") { emitter.emitEvent("entrySave", [{ data: event.data.data }]); emitter.emitEvent("updateFields", [