Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sends modal event data to modal wrapper for use in hooks #1170

Open
wants to merge 9 commits into
base: feature/DTCRCMERC-3611-modal-lander-v6
Choose a base branch
from
71 changes: 71 additions & 0 deletions src/components/modal/v2/lib/postMessage.js
Original file line number Diff line number Diff line change
@@ -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'
};

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 && !document.referrer) {
return;
}

const isTest = process.env.NODE_ENV === 'test';
const targetWindow = !isTest && window.parent === window ? window.opener : window.parent;

// referrer origin is used by integrations not passing in props.origin manually
// eslint-disable-next-line compat/compat
const referrerOrigin = !isTest ? new window.URL(document.referrer)?.origin : undefined;

targetWindow.postMessage(payload, trustedOrigin || referrerOrigin);
}

// 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 = {};
const entries = Object.entries(unscreenedPayload);
entries.forEach(entry => {
const [key, value] = entry;
if (allowedFields.includes(key)) {
safePayload[key] = value;
}
Comment on lines +44 to +46
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to help us debug this in the future when we inevitably forget this filter exists, should we add an else that includes a simple console warn or something to help point this out?

});

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
eventPayload = createSafePayload(eventPayloadArg);
}

return {
eventName,
id: uniqueID(),
type,
eventPayload
};
}
47 changes: 3 additions & 44 deletions src/components/modal/v2/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
34 changes: 24 additions & 10 deletions src/components/modal/v2/lib/zoid-polyfill.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* global Android */
import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src';
import { getOrCreateDeviceID, logger } from '../../../../utils';
import { isIframe, validateProps, sendEventAck } from './utils';
import { validateProps } from './utils';
import { sendEvent, createPostMessengerEvent, POSTMESSENGER_EVENT_NAMES } from './postMessage';

const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler';
const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler';
// these constants should maintain parity with MESSAGE_MODAL_EVENT_NAMES in core-web-sdk

function updateProps(newProps, propListeners) {
Array.from(propListeners.values()).forEach(listener => {
Expand All @@ -13,16 +15,15 @@ function updateProps(newProps, propListeners) {
Object.assign(window.xprops, newProps);
}

export function handleBrowserEvents(initialProps, propListeners, updatedPropsEvent) {
export function handleBrowserEvents(clientOrigin, 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);
// send event ack with original event id so PostMessenger will stop reposting event
sendEvent(createPostMessengerEvent('ack', id), clientOrigin);
const validProps = validateProps(newProps);
updateProps(validProps, propListeners);
}
Expand All @@ -43,11 +44,12 @@ const getAccount = (merchantId, clientId, payerId) => {

const setupBrowser = props => {
const propListeners = new Set();
const clientOrigin = decodeURIComponent(props.origin);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we able to test if new window.URL(document.referrer).origin would be sufficient for our use-cases? Or is there a scenario we know where the query param is required? Maybe when inside a webview? If not though it would be nice to simplify around that since that's what the one post message hook we had prior was using.


window.addEventListener(
'message',
event => {
handleBrowserEvents(props, propListeners, event);
handleBrowserEvents(clientOrigin, propListeners, event);
},
false
);
Expand Down Expand Up @@ -110,6 +112,13 @@ const setupBrowser = props => {
});
},
onCalculate: ({ value }) => {
const eventPayload = {
// for data security, also add new params to createSafePayload in ./postMessage.js
};
Comment on lines +115 to +117
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just make the eventPayload optional so that it can be omitted instead of creating an empty object to pass in?

sendEvent(
createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CALCULATE, eventPayload),
clientOrigin
);
logger.track({
index: '1',
et: 'CLICK',
Expand All @@ -120,6 +129,10 @@ const setupBrowser = props => {
});
},
onShow: () => {
const eventPayload = {
// for data security, also add new params to createSafePayload in ./postMessage.js
};
sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.SHOW, eventPayload), clientOrigin);
logger.track({
index: '1',
et: 'CLIENT_IMPRESSION',
Expand All @@ -128,10 +141,11 @@ 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);
}
const eventPayload = {
linkName
// for data security, also add new params to createSafePayload in ./postMessage.js
};
sendEvent(createPostMessengerEvent('message', POSTMESSENGER_EVENT_NAMES.CLOSE, eventPayload), clientOrigin);
logger.track({
index: '1',
et: 'CLICK',
Expand Down
25 changes: 24 additions & 1 deletion tests/unit/spec/src/components/modal/v2/lib/utils.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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 });
});
});
80 changes: 61 additions & 19 deletions tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -443,21 +451,23 @@ describe('zoidPollyfill', () => {
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 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']
}
}
};

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(
Expand All @@ -484,33 +494,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'
);
});
});
});
});
Loading