Skip to content

Commit

Permalink
PP-13313: Validations for Worldpay credentials for a MOTO gateway acc…
Browse files Browse the repository at this point in the history
…ount (#4371)

* PP-13313: Worldpay details index page

This commit deals with a moto-enabled gateway account that hasn't configured
the one_off_customer_initiated credentials yet.
  • Loading branch information
oswaldquek authored Dec 4, 2024
1 parent f2fb514 commit fbba0ad
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,3 +12,4 @@ function get (req, res) {
}

module.exports.get = get
module.exports.worldpayCredentials = require('./credentials/worldpay-credentials.controller')
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,17 @@ 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)
expect(actual.req.selectedController).to.equal(expectedController.getEmailNotificationsSettingsPage)
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)
Expand Down
8 changes: 6 additions & 2 deletions app/models/WorldpayTasks.class.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
'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

const credential = gatewayAccount.activeCredential

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
Expand Down
3 changes: 2 additions & 1 deletion app/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions app/simplified-account-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}

<h1 class="govuk-heading-l">Your Worldpay credentials</h1>

<p class="govuk-body govuk-!-margin-bottom-6 hint-and-body-width">
Go to your <a class="govuk-link" href="https://secure.worldpay.com/sso/public/auth/login.html">Worldpay account</a>
to get the details you need to enter here or
<a class="govuk-link" href="https://docs.payments.service.gov.uk/switching_to_live/set_up_a_live_worldpay_account/#connect-your-live-account-to-worldpay">read more in our documentation</a>.
</p>

<form id="credentials-form" method="post" novalidate>
<input id="csrf" name="csrfToken" type="hidden" value="{{csrf}}" />

{{ 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'
}
})
}}
</form>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit fbba0ad

Please sign in to comment.