diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts index f965f932..45174bd8 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts +++ b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts @@ -1,12 +1,14 @@ import { flushPromises } from '@vue/test-utils'; -import { handle, publish } from '@shopware-ag/meteor-admin-sdk/es/channel'; +import { handle, publish, setExtensions } from '@shopware-ag/meteor-admin-sdk/es/channel'; import { ExtensionStoreChannelService } from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-channel.service'; +import extensionStoreContextStore from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-context.store'; import extensionStorePurchaseConfirmationStore from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-purchase-confirmation.store'; import type { ExtensionStoreBasket } from 'SwagExtensionStore/module/sw-extension-store/types/extension-store-basket.types'; jest.mock('@shopware-ag/meteor-admin-sdk/es/channel', () => ({ handle: jest.fn(() => jest.fn()), publish: jest.fn(), + setExtensions: jest.fn(), })); jest.mock('SwagExtensionStore/util/telemetry', () => ({ @@ -15,6 +17,13 @@ jest.mock('SwagExtensionStore/util/telemetry', () => ({ const handleMock = handle as jest.MockedFunction; const publishMock = publish as jest.MockedFunction; +const setExtensionsMock = setExtensions as jest.MockedFunction; + +const STORE_ORIGIN = 'https://sky-bridge-store.production.shopware.in'; + +type ChannelInformation = { + _event_: MessageEvent; +}; const buildCart = (type: 'app' | 'plugin' = 'plugin') => ({ positions: [ @@ -52,6 +61,7 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c let extensionStorePreferencesService: { state: { installAfterPurchase: boolean } }; let createNotificationSpy: jest.SpyInstance; let router: { push: jest.Mock; afterEach: jest.Mock; currentRoute: { value: { params: object; query: object } } }; + let channelWindow: Window; const createService = (cart: ExtensionStoreBasket) => { extensionStoreActionService = { @@ -93,15 +103,28 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c Shopware.Context.app.config.bundles = bundles as never; service.register(); - const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise<{ isDemoShop: boolean }>; - - return channelHandler({ action: 'handshake', sessionToken: 'session-token', version: '1.0.0' }); + const channelHandler = handleMock.mock.calls[0][1] as ( + data: unknown, + additionalInformation: ChannelInformation + ) => Promise<{ isDemoShop: boolean }>; + + return channelHandler( + { action: 'handshake', sessionToken: 'session-token', version: '1.0.0' }, + { + _event_: { + source: channelWindow, + origin: STORE_ORIGIN, + } as MessageEvent, + }, + ); }; /** Runs the purchase action through the channel and returns the confirm callback result. */ const performPurchase = async () => { service.register(); + await performHandshake(); + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; await channelHandler(purchaseActionData); @@ -111,10 +134,25 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c return onConfirm!(); }; + const expectPublishedToStore = (data: object) => { + expect(publishMock).toHaveBeenCalledWith( + 'swag-extension-store-channel', + data, + [expect.objectContaining({ + source: channelWindow, + origin: STORE_ORIGIN, + sdkVersion: '1.0.0', + })], + ); + }; + beforeEach(() => { handleMock.mockClear(); publishMock.mockClear(); + setExtensionsMock.mockClear(); extensionStorePurchaseConfirmationStore().$reset(); + channelWindow = { postMessage: jest.fn() } as unknown as Window; + extensionStoreContextStore().iframeUrl = `${STORE_ORIGIN}/`; createNotificationSpy = jest.spyOn(Shopware.Store.get('notification'), 'createNotification') .mockImplementation(() => null); service = createService(buildCart()); @@ -145,6 +183,34 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c }); }); + describe('channel registration', () => { + it('should register and target the store iframe after its handshake', async () => { + await performHandshake(); + + expect(setExtensionsMock).toHaveBeenCalledWith({ + 'swag-extension-store-sky-bridge': { + baseUrl: STORE_ORIGIN, + permissions: {}, + }, + }); + }); + + it('should remove the same popstate listener that was registered', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + service.register(); + + const popstateListener = addEventListenerSpy.mock.calls + .find(([eventName]) => eventName === 'popstate')?.[1]; + + service.unregister(); + + expect(popstateListener).toBeDefined(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('popstate', popstateListener); + }); + }); + describe('routeTo', () => { it('should navigate to the named route with its parameters', async () => { service.register(); @@ -182,7 +248,7 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c expect(extensionHelperService.downloadAndActivateExtension).toHaveBeenCalledWith('SwagExtension', 'plugin'); expect(result).toEqual({ success: true, requiresReload: true }); - expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + expectPublishedToStore({ action: 'purchaseResult', sessionToken: 'session-token', success: true, @@ -223,6 +289,7 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c })); service.register(); + await performHandshake(); const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; await channelHandler(purchaseActionData); @@ -239,7 +306,7 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c await confirmed; expect(publishMock).toHaveBeenCalledTimes(1); - expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + expectPublishedToStore({ action: 'purchaseResult', sessionToken: 'session-token', success: true, @@ -270,8 +337,9 @@ describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-c const result = await performPurchase(); expect(result).toEqual({ success: true, requiresReload: false }); - expect(extensionStoreActionService.getMyExtensions).not.toHaveBeenCalled(); - expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + // The handshake loads the extensions once. A failed install must not look the version up again. + expect(extensionStoreActionService.getMyExtensions).toHaveBeenCalledTimes(1); + expectPublishedToStore({ action: 'purchaseResult', sessionToken: 'session-token', success: true, diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts index aac62fc1..219af92d 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts +++ b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts @@ -1,4 +1,4 @@ -import { handle, publish } from '@shopware-ag/meteor-admin-sdk/es/channel'; +import { handle, publish, setExtensions } from '@shopware-ag/meteor-admin-sdk/es/channel'; import type ExtensionStoreActionService from 'src/module/sw-extension/service/extension-store-action.service'; import type ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; import type ExtensionStoreLicensesService from './extension-store-licenses.service'; @@ -88,6 +88,12 @@ type PurchaseResultData = { extensionVersion?: string | null; }; +type StoreChannelSource = { + source: Window; + origin: string; + sdkVersion: string; +}; + type CopyToClipboardActionData = StoreChannelActionData & { text: string; }; @@ -117,6 +123,8 @@ export class ExtensionStoreChannelService { private removeRouterAfterEachHook?: () => void; private unsubscribeFunction?: () => void; private registeredAt?: number; + private channelSource?: StoreChannelSource; + private readonly onPopState = (): void => this.publishRouterSync(); constructor( private readonly extensionStoreActionService: ExtensionStoreActionService, @@ -136,19 +144,26 @@ export class ExtensionStoreChannelService { this.registeredAt = performance.now(); - this.unsubscribeFunction = handle('swag-extension-store-channel' as keyof ShopwareMessageTypes, (data: unknown) => { - try { - return this.handleAction(data); - } catch (err) { - return { - success: false, - }; - } - }); + this.unsubscribeFunction = handle( + 'swag-extension-store-channel' as keyof ShopwareMessageTypes, + (data: unknown, additionalInformation) => { + try { + if (this.isHandshakeActionData(data)) { + this.registerChannelSource(additionalInformation._event_, data.version); + } + + return this.handleAction(data); + } catch (err) { + return { + success: false, + }; + } + }, + ); this.removeRouterAfterEachHook = this.router.afterEach((to, from) => this.publishReturnToExtensionStore(to, from)); - window.addEventListener('popstate', () => this.publishRouterSync()); + window.addEventListener('popstate', this.onPopState); } unregister(): void { @@ -160,9 +175,10 @@ export class ExtensionStoreChannelService { this.removeRouterAfterEachHook?.(); this.removeRouterAfterEachHook = undefined; - window.removeEventListener('popstate', () => this.publishRouterSync()); + window.removeEventListener('popstate', this.onPopState); this.registeredAt = undefined; + this.channelSource = undefined; } private publishReturnToExtensionStore(to: RouteLocationNormalizedGeneric, from: RouteLocationNormalizedLoadedGeneric): void { @@ -398,10 +414,7 @@ export class ExtensionStoreChannelService { ...(extensionVersion !== undefined ? { extensionVersion } : {}), }; - publish( - 'swag-extension-store-channel' as keyof ShopwareMessageTypes, - resultData, - ); + this.publishToExtensionStore(resultData); } private publishRouterSync(): void { @@ -413,9 +426,49 @@ export class ExtensionStoreChannelService { currentRouteQuery, }; + this.publishToExtensionStore(data); + } + + private registerChannelSource(event: MessageEvent, sdkVersion: string): void { + if (!event.source || !this.isStoreOrigin(event.origin)) { + return; + } + + const source = event.source as Window; + + setExtensions({ + 'swag-extension-store-sky-bridge': { + baseUrl: event.origin, + permissions: {}, + }, + }); + + this.channelSource = { + source, + origin: event.origin, + sdkVersion, + }; + } + + private isStoreOrigin(origin: string): boolean { + const iframeUrl = extensionStoreContextStore().iframeUrl; + + if (!iframeUrl) { + return false; + } + + return new URL(iframeUrl).origin === origin; + } + + private publishToExtensionStore(data: PurchaseResultData | RouterSyncData): void { + if (!this.channelSource) { + return; + } + publish( 'swag-extension-store-channel' as keyof ShopwareMessageTypes, data, + [this.channelSource], ); }