diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index 0d42b61cbf..d3a5bffe0e 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -2,6 +2,7 @@ import objectEntries from 'core-js-pure/stable/object/entries'; import arrayFrom from 'core-js-pure/stable/array/from'; import { isIosWebview, isAndroidWebview } from '@krakenjs/belter/src'; import { request, memoize, ppDebug } from '../../../../utils'; +import validate from '../../../../library/zoid/message/validation'; export const getContent = memoize( ({ @@ -112,3 +113,59 @@ export function formatDateByCountry(country) { } return currentDate.toLocaleDateString('en-GB', options); } + +export function createUUID() { + // crypto.randomUUID() is only available in HTTPS secure environments and modern browsers + if (typeof crypto !== 'undefined' && crypto && crypto.randomUUID instanceof Function) { + return crypto.randomUUID(); + } + + const validChars = '0123456789abcdefghijklmnopqrstuvwxyz'; + const stringLength = 32; + let randomId = ''; + for (let index = 0; index < stringLength; index++) { + const randomIndex = Math.floor(Math.random() * validChars.length); + randomId += validChars.charAt(randomIndex); + } + return randomId; +} + +export function validateProps(updatedProps) { + const validatedProps = {}; + Object.entries(updatedProps).forEach(entry => { + const [k, v] = entry; + if (k === 'offerTypes') { + validatedProps.offer = validate.offer({ props: { offer: v } }); + } else { + validatedProps[k] = validate[k]({ props: { [k]: v } }); + } + }); + return validatedProps; +} + +export function sendEventAck(eventId, trustedOrigin) { + // skip this step if running in test env because jest's target windows don't support postMessage + if (process.env.NODE_ENV === 'test') { + return; + } + + // target window selection depends on if checkout window is in popup or modal iframe + let targetWindow; + const popupCheck = window.parent === window; + if (popupCheck) { + targetWindow = window.opener; + } else { + targetWindow = window.parent; + } + + targetWindow.postMessage( + { + // PostMessenger stops reposting an event when it receives an eventName which matches the id in the message it sent and type 'ack' + eventName: eventId, + type: 'ack', + eventPayload: { ok: true }, + id: createUUID() + }, + trustedOrigin + ); +} diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 5e6f0e69b4..8e15e39663 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,15 +1,46 @@ /* global Android */ import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { getOrCreateDeviceID, logger } from '../../../../utils'; -import { isIframe } from './utils'; +import { isIframe, validateProps, sendEventAck } from './utils'; const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; +function listenAndAssignProps(newProps, propListeners) { + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...newProps }); + }); + Object.assign(window.xprops, newProps); +} + +export function validateAndUpdateBrowserProps(initialProps, propListeners, updatedPropsEvent) { + const { + origin: eventOrigin, + data: { eventName, id, eventPayload: newProps } + } = updatedPropsEvent; + const clientOrigin = decodeURIComponent(initialProps.origin); + + if (eventOrigin === clientOrigin && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { + // send event ack so PostMessenger will stop reposting event + sendEventAck(id, clientOrigin); + const validProps = validateProps(newProps); + listenAndAssignProps(validProps, propListeners); + } +} + const setupBrowser = props => { + const propListeners = new Set(); + + window.addEventListener( + 'message', + event => { + validateAndUpdateBrowserProps(props, propListeners, event); + }, + false + ); + window.xprops = { - // We will never recieve new props via this integration style - onProps: () => {}, + onProps: listener => propListeners.add(listener), // TODO: Verify these callbacks are instrumented correctly onReady: ({ products, meta }) => { const { clientId, payerId, merchantId, offer, partnerAttributionId } = props; @@ -126,11 +157,7 @@ const setupWebview = props => { window.actions = { updateProps: newProps => { if (newProps && typeof newProps === 'object') { - Array.from(propListeners.values()).forEach(listener => { - listener({ ...window.xprops, ...newProps }); - }); - - Object.assign(window.xprops, newProps); + listenAndAssignProps(newProps, propListeners); } } }; diff --git a/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js b/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js index f70b6c43f6..dc21f03ec8 100644 --- a/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js +++ b/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js @@ -1,13 +1,19 @@ -import zoidPolyfill from 'src/components/modal/v2/lib/zoid-polyfill'; +import zoidPolyfill, { validateAndUpdateBrowserProps } from 'src/components/modal/v2/lib/zoid-polyfill'; import { logger } from 'src/utils'; // Mock all of utils because the `stats` util that would be included has a side-effect call to logger.track -jest.mock('src/utils', () => ({ - logger: { - track: jest.fn(), - addMetaBuilder: jest.fn() - } -})); +jest.mock('src/utils', () => { + const originalModule = jest.requireActual('@krakenjs/belter/src'); + + return { + ...originalModule, + logger: { + track: jest.fn(), + addMetaBuilder: jest.fn(), + warn: jest.fn() + } + }; +}); jest.mock('@krakenjs/belter/src', () => { const originalModule = jest.requireActual('@krakenjs/belter/src'); @@ -25,9 +31,21 @@ jest.mock('@krakenjs/belter/src', () => { }) }; }); -jest.mock('src/components/modal/v2/lib/utils', () => ({ - isIframe: true -})); +jest.mock('src/components/modal/v2/lib/utils', () => { + const originalModule = jest.requireActual('src/components/modal/v2/lib/utils'); + + return { + ...originalModule, + isIframe: true + }; +}); + +const addEventListenerSpy = jest.fn(); +const addEventListener = window.addEventListener.bind(window); +window.addEventListener = (...args) => { + addEventListenerSpy(...args); + addEventListener(...args); +}; const mockLoadUrl = (url, { platform = 'web' } = {}) => { delete window.location; @@ -73,13 +91,14 @@ describe('zoidPollyfill', () => { describe('sets up xprops for browser', () => { beforeAll(() => { mockLoadUrl( - 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true' + 'https://localhost.paypal.com:8080/credit-presentment/lander/modal?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true' ); zoidPolyfill(); }); afterEach(() => { logger.track.mockClear(); + addEventListenerSpy.mockClear(); }); test('window.xprops initalized', () => { expect(window.actions).toBeUndefined(); @@ -177,7 +196,7 @@ describe('zoidPollyfill', () => { test('sets up xprops for webview', () => { mockLoadUrl( - 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&dev_touchpoint=true', + 'https://localhost.paypal.com:8080/credit-presentment/native/modal?client_id=client_1&logo_type=inline&amount=500&dev_touchpoint=true', { platform: 'ios' } @@ -316,94 +335,159 @@ describe('zoidPollyfill', () => { postMessage.mockClear(); }); - test('notifies when props update', () => { - mockLoadUrl( - 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true', - { - platform: 'android' - } - ); - const postMessage = global.Android.paypalMessageModalCallbackHandler; + describe('notifies when props update', () => { + test('webview', () => { + mockLoadUrl( + 'https://localhost.paypal.com:8080/credit-presentment/native/modal?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true', + { + platform: 'android' + } + ); + const postMessage = global.Android.paypalMessageModalCallbackHandler; - zoidPolyfill(); + zoidPolyfill(); - expect(window.actions).toEqual( - expect.objectContaining({ - updateProps: expect.any(Function) - }) - ); - expect(window.xprops).toEqual( - expect.objectContaining({ - onProps: expect.any(Function) - }) - ); + expect(window.actions).toEqual( + expect.objectContaining({ + updateProps: expect.any(Function) + }) + ); + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function) + }) + ); - const onPropsCallback = jest.fn(); + const onPropsCallback = jest.fn(); - window.xprops.onProps(onPropsCallback); - window.actions.updateProps({ amount: 1000 }); + window.xprops.onProps(onPropsCallback); + window.actions.updateProps({ amount: 1000 }); - expect(onPropsCallback).toHaveBeenCalledTimes(1); - expect(onPropsCallback).toHaveBeenCalledWith( - expect.objectContaining({ - clientId: 'client_1', - logoType: 'inline', - amount: 1000 - }) - ); + expect(onPropsCallback).toHaveBeenCalledTimes(1); + expect(onPropsCallback).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_1', + logoType: 'inline', + amount: 1000 + }) + ); - window.actions.updateProps({ offer: 'TEST' }); + window.actions.updateProps({ offer: 'TEST' }); - expect(onPropsCallback).toHaveBeenCalledTimes(2); - expect(onPropsCallback).toHaveBeenCalledWith( - expect.objectContaining({ - clientId: 'client_1', - logoType: 'inline', - amount: 1000, - offer: 'TEST' - }) - ); + expect(onPropsCallback).toHaveBeenCalledTimes(2); + expect(onPropsCallback).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_1', + logoType: 'inline', + amount: 1000, + offer: 'TEST' + }) + ); - window.xprops.onReady({ - products: ['PRODUCT_1', 'PRODUCT_2'], - meta: { - trackingDetails: { - fdata: '123abc', - credit_product_identifiers: ['PAY_LATER_LONG_TERM_US'], - offer_country_code: 'US', - extra_field: 'should not be present' + window.xprops.onReady({ + products: ['PRODUCT_1', 'PRODUCT_2'], + meta: { + trackingDetails: { + fdata: '123abc', + credit_product_identifiers: ['PAY_LATER_LONG_TERM_US'], + offer_country_code: 'US', + extra_field: 'should not be present' + } } - } - }); + }); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); - expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` - Object { - "args": Array [ + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` Object { - "__shared__": Object { - "credit_product_identifiers": Array [ - "PAY_LATER_LONG_TERM_US", - ], - "fdata": "123abc", - "offer_country_code": "US", - }, - "event_type": "modal_rendered", - "render_duration": "50", - "request_duration": "100", - }, - ], - "name": "onReady", - } - `); - postMessage.mockClear(); + "args": Array [ + Object { + "__shared__": Object { + "credit_product_identifiers": Array [ + "PAY_LATER_LONG_TERM_US", + ], + "fdata": "123abc", + "offer_country_code": "US", + }, + "event_type": "modal_rendered", + "render_duration": "50", + "request_duration": "100", + }, + ], + "name": "onReady", + } + `); + postMessage.mockClear(); + }); + describe('browser', () => { + beforeAll(() => { + mockLoadUrl( + 'https://localhost.paypal.com:8080/credit-presentment/lander/modal?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true&origin=http://example.com' + ); + zoidPolyfill(); + }); + afterEach(() => { + logger.track.mockClear(); + addEventListenerSpy.mockClear(); + }); + test('event listener is added', () => { + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function) + }) + ); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function), false); + }); + test('validateAndUpdateBrowserProps updates props when values are valid', () => { + // jest doesn't support calling postMessage, so we cannot use the event listener above + // instead we will manually verify that validateAndUpdateBrowserProps works as intended + const newPropsEvent = { + origin: 'http://example.com', + data: { + eventName: 'PROPS_UPDATE', + eventPayload: { + amount: 1000, + offerTypes: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + } + } + }; + + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + validateAndUpdateBrowserProps(window.xprops, propListeners, newPropsEvent); + + expect(onPropsCallback).toHaveBeenCalledTimes(1); + expect(onPropsCallback).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_1', + logoType: 'inline', + amount: 1000, + offer: 'PAY_LATER_LONG_TERM,PAY_LATER_SHORT_TERM' + }) + ); + }); + test('validateAndUpdateBrowserProps handles unrelated events with no data', () => { + const unrelatedEvent = { + data: {} + }; + + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + validateAndUpdateBrowserProps(window.xprops, propListeners, unrelatedEvent); + + expect(onPropsCallback).toHaveBeenCalledTimes(0); + }); + }); }); describe('communication with parent window on onClose ', () => { beforeAll(() => { mockLoadUrl( - 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true' + 'https://localhost.paypal.com:8080/credit-presentment/native/modal?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true' ); zoidPolyfill(); const postMessage = jest.fn();