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 0d42b61cbf..63f8a501dd 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,18 @@ export function formatDateByCountry(country) { } return currentDate.toLocaleDateString('en-GB', options); } + +export function validateProps(updatedProps) { + const validatedProps = {}; + Object.entries(updatedProps).forEach(entry => { + const [k, v] = entry; + 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; +} diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index 3dd49ba5ea..e18784f913 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,11 +1,56 @@ /* global Android */ import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { getOrCreateDeviceID, logger } from '../../../../utils'; -import { isIframe } 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'; +function updateProps(newProps, propListeners) { + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...newProps }); + }); + Object.assign(window.xprops, newProps); +} + +export function handlePropsUpdateEvent(propListeners, updatedPropsEvent) { + const { + data: { eventPayload: newProps } + } = updatedPropsEvent; + 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; @@ -20,9 +65,23 @@ 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(trustedOrigin, 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; @@ -79,6 +138,7 @@ const setupBrowser = props => { }); }, onCalculate: ({ value }) => { + sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CALCULATE), trustedOrigin); logger.track({ index: '1', et: 'CLICK', @@ -89,6 +149,7 @@ const setupBrowser = props => { }); }, onShow: () => { + sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.SHOW), trustedOrigin); logger.track({ index: '1', et: 'CLIENT_IMPRESSION', @@ -97,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__, @@ -139,11 +199,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); + updateProps(newProps, propListeners); } } }; 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 f70b6c43f6..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,13 +1,20 @@ -import zoidPolyfill from 'src/components/modal/v2/lib/zoid-polyfill'; +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 -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 +32,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; @@ -70,16 +89,24 @@ 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( - '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 +204,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,117 +343,246 @@ 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('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: clientOrigin, + data: { + eventName: 'PROPS_UPDATE', + eventPayload: { + amount: 1000, + offerType: ['PAY_LATER_LONG_TERM', 'PAY_LATER_SHORT_TERM'] + } + } + }; + + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + handleBrowserEvents(clientOrigin, 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('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: {} + }; + + const propListeners = new Set(); + const onPropsCallback = jest.fn(); + propListeners.add(onPropsCallback); + handleBrowserEvents(window.xprops, propListeners, unrelatedEvent); + + expect(onPropsCallback).toHaveBeenCalledTimes(0); + }); + }); }); - 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/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&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' + ); + }); }); }); });