Skip to content

Commit

Permalink
add eligibility check and protect export for stage and local (#2451)
Browse files Browse the repository at this point in the history
* chore: prettier

* allow three-domain-secure component

* refactor threedomainsecure component to class

* correct typo

* refactor test for class component

* chore: fix lint

* chore: fix flow issues

* pin flow-remove-types and hermes-parser version due to flow errors

* return methods only instead of entire class

* modify interface to reflect future state

* resolve WIP stash merge

* implement isEligible request to API

* change sdktoken to idtoken

* modify protectedExport to Local or Stage check

* change protectedexport to local/stage export

* pass transaction context as received

* fix flow type errors

* linting / flow fixes and skipping test for now

* add isEligible test skeleton

* check for payer-action rel in links

* throw error on API error isntead of false

* wip: add test for isEligible

* remove comments

* additional test for isEligble

* remove console logs

* change name of export to devEnvOnly

* move url to constants file

* resolve dist merge conflict

---------

Co-authored-by: Shraddha Shah <spshah@paypal.com>
  • Loading branch information
mchoun and siddy2181 authored Nov 20, 2024
1 parent b7545fa commit f0b5eeb
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 22 deletions.
1 change: 1 addition & 0 deletions src/constants/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const HEADERS = {
};

export const ELIGIBLE_PAYMENT_METHODS = "v2/payments/find-eligible-methods";
export const PAYMENT_3DS_VERIFICATION = "v2/payments/payment";

export const FPTI_TRANSITION = {
SHOPPER_INSIGHTS_API_INIT: "sdk_shopper_insights_recommended_init",
Expand Down
13 changes: 12 additions & 1 deletion src/lib/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { isSameDomain } from "@krakenjs/cross-domain-utils/src";
import { supportsPopups } from "@krakenjs/belter/src";
import { isPayPalDomain } from "@paypal/sdk-client/src";
import { getEnv, isPayPalDomain } from "@paypal/sdk-client/src";
import { ENV } from "@paypal/sdk-constants/src";

export function allowIframe(): boolean {
if (!isPayPalDomain()) {
Expand All @@ -28,3 +29,13 @@ export function allowIframe(): boolean {
export const protectedExport = (unprotectedExport) =>
isPayPalDomain() ? unprotectedExport : undefined;
/* eslint-enable no-confusing-arrow */

// $FlowIssue
export const devEnvOnlyExport = (unprotectedExport) => {
const env = getEnv();
if (env === ENV.LOCAL || env === ENV.STAGE) {
return unprotectedExport;
} else {
return undefined;
}
};
132 changes: 124 additions & 8 deletions src/three-domain-secure/component.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,81 @@
/* @flow */
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable no-restricted-globals, promise/no-native */
import { type LoggerType } from "@krakenjs/beaver-logger/src";
import { ZalgoPromise } from "@krakenjs/zalgo-promise/src";
import { create, type ZoidComponent } from "@krakenjs/zoid/src";
import { FPTI_KEY } from "@paypal/sdk-constants/src";

import { ValidationError } from "../lib";
import { PAYMENT_3DS_VERIFICATION } from "../constants/api";

type MerchantPayloadData = {|
amount: string,
currency: string,
nonce: string,
threeDSRequested?: boolean,
transactionContext?: Object,
|};

// eslint-disable-next-line no-undef
type Request = <TRequestData, TResponse>({|
method?: string,
url: string,
// eslint-disable-next-line no-undef
data: TRequestData,
accessToken: ?string,
// eslint-disable-next-line no-undef
|}) => Promise<TResponse>;

type requestData = {|
intent: "THREE_DS_VERIFICATION",
payment_source: {|
card: {|
single_use_token: string,
verification_method: string,
|},
|},
amount: {|
currency_code: string,
value: string,
|},
transaction_context?: {|
soft_descriptor?: string,
|},
|};

type responseBody = {|
payment_id: string,
status: string,
intent: string,
payment_source: {|
card: {|
last_digits: string,
type: string,
name: string,
expiry: string,
|},
|},
amount: {|
currency_code: string,
value: string,
|},
transaction_context: {|
soft_descriptor: string,
|},
links: $ReadOnlyArray<{|
href: string,
rel: string,
method: string,
|}>,
|};

type SdkConfig = {|
sdkToken: ?string,
authenticationToken: ?string,
paypalApiDomain: string,
|};

const parseSdkConfig = ({ sdkConfig, logger }): SdkConfig => {
if (!sdkConfig.sdkToken) {
if (!sdkConfig.authenticationToken) {
throw new ValidationError(
`script data attribute sdk-client-token is required but was not passed`
);
Expand All @@ -23,29 +87,81 @@ const parseSdkConfig = ({ sdkConfig, logger }): SdkConfig => {

return sdkConfig;
};

const parseMerchantPayload = ({
merchantPayload,
}: {|
merchantPayload: MerchantPayloadData,
|}): requestData => {
const { threeDSRequested, amount, currency, nonce, transactionContext } =
merchantPayload;

return {
intent: "THREE_DS_VERIFICATION",
payment_source: {
card: {
single_use_token: nonce,
verification_method: threeDSRequested
? "SCA_ALWAYS"
: "SCA_WHEN_REQUIRED",
},
},
amount: {
currency_code: currency,
value: amount,
},
...transactionContext,
};
};

export interface ThreeDomainSecureComponentInterface {
isEligible(): ZalgoPromise<boolean>;
isEligible(): Promise<boolean>;
show(): ZoidComponent<void>;
}
export class ThreeDomainSecureComponent {
logger: LoggerType;
request: Request;
sdkConfig: SdkConfig;
authenticationURL: string;

constructor({
logger,
request,
sdkConfig,
}: {|
logger: LoggerType,
request: Request,
sdkConfig: SdkConfig,
|}) {
this.logger = logger;
this.request = request;
this.sdkConfig = parseSdkConfig({ sdkConfig, logger });
}

isEligible(): ZalgoPromise<boolean> {
return new ZalgoPromise((resolve) => {
resolve(false);
});
async isEligible(merchantPayload: MerchantPayloadData): Promise<boolean> {
const data = parseMerchantPayload({ merchantPayload });
try {
// $FlowFixMe
const { status, links } = await this.request<requestData, responseBody>({
method: "POST",
url: `${this.sdkConfig.paypalApiDomain}/${PAYMENT_3DS_VERIFICATION}`,
data,
accessToken: this.sdkConfig.authenticationToken,
});

let responseStatus = false;

if (status === "PAYER_ACTION_REQUIRED") {
this.authenticationURL = links.find(
(link) => link.rel === "payer-action"
).href;
responseStatus = true;
}
return responseStatus;
} catch (error) {
this.logger.warn(error);
throw error;
}
}

show() {
Expand Down
107 changes: 99 additions & 8 deletions src/three-domain-secure/component.test.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
/* @flow */
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable no-restricted-globals, promise/no-native, compat/compat */
import { describe, expect, vi } from "vitest";

import { ThreeDomainSecureComponent } from "./component";

const defaultSdkConfig = {
sdkToken: "sdk-client-token",
authenticationToken: "sdk-client-token",
};

const defaultEligibilityResponse = {
status: "PAYER_ACTION_REQUIRED",
links: [{ href: "https://testurl.com", rel: "payer-action" }],
};

const defaultMerchantPayload = {
amount: "1.00",
currency: "USD",
nonce: "test-nonce",
};

const mockEligibilityRequest = (body = defaultEligibilityResponse) => {
return vi.fn().mockResolvedValue(body);
};

const createThreeDomainSecureComponent = ({
sdkConfig = defaultSdkConfig,
request = mockEligibilityRequest(),
logger = {
info: vi.fn().mockReturnThis(),
warn: vi.fn().mockReturnThis(),
Expand All @@ -18,8 +36,11 @@ const createThreeDomainSecureComponent = ({
},
} = {}) =>
new ThreeDomainSecureComponent({
// $FlowFixMe
sdkConfig,
// $FlowIssue
request,
// $FlowIssue
logger,
});

Expand All @@ -28,17 +49,87 @@ afterEach(() => {
});

describe("three domain secure component - isEligible method", () => {
test("should return false", async () => {
const threeDomainSecuretClient = createThreeDomainSecureComponent();
const eligibility = await threeDomainSecuretClient.isEligible();
test("should return true if payer action required", async () => {
const threeDomainSecureClient = createThreeDomainSecureComponent();
const eligibility = await threeDomainSecureClient.isEligible(
defaultMerchantPayload
);
expect(eligibility).toEqual(true);
});

test("should return false if payer action is not returned", async () => {
const threeDomainSecureClient = createThreeDomainSecureComponent({
request: () =>
Promise.resolve({ ...defaultEligibilityResponse, status: "SUCCESS" }),
});
const eligibility = await threeDomainSecureClient.isEligible(
defaultMerchantPayload
);
expect(eligibility).toEqual(false);
});

test("should assign correct URL to authenticationURL", async () => {
const threeDomainSecureClient = createThreeDomainSecureComponent({
request: () =>
Promise.resolve({
...defaultEligibilityResponse,
links: [
{ href: "https://not-payer-action.com", rel: "not-payer-action" },
...defaultEligibilityResponse.links,
],
}),
});
await threeDomainSecureClient.isEligible(defaultMerchantPayload);
expect(threeDomainSecureClient.authenticationURL).toEqual(
"https://testurl.com"
);
});

test("create payload with correctly parameters", async () => {
const mockedRequest = mockEligibilityRequest();
const threeDomainSecureClient = createThreeDomainSecureComponent({
request: mockedRequest,
});

await threeDomainSecureClient.isEligible(defaultMerchantPayload);

expect(mockedRequest).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
intent: "THREE_DS_VERIFICATION",
payment_source: expect.objectContaining({
card: expect.objectContaining({
single_use_token: defaultMerchantPayload.nonce,
verification_method: "SCA_WHEN_REQUIRED",
}),
}),
amount: expect.objectContaining({
currency_code: defaultMerchantPayload.currency,
value: defaultMerchantPayload.amount,
}),
}),
})
);
});

test("catch errors from the API", async () => {
const mockRequest = vi.fn().mockRejectedValue(new Error("Error with API"));
const threeDomainSecureClient = createThreeDomainSecureComponent({
request: mockRequest,
});

expect.assertions(2);
await expect(() =>
threeDomainSecureClient.isEligible(defaultMerchantPayload)
).rejects.toThrow(new Error("Error with API"));
expect(mockRequest).toHaveBeenCalled();
});
});

describe("three domain descure component - show method", () => {
test.skip("should return a zoid component", () => {
const threeDomainSecuretClient = createThreeDomainSecureComponent();
threeDomainSecuretClient.show();
test.todo("should return a zoid component", () => {
const threeDomainSecureClient = createThreeDomainSecureComponent();
threeDomainSecureClient.show();
// create test for zoid component
});
});
Expand All @@ -49,7 +140,7 @@ describe("three domain secure component - initialization", () => {
createThreeDomainSecureComponent({
sdkConfig: {
...defaultSdkConfig,
sdkToken: "",
authenticationToken: "",
},
})
).toThrowError(
Expand Down
17 changes: 12 additions & 5 deletions src/three-domain-secure/interface.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/* @flow */
import { getLogger, getSDKToken } from "@paypal/sdk-client/src";
import {
getLogger,
getPayPalAPIDomain,
getUserIDToken,
} from "@paypal/sdk-client/src";

import { callRestAPI, devEnvOnlyExport } from "../lib";
import type { LazyExport } from "../types";
import { protectedExport } from "../lib";

import {
ThreeDomainSecureComponent,
Expand All @@ -14,12 +18,15 @@ export const ThreeDomainSecureClient: LazyExport<ThreeDomainSecureComponentInter
__get__: () => {
const threeDomainSecureInstance = new ThreeDomainSecureComponent({
logger: getLogger(),
// $FlowIssue ZalgoPromise vs Promise
request: callRestAPI,
sdkConfig: {
sdkToken: getSDKToken(),
authenticationToken: getUserIDToken(),
paypalApiDomain: getPayPalAPIDomain(),
},
});
return protectedExport({
isEligible: () => threeDomainSecureInstance.isEligible(),
return devEnvOnlyExport({
isEligible: (payload) => threeDomainSecureInstance.isEligible(payload),
show: () => threeDomainSecureInstance.show(),
});
},
Expand Down

0 comments on commit f0b5eeb

Please sign in to comment.