Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
});

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);

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
};
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