Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PP-13313: Validations for Worldpay credentials for a MOTO gateway account #4371

Merged
merged 4 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may as well use the alias for this

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 }) => {
const merchantCode = req.body.merchantCode
if (req.account.allowMoto && !merchantCode.endsWith('MOTO') && !merchantCode.endsWith('MOTOGBP')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the value here should be the merchant code, so you should just be able to use that rather than re-referencing it from req.body

throw new Error('Enter a MOTO merchant code. MOTO payments are enabled for the account')
}
DomBelcher marked this conversation as resolved.
Show resolved Hide resolved
}),
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alias this

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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alias this

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: form.errors.merchantId and {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be errors.formErrors.merchantCode

text: form.errors.merchantId
}
})
}}

{{ govukInput({
label: {
text: 'Username'
},
id: 'username',
name: 'username',
classes: 'govuk-input--width-20',
type: 'text',
value: username,
errorMessage: form.errors.username and {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be errors.formErrors.username

text: form.errors.username
},
autocomplete: 'off'
})
}}

{{ govukInput({
label: {
text: 'Password'
},
id: 'password',
name: 'password',
classes: "govuk-input--width-20",
type: 'password',
value: password,
errorMessage: form.errors.password and {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be errors.formErrors.password

text: form.errors.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
Loading