Skip to content

Commit 7f57609

Browse files
authored
Merge pull request #314 from DEFRA/feat/df-820-api-keys
feat/df-820: Reads payment api key from secrets
2 parents f44dc20 + d37a5e1 commit 7f57609

File tree

19 files changed

+324
-145
lines changed

19 files changed

+324
-145
lines changed

jest.setup.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket'
1313
process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789'
1414
process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk'
1515
process.env.ORDNANCE_SURVEY_API_KEY = 'dummy'
16-
process.env.PAYMENT_PROVIDER_API_KEY_TEST_formid = 'test-api-key'
16+
process.env.PAYMENT_PROVIDER_API_KEY_TEST = 'test-api-key'

src/server/plugins/engine/beta/form-context.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ describe('getFormModel helper', () => {
150150
formsService: {
151151
getFormMetadata: jest.fn().mockResolvedValue(metadata),
152152
getFormMetadataById: jest.fn(),
153-
getFormDefinition: jest.fn().mockResolvedValue(definition)
153+
getFormDefinition: jest.fn().mockResolvedValue(definition),
154+
getFormSecret: jest.fn()
154155
},
155156
formSubmissionService: {
156157
persistFiles: jest.fn(),

src/server/plugins/engine/components/PaymentField.test.ts

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type FormMetadata,
44
type PaymentFieldComponent
55
} from '@defra/forms-model'
6+
import { StatusCodes } from 'http-status-codes'
67

78
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
89
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
@@ -14,18 +15,26 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
1415
import { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js'
1516
import {
1617
type FormContext,
17-
type FormValue
18+
type FormValue,
19+
type PaymentExternalArgs
1820
} from '~/src/server/plugins/engine/types.js'
1921
import {
2022
type FormRequestPayload,
2123
type FormResponseToolkit
2224
} from '~/src/server/routes/types.js'
2325
import { get, post, postJson } from '~/src/server/services/httpService.js'
26+
import { type Services } from '~/src/server/types.js'
2427
import definition from '~/test/form/definitions/blank.js'
2528
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
2629

2730
jest.mock('~/src/server/services/httpService.ts')
2831

32+
const mockServices = {
33+
formsService: {
34+
getFormSecret: () => 'secret-value'
35+
}
36+
} as unknown as Services
37+
2938
describe('PaymentField', () => {
3039
let model: FormModel
3140

@@ -250,6 +259,7 @@ describe('PaymentField', () => {
250259

251260
const collection = new ComponentCollection([def], { model })
252261
const paymentField = collection.fields[0] as PaymentField
262+
paymentField.model = { services: mockServices } as unknown as FormModel
253263

254264
describe('dispatcher', () => {
255265
it('should create payment and redirect to gov pay', async () => {
@@ -277,7 +287,8 @@ describe('PaymentField', () => {
277287
model: {
278288
formId: 'formid',
279289
basePath: 'base-path',
280-
name: 'PaymentModel'
290+
name: 'PaymentModel',
291+
services: mockServices
281292
},
282293
getState: jest
283294
.fn()
@@ -287,7 +298,7 @@ describe('PaymentField', () => {
287298
sourceUrl: 'http://localhost:3009/test-payment',
288299
isLive: false,
289300
isPreview: true
290-
}
301+
} as unknown as PaymentExternalArgs
291302
// @ts-expect-error - partial mock
292303
jest.mocked(postJson).mockResolvedValueOnce({
293304
payload: {
@@ -342,7 +353,8 @@ describe('PaymentField', () => {
342353
model: {
343354
formId: 'formid',
344355
basePath: 'base-path',
345-
name: 'PaymentModel'
356+
name: 'PaymentModel',
357+
services: mockServices
346358
},
347359
getState: jest.fn().mockResolvedValueOnce({
348360
$$__referenceNumber: 'pay-ref-123',
@@ -361,7 +373,7 @@ describe('PaymentField', () => {
361373
sourceUrl: 'http://localhost:3009/test-payment',
362374
isLive: false,
363375
isPreview: true
364-
}
376+
} as unknown as PaymentExternalArgs
365377

366378
const res = await PaymentField.dispatcher(mockRequest, mockH, args)
367379

@@ -372,6 +384,128 @@ describe('PaymentField', () => {
372384
expect(mockRedirectCode).toHaveBeenCalledWith(303)
373385
expect(postJson).not.toHaveBeenCalled()
374386
})
387+
388+
it('should display error if create payment fails (e.g. network or bad api key) - test payment', async () => {
389+
const mockYarSet = jest.fn()
390+
const mockYarFlash = jest.fn()
391+
const mockRequest = {
392+
server: {
393+
plugins: {
394+
// eslint-disable-next-line no-useless-computed-key
395+
['forms-engine-plugin']: {
396+
baseUrl: 'base-url'
397+
}
398+
}
399+
},
400+
yar: {
401+
set: mockYarSet,
402+
flash: mockYarFlash
403+
},
404+
url: {
405+
href: '/here'
406+
}
407+
} as unknown as FormRequestPayload
408+
const mockH = {
409+
redirect: jest
410+
.fn()
411+
.mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
412+
} as unknown as FormResponseToolkit
413+
const args = {
414+
controller: {
415+
model: {
416+
formId: 'formid',
417+
basePath: 'base-path',
418+
name: 'PaymentModel',
419+
services: mockServices
420+
},
421+
getState: jest
422+
.fn()
423+
.mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
424+
},
425+
component: paymentField,
426+
sourceUrl: 'http://localhost:3009/test-payment',
427+
isLive: false,
428+
isPreview: true
429+
} as unknown as PaymentExternalArgs
430+
jest.mocked(postJson).mockImplementationOnce(() => {
431+
// eslint-disable-next-line @typescript-eslint/only-throw-error
432+
throw { output: { statusCode: StatusCodes.UNAUTHORIZED } }
433+
})
434+
435+
const res = await PaymentField.dispatcher(mockRequest, mockH, args)
436+
expect(res).toBe('ok')
437+
expect(mockYarSet).not.toHaveBeenCalled()
438+
expect(mockYarFlash).toHaveBeenCalledWith(
439+
'COMPONENT_STATE_ERROR',
440+
{
441+
href: '#myComponent',
442+
name: 'myComponent',
443+
text: 'Add a valid test API key before you can preview the payment journey.'
444+
},
445+
true
446+
)
447+
})
448+
449+
it('should display error if create payment fails (e.g. network or bad api key) - live payment', async () => {
450+
const mockYarSet = jest.fn()
451+
const mockYarFlash = jest.fn()
452+
const mockRequest = {
453+
server: {
454+
plugins: {
455+
// eslint-disable-next-line no-useless-computed-key
456+
['forms-engine-plugin']: {
457+
baseUrl: 'base-url'
458+
}
459+
}
460+
},
461+
yar: {
462+
set: mockYarSet,
463+
flash: mockYarFlash
464+
},
465+
url: {
466+
href: '/here'
467+
}
468+
} as unknown as FormRequestPayload
469+
const mockH = {
470+
redirect: jest
471+
.fn()
472+
.mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
473+
} as unknown as FormResponseToolkit
474+
const args = {
475+
controller: {
476+
model: {
477+
formId: 'formid',
478+
basePath: 'base-path',
479+
name: 'PaymentModel',
480+
services: mockServices
481+
},
482+
getState: jest
483+
.fn()
484+
.mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
485+
},
486+
component: paymentField,
487+
sourceUrl: 'http://localhost:3009/test-payment',
488+
isLive: true,
489+
isPreview: false
490+
} as unknown as PaymentExternalArgs
491+
jest.mocked(postJson).mockImplementationOnce(() => {
492+
// eslint-disable-next-line @typescript-eslint/only-throw-error
493+
throw { output: { statusCode: StatusCodes.UNAUTHORIZED } }
494+
})
495+
496+
const res = await PaymentField.dispatcher(mockRequest, mockH, args)
497+
expect(res).toBe('ok')
498+
expect(mockYarSet).not.toHaveBeenCalled()
499+
expect(mockYarFlash).toHaveBeenCalledWith(
500+
'COMPONENT_STATE_ERROR',
501+
{
502+
href: '#myComponent',
503+
name: 'myComponent',
504+
text: 'There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.'
505+
},
506+
true
507+
)
508+
})
375509
})
376510

377511
describe('onSubmit', () => {

src/server/plugins/engine/components/PaymentField.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,19 @@ import {
77
import { StatusCodes } from 'http-status-codes'
88
import joi, { type ObjectSchema } from 'joi'
99

10+
import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
1011
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
1112
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
12-
import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
13+
import {
14+
createError,
15+
getPluginOptions
16+
} from '~/src/server/plugins/engine/helpers.js'
1317
import {
1418
PaymentErrorTypes,
1519
PaymentPreAuthError,
1620
PaymentSubmissionError
1721
} from '~/src/server/plugins/engine/pageControllers/errors.js'
1822
import {
19-
type AnyFormRequest,
2023
type FormContext,
2124
type FormRequestPayload,
2225
type FormResponseToolkit
@@ -27,7 +30,8 @@ import {
2730
type FormState,
2831
type FormStateValue,
2932
type FormSubmissionError,
30-
type FormSubmissionState
33+
type FormSubmissionState,
34+
type PaymentExternalArgs
3135
} from '~/src/server/plugins/engine/types.js'
3236
import {
3337
createPaymentService,
@@ -186,7 +190,7 @@ export class PaymentField extends FormComponent {
186190
static async dispatcher(
187191
request: FormRequestPayload,
188192
h: FormResponseToolkit,
189-
args: PaymentDispatcherArgs
193+
args: PaymentExternalArgs
190194
): Promise<unknown> {
191195
const { options, name: componentName } = args.component
192196
const { model } = args.controller
@@ -205,7 +209,12 @@ export class PaymentField extends FormComponent {
205209

206210
const isLivePayment = args.isLive && !args.isPreview
207211
const formId = args.controller.model.formId
208-
const paymentService = createPaymentService(isLivePayment, formId)
212+
const formsService = model.services.formsService
213+
const paymentService = await createPaymentService(
214+
isLivePayment,
215+
formId,
216+
formsService
217+
)
209218

210219
const uuid = randomUUID()
211220

@@ -229,6 +238,15 @@ export class PaymentField extends FormComponent {
229238
{ formId, slug }
230239
)
231240

241+
if (!payment) {
242+
const message = isLivePayment
243+
? 'There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.'
244+
: 'Add a valid test API key before you can preview the payment journey.'
245+
const govukError = createError(componentName, message)
246+
request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
247+
return h.redirect(request.url.href).code(StatusCodes.SEE_OTHER)
248+
}
249+
232250
const sessionData: PaymentSessionData = {
233251
uuid,
234252
formId,
@@ -272,7 +290,12 @@ export class PaymentField extends FormComponent {
272290
}
273291

274292
const { paymentId, isLivePayment, formId } = paymentState
275-
const paymentService = createPaymentService(isLivePayment, formId)
293+
const formsService = this.model.services.formsService
294+
const paymentService = await createPaymentService(
295+
isLivePayment,
296+
formId,
297+
formsService
298+
)
276299

277300
/**
278301
* @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
@@ -343,21 +366,6 @@ export class PaymentField extends FormComponent {
343366
}
344367
}
345368

346-
export interface PaymentDispatcherArgs {
347-
controller: {
348-
model: {
349-
formId: string
350-
basePath: string
351-
name: string
352-
}
353-
getState: (request: AnyFormRequest) => Promise<FormSubmissionState>
354-
}
355-
component: PaymentField
356-
sourceUrl: string
357-
isLive: boolean
358-
isPreview: boolean
359-
}
360-
361369
/**
362370
* Session data stored when dispatching to GOV.UK Pay
363371
*/

src/server/plugins/engine/plugin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export const plugin = {
4141
onRequest,
4242
ordnanceSurveyApiKey,
4343
baseUrl,
44-
ordnanceSurveyApiSecret
44+
ordnanceSurveyApiSecret,
45+
services
4546
} = options
4647

4748
const cacheService =
@@ -77,6 +78,7 @@ export const plugin = {
7778
server.expose('cacheService', cacheService)
7879
server.expose('saveAndExit', saveAndExit)
7980
server.expose('baseUrl', baseUrl)
81+
server.expose('services', services)
8082

8183
server.app.model = model
8284

src/server/plugins/engine/routes/payment-helper.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import Boom from '@hapi/boom'
22

33
import { PAYMENT_SESSION_PREFIX } from '~/src/server/plugins/engine/routes/payment.js'
4-
import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js'
5-
import { PaymentService } from '~/src/server/plugins/payment/service.js'
4+
import { createPaymentService } from '~/src/server/plugins/payment/helper.js'
65

76
/**
87
* Validates session data and retrieves payment status
98
* @param {Request} request - the request
109
* @param {string} uuid - the payment UUID
10+
* @param {FormsService} formsService - the forms service
1111
* @returns {Promise<{ session: PaymentSessionData, sessionKey: string, paymentStatus: GetPaymentResponse }>}
1212
*/
13-
export async function getPaymentContext(request, uuid) {
13+
export async function getPaymentContext(request, uuid, formsService) {
1414
const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}`
1515
const session = /** @type {PaymentSessionData | null} */ (
1616
request.yar.get(sessionKey)
@@ -26,8 +26,11 @@ export async function getPaymentContext(request, uuid) {
2626
throw Boom.badRequest('No paymentId in session')
2727
}
2828

29-
const apiKey = getPaymentApiKey(isLivePayment, formId)
30-
const paymentService = new PaymentService(apiKey)
29+
const paymentService = await createPaymentService(
30+
isLivePayment,
31+
formId,
32+
formsService
33+
)
3134
const paymentStatus = await paymentService.getPaymentStatus(
3235
paymentId,
3336
isLivePayment
@@ -73,4 +76,5 @@ export function convertPenceToPounds(amount) {
7376
/**
7477
* @import { Request } from '@hapi/hapi'
7578
* @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
79+
* @import { FormsService } from '~/src/server/types.js'
7680
*/

0 commit comments

Comments
 (0)