From c8ac97e38bc4de188aae128d6670d12993824c0c Mon Sep 17 00:00:00 2001 From: Stephen Daly Date: Fri, 25 Aug 2023 15:49:58 +0100 Subject: [PATCH] PP-11363 Use correct Apple Pay certificate for payment provider Determine which certificate environment variables to use (stripe/worldpay) by looking at the payment provider for the charge. Convert tests to unit tests using sinon/proxyquire to make them easier to write/debug. Add in additional logging for the expiry date for the Stripe certificate making sure not to break the Splunk alert for when the certificate is close to expiring. The Splunk alert will fire for both Worldpay/Stripe --- .env.example | 3 +- .secrets.baseline | 13 +- .../browsered/web-payments/apple-pay.js | 5 +- .../merchant-validation.controller.js | 50 +++-- app/views/includes/scripts.njk | 3 +- server.js | 22 +- .../merchant-validation.controller.test.js | 194 ++++++++++++------ test/cypress/test.env | 2 + test/test.env | 3 +- 9 files changed, 214 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index 2616367e9..c7d9e34f3 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,8 @@ SECURE_COOKIE_OFF=false COOKIE_MAX_AGE=5400000 SESSION_ENCRYPTION_KEY=naskjwefvwei72rjkwfmjwfi72rfkjwefmjwefiuwefjkbwfiu24fmjbwfk CSRF_USER_SECRET=123456789012345678 -APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.test +WORLDPAY_APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.test +STRIPE_APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.stripe.test APPLE_PAY_MERCHANT_DOMAIN=www.pymnt.uk GOOGLE_PAY_GATEWAY_MERCHANT_ID=exampleGatewayMerchantId GOOGLE_PAY_MERCHANT_ID=01234567890123456789 diff --git a/.secrets.baseline b/.secrets.baseline index 37ff6a3ae..0c3ff4f27 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -118,7 +118,7 @@ "filename": "app/controllers/web-payments/apple-pay/merchant-validation.controller.js", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 13, + "line_number": 14, "is_secret": false } ], @@ -132,6 +132,15 @@ "is_secret": false } ], + "test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js": [ + { + "type": "Private Key", + "filename": "test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js", + "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_verified": false, + "line_number": 68 + } + ], "test/controllers/web-payments/apple-pay/normalise-apple-pay-payload.test.js": [ { "type": "Base64 High Entropy String", @@ -409,5 +418,5 @@ } ] }, - "generated_at": "2023-08-23T10:41:36Z" + "generated_at": "2023-08-25T15:23:05Z" } diff --git a/app/assets/javascripts/browsered/web-payments/apple-pay.js b/app/assets/javascripts/browsered/web-payments/apple-pay.js index 50c2935f8..6be3db7bd 100644 --- a/app/assets/javascripts/browsered/web-payments/apple-pay.js +++ b/app/assets/javascripts/browsered/web-payments/apple-pay.js @@ -15,7 +15,10 @@ module.exports = () => { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }) + body: JSON.stringify({ + url, + paymentProvider: window.Charge.payment_provider + }) }).then(response => { if (response.status >= 200 && response.status < 300) { return response.json().then(data => { diff --git a/app/controllers/web-payments/apple-pay/merchant-validation.controller.js b/app/controllers/web-payments/apple-pay/merchant-validation.controller.js index 22807544d..9d8bee230 100644 --- a/app/controllers/web-payments/apple-pay/merchant-validation.controller.js +++ b/app/controllers/web-payments/apple-pay/merchant-validation.controller.js @@ -4,15 +4,36 @@ const request = require('requestretry') const logger = require('../../../utils/logger')(__filename) const { getLoggingFields } = require('../../../utils/logging-fields-helper') -// Local constants -const { APPLE_PAY_MERCHANT_ID, APPLE_PAY_MERCHANT_DOMAIN, APPLE_PAY_MERCHANT_ID_CERTIFICATE, APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY } = process.env - -const APPLE_PAY_MERCHANT_ID_CERTIFICATE_MULTILINE = `-----BEGIN CERTIFICATE----- -${APPLE_PAY_MERCHANT_ID_CERTIFICATE} +function getCertificateMultiline (cert) { + return `-----BEGIN CERTIFICATE----- +${cert} -----END CERTIFICATE-----` -const APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY_MULTILINE = `-----BEGIN PRIVATE KEY----- -${APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY} +} + +function getPrivateKeyMultiline (key) { + return `-----BEGIN PRIVATE KEY----- +${key} -----END PRIVATE KEY-----` +} + +function getApplePayMerchantIdentityVariables (paymentProvider) { + if (paymentProvider === 'worldpay') { + return { + merchantIdentifier: process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID, + cert: getCertificateMultiline(process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE), + key: getPrivateKeyMultiline(process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY) + } + } else if (paymentProvider === 'stripe') { + return { + merchantIdentifier: process.env.STRIPE_APPLE_PAY_MERCHANT_ID, + cert: getCertificateMultiline(process.env.STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE), + key: getPrivateKeyMultiline(process.env.STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY) + } + } else { + logger.error(`Unexpected payment provider [${paymentProvider}] when getting Merchant Identity variables for Apple Pay`) + return false + } +} // When an Apple payment is initiated in Safari, it must check that the request // is coming from a registered and authorised Apple Merchant Account. The @@ -21,17 +42,22 @@ module.exports = (req, res) => { if (!req.body.url) { return res.sendStatus(400) } + const { url, paymentProvider } = req.body + const merchantIdentityVars = getApplePayMerchantIdentityVariables(paymentProvider) + if (!merchantIdentityVars) { + return res.sendStatus(400) + } const options = { - url: req.body.url, - cert: APPLE_PAY_MERCHANT_ID_CERTIFICATE_MULTILINE, - key: APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY_MULTILINE, + url: url, + cert: merchantIdentityVars.cert, + key: merchantIdentityVars.key, method: 'post', body: { - merchantIdentifier: APPLE_PAY_MERCHANT_ID, + merchantIdentifier: merchantIdentityVars.merchantIdentifier, displayName: 'GOV.UK Pay', initiative: 'web', - initiativeContext: APPLE_PAY_MERCHANT_DOMAIN + initiativeContext: process.env.APPLE_PAY_MERCHANT_DOMAIN }, json: true } diff --git a/app/views/includes/scripts.njk b/app/views/includes/scripts.njk index edace7e67..2729c13ad 100644 --- a/app/views/includes/scripts.njk +++ b/app/views/includes/scripts.njk @@ -32,7 +32,8 @@ collect_billing_address: {{ "true" if collectBillingAddress else "false" }}, collect_additional_browser_data_for_epdq_3ds: {{"true" if collectAdditionalBrowserDataForEpdq3ds else "false" }}, worldpay_3ds_flex_ddc_jwt: '{{ worldpay3dsFlexDdcJwt }}', - worldpay_3ds_flex_ddc_url: '{{ worldpay3dsFlexDdcUrl }}' + worldpay_3ds_flex_ddc_url: '{{ worldpay3dsFlexDdcUrl }}', + payment_provider: '{{ paymentProvider }}' } var mainWrap = document.getElementsByTagName('main')[0] diff --git a/server.js b/server.js index a099e774a..187d1585c 100644 --- a/server.js +++ b/server.js @@ -27,7 +27,14 @@ const correlationHeader = require('./app/middleware/correlation-header') const errorHandlers = require('./app/middleware/error-handlers') // Global constants -const { NODE_ENV, PORT, ANALYTICS_TRACKING_ID, GOOGLE_PAY_MERCHANT_ID, APPLE_PAY_MERCHANT_ID_CERTIFICATE } = process.env +const { + NODE_ENV, + PORT, + ANALYTICS_TRACKING_ID, + GOOGLE_PAY_MERCHANT_ID, + WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE, + STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE +} = process.env const CSS_PATH = '/stylesheets/application.min.css' const JAVASCRIPT_PATH = '/javascripts/application.min.js' const argv = require('minimist')(process.argv.slice(2)) @@ -155,10 +162,17 @@ function listen () { } function logApplePayCertificateTimeToExpiry () { - if (APPLE_PAY_MERCHANT_ID_CERTIFICATE !== undefined) { - const merchantIdCert = certinfo.info(APPLE_PAY_MERCHANT_ID_CERTIFICATE) + if (WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE !== undefined) { + const merchantIdCert = certinfo.info(WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE) const certificateTimeToExpiry = Math.floor((merchantIdCert.expiresAt - Date.now()) / 1000 / 60 / 60 / 24) - logger.info(`The Apple Pay Merchant identity cert will expire in ${certificateTimeToExpiry} days`) + // used by Splunk alert + logger.info(`The Apple Pay Merchant identity cert will expire in ${certificateTimeToExpiry} days for Worldpay`) + } + if (STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE !== undefined) { + const merchantIdCert = certinfo.info(STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE) + const certificateTimeToExpiry = Math.floor((merchantIdCert.expiresAt - Date.now()) / 1000 / 60 / 60 / 24) + // used by Splunk alert + logger.info(`The Apple Pay Merchant identity cert will expire in ${certificateTimeToExpiry} days for Stripe`) } } diff --git a/test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js b/test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js index 033c15bc7..2f33c818f 100644 --- a/test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js +++ b/test/controllers/web-payments/apple-pay/merchant-validation.controller.test.js @@ -1,74 +1,150 @@ 'use strict' -// NPM dependencies -const { expect } = require('chai') -const request = require('supertest') -const nock = require('nock') +const sinon = require('sinon') +const proxyquire = require('proxyquire') -// Local constants -const { APPLE_PAY_MERCHANT_ID, APPLE_PAY_MERCHANT_DOMAIN } = process.env +const merchantDomain = 'www.pymnt.uk' +const worldpayMerchantId = 'worldpay.merchant.id' +const worldpayCertificate = 'A-WORLDPAY-CERTIFICATE' +const worldpayKey = 'A-WORLDPAY-KEY' +const stripeMerchantId = 'stripe.merchant.id' +const stripeCertificate = 'A-STRIPE-CERTIFICATE' +const stripeKey = 'A-STRIPE-KEY' +const url = 'https://fakeapple.url' -// Local dependencies -const { getApp } = require('../../../../server') -const paths = require('../../../../app/paths') -require('../../../test-helpers/html-assertions') +const appleResponse = { status: 200 } +const appleResponseBody = { foo: 'bar' } + +function getControllerWithMocks (requestMock) { + return proxyquire('../../../../app/controllers/web-payments/apple-pay/merchant-validation.controller', { + requestretry: requestMock + }) +} describe('Validate with Apple the merchant is legitimate', () => { - it('should return a payload if Merchant is valid', done => { - const url = 'https://fakeapple.url' - const body = { - merchantIdentifier: APPLE_PAY_MERCHANT_ID, - displayName: 'GOV.UK Pay', - initiative: 'web', - initiativeContext: APPLE_PAY_MERCHANT_DOMAIN + let res, sendSpy + + beforeEach(() => { + process.env.APPLE_PAY_MERCHANT_DOMAIN = merchantDomain + process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID = worldpayMerchantId + process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE = worldpayCertificate + process.env.WORLDPAY_APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY = worldpayKey + process.env.STRIPE_APPLE_PAY_MERCHANT_ID = stripeMerchantId + process.env.STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE = stripeCertificate + process.env.STRIPE_APPLE_PAY_MERCHANT_ID_CERTIFICATE_KEY = stripeKey + + sendSpy = sinon.spy() + res = { + status: sinon.spy(() => ({ send: sendSpy })), + sendStatus: sinon.spy() + } + }) + + it('should return a payload for a Worldpay payment if Merchant is valid', async () => { + const mockRequest = sinon.stub().yields(null, appleResponse, appleResponseBody) + const controller = getControllerWithMocks(mockRequest) + + const req = { + body: { + url, + paymentProvider: 'worldpay' + } + } + await controller(req, res) + + sinon.assert.calledWith(mockRequest, { + url, + body: { + merchantIdentifier: worldpayMerchantId, + displayName: 'GOV.UK Pay', + initiative: 'web', + initiativeContext: merchantDomain + }, + method: 'post', + json: true, + cert: `-----BEGIN CERTIFICATE----- +${worldpayCertificate} +-----END CERTIFICATE-----`, + key: `-----BEGIN PRIVATE KEY----- +${worldpayKey} +-----END PRIVATE KEY-----` + }) + sinon.assert.calledWith(res.status, 200) + sinon.assert.calledWith(sendSpy, appleResponseBody) + }) + + it('should return a payload for a Stripe payment if Merchant is valid', async () => { + const mockRequest = sinon.stub().yields(null, appleResponse, appleResponseBody) + const controller = getControllerWithMocks(mockRequest) + + const req = { + body: { + url, + paymentProvider: 'stripe' + } } - const response = { encryptedThing: 'cryptoMagic' } - - nock(url) - .post('/', body) - .reply(200, response) - - request(getApp()) - .post(paths.applePay.session.path) - .set('Accept', 'application/json') - .send({ - url - }) - .expect(200) - .expect(res => { - expect(res.body).to.deep.equal(response) - }) - .end(done) + await controller(req, res) + + sinon.assert.calledWith(mockRequest, { + url, + body: { + merchantIdentifier: stripeMerchantId, + displayName: 'GOV.UK Pay', + initiative: 'web', + initiativeContext: merchantDomain + }, + method: 'post', + json: true, + cert: `-----BEGIN CERTIFICATE----- +${stripeCertificate} +-----END CERTIFICATE-----`, + key: `-----BEGIN PRIVATE KEY----- +${stripeKey} +-----END PRIVATE KEY-----` + }) + sinon.assert.calledWith(res.status, 200) + sinon.assert.calledWith(sendSpy, appleResponseBody) }) - it('should return 400 if no url is provided', done => { - request(getApp()) - .post(paths.applePay.session.path) - .set('Accept', 'application/json') - .expect(400) - .end(done) + it('should return 400 if no url is provided', async () => { + const mockRequest = sinon.stub().yields(null, appleResponse, appleResponseBody) + const controller = getControllerWithMocks(mockRequest) + + const req = { + body: { + paymentProvider: 'worldpay' + } + } + await controller(req, res) + sinon.assert.calledWith(res.sendStatus, 400) }) - it('should return an error if Merchant is invalid, the merchant details or crypto stuff', done => { - const url = 'https://fakeapple.url' - const body = { - merchantIdentifier: APPLE_PAY_MERCHANT_ID, - displayName: 'GOV.UK Pay', - initiative: 'web', - initiativeContext: APPLE_PAY_MERCHANT_DOMAIN + it('should return 400 for unexpected payment provider', async () => { + const mockRequest = sinon.stub().yields(null, appleResponse, appleResponseBody) + const controller = getControllerWithMocks(mockRequest) + + const req = { + body: { + url, + paymentProvider: 'sandbox' + } } + await controller(req, res) + sinon.assert.calledWith(res.sendStatus, 400) + }) - nock(url) - .post('/', body) - .replyWithError('nope') - - request(getApp()) - .post(paths.applePay.session.path) - .set('Accept', 'application/json') - .send({ - url - }) - .expect(500) - .end(done) + it('should return an error if Apple Pay returns an error', async () => { + const mockRequest = sinon.stub().yields(new Error(), appleResponse, appleResponseBody) + const controller = getControllerWithMocks(mockRequest) + + const req = { + body: { + url, + paymentProvider: 'worldpay' + } + } + await controller(req, res) + sinon.assert.calledWith(res.status, 500) + sinon.assert.calledWith(sendSpy, appleResponseBody) }) }) diff --git a/test/cypress/test.env b/test/cypress/test.env index 45e00b1d3..e609e7642 100644 --- a/test/cypress/test.env +++ b/test/cypress/test.env @@ -9,3 +9,5 @@ NODE_ENV=test NODE_WORKER_COUNT=1 APPLE_PAY_STUBS_URL=http://127.0.0.1:8000 WORLDPAY_3DS_FLEX_DDC_TEST_URL=http://127.0.0.1:8000/shopper/3ds/ddc.html +WORLDPAY_APPLE_PAY_MERCHANT_ID=worldpay.merchant.id +STRIPE_APPLE_PAY_MERCHANT_ID=stripe.merchant.id \ No newline at end of file diff --git a/test/test.env b/test/test.env index ddd150ab4..a43c288e8 100644 --- a/test/test.env +++ b/test/test.env @@ -5,7 +5,8 @@ COOKIE_MAX_AGE=5400000 SESSION_ENCRYPTION_KEY=naskjwefvwei72rjkwfmjwfi72rfkjwefmjwefiuwefjkbwfiu24fmjbwfk CSRF_USER_SECRET=123456789012345678 CARDID_HOST=http://cardid.pymnt.localdomain:65530 -APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.test +WORLDPAY_APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.test +STRIPE_APPLE_PAY_MERCHANT_ID=merchant.uk.gov.service.payments.stripe.test APPLE_PAY_MERCHANT_DOMAIN=www.pymnt.uk GOOGLE_PAY_GATEWAY_MERCHANT_ID=exampleGatewayMerchantId GOOGLE_PAY_MERCHANT_ID=01234567890123456789