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 3 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,51 @@
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')
}
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')
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
Loading