generated from saleor/saleor-app-payment-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Apple Pay validate merchant implementation
- Loading branch information
Showing
6 changed files
with
256 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
import https from "node:https"; | ||
import { httpsPromisified } from "./httpsPromisify"; | ||
import { | ||
ApplePayHttpError, | ||
ApplePayInvalidMerchantDomainError, | ||
ApplePayMissingCertificateError, | ||
} from "@/errors"; | ||
import { type JSONValue } from "@/types"; | ||
import { unpackPromise } from "@/lib/utils"; | ||
import { createLogger } from "@/lib/logger"; | ||
|
||
/** | ||
* https://developer.apple.com/documentation/apple_pay_on_the_web/setting_up_your_server#3172427 | ||
*/ | ||
const applePayValidateMerchantDomains = [ | ||
// For production environment: | ||
"apple-pay-gateway.apple.com", | ||
"cn-apple-pay-gateway.apple.com", | ||
// Additional domain names and IP addresses: | ||
"apple-pay-gateway-nc-pod1.apple.com", | ||
"apple-pay-gateway-nc-pod2.apple.com", | ||
"apple-pay-gateway-nc-pod3.apple.com", | ||
"apple-pay-gateway-nc-pod4.apple.com", | ||
"apple-pay-gateway-nc-pod5.apple.com", | ||
"apple-pay-gateway-pr-pod1.apple.com", | ||
"apple-pay-gateway-pr-pod2.apple.com", | ||
"apple-pay-gateway-pr-pod3.apple.com", | ||
"apple-pay-gateway-pr-pod4.apple.com", | ||
"apple-pay-gateway-pr-pod5.apple.com", | ||
"cn-apple-pay-gateway-sh-pod1.apple.com", | ||
"cn-apple-pay-gateway-sh-pod2.apple.com", | ||
"cn-apple-pay-gateway-sh-pod3.apple.com", | ||
"cn-apple-pay-gateway-tj-pod1.apple.com", | ||
"cn-apple-pay-gateway-tj-pod2.apple.com", | ||
"cn-apple-pay-gateway-tj-pod3.apple.com", | ||
// For sandbox testing only: | ||
"apple-pay-gateway-cert.apple.com", | ||
"cn-apple-pay-gateway-cert.apple.com", | ||
] as const; | ||
|
||
export const validateMerchant = async ({ | ||
validationURL, | ||
merchantName, | ||
merchantIdentifier, | ||
domain, | ||
applePayCertificate, | ||
}: { | ||
/** Fully qualified validation URL that you receive in `onvalidatemerchant` */ | ||
validationURL: string; | ||
/** A string of 64 or fewer UTF-8 characters containing the canonical name for your store, suitable for display. This needs to remain a consistent value for the store and shouldn’t contain dynamic values such as incrementing order numbers. Don’t localize the name. */ | ||
merchantName: string; | ||
/** Your Apple merchant identifier (`partnerInternalMerchantIdentifier`) as described in https://developer.apple.com/documentation/apple_pay_on_the_web/applepayrequest/2951611-merchantidentifier */ | ||
merchantIdentifier: string; | ||
/** Fully qualified domain name associated with your Apple Pay Merchant Identity Certificate. */ | ||
domain: string; | ||
/** base64 encoded `apple-pay-cert.pem` file */ | ||
applePayCertificate: string; | ||
}) => { | ||
const logger = createLogger({ name: "validateMerchant" }); | ||
|
||
logger.debug("Received validation URL", { validationURL, merchantName, merchantIdentifier }); | ||
|
||
if (!applePayCertificate) { | ||
logger.error("Missing Apple Pay Merchant Identity Certificate"); | ||
throw new ApplePayMissingCertificateError("Missing Apple Pay Merchant Identity Certificate"); | ||
} | ||
|
||
const applePayURL = new URL(validationURL); | ||
const applePayDomain = applePayURL.hostname; | ||
|
||
logger.debug("Validation URL domain", { applePayDomain }); | ||
if (!applePayValidateMerchantDomains.includes(applePayDomain)) { | ||
throw new ApplePayInvalidMerchantDomainError(`Invalid validationURL domain: ${applePayDomain}`); | ||
} | ||
|
||
const requestData = { | ||
merchantIdentifier, | ||
displayName: merchantName, | ||
initiative: "web", | ||
initiativeContext: domain, | ||
}; | ||
|
||
logger.debug("requestData", { | ||
merchantIdentifier, | ||
displayName: merchantName, | ||
initiative: "web", | ||
initiativeContext: domain, | ||
}); | ||
|
||
const cert = Buffer.from(applePayCertificate, "base64"); | ||
|
||
const agent = new https.Agent({ cert, key: cert, requestCert: true, rejectUnauthorized: true }); | ||
|
||
logger.debug("Created authenticated HTTPS agent"); | ||
|
||
const [requestError, requestResult] = await unpackPromise( | ||
httpsPromisified( | ||
validationURL, | ||
{ | ||
method: "POST", | ||
agent, | ||
}, | ||
JSON.stringify(requestData), | ||
), | ||
); | ||
|
||
if (requestError) { | ||
logger.error("Request failed", { requestError: requestError }); | ||
throw requestError; | ||
} | ||
|
||
const { body, statusCode } = requestResult; | ||
|
||
logger.debug("Request done", { statusCode }); | ||
|
||
if (statusCode < 200 || statusCode >= 300) { | ||
logger.error("Got error response from Apple Pay", { statusCode }); | ||
|
||
throw new ApplePayHttpError(body, { | ||
props: { | ||
statusCode, | ||
}, | ||
}); | ||
} | ||
|
||
logger.info("Got successful response from Apple Pay", { statusCode }); | ||
const json = JSON.parse(body) as JSONValue; | ||
|
||
return json; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { describe, it, expect } from "vitest"; | ||
import { httpsPromisified } from "./httpsPromisify"; | ||
import { setupRecording } from "@/__tests__/polly"; | ||
|
||
describe("httpsPromisified", () => { | ||
setupRecording({}); | ||
|
||
const TEST_URL = "https://test-http.local/"; | ||
|
||
it("should make a GET request", async (ctx) => { | ||
ctx.polly?.server.get(TEST_URL).intercept((_req, res) => { | ||
res.json({ | ||
data: {}, | ||
}); | ||
}); | ||
|
||
await expect(httpsPromisified(TEST_URL, {}, "")).resolves.toMatchInlineSnapshot(` | ||
{ | ||
"body": "{\\"data\\":{}}", | ||
"headers": { | ||
"content-type": "application/json; charset=utf-8", | ||
}, | ||
"statusCode": 200, | ||
} | ||
`); | ||
}); | ||
|
||
it("should make a POST request", async (ctx) => { | ||
ctx.polly?.server.post(TEST_URL).intercept((req, res) => { | ||
expect(req.jsonBody()).toEqual({ aaa: 123 }); | ||
res.json({ | ||
data: {}, | ||
}); | ||
}); | ||
|
||
await expect(httpsPromisified(TEST_URL, { method: "POST" }, JSON.stringify({ aaa: 123 }))) | ||
.resolves.toMatchInlineSnapshot(` | ||
{ | ||
"body": "{\\"data\\":{}}", | ||
"headers": { | ||
"content-type": "application/json; charset=utf-8", | ||
}, | ||
"statusCode": 200, | ||
} | ||
`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import https from "node:https"; | ||
import { type IncomingHttpHeaders } from "node:http2"; | ||
import { isNotNullish } from "@/lib/utils"; | ||
import { HttpRequestError } from "@/errors"; | ||
|
||
type HttpsPromisifiedResult = { statusCode: number; headers: IncomingHttpHeaders; body: string }; | ||
|
||
// based on https://gist.github.com/ktheory/df3440b01d4b9d3197180d5254d7fb65 | ||
export const httpsPromisified = (url: string | URL, options: https.RequestOptions, data: string) => | ||
new Promise<HttpsPromisifiedResult>((resolve, reject) => { | ||
try { | ||
const req = https.request(url, options, (res) => { | ||
let body = ""; | ||
|
||
res.on("data", (chunk) => (body += chunk)); | ||
res.on("error", reject); | ||
res.on("end", () => { | ||
const { statusCode, headers } = res; | ||
|
||
// @TODO: how to test failing requests | ||
/* c8 ignore start */ | ||
if (isNotNullish(statusCode) && statusCode !== 0) { | ||
return resolve({ statusCode, headers, body }); | ||
} else { | ||
return reject( | ||
new HttpRequestError("Http Request Error", { props: { statusCode, body, headers } }), | ||
); | ||
/* c8 ignore stop */ | ||
} | ||
}); | ||
}); | ||
|
||
req.on("error", reject); | ||
req.on("timeout", () => { | ||
req.destroy(); | ||
reject("Timeout"); | ||
}); | ||
req.setTimeout(20_000); // Saleor sync webhook timeout is 20s | ||
req.write(data, "utf-8"); | ||
req.end(); | ||
} catch (e) { | ||
reject(e); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters