diff --git a/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.js b/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.js new file mode 100644 index 000000000..e242a262a --- /dev/null +++ b/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.js @@ -0,0 +1,52 @@ +const { response } = require('@utils/response') +const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for') +const paths = require('@root/paths') +const { body, validationResult } = require('express-validator') +const formatValidationErrors = require('@utils/simplified-account/format/format-validation-errors') + +function get (req, res) { + return response(req, res, 'simplified-account/settings/worldpay-details/credentials', { + backLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.index, + req.service.externalId, req.account.type) + }) +} + +const worldpayCredentialsValidations = [ + body('merchantCode').not().isEmpty().withMessage('Enter your merchant code').bail() + .custom((value, { req }) => { + if (req.account.allowMoto && !value.endsWith('MOTO') && !value.endsWith('MOTOGBP')) { + throw new Error('Enter a MOTO merchant code. MOTO payments are enabled for the account') + } + return true + }), + body('username').not().isEmpty().withMessage('Enter your username'), + body('password').not().isEmpty().withMessage('Enter your password') +] + +async function post (req, res) { + await Promise.all(worldpayCredentialsValidations.map(validation => validation.run(req))) + const validationErrors = validationResult(req) + if (!validationErrors.isEmpty()) { + const formattedErrors = formatValidationErrors(validationErrors) + return errorResponse(req, res, { + summary: formattedErrors.errorSummary, + formErrors: formattedErrors.formErrors + }) + } +} + +const errorResponse = (req, res, errors) => { + return response(req, res, 'simplified-account/settings/worldpay-details/credentials', { + errors, + merchantCode: req.body.merchantCode, + username: req.body.username, + password: req.body.password, + backLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.index, + req.service.externalId, req.account.type) + }) +} + +module.exports = { + get, + post +} diff --git a/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.test.js b/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.test.js new file mode 100644 index 000000000..4e059399c --- /dev/null +++ b/app/controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller.test.js @@ -0,0 +1,127 @@ +const ControllerTestBuilder = require('@test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class') +const Service = require('@models/Service.class') +const GatewayAccount = require('@models/GatewayAccount.class') +const sinon = require('sinon') +const { expect } = require('chai') +const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for') +const paths = require('@root/paths') + +const ACCOUNT_TYPE = 'live' +const SERVICE_ID = 'service-id-123abc' + +const mockResponse = sinon.spy() + +const { req, res, nextRequest, call } = new ControllerTestBuilder('@controllers/simplified-account/settings/worldpay-details/credentials/worldpay-credentials.controller') + .withService(new Service({ + external_id: SERVICE_ID + })) + .withAccount(new GatewayAccount({ + type: ACCOUNT_TYPE, + allow_moto: true, + gateway_account_id: 1, + gateway_account_credentials: [{ + external_id: 'creds-id', + payment_provider: 'worldpay', + state: 'CREATED', + created_date: '2024-11-29T11:58:36.214Z', + gateway_account_id: 1, + credentials: {} + }] + })) + .withStubs({ + '@utils/response': { response: mockResponse } + }) + .build() + +describe('Controller: settings/worldpay-details/credentials', () => { + describe('get', () => { + before(() => { + call('get') + }) + + it('should call the response method', () => { + expect(mockResponse.called).to.be.true // eslint-disable-line + }) + + it('should pass req, res and template path to the response method', () => { + expect(mockResponse.args[0][0]).to.deep.equal(req) + expect(mockResponse.args[0][1]).to.deep.equal(res) + expect(mockResponse.args[0][2]).to.equal('simplified-account/settings/worldpay-details/credentials') + }) + + it('should pass context data to the response method', () => { + expect(mockResponse.args[0][3]).to.have.property('backLink').to.equal( + formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.index, SERVICE_ID, ACCOUNT_TYPE) + ) + }) + }) + + describe('post', () => { + describe('for MOTO gateway accounts', () => { + describe('when submitting invalid data', () => { + it('should render the form with validation errors when input fields are missing', async () => { + nextRequest({ + body: { + merchantCode: '', + username: '', + password: '' + } + }) + await call('post') + + expect(mockResponse).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + 'simplified-account/settings/worldpay-details/credentials', + { + errors: { + summary: [ + { text: 'Enter your username', href: '#username' }, + { text: 'Enter your password', href: '#password' }, + { text: 'Enter your merchant code', href: '#merchant-code' } + ], + formErrors: { + username: 'Enter your username', + password: 'Enter your password', // pragma: allowlist secret + merchantCode: 'Enter your merchant code' + } + }, + merchantCode: '', + username: '', + password: '', + backLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.index, SERVICE_ID, ACCOUNT_TYPE) + }) + }) + it('should render the form with MOTO validation error when merchant code is invalid', async () => { + nextRequest({ + body: { + merchantCode: 'invalid-merchant-code', + username: 'username', + password: 'password' // pragma: allowlist secret + } + }) + await call('post') + + expect(mockResponse).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + 'simplified-account/settings/worldpay-details/credentials', + { + errors: { + summary: [ + { text: 'Enter a MOTO merchant code. MOTO payments are enabled for the account', href: '#merchant-code' } + ], + formErrors: { + merchantCode: 'Enter a MOTO merchant code. MOTO payments are enabled for the account' + } + }, + merchantCode: 'invalid-merchant-code', + username: 'username', + password: 'password', // pragma: allowlist secret + backLink: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.index, SERVICE_ID, ACCOUNT_TYPE) + }) + }) + }) + }) + }) +}) diff --git a/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.js b/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.js index aae4f708f..561c29898 100644 --- a/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.js +++ b/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.js @@ -2,7 +2,7 @@ const { response } = require('@utils/response') const { WorldpayTasks } = require('@models/WorldpayTasks.class') function get (req, res) { - const worldpayTasks = new WorldpayTasks(req.account) + const worldpayTasks = new WorldpayTasks(req.account, req.service) const context = { tasks: worldpayTasks.tasks, @@ -12,3 +12,4 @@ function get (req, res) { } module.exports.get = get +module.exports.worldpayCredentials = require('./credentials/worldpay-credentials.controller') diff --git a/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.test.js b/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.test.js index 6c673c735..1fee34c96 100644 --- a/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.test.js +++ b/app/controllers/simplified-account/settings/worldpay-details/worldpay-details.controller.test.js @@ -3,6 +3,8 @@ const sinon = require('sinon') const { expect } = require('chai') const Service = require('@models/Service.class') const GatewayAccount = require('@models/GatewayAccount.class') +const formatSimplifiedAccountPathsFor = require('@utils/simplified-account/format/format-simplified-account-paths-for') +const paths = require('@root/paths') const mockResponse = sinon.spy() @@ -13,7 +15,6 @@ const { req, res, call } = new ControllerTestBuilder('@controllers/simplified-ac .withService(new Service({ external_id: SERVICE_ID })) - .withAccountType(ACCOUNT_TYPE) .withAccount(new GatewayAccount({ type: ACCOUNT_TYPE, allow_moto: true, @@ -50,7 +51,8 @@ describe('Controller: settings/worldpay-details', () => { it('should pass context data to the response method', () => { const tasks = [{ - href: '#', + href: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.credentials, + SERVICE_ID, ACCOUNT_TYPE), id: 'worldpay-credentials', linkText: 'Link your Worldpay account with GOV.UK Pay', complete: false diff --git a/app/middleware/simplified-account/settings/default-view-decider.middleware.test.js b/app/middleware/simplified-account/settings/default-view-decider.middleware.test.js index 1b4f808c5..82bdcdbb1 100644 --- a/app/middleware/simplified-account/settings/default-view-decider.middleware.test.js +++ b/app/middleware/simplified-account/settings/default-view-decider.middleware.test.js @@ -95,8 +95,8 @@ describe('Middleware: defaultViewDecider', () => { }) }) -const assertEmailNotificationsControllerIsSelected = (accountType) => { - const actual = call() +const assertEmailNotificationsControllerIsSelected = async (accountType) => { + const actual = await call() const expectedUrl = formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.emailNotifications.index, SERVICE_ID, accountType) const expectedController = require('@controllers/simplified-account/settings/email-notifications/email-notifications.controller') expect(actual.req.url).to.equal(expectedUrl) @@ -104,8 +104,8 @@ const assertEmailNotificationsControllerIsSelected = (accountType) => { sinon.assert.called(next) } -const assertServiceNameControllerIsSelected = (accountType) => { - const actual = call() +const assertServiceNameControllerIsSelected = async (accountType) => { + const actual = await call() const expectedUrl = formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.serviceName.index, SERVICE_ID, accountType) const expectedController = require('@controllers/simplified-account/settings/service-name/service-name.controller') expect(actual.req.url).to.equal(expectedUrl) diff --git a/app/models/WorldpayTasks.class.js b/app/models/WorldpayTasks.class.js index 26493ccfb..02dd82b46 100644 --- a/app/models/WorldpayTasks.class.js +++ b/app/models/WorldpayTasks.class.js @@ -1,10 +1,13 @@ 'use strict' +const formatSimplifiedAccountPathsFor = require('../utils/simplified-account/format/format-simplified-account-paths-for') +const paths = require('@root/paths') + class WorldpayTasks { /** * @param {GatewayAccount} gatewayAccount */ - constructor (gatewayAccount) { + constructor (gatewayAccount, service) { this.tasks = [] this.incompleteTasks = true @@ -12,7 +15,8 @@ class WorldpayTasks { if (gatewayAccount.allowMoto) { const worldpayCredentials = { - href: '#', + href: formatSimplifiedAccountPathsFor(paths.simplifiedAccount.settings.worldpayDetails.credentials, + service.externalId, gatewayAccount.type), id: 'worldpay-credentials', linkText: 'Link your Worldpay account with GOV.UK Pay', complete: true diff --git a/app/paths.js b/app/paths.js index 2c5ba47c7..acc64cecd 100644 --- a/app/paths.js +++ b/app/paths.js @@ -214,7 +214,8 @@ module.exports = { } }, worldpayDetails: { - index: '/settings/worldpay-details' + index: '/settings/worldpay-details', + credentials: '/settings/worldpay-details/credentials' }, cardPayments: { index: '/settings/card-payments' diff --git a/app/simplified-account-routes.js b/app/simplified-account-routes.js index 2826740d0..246980a47 100644 --- a/app/simplified-account-routes.js +++ b/app/simplified-account-routes.js @@ -64,6 +64,11 @@ simplifiedAccount.get(paths.simplifiedAccount.settings.cardTypes.index, permissi // worldpay details simplifiedAccount.get(paths.simplifiedAccount.settings.worldpayDetails.index, permission('gateway-credentials:read'), serviceSettingsController.worldpayDetails.get) +// worldpay details +simplifiedAccount.get(paths.simplifiedAccount.settings.worldpayDetails.index, permission('gateway-credentials:read'), serviceSettingsController.worldpayDetails.get) +simplifiedAccount.get(paths.simplifiedAccount.settings.worldpayDetails.credentials, permission('gateway-credentials:update'), serviceSettingsController.worldpayDetails.worldpayCredentials.get) +simplifiedAccount.post(paths.simplifiedAccount.settings.worldpayDetails.credentials, permission('gateway-credentials:update'), serviceSettingsController.worldpayDetails.worldpayCredentials.post) + // stripe details const stripeDetailsPath = paths.simplifiedAccount.settings.stripeDetails const stripeDetailsRouter = new Router({ mergeParams: true }) diff --git a/app/views/simplified-account/settings/worldpay-details/credentials.njk b/app/views/simplified-account/settings/worldpay-details/credentials.njk new file mode 100644 index 000000000..3e561245e --- /dev/null +++ b/app/views/simplified-account/settings/worldpay-details/credentials.njk @@ -0,0 +1,88 @@ +{% extends "../settings-layout.njk" %} + +{% block settingsPageTitle %} + Worldpay details +{% endblock %} + +{% block settingsContent %} + + {{ govukBackLink({ + text: "Back", + href: backLink + }) }} + + {% if errors %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errors.summary + }) }} + {% endif %} + +

Your Worldpay credentials

+ +

+ Go to your Worldpay account + to get the details you need to enter here or + read more in our documentation. +

+ +
+ + + {{ govukInput({ + label: { + text: 'Merchant code' + }, + id: 'merchantCode', + name: 'merchantCode', + classes: 'govuk-input--width-20', + type: 'text', + value: merchantCode, + errorMessage: errors.formErrors.merchantCode and { + text: errors.formErrors.merchantCode + } + }) + }} + + {{ govukInput({ + label: { + text: 'Username' + }, + id: 'username', + name: 'username', + classes: 'govuk-input--width-20', + type: 'text', + value: username, + errorMessage: errors.formErrors.username and { + text: errors.formErrors.username + }, + autocomplete: 'off' + }) + }} + + {{ govukInput({ + label: { + text: 'Password' + }, + id: 'password', + name: 'password', + classes: "govuk-input--width-20", + type: 'password', + value: password, + errorMessage: errors.formErrors.password and { + text: errors.formErrors.password + }, + autocomplete: 'off' + }) + }} + + {{ + govukButton({ + text: 'Save credentials', + attributes: { + id: 'submitCredentials' + } + }) + }} +
+{% endblock %} diff --git a/test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class.js b/test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class.js index 821e170f9..b6750aff3 100644 --- a/test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class.js +++ b/test/test-helpers/simplified-account/controllers/ControllerTestBuilder.class.js @@ -70,7 +70,7 @@ module.exports = class ControllerTestBuilder { nextRequest: this.nextRequest.bind(this), nextResponse: this.nextResponse.bind(this), nextStubs: this.nextStubs.bind(this), - call: (method, index) => { + call: async (method, index) => { sinon.resetHistory() // ensure fresh mock data for each call if (this.nextStubsData) { Object.assign(this.stubs, this.nextStubsData) // copy by ref @@ -88,7 +88,7 @@ module.exports = class ControllerTestBuilder { if (typeof fn !== 'function') { throw new Error(`No function found for method '${method}'${index !== undefined ? ` at index ${index}` : ''}`) } - const result = fn(this.nextReq || this.req, this.nextRes || this.res, this.next) + const result = await fn(this.nextReq || this.req, this.nextRes || this.res, this.next) const currentReq = this.nextReq || this.req const currentRes = this.nextRes || this.res this.nextReq = this.nextRes = null