From ecf1a927acdad188683c18c1eb4e11d4c4f221f3 Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Tue, 10 Dec 2024 13:11:13 -0500 Subject: [PATCH 1/9] adds browser new prop event listener --- src/components/modal/v2/lib/zoid-polyfill.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 5e6f0e69b4..64c79bf38b 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -7,9 +7,23 @@ const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const setupBrowser = props => { + const propListeners = new Set(); + + window.addEventListener( + 'message', + newProps => { + if (newProps && typeof newProps === 'object') { + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...newProps }); + }); + Object.assign(window.xprops, newProps); + } + }, + 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; From 71f28c8e32f11dac872d370791f4fe079be674eb Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Tue, 7 Jan 2025 17:17:58 -0500 Subject: [PATCH 2/9] adds prop update listening to modal browser zoid polyfill --- src/components/modal/v2/lib/utils.js | 30 +++ src/components/modal/v2/lib/zoid-polyfill.js | 94 ++++++- .../modal/v2/lib/zoid-polyfill.test.js | 238 +++++++++++------- 3 files changed, 269 insertions(+), 93 deletions(-) diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index 0d42b61cbf..d8d4d18b46 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,32 @@ 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 validateUpdatedProps(updatedProps) { + const validatedProps = {}; + Object.entries(updatedProps).forEach(entry => { + const [k, v] = entry; + if (k === 'offerType') { + validatedProps.offer = validate.offer({ props: { offer: v } }); + } else { + validatedProps[k] = validate[k]({ props: { [k]: v } }); + } + }); + return validatedProps; +} diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 64c79bf38b..3cfff945a7 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,24 +1,100 @@ /* global Android */ import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { getOrCreateDeviceID, logger } from '../../../../utils'; -import { isIframe } from './utils'; +import { isIframe, createUUID, validateUpdatedProps } from './utils'; const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; +export function updateBrowserProps(initialProps, propListeners, newPropsEvent) { + const { origin, eventName, id, eventPayload: updatedProps } = newPropsEvent.data; + if ( + // verify the event is coming from the merchant page and is the correct event + origin === initialProps.origin && + eventName === 'PROPS_UPDATE' && + updatedProps && + typeof updatedProps === 'object' + ) { + // send ack so PostMessenger will stop trying to resend message + postMessage( + { + eventName: id, + eventPayload: { ok: true }, + id: createUUID() + }, + // TODO: resolve Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('https://www.msmaster.qa.paypal.com') does not match the recipient window's origin ('https://localhost:8443'). + initialProps.origin + ); + + const validatedProps = validateUpdatedProps(updatedProps); + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...validatedProps }); + }); + + console.debug( + 'LearnMore message ack', + { + eventName: id, + eventPayload: { ok: true } + }, + initialProps.origin + ); + } +} + const setupBrowser = props => { const propListeners = new Set(); window.addEventListener( 'message', - newProps => { - if (newProps && typeof newProps === 'object') { - Array.from(propListeners.values()).forEach(listener => { - listener({ ...window.xprops, ...newProps }); - }); - Object.assign(window.xprops, newProps); - } - }, + event => updateBrowserProps(props, propListeners, event), + // event => { + // console.log('LearnMore message event.data', event.data); + // const { eventName, eventPayload: updatedProps, id } = event.data; + // if ( + // // verify the event is coming from the merchant page + // event.data.origin === props.origin && + // eventName === 'PROPS_UPDATE' && + // updatedProps && + // typeof updatedProps === 'object' + // ) { + // Array.from(propListeners.values()).forEach(listener => { + // listener({ ...window.xprops, ...updatedProps }); + // }); + + // let validatedProps; + // Object.entries(updatedProps).forEach(entry => { + // const [k, v] = entry; + // if (k === 'offerType') { + // validatedProps.offer = validate.offer({ props: { offer: v } }); + // } else { + // validatedProps[k] = validate[k]({ props: { [k]: v } }); + // } + // }); + + // Object.assign(window.xprops, validatedProps); + + // console.log( + // 'LearnMore message ack', + // { + // eventName: id, + // eventPayload: { ok: true }, + // id: createUUID() + // }, + // props.origin + // ); + + // // send back ack so PostMessenger won't retry sending message unnecessarily + // postMessage( + // { + // eventName: id, + // eventPayload: { ok: true }, + // id: createUUID() + // }, + // props.origin + // ); + // } + // }, false ); 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..407adea316 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, { updateBrowserProps } 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,145 @@ 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(); + }); + test('browser', () => { + 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(); + + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function) + }) + ); + + // verify event listener was added + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function), false); + + const newPropsEvent = { + data: { + origin: 'http://example.com', + eventName: 'PROPS_UPDATE', + eventPayload: { + amount: 1000, + offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + } + } + }; + + // jest doesn't support calling postMessage, so we cannot use the event listener above + // instead we will manually verify that updateBrowserProps works as intended + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + updateBrowserProps(window.xprops, propListeners, newPropsEvent); + + // subscribeCallback({ + // amount: 1000 + // }); + + 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' + }) + ); + }); }); 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(); From 701bdd8f40cc0643a80e104077eee791894e6b43 Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Mon, 13 Jan 2025 15:04:26 -0500 Subject: [PATCH 3/9] fixes postMessage and refactors a bit --- src/components/modal/v2/lib/utils.js | 29 +++- src/components/modal/v2/lib/zoid-polyfill.js | 127 ++++++------------ .../modal/v2/lib/zoid-polyfill.test.js | 104 +++++++------- 3 files changed, 128 insertions(+), 132 deletions(-) diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index d8d4d18b46..5700589d63 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -130,7 +130,7 @@ export function createUUID() { return randomId; } -export function validateUpdatedProps(updatedProps) { +export function validateProps(updatedProps) { const validatedProps = {}; Object.entries(updatedProps).forEach(entry => { const [k, v] = entry; @@ -142,3 +142,30 @@ export function validateUpdatedProps(updatedProps) { }); return validatedProps; } + +export function sendEventAck(eventId) { + // skip this step if running in test env because jest's target windows don't support postMessage + if (window.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() + }, + '*' + ); +} diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 3cfff945a7..c834af998a 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,44 +1,30 @@ /* global Android */ import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { getOrCreateDeviceID, logger } from '../../../../utils'; -import { isIframe, createUUID, validateUpdatedProps } from './utils'; +import { isIframe, validateProps, sendEventAck } from './utils'; const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; -export function updateBrowserProps(initialProps, propListeners, newPropsEvent) { - const { origin, eventName, id, eventPayload: updatedProps } = newPropsEvent.data; - if ( - // verify the event is coming from the merchant page and is the correct event - origin === initialProps.origin && - eventName === 'PROPS_UPDATE' && - updatedProps && - typeof updatedProps === 'object' - ) { - // send ack so PostMessenger will stop trying to resend message - postMessage( - { - eventName: id, - eventPayload: { ok: true }, - id: createUUID() - }, - // TODO: resolve Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('https://www.msmaster.qa.paypal.com') does not match the recipient window's origin ('https://localhost:8443'). - initialProps.origin - ); - - const validatedProps = validateUpdatedProps(updatedProps); - Array.from(propListeners.values()).forEach(listener => { - listener({ ...window.xprops, ...validatedProps }); - }); +function listenAndAssignProps(newProps, propListeners) { + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...newProps }); + }); + Object.assign(window.xprops, newProps); +} - console.debug( - 'LearnMore message ack', - { - eventName: id, - eventPayload: { ok: true } - }, - initialProps.origin - ); +export function validateAndUpdateBrowserProps(initialProps, propListeners, updatedPropsEvent) { + const { + origin, + data: { eventName, id, eventPayload: newProps } + } = updatedPropsEvent; + // verify the event is coming from the merchant page + const eventOriginCheck = origin === decodeURIComponent(initialProps.origin); + if (eventOriginCheck && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { + // send event ack so PostMessenger will stop reposting event + sendEventAck(id); + const validProps = validateProps(newProps); + listenAndAssignProps(validProps, propListeners, true); } } @@ -47,57 +33,30 @@ const setupBrowser = props => { window.addEventListener( 'message', - event => updateBrowserProps(props, propListeners, event), - // event => { - // console.log('LearnMore message event.data', event.data); - // const { eventName, eventPayload: updatedProps, id } = event.data; - // if ( - // // verify the event is coming from the merchant page - // event.data.origin === props.origin && - // eventName === 'PROPS_UPDATE' && - // updatedProps && - // typeof updatedProps === 'object' - // ) { - // Array.from(propListeners.values()).forEach(listener => { - // listener({ ...window.xprops, ...updatedProps }); - // }); - - // let validatedProps; - // Object.entries(updatedProps).forEach(entry => { - // const [k, v] = entry; - // if (k === 'offerType') { - // validatedProps.offer = validate.offer({ props: { offer: v } }); - // } else { - // validatedProps[k] = validate[k]({ props: { [k]: v } }); - // } - // }); - - // Object.assign(window.xprops, validatedProps); - - // console.log( - // 'LearnMore message ack', - // { - // eventName: id, - // eventPayload: { ok: true }, - // id: createUUID() - // }, - // props.origin - // ); - - // // send back ack so PostMessenger won't retry sending message unnecessarily - // postMessage( - // { - // eventName: id, - // eventPayload: { ok: true }, - // id: createUUID() - // }, - // props.origin - // ); - // } - // }, + event => { + validateAndUpdateBrowserProps(props, propListeners, event); + }, false ); + // window.addEventListener( + // 'message', + // event => { + // const { + // origin, + // data: { eventName, id, eventPayload: newProps } + // } = event; + // // verify the event is coming from the merchant page + // const eventOriginCheck = origin === decodeURIComponent(props.origin); + // if (eventOriginCheck && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { + // // send event ack so PostMessenger will stop reposting event + // sendEventAck(id); + // listenAndAssignProps(newProps, propListeners, true); + // } + // }, + // false + // ); + window.xprops = { onProps: listener => propListeners.add(listener), // TODO: Verify these callbacks are instrumented correctly @@ -216,11 +175,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, false); } } }; 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 407adea316..647f01075d 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,4 +1,4 @@ -import zoidPolyfill, { updateBrowserProps } 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 @@ -419,54 +419,68 @@ describe('zoidPollyfill', () => { `); postMessage.mockClear(); }); - test('browser', () => { - 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(); - - expect(window.xprops).toEqual( - expect.objectContaining({ - onProps: expect.any(Function) - }) - ); - - // verify event listener was added - expect(addEventListenerSpy).toHaveBeenCalledTimes(1); - expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function), false); - - const newPropsEvent = { - data: { + 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', - eventName: 'PROPS_UPDATE', - eventPayload: { - amount: 1000, - offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + data: { + eventName: 'PROPS_UPDATE', + eventPayload: { + amount: 1000, + offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + } } - } - }; - - // jest doesn't support calling postMessage, so we cannot use the event listener above - // instead we will manually verify that updateBrowserProps works as intended - const propListeners = new Set(); - const onPropsCallback = jest.fn(); - propListeners.add(onPropsCallback); - updateBrowserProps(window.xprops, propListeners, newPropsEvent); + }; + + 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: {} + }; - // subscribeCallback({ - // amount: 1000 - // }); + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + validateAndUpdateBrowserProps(window.xprops, propListeners, unrelatedEvent); - 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' - }) - ); + expect(onPropsCallback).toHaveBeenCalledTimes(0); + }); }); }); From a0c0b0b2d2264cf95a3224a760fdd8d09a5c0124 Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Mon, 13 Jan 2025 15:10:42 -0500 Subject: [PATCH 4/9] removes comments --- src/components/modal/v2/lib/zoid-polyfill.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index c834af998a..19bcb0a5bc 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -39,24 +39,6 @@ const setupBrowser = props => { false ); - // window.addEventListener( - // 'message', - // event => { - // const { - // origin, - // data: { eventName, id, eventPayload: newProps } - // } = event; - // // verify the event is coming from the merchant page - // const eventOriginCheck = origin === decodeURIComponent(props.origin); - // if (eventOriginCheck && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { - // // send event ack so PostMessenger will stop reposting event - // sendEventAck(id); - // listenAndAssignProps(newProps, propListeners, true); - // } - // }, - // false - // ); - window.xprops = { onProps: listener => propListeners.add(listener), // TODO: Verify these callbacks are instrumented correctly From 3f5908f48558d24abb4ee8e6e16b6126c5cd7b92 Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Mon, 13 Jan 2025 15:34:15 -0500 Subject: [PATCH 5/9] adds trusted origin to event ack postMessage --- src/components/modal/v2/lib/utils.js | 4 ++-- src/components/modal/v2/lib/zoid-polyfill.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index 5700589d63..b1fefc37a1 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -143,7 +143,7 @@ export function validateProps(updatedProps) { return validatedProps; } -export function sendEventAck(eventId) { +export function sendEventAck(eventId, trustedOrigin) { // skip this step if running in test env because jest's target windows don't support postMessage if (window.process?.env?.NODE_ENV === 'test') { return; @@ -166,6 +166,6 @@ export function sendEventAck(eventId) { 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 19bcb0a5bc..7a00748948 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -15,16 +15,16 @@ function listenAndAssignProps(newProps, propListeners) { export function validateAndUpdateBrowserProps(initialProps, propListeners, updatedPropsEvent) { const { - origin, + origin: eventOrigin, data: { eventName, id, eventPayload: newProps } } = updatedPropsEvent; - // verify the event is coming from the merchant page - const eventOriginCheck = origin === decodeURIComponent(initialProps.origin); - if (eventOriginCheck && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { + const merchantOrigin = decodeURIComponent(initialProps.origin); + + if (eventOrigin === merchantOrigin && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { // send event ack so PostMessenger will stop reposting event - sendEventAck(id); + sendEventAck(id, merchantOrigin); const validProps = validateProps(newProps); - listenAndAssignProps(validProps, propListeners, true); + listenAndAssignProps(validProps, propListeners); } } @@ -157,7 +157,7 @@ const setupWebview = props => { window.actions = { updateProps: newProps => { if (newProps && typeof newProps === 'object') { - listenAndAssignProps(newProps, propListeners, false); + listenAndAssignProps(newProps, propListeners); } } }; From 7da92bbd9d58e3148997fde78297e2b2b5fea49d Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Wed, 15 Jan 2025 16:42:27 -0500 Subject: [PATCH 6/9] corrects offerTypes param name --- src/components/modal/v2/lib/utils.js | 2 +- .../unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index b1fefc37a1..3088ea0c05 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -134,7 +134,7 @@ export function validateProps(updatedProps) { const validatedProps = {}; Object.entries(updatedProps).forEach(entry => { const [k, v] = entry; - if (k === 'offerType') { + if (k === 'offerTypes') { validatedProps.offer = validate.offer({ props: { offer: v } }); } else { validatedProps[k] = validate[k]({ props: { [k]: v } }); 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 647f01075d..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 @@ -449,7 +449,7 @@ describe('zoidPollyfill', () => { eventName: 'PROPS_UPDATE', eventPayload: { amount: 1000, - offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + offerTypes: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] } } }; From 8f938f5648e6159bd6ecd930dcabdf8c06e7826e Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Wed, 22 Jan 2025 11:19:11 -0500 Subject: [PATCH 7/9] addresses comments --- src/components/modal/v2/lib/utils.js | 2 +- src/components/modal/v2/lib/zoid-polyfill.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index 3088ea0c05..d3a5bffe0e 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -145,7 +145,7 @@ export function validateProps(updatedProps) { export function sendEventAck(eventId, trustedOrigin) { // skip this step if running in test env because jest's target windows don't support postMessage - if (window.process?.env?.NODE_ENV === 'test') { + if (process.env.NODE_ENV === 'test') { return; } diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 7a00748948..8e15e39663 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -18,11 +18,11 @@ export function validateAndUpdateBrowserProps(initialProps, propListeners, updat origin: eventOrigin, data: { eventName, id, eventPayload: newProps } } = updatedPropsEvent; - const merchantOrigin = decodeURIComponent(initialProps.origin); + const clientOrigin = decodeURIComponent(initialProps.origin); - if (eventOrigin === merchantOrigin && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { + if (eventOrigin === clientOrigin && eventName === 'PROPS_UPDATE' && newProps && typeof newProps === 'object') { // send event ack so PostMessenger will stop reposting event - sendEventAck(id, merchantOrigin); + sendEventAck(id, clientOrigin); const validProps = validateProps(newProps); listenAndAssignProps(validProps, propListeners); } From 7cc1fdcfc8ff3f76d2d35977aed4431127a6be52 Mon Sep 17 00:00:00 2001 From: Dan Haas Date: Fri, 28 Mar 2025 11:36:42 -0400 Subject: [PATCH 8/9] improves function names --- src/components/modal/v2/lib/zoid-polyfill.js | 10 +++++----- .../components/modal/v2/lib/zoid-polyfill.test.js | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 76238267f1..cefb07a1e0 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -6,14 +6,14 @@ import { isIframe, validateProps, sendEventAck } from './utils'; const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; -function listenAndAssignProps(newProps, propListeners) { +function updateProps(newProps, propListeners) { Array.from(propListeners.values()).forEach(listener => { listener({ ...window.xprops, ...newProps }); }); Object.assign(window.xprops, newProps); } -export function validateAndUpdateBrowserProps(initialProps, propListeners, updatedPropsEvent) { +export function handleBrowserEvents(initialProps, propListeners, updatedPropsEvent) { const { origin: eventOrigin, data: { eventName, id, eventPayload: newProps } @@ -24,7 +24,7 @@ export function validateAndUpdateBrowserProps(initialProps, propListeners, updat // send event ack so PostMessenger will stop reposting event sendEventAck(id, clientOrigin); const validProps = validateProps(newProps); - listenAndAssignProps(validProps, propListeners); + updateProps(validProps, propListeners); } } @@ -47,7 +47,7 @@ const setupBrowser = props => { window.addEventListener( 'message', event => { - validateAndUpdateBrowserProps(props, propListeners, event); + handleBrowserEvents(props, propListeners, event); }, false ); @@ -170,7 +170,7 @@ const setupWebview = props => { window.actions = { updateProps: newProps => { if (newProps && typeof newProps === 'object') { - listenAndAssignProps(newProps, propListeners); + updateProps(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 dc21f03ec8..ca964607d2 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,4 +1,4 @@ -import zoidPolyfill, { validateAndUpdateBrowserProps } from 'src/components/modal/v2/lib/zoid-polyfill'; +import zoidPolyfill, { handleBrowserEvents } 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 @@ -440,9 +440,9 @@ describe('zoidPollyfill', () => { expect(addEventListenerSpy).toHaveBeenCalledTimes(1); expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function), false); }); - test('validateAndUpdateBrowserProps updates props when values are valid', () => { + test('handleBrowserEvents 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 + // instead we will manually verify that handleBrowserEvents works as intended const newPropsEvent = { origin: 'http://example.com', data: { @@ -457,7 +457,7 @@ describe('zoidPollyfill', () => { const propListeners = new Set(); const onPropsCallback = jest.fn(); propListeners.add(onPropsCallback); - validateAndUpdateBrowserProps(window.xprops, propListeners, newPropsEvent); + handleBrowserEvents(window.xprops, propListeners, newPropsEvent); expect(onPropsCallback).toHaveBeenCalledTimes(1); expect(onPropsCallback).toHaveBeenCalledWith( @@ -469,7 +469,7 @@ describe('zoidPollyfill', () => { }) ); }); - test('validateAndUpdateBrowserProps handles unrelated events with no data', () => { + test('handleBrowserEvents handles unrelated events with no data', () => { const unrelatedEvent = { data: {} }; @@ -477,7 +477,7 @@ describe('zoidPollyfill', () => { const propListeners = new Set(); const onPropsCallback = jest.fn(); propListeners.add(onPropsCallback); - validateAndUpdateBrowserProps(window.xprops, propListeners, unrelatedEvent); + handleBrowserEvents(window.xprops, propListeners, unrelatedEvent); expect(onPropsCallback).toHaveBeenCalledTimes(0); }); From 50fb154bd32c41e2c6d1cc29fcb038f93d64c77e Mon Sep 17 00:00:00 2001 From: Dan Haas <123580890+danzhaaspaypal@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:10:12 -0400 Subject: [PATCH 9/9] feat: sends modal event data to modal wrapper for use in hooks (#1170) --- src/components/modal/v2/lib/postMessage.js | 71 +++++++++++ src/components/modal/v2/lib/utils.js | 47 +------- src/components/modal/v2/lib/zoid-polyfill.js | 69 +++++++---- src/components/modal/v2/parts/Container.jsx | 2 - .../src/components/modal/v2/lib/utils.test.js | 25 +++- .../modal/v2/lib/zoid-polyfill.test.js | 112 ++++++++++++++---- 6 files changed, 239 insertions(+), 87 deletions(-) create mode 100644 src/components/modal/v2/lib/postMessage.js diff --git a/src/components/modal/v2/lib/postMessage.js b/src/components/modal/v2/lib/postMessage.js new file mode 100644 index 0000000000..a3c2091f98 --- /dev/null +++ b/src/components/modal/v2/lib/postMessage.js @@ -0,0 +1,71 @@ +import { uniqueID } from '@krakenjs/belter/src'; + +// these constants are defined in PostMessenger +const POSTMESSENGER_EVENT_TYPES = { + ACK: 'ack', + MESSAGE: 'message' +}; +const POSTMESSENGER_ACK_PAYLOAD = { + ok: 'true' +}; +// these constants should maintain parity with MESSAGE_MODAL_EVENT_NAMES in core-web-sdk +export const POSTMESSENGER_EVENT_NAMES = { + CALCULATE: 'paypal-messages-modal-calculate', + CLOSE: 'paypal-messages-modal-close', + SHOW: 'paypal-messages-modal-show' +}; + +export function sendEvent(payload, trustedOrigin) { + if (!trustedOrigin) { + return; + } + + const isTest = process.env.NODE_ENV === 'test'; + const targetWindow = !isTest && window.parent === window ? window.opener : window.parent; + + targetWindow.postMessage(payload, trustedOrigin); +} + +// This function provides data security by preventing accidentally exposing sensitive data; we are adding +// an extra layer of validation here by only allowing explicitly approved fields to be included +function createSafePayload(unscreenedPayload) { + const allowedFields = [ + 'linkName' // close event + ]; + + const safePayload = {}; + if (unscreenedPayload) { + const entries = Object.entries(unscreenedPayload); + entries.forEach(entry => { + const [key, value] = entry; + if (allowedFields.includes(key)) { + safePayload[key] = value; + } else { + console.warn(`modal hook payload param should be allowlisted if secure: ${key}`); + } + }); + } + + return safePayload; +} + +export function createPostMessengerEvent(typeArg, eventName, eventPayloadArg) { + let type; + let eventPayload; + + if (typeArg === 'ack') { + type = POSTMESSENGER_EVENT_TYPES.ACK; + eventPayload = POSTMESSENGER_ACK_PAYLOAD; + } else if (typeArg === 'message') { + type = POSTMESSENGER_EVENT_TYPES.MESSAGE; + // createSafePayload, only call this if a payload is sent + eventPayload = createSafePayload(eventPayloadArg); + } + + return { + eventName, + id: uniqueID(), + type, + eventPayload: eventPayload || {} + }; +} diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index d3a5bffe0e..63f8a501dd 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -114,58 +114,17 @@ 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') { + if (k === 'offerType') { validatedProps.offer = validate.offer({ props: { offer: v } }); + } else if (!Object.keys(validate).includes(k)) { + validatedProps[k] = 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 cefb07a1e0..e18784f913 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,7 +1,8 @@ /* global Android */ import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { getOrCreateDeviceID, logger } from '../../../../utils'; -import { isIframe, validateProps, sendEventAck } from './utils'; +import { validateProps, isIframe } from './utils'; +import { sendEvent, createPostMessengerEvent, POSTMESSENGER_EVENT_NAMES } from './postMessage'; const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; @@ -13,21 +14,43 @@ function updateProps(newProps, propListeners) { Object.assign(window.xprops, newProps); } -export function handleBrowserEvents(initialProps, propListeners, updatedPropsEvent) { +export function handlePropsUpdateEvent(propListeners, updatedPropsEvent) { const { - origin: eventOrigin, - data: { eventName, id, eventPayload: newProps } + data: { 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); + if (newProps && typeof newProps === 'object') { const validProps = validateProps(newProps); updateProps(validProps, propListeners); } } +export function logModalClose(linkName) { + logger.track({ + index: '1', + et: 'CLICK', + event_type: 'modal_close', + page_view_link_name: linkName + }); +} + +export function handleBrowserEvents(clientOrigin, propListeners, event) { + const { + origin: eventOrigin, + data: { eventName, id } + } = event; + if (eventOrigin !== clientOrigin) { + return; + } + if (eventName === 'PROPS_UPDATE') { + handlePropsUpdateEvent(propListeners, event); + } + if (eventName === 'MODAL_CLOSED') { + logModalClose(event.data.eventPayload.linkName); + } + // send event ack with original event id so PostMessenger will stop reposting event + sendEvent(createPostMessengerEvent('ack', id), clientOrigin); +} + const getAccount = (merchantId, clientId, payerId) => { if (merchantId) { return merchantId; @@ -44,10 +67,15 @@ const getAccount = (merchantId, clientId, payerId) => { const setupBrowser = props => { const propListeners = new Set(); + let trustedOrigin = decodeURIComponent(props.origin || ''); + if (isIframe && document.referrer && !process.env.NODE_ENV === 'test') { + trustedOrigin = new window.URL(document.referrer).origin; + } + window.addEventListener( 'message', event => { - handleBrowserEvents(props, propListeners, event); + handleBrowserEvents(trustedOrigin, propListeners, event); }, false ); @@ -110,6 +138,7 @@ const setupBrowser = props => { }); }, onCalculate: ({ value }) => { + sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CALCULATE), trustedOrigin); logger.track({ index: '1', et: 'CLICK', @@ -120,6 +149,7 @@ const setupBrowser = props => { }); }, onShow: () => { + sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.SHOW), trustedOrigin); logger.track({ index: '1', et: 'CLIENT_IMPRESSION', @@ -128,16 +158,15 @@ const setupBrowser = props => { }); }, onClose: ({ linkName }) => { - if (isIframe && document.referrer) { - const targetOrigin = new window.URL(document.referrer).origin; - window.parent.postMessage('paypal-messages-modal-close', targetOrigin); - } - logger.track({ - index: '1', - et: 'CLICK', - event_type: 'modal_close', - page_view_link_name: linkName - }); + const eventPayload = { + linkName + // for data security, also add new params to createSafePayload in ./postMessage.js + }; + sendEvent( + createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CLOSE, eventPayload), + trustedOrigin + ); + logModalClose(linkName); }, // Overridable defaults integrationType: __MESSAGES__.__TARGET__, diff --git a/src/components/modal/v2/parts/Container.jsx b/src/components/modal/v2/parts/Container.jsx index c56c962f51..5611482ccf 100644 --- a/src/components/modal/v2/parts/Container.jsx +++ b/src/components/modal/v2/parts/Container.jsx @@ -45,8 +45,6 @@ const Container = ({ children }) => { useEffect(() => { if (transitionState === 'CLOSED') { contentWrapperRef.current.scrollTop = 0; - } else if (transitionState === 'OPEN') { - window.focus(); } }, [transitionState]); diff --git a/tests/unit/spec/src/components/modal/v2/lib/utils.test.js b/tests/unit/spec/src/components/modal/v2/lib/utils.test.js index bd66633028..3cdd0ee936 100644 --- a/tests/unit/spec/src/components/modal/v2/lib/utils.test.js +++ b/tests/unit/spec/src/components/modal/v2/lib/utils.test.js @@ -1,4 +1,4 @@ -import { formatDateByCountry } from 'src/components/modal/v2/lib/utils'; +import { formatDateByCountry, validateProps } from 'src/components/modal/v2/lib/utils'; describe('Date function should return correct date format based on country', () => { it('US country date should be formatted MM/DD/YYYY', () => { @@ -14,3 +14,26 @@ describe('Date function should return correct date format based on country', () expect(result).toMatch(expectedFormat); }); }); + +describe('validateProps', () => { + it('validates amount, contextualComponents, and offerType, and preserves value of other props', () => { + const propsToFix = { + amount: '10', + offerType: 'PAY_LATER_SHORT_TERM, PAY_LATER_LONG_TERM', + contextualComponents: 'paypal_button' + }; + const propsToPreserve = { + itemSkus: ['123', '456'], + presentationMode: 'auto' + }; + + const output = validateProps({ ...propsToFix, ...propsToPreserve }); + + const fixedPropOutputValues = { + amount: 10, + offer: 'PAY_LATER_LONG_TERM,PAY_LATER_SHORT_TERM', + contextualComponents: 'PAYPAL_BUTTON' + }; + expect(output).toMatchObject({ ...fixedPropOutputValues, ...propsToPreserve }); + }); +}); 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 ca964607d2..68899e3e1c 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,4 +1,5 @@ import zoidPolyfill, { handleBrowserEvents } from 'src/components/modal/v2/lib/zoid-polyfill'; +import { POSTMESSENGER_EVENT_NAMES } from 'src/components/modal/v2/lib/postMessage'; 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 @@ -88,6 +89,13 @@ const mockLoadUrl = (url, { platform = 'web' } = {}) => { }; describe('zoidPollyfill', () => { + beforeAll(() => { + const postMessage = jest.fn(); + window.parent.postMessage = postMessage; + }); + afterEach(() => { + postMessage.mockClear(); + }); describe('sets up xprops for browser', () => { beforeAll(() => { mockLoadUrl( @@ -440,16 +448,18 @@ describe('zoidPollyfill', () => { expect(addEventListenerSpy).toHaveBeenCalledTimes(1); expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function), false); }); - test('handleBrowserEvents updates props when values are valid', () => { + test('handleBrowserEvents handles PROPS_UPDATE and 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 handleBrowserEvents works as intended + const clientOrigin = 'http://example.com'; + const newPropsEvent = { - origin: 'http://example.com', + origin: clientOrigin, data: { eventName: 'PROPS_UPDATE', eventPayload: { amount: 1000, - offerTypes: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] } } }; @@ -457,7 +467,7 @@ describe('zoidPollyfill', () => { const propListeners = new Set(); const onPropsCallback = jest.fn(); propListeners.add(onPropsCallback); - handleBrowserEvents(window.xprops, propListeners, newPropsEvent); + handleBrowserEvents(clientOrigin, propListeners, newPropsEvent); expect(onPropsCallback).toHaveBeenCalledTimes(1); expect(onPropsCallback).toHaveBeenCalledWith( @@ -469,6 +479,36 @@ describe('zoidPollyfill', () => { }) ); }); + test('handleBrowserEvents handles MODAL_CLOSE and logs close method', () => { + // jest doesn't support calling postMessage, so we cannot use the event listener above + // instead we will manually verify that handleBrowserEvents works as intended + const clientOrigin = 'http://example.com'; + + const newPropsEvent = { + origin: clientOrigin, + data: { + eventName: 'MODAL_CLOSED', + eventPayload: { + linkName: 'Custom Close Button' + } + } + }; + + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + handleBrowserEvents(clientOrigin, propListeners, newPropsEvent); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + index: '1', + et: 'CLICK', + event_type: 'modal_close', + page_view_link_name: 'Custom Close Button' + }) + ); + }); test('handleBrowserEvents handles unrelated events with no data', () => { const unrelatedEvent = { data: {} @@ -484,33 +524,65 @@ describe('zoidPollyfill', () => { }); }); - describe('communication with parent window on onClose ', () => { + describe('communication with parent window on modal events ', () => { beforeAll(() => { mockLoadUrl( - 'https://localhost.paypal.com:8080/credit-presentment/native/modal?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&origin=http://localhost.paypal.com:8080' ); zoidPolyfill(); - const postMessage = jest.fn(); - window.parent.postMessage = postMessage; }); afterEach(() => { logger.track.mockClear(); - postMessage.mockClear(); }); - test('does not send post message to parent window when referrer not present', () => { - window.xprops.onClose({ linkName: 'Escape Key' }); - expect(postMessage).not.toHaveBeenCalled(); - }); - - test('sends post message to parent window when referrer is present', () => { - Object.defineProperty(window.document, 'referrer', { - value: 'http://localhost.paypal.com:8080/lander' + describe('communication with parent window on onClose ', () => { + test.skip('does not send post message to parent window when referrer not present', () => { + window.xprops.onClose({ linkName: 'Escape Key' }); + expect(postMessage).not.toHaveBeenCalled(); }); - window.xprops.onClose({ linkName: 'Escape Key' }); + test('sends post message to parent window when referrer is present', () => { + Object.defineProperty(window.document, 'referrer', { + value: 'http://localhost.paypal.com:8080/lander' + }); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toBeCalledWith('paypal-messages-modal-close', 'http://localhost.paypal.com:8080'); + window.xprops.onClose({ linkName: 'Escape Key' }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toBeCalledWith( + expect.objectContaining({ eventName: POSTMESSENGER_EVENT_NAMES.CLOSE }), + 'http://localhost.paypal.com:8080' + ); + }); + }); + describe('communication with parent window on onShow ', () => { + test('sends post message to parent window when referrer is present', () => { + Object.defineProperty(window.document, 'referrer', { + value: 'http://localhost.paypal.com:8080/lander' + }); + + window.xprops.onShow(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toBeCalledWith( + expect.objectContaining({ eventName: POSTMESSENGER_EVENT_NAMES.SHOW }), + 'http://localhost.paypal.com:8080' + ); + }); + }); + describe('communication with parent window on onCalculate ', () => { + test('sends post message to parent window when referrer is present', () => { + Object.defineProperty(window.document, 'referrer', { + value: 'http://localhost.paypal.com:8080/lander' + }); + + window.xprops.onCalculate({ amount: 40 }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toBeCalledWith( + expect.objectContaining({ eventName: POSTMESSENGER_EVENT_NAMES.CALCULATE }), + 'http://localhost.paypal.com:8080' + ); + }); }); }); });