From 422ff12f3db91f54b74fff98552a4b93a52665d2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 29 Aug 2025 18:47:25 +0100 Subject: [PATCH 01/27] Add security questions schema --- src/server/models/save-and-exit.js | 46 ++++++++++++++++++++++++++++-- src/server/plugins/router.ts | 32 ++------------------- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 264ac5728..9d90d85dd 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -1,6 +1,9 @@ import { SecurityQuestionsEnum } from '@defra/forms-model' import Joi from 'joi' +import { FORM_PREFIX } from '~/src/server/constants.js' +import { crumbSchema } from '~/src/server/schemas/index.js' + const pageTitle = 'Save your progress for later' // Field names/ids @@ -14,7 +17,7 @@ const GOVUK_LABEL__M = 'govuk-label--m' /** * @type { SecurityQuestion[]} */ -export const securityQuestions = [ +const securityQuestions = [ { text: 'What is a memorable place you have visited?', value: SecurityQuestionsEnum.MemorablePlace @@ -173,6 +176,37 @@ function buildSecurityAnswerField(payload, error) { } } +/** + * Save and exit form payload schema + */ +export const payloadSchema = Joi.object() + .keys({ + crumb: crumbSchema, + email: Joi.string().email().required().messages({ + 'string.email': + 'Enter an email address in the correct format, for example, hello@example.com', + '*': 'Enter an email address' + }), + emailConfirmation: Joi.string() + .valid(Joi.ref('email')) + .required() + .messages({ + '*': 'Your email address does not match. Check and try again.' + }), + securityQuestion: Joi.string() + .valid(...securityQuestions.map(({ value }) => value.toString())) + .required() + .messages({ + '*': 'Choose a security question to answer' + }), + securityAnswer: Joi.string().min(3).max(40).required().messages({ + 'string.min': 'Your answer must be between 3 and 40 characters long', + 'string.max': 'Your answer must be between 3 and 40 characters long', + '*': 'Enter an answer to the security question' + }) + }) + .required() + /** * Get save and exit session flash key * @param { string } state - the form state @@ -188,8 +222,13 @@ export function getFlashKey(state, formId) { * @param {SaveAndExitPayload} [payload] * @param {Error} [err] */ -export function saveAndExitViewModel(params, payload, err) { +export function viewModel(params, payload, err) { const { state, slug } = params + const formPath = `/${FORM_PREFIX}/${state}/${slug}` + + const backLink = { + href: formPath + } const { errors, @@ -220,11 +259,12 @@ export function saveAndExitViewModel(params, payload, err) { const cancelButton = { text: 'Cancel', classes: 'govuk-button--secondary', - href: `/${state}/${slug}` + href: formPath } return { pageTitle, + backLink, errors, fields, buttons: { continueButton, cancelButton } diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 661568b0d..2e5f95e95 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -29,8 +29,8 @@ import { FORM_PREFIX } from '~/src/server/constants.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { getFlashKey, - saveAndExitViewModel, - securityQuestions, + payloadSchema as saveAndExitPayloadSchema, + viewModel as saveAndExitViewModel, type SaveAndExitParams, type SaveAndExitPayload } from '~/src/server/models/save-and-exit.js' @@ -382,33 +382,7 @@ export default { slug: slugSchema }) .required(), - payload: Joi.object() - .keys({ - crumb: crumbSchema, - email: Joi.string().email().required().messages({ - 'string.empty': 'Enter an email address', - 'string.email': 'Enter an email address in the correct format' - }), - emailConfirmation: Joi.string() - .valid(Joi.ref('email')) - .required() - .messages({ - 'any.only': - 'Your email address does not match. Check and try again.' - }), - securityQuestion: Joi.string() - .valid( - ...securityQuestions.map(({ value }) => value.toString()) - ) - .required() - .messages({ - '*': 'Choose a security question' - }), - securityAnswer: Joi.string().required().messages({ - '*': 'Enter a security answer' - }) - }) - .required() + payload: saveAndExitPayloadSchema } } }) From 02f0a20983a8cc3f4b73ec47404ed3af6262ae86 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 11:57:03 +0100 Subject: [PATCH 02/27] Bump @defra/forms-model to 3.0.546 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d9d25ced..430d37dfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/client-s3": "^3.679.0", "@aws-sdk/client-sns": "^3.864.0", "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.545", + "@defra/forms-model": "^3.0.546", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -3661,9 +3661,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.545", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.545.tgz", - "integrity": "sha512-jPAJGgFomQz2t1d8m+ttyijdpNYzoIxX17MkCtTl98cJ4QFOLAJOkZB442MlrhEhopLqCDxYXoIxNJHz6w8+Vg==", + "version": "3.0.546", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.546.tgz", + "integrity": "sha512-eYBXWxGl3wFluq0RUCDHOKiU1i5kSpAxGH9iELx5pzNNO1gpja7gI+3vE25DXW2Ppyl+anLaz/IZ3dM0OyfBGg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index 317acbf41..fd3c9edeb 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@aws-sdk/client-sns": "^3.864.0", "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.545", + "@defra/forms-model": "^3.0.546", "@aws-sdk/client-s3": "^3.679.0", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", From 690f789a08cdd3af963ba1956bfa344587e61c90 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 11:58:20 +0100 Subject: [PATCH 03/27] import FormStatus from '@defra/forms-engine-plugin/types' --- src/server/index.test.ts | 2 +- .../plugins/error-preview/error-preview.js | 4 +- src/server/plugins/nunjucks/context.js | 2 +- src/server/plugins/nunjucks/context.test.js | 2 +- src/server/routes/types.ts | 47 ------------------- src/server/schemas/index.ts | 34 -------------- src/server/services/formsService.js | 2 +- src/server/services/formsService.test.js | 2 +- src/server/types.ts | 9 ++-- test/form/legacy-redirects.test.js | 2 +- 10 files changed, 12 insertions(+), 94 deletions(-) delete mode 100644 src/server/routes/types.ts delete mode 100644 src/server/schemas/index.ts diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 2f0078979..b38670206 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -4,12 +4,12 @@ import { UploadStatus, type UploadStatusResponse } from '@defra/forms-engine-plugin/engine/types.js' +import { FormStatus } from '@defra/forms-engine-plugin/types' import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, getFormMetadata diff --git a/src/server/plugins/error-preview/error-preview.js b/src/server/plugins/error-preview/error-preview.js index 496a05d28..3ca1652ea 100644 --- a/src/server/plugins/error-preview/error-preview.js +++ b/src/server/plugins/error-preview/error-preview.js @@ -1,7 +1,7 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import Boom from '@hapi/boom' import { createErrorPreviewModel } from '~/src/server/plugins/error-preview/error-preview-helper.js' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, getFormMetadata @@ -34,5 +34,5 @@ export async function getErrorPreviewHandler(request, h) { /** * @import { ResponseToolkit } from '@hapi/hapi' - * @import { FormRequest } from '~/src/server/routes/types.js' + * @import { FormRequest } from '@defra/forms-engine-plugin/engine/types/index.js' */ diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index c5c61f345..b22f50604 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -85,5 +85,5 @@ export function context(request) { /** * @import { ViewContext } from '~/src/server/plugins/nunjucks/types.js' - * @import { FormRequest, FormRequestPayload } from '~/src/server/routes/types.js' + * @import { FormRequest, FormRequestPayload } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index ad8350fe3..46e7c9211 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -139,5 +139,5 @@ describe('Nunjucks context', () => { }) /** - * @import { FormRequest } from '~/src/server/routes/types.js' + * @import { FormRequest } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts deleted file mode 100644 index 12a0a5c47..000000000 --- a/src/server/routes/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type FormPayload } from '@defra/forms-engine-plugin/engine/types.js' -import { type ReqRefDefaults, type Request } from '@hapi/hapi' - -export interface FormQuery extends Partial> { - /** - * Allow preview URL direct access without relevant page checks - */ - force?: string - - /** - * Redirect location after 'continue' form action - */ - returnUrl?: string -} - -export interface FormParams extends Partial> { - path: string - slug: string - state?: FormStatus -} - -export interface FormRequestRefs - extends Omit { - Params: FormParams - Payload: object | undefined - Query: FormQuery -} - -export interface FormRequestPayloadRefs extends FormRequestRefs { - Payload: FormPayload -} - -export type FormRequest = Request -export type FormRequestPayload = Request - -export enum FormAction { - Continue = 'continue', - Validate = 'validate', - Delete = 'delete', - AddAnother = 'add-another', - Send = 'send' -} - -export enum FormStatus { - Draft = 'draft', - Live = 'live' -} diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts deleted file mode 100644 index 8cd4a61b3..000000000 --- a/src/server/schemas/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type FormPayloadParams } from '@defra/forms-engine-plugin/engine/types.js' -import Joi from 'joi' - -import { FormAction, FormStatus } from '~/src/server/routes/types.js' - -export const stateSchema = Joi.string() - .valid(FormStatus.Draft, FormStatus.Live) - .required() - -export const actionSchema = Joi.string() - .valid( - FormAction.Continue, - FormAction.Validate, - FormAction.Delete, - FormAction.AddAnother, - FormAction.Send - ) - .default(FormAction.Validate) - .optional() - -export const pathSchema = Joi.string().required() -export const itemIdSchema = Joi.string().uuid().required() -export const crumbSchema = Joi.string().optional().allow('') -export const confirmSchema = Joi.boolean().empty(false) - -export const paramsSchema = Joi.object() - .keys({ - action: actionSchema, - confirm: confirmSchema, - crumb: crumbSchema, - itemId: itemIdSchema.optional() - }) - .default({}) - .optional() diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 3e0ac0f1f..e43ac804f 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -1,7 +1,7 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { formMetadataSchema } from '@defra/forms-model' import { config } from '~/src/config/index.js' -import { FormStatus } from '~/src/server/routes/types.js' import { getJson } from '~/src/server/services/httpService.js' /** diff --git a/src/server/services/formsService.test.js b/src/server/services/formsService.test.js index 2008f6164..f30d81ea1 100644 --- a/src/server/services/formsService.test.js +++ b/src/server/services/formsService.test.js @@ -1,6 +1,6 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { StatusCodes } from 'http-status-codes' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, getFormMetadata diff --git a/src/server/types.ts b/src/server/types.ts index 4fc49b4c9..d212f88a4 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,6 +1,10 @@ import { type FormModel } from '@defra/forms-engine-plugin/engine/models/index.js' import { type DetailItem } from '@defra/forms-engine-plugin/engine/models/types.js' import { type FormContext } from '@defra/forms-engine-plugin/engine/types.js' +import { + type FormRequestPayload, + type FormStatus +} from '@defra/forms-engine-plugin/types' import { type FormDefinition, type FormMetadata, @@ -8,11 +12,6 @@ import { type SubmitResponsePayload } from '@defra/forms-model' -import { - type FormRequestPayload, - type FormStatus -} from '~/src/server/routes/types.js' - export interface FormsService { getFormMetadata: (slug: string) => Promise getFormDefinition: ( diff --git a/test/form/legacy-redirects.test.js b/test/form/legacy-redirects.test.js index c8ad2cb0b..482e2a83c 100644 --- a/test/form/legacy-redirects.test.js +++ b/test/form/legacy-redirects.test.js @@ -1,8 +1,8 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { FormStatus } from '~/src/server/routes/types.js' describe('Legacy Redirect Routes', () => { /** @type {import('@hapi/hapi').Server} */ From 9ce2474821a3a09d917bbba286d9e418b6297ca6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 12:29:48 +0100 Subject: [PATCH 04/27] Update save and exit routes --- src/server/index.ts | 25 ++++++- src/server/models/save-and-exit.js | 45 +++++++++---- src/server/plugins/router.ts | 102 +++++++++++++---------------- src/typings/hapi/index.d.ts | 7 +- test/form/save-and-exit.test.js | 18 ++--- 5 files changed, 114 insertions(+), 83 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 175b05454..ef478a44f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,15 @@ import { join, parse } from 'path' import plugin from '@defra/forms-engine-plugin' +import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' -import { type PluginOptions } from '@defra/forms-engine-plugin/engine/types.js' import { formSubmissionService } from '@defra/forms-engine-plugin/services/index.js' +import { + type FormContext, + type FormRequestPayload, + type FormResponseToolkit, + type PluginOptions +} from '@defra/forms-engine-plugin/types' import { type FormDefinition } from '@defra/forms-model' import { Engine as CatboxMemory } from '@hapi/catbox-memory' import { Engine as CatboxRedis } from '@hapi/catbox-redis' @@ -137,7 +143,22 @@ export const configureEnginePlugin = async ({ model, services, viewContext: context, - baseUrl: config.get('baseUrl') + baseUrl: config.get('baseUrl'), + saveAndExit: ( + request: FormRequestPayload, + h: FormResponseToolkit, + _context: FormContext + ) => { + const { params } = request + const { slug } = params + const { isPreview, state } = checkFormStatus(params) + + return h.redirect( + !isPreview + ? `/save-and-exit/${slug}` + : `/save-and-exit/${slug}?status=${state}` + ) + } } } const routeOptions = { diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 9d90d85dd..446a39066 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -1,8 +1,8 @@ -import { SecurityQuestionsEnum } from '@defra/forms-model' +import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { SecurityQuestionsEnum, slugSchema } from '@defra/forms-model' import Joi from 'joi' import { FORM_PREFIX } from '~/src/server/constants.js' -import { crumbSchema } from '~/src/server/schemas/index.js' const pageTitle = 'Save your progress for later' @@ -176,6 +176,18 @@ function buildSecurityAnswerField(payload, error) { } } +/** + * Save and exit params + */ +export const paramsSchema = Joi.object().keys({ slug: slugSchema }).required() + +/** + * Save and exit query + */ +export const querySchema = Joi.object() + .keys({ status: stateSchema.optional() }) + .optional() + /** * Save and exit form payload schema */ @@ -208,23 +220,26 @@ export const payloadSchema = Joi.object() .required() /** - * Get save and exit session flash key - * @param { string } state - the form state - * @param { string } formId - the form id + * Get save and exit session key + * @param { string } slug - the form slug */ -export function getFlashKey(state, formId) { - return `${state}_${formId}_save_and_exit_email` +export function getKey(slug) { + return `${slug}_save_and_exit` } /** * The save and exit form view model * @param {SaveAndExitParams} params * @param {SaveAndExitPayload} [payload] + * @param {FormStatus} [status] * @param {Error} [err] */ -export function viewModel(params, payload, err) { - const { state, slug } = params - const formPath = `/${FORM_PREFIX}/${state}/${slug}` +export function viewModel(params, payload, status, err) { + const { slug } = params + const isPreview = !!status + const formPath = isPreview + ? `${FORM_PREFIX}/preview/${status}/${slug}` + : `${FORM_PREFIX}/${slug}` const backLink = { href: formPath @@ -279,10 +294,14 @@ export function viewModel(params, payload, err) { /** * @typedef {object} SaveAndExitParams - * @property {string} state - the preview/live state * @property {string} slug - the form slug */ +/** + * @typedef {object} SaveAndExitQuery + * @property {FormStatus} [status] - the form status (draft/live) when in preview mode + */ + /** * @typedef {object} SaveAndExitPayload * @property {string} email - email @@ -290,3 +309,7 @@ export function viewModel(params, payload, err) { * @property {string} securityQuestion - the security question * @property {string} securityAnswer - the security answer */ + +/** + * @import { FormStatus } from '@defra/forms-engine-plugin/types' + */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 2e5f95e95..21de46dd1 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -4,10 +4,16 @@ import { isPathRelative } from '@defra/forms-engine-plugin/engine/helpers.js' import { - slugSchema, - type FormStatus, - type SecurityQuestionsEnum -} from '@defra/forms-model' + crumbSchema, + itemIdSchema, + pathSchema, + stateSchema +} from '@defra/forms-engine-plugin/schema.js' +import { + type FormRequestPayload, + type FormStatus +} from '@defra/forms-engine-plugin/types' +import { slugSchema, type SecurityQuestionsEnum } from '@defra/forms-model' import Boom from '@hapi/boom' import { type Request, @@ -28,21 +34,17 @@ import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { - getFlashKey, + getKey, + paramsSchema as saveAndExitParamsSchema, payloadSchema as saveAndExitPayloadSchema, + querySchema as saveAndExitQuerySchema, viewModel as saveAndExitViewModel, type SaveAndExitParams, - type SaveAndExitPayload + type SaveAndExitPayload, + type SaveAndExitQuery } from '~/src/server/models/save-and-exit.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' -import { - crumbSchema, - itemIdSchema, - pathSchema, - stateSchema -} from '~/src/server/schemas/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] @@ -296,60 +298,58 @@ export default { server.route<{ Params: SaveAndExitParams + Query: SaveAndExitQuery Payload: SaveAndExitPayload }>({ method: 'GET', - path: '/save-and-exit/{state}/{slug}', + path: '/save-and-exit/{slug}', handler(request, h) { - const { params, payload } = request - const model = saveAndExitViewModel(params, payload) + const { params, query, payload } = request + const status = query.status + const model = saveAndExitViewModel(params, payload, status) return h.view('save-and-exit-details', model) }, options: { validate: { - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required() + query: saveAndExitQuerySchema, + params: saveAndExitParamsSchema } } }) server.route<{ Params: SaveAndExitParams + Query: SaveAndExitQuery Payload: SaveAndExitPayload }>({ method: 'POST', - path: '/save-and-exit/{state}/{slug}', + path: '/save-and-exit/{slug}', async handler(request, h) { - const { params, payload } = request - const { state, slug } = params + const { params, query, payload } = request + const { slug } = params + const { status } = query const { email, securityQuestion, securityAnswer } = payload const metadata = await getFormMetadata(slug) - const cacheService = getCacheService(request.server) // Publish topic message - const formStatus = { - status: state as FormStatus, - isPreview: false - } const security = { question: securityQuestion as SecurityQuestionsEnum, answer: securityAnswer } + const state = await cacheService.getState( + request as unknown as FormRequestPayload + ) await publishSaveAndExitEvent( metadata.id, + metadata.slug, + metadata.title, email, security, - formStatus, - await cacheService.getState( - request as unknown as FormRequestPayload - ) + state, + status ) // Clear all form data @@ -358,30 +358,27 @@ export default { ) // Flash the email over to the confirmation page - const key = getFlashKey(state, metadata.id) - request.yar.flash(key, email) + request.yar.flash(getKey(slug), email) // Redirect to the save and exit confirmation page - return h.redirect(`/save-and-exit/${state}/${slug}/confirmation`) + return h.redirect(`/save-and-exit/${slug}/confirmation`) }, options: { validate: { failAction: (request, h, err) => { - const { params, payload } = request + const { params, query, payload } = request + const { status } = query const model = saveAndExitViewModel( params as SaveAndExitParams, payload as SaveAndExitPayload, + status as FormStatus, err ) return h.view('save-and-exit-details', model).takeover() }, - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required(), + params: saveAndExitParamsSchema, + query: saveAndExitQuerySchema, payload: saveAndExitPayloadSchema } } @@ -391,15 +388,13 @@ export default { Params: SaveAndExitParams }>({ method: 'GET', - path: '/save-and-exit/{state}/{slug}/confirmation', - async handler(request, h) { + path: '/save-and-exit/{slug}/confirmation', + handler(request, h) { const { params } = request - const { state, slug } = params - const metadata = await getFormMetadata(slug) + const { slug } = params // Get the flashed email - const key = getFlashKey(state, metadata.id) - const messages = request.yar.flash(key) + const messages = request.yar.flash(getKey(slug)) if (messages.length === 0) { return Boom.badRequest('No email found in flash cache') @@ -411,12 +406,7 @@ export default { }, options: { validate: { - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required() + params: saveAndExitParamsSchema } } }) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index c3c8ee505..473799461 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,10 +5,7 @@ import { type Plugin } from '@hapi/hapi' import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' -import { - type FormRequest, - type FormRequestPayload -} from '~/src/server/routes/types.js' +import {} from '~/src/server/routes/types.js' import { type CacheService } from '~/src/server/services/index.js' declare module '@hapi/hapi' { @@ -16,7 +13,7 @@ declare module '@hapi/hapi' { // props from plugins which doesn't export @types interface PluginProperties { crumb: { - generate?: (request: Request | FormRequest | FormRequestPayload) => string + generate?: (request: Request) => string } } diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index 241a28f44..0e3cd73f2 100644 --- a/test/form/save-and-exit.test.js +++ b/test/form/save-and-exit.test.js @@ -37,7 +37,7 @@ describe('Save and exit', () => { it('shows the details page', async () => { const options = { method: 'GET', - url: '/save-and-exit/draft/basic' + url: '/save-and-exit/basic' } const { container } = await renderResponse(server, options) @@ -68,7 +68,7 @@ describe('Save and exit', () => { it('shows the details page with errors', async () => { const options = { method: 'POST', - url: '/save-and-exit/draft/basic', + url: '/save-and-exit/basic', payload: { email: '', emailConfirmation: '', @@ -89,13 +89,15 @@ describe('Save and exit', () => { expect($heading).toBeInTheDocument() expect($errorItems[0]).toHaveTextContent('Enter an email address') expect($errorItems[1]).toHaveTextContent('Choose a security question') - expect($errorItems[2]).toHaveTextContent('Enter a security answer') + expect($errorItems[2]).toHaveTextContent( + 'Enter an answer to the security question' + ) }) it('posts details page successfully', async () => { const options = { method: 'POST', - url: '/save-and-exit/draft/basic', + url: '/save-and-exit/basic', payload: { email: 'enrique.chase@defra.gov.uk', emailConfirmation: 'enrique.chase@defra.gov.uk', @@ -107,15 +109,13 @@ describe('Save and exit', () => { const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe( - '/save-and-exit/draft/basic/confirmation' - ) + expect(response.headers.location).toBe('/save-and-exit/basic/confirmation') const headers = getCookieHeader(response, ['session', 'crumb']) const { response: response2 } = await renderResponse(server, { method: 'get', - url: '/save-and-exit/draft/basic/confirmation', + url: '/save-and-exit/basic/confirmation', headers }) @@ -125,7 +125,7 @@ describe('Save and exit', () => { it('confirmation page errors if no details are flashed', async () => { const options = { method: 'GET', - url: '/save-and-exit/draft/basic/confirmation' + url: '/save-and-exit/basic/confirmation' } const { response } = await renderResponse(server, options) From c2ede95e5c7993d232639679ecb08ea3869499a0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 12:30:23 +0100 Subject: [PATCH 05/27] Update save and exit message publication --- src/server/messaging/__stubs__/builder.js | 12 ++++--- src/server/messaging/mappers/events.js | 26 ++++++++++++--- src/server/messaging/mappers/events.test.js | 36 +++++++++++++-------- src/server/messaging/publish-base.js | 2 +- src/server/messaging/publish.js | 20 +++++++++--- src/server/messaging/publish.test.js | 25 ++++++++------ 6 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/server/messaging/__stubs__/builder.js b/src/server/messaging/__stubs__/builder.js index 33606ecb9..be0607a89 100644 --- a/src/server/messaging/__stubs__/builder.js +++ b/src/server/messaging/__stubs__/builder.js @@ -16,16 +16,18 @@ export function buildSaveAndExitMessageData( partialSaveAndExitMessageData = {} ) { return { - formId: saveAndExitFormId, + form: { + id: 'formId', + title: 'formId', + slug: 'my-form', + isPreview: false, + status: FormStatus.Draft + }, security: { question: SecurityQuestionsEnum.MemorablePlace, answer: 'a1' }, email: 'forms@example.com', - formStatus: { - status: FormStatus.Draft, - isPreview: false - }, state: {}, ...partialSaveAndExitMessageData } diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index b7a9e8af5..d53999e77 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -1,4 +1,5 @@ import { + FormStatus, SubmissionEventMessageCategory, SubmissionEventMessageSchemaVersion, SubmissionEventMessageSource, @@ -7,19 +8,34 @@ import { /** * @param { string } formId + * @param { string } formSlug + * @param { string } formTitle * @param { string } email * @param {{ question: SecurityQuestionsEnum, answer: string }} security - * @param {{ status: FormStatus, isPreview: boolean }} formStatus * @param { FormState } state + * @param { FormStatus } [status] * @returns {SaveAndExitMessage} */ -export function saveAndExitMapper(formId, email, security, formStatus, state) { +export function saveAndExitMapper( + formId, + formSlug, + formTitle, + email, + security, + state, + status +) { /** @type {SaveAndExitMessageData} */ const data = { - formId, + form: { + id: formId, + slug: formSlug, + title: formTitle, + status: status ?? FormStatus.Live, + isPreview: !!status + }, email, security, - formStatus, state } const now = new Date() @@ -35,6 +51,6 @@ export function saveAndExitMapper(formId, email, security, formStatus, state) { } /** - * @import { FormStatus, SaveAndExitMessage, SaveAndExitMessageData, SecurityQuestionsEnum } from '@defra/forms-model' + * @import { SaveAndExitMessage, SaveAndExitMessageData, SecurityQuestionsEnum } from '@defra/forms-model' * @import { FormState } from '@defra/forms-engine-plugin/engine/types.js' */ diff --git a/src/server/messaging/mappers/events.test.js b/src/server/messaging/mappers/events.test.js index d738a83f8..631882c3c 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -12,29 +12,37 @@ import { saveAndExitMapper } from '~/src/server/messaging/mappers/events.js' describe('runner-events', () => { describe('saveAndExitMapper', () => { it('should map a payload into a SAVE_AND_EXIT event', () => { + /** + * @type {import('@defra/forms-model').SaveAndExitMessageData} + */ const payload = { - formId: 'formId', + form: { + id: 'formId', + title: 'formId', + slug: 'my-form', + isPreview: true, + status: FormStatus.Draft + }, email: 'my-email@here.com', security: { question: SecurityQuestionsEnum.CharacterName, answer: 'brown' }, - formStatus: { - status: FormStatus.Draft, - isPreview: false - }, state: { formVal1: '123', formVal2: '456' } } + expect( saveAndExitMapper( - payload.formId, + payload.form.id, + payload.form.slug, + payload.form.title, payload.email, payload.security, - payload.formStatus, - payload.state + payload.state, + payload.form.status ) ).toEqual({ schemaVersion: SubmissionEventMessageSchemaVersion.V1, @@ -44,16 +52,18 @@ describe('runner-events', () => { createdAt: expect.any(Date), messageCreatedAt: expect.any(Date), data: { - formId: payload.formId, + form: { + id: payload.form.id, + title: payload.form.title, + slug: payload.form.slug, + isPreview: payload.form.isPreview, + status: payload.form.status + }, email: payload.email, security: { question: payload.security.question, answer: payload.security.answer }, - formStatus: { - status: payload.formStatus.status, - isPreview: payload.formStatus.isPreview - }, state: payload.state } }) diff --git a/src/server/messaging/publish-base.js b/src/server/messaging/publish-base.js index 6ce9e6fe9..45145bda1 100644 --- a/src/server/messaging/publish-base.js +++ b/src/server/messaging/publish-base.js @@ -23,7 +23,7 @@ export async function publishEvent(message) { const result = await client.send(command) logger.info( - `Published ${message.type} event for formId ${message.data.formId}. MessageId: ${result.MessageId}` + `Published ${message.type} event for formId ${message.data.form.id}. MessageId: ${result.MessageId}` ) return result diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index 7580ff8d0..9dc0bc72a 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -20,19 +20,31 @@ async function validateAndPublishEvent(saveAndExitMessage) { * Publish 'save and exit' event * The returned entityId will be a newly-generated guid * @param {string} formId + * @param {string} formSlug + * @param {string} formTitle * @param {string} email * @param {{ question: SecurityQuestionsEnum, answer: string }} security - * @param {{ status: FormStatus, isPreview: boolean }} formStatus * @param {FormState} state + * @param {FormStatus} [status] */ export async function publishSaveAndExitEvent( formId, + formSlug, + formTitle, email, security, - formStatus, - state + state, + status ) { - const message = saveAndExitMapper(formId, email, security, formStatus, state) + const message = saveAndExitMapper( + formId, + formSlug, + formTitle, + email, + security, + state, + status + ) return validateAndPublishEvent(message) } diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index 36258a3b4..d6a8fcd3d 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -13,17 +13,22 @@ import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' jest.mock('~/src/server/messaging/publish-base.js') +/** + * @type {import('@defra/forms-model').SaveAndExitMessageData} + */ const saveAndExitPayload = { - formId: 'formId', + form: { + id: 'formId', + title: 'formId', + slug: 'my-form', + isPreview: true, + status: FormStatus.Draft + }, email: 'my-email@here.com', security: { question: SecurityQuestionsEnum.CharacterName, answer: 'brown' }, - formStatus: { - status: FormStatus.Draft, - isPreview: true - }, state: { formVal1: '123', formVal2: '456' @@ -45,11 +50,13 @@ describe('publish', () => { describe('publishSaveAndExitEvent', () => { it('should publish SAVE_AND_EXIT event', async () => { await publishSaveAndExitEvent( - saveAndExitPayload.formId, + saveAndExitPayload.form.id, + saveAndExitPayload.form.slug, + saveAndExitPayload.form.title, saveAndExitPayload.email, saveAndExitPayload.security, - saveAndExitPayload.formStatus, - saveAndExitPayload.state + saveAndExitPayload.state, + saveAndExitPayload.form.status ) expect(publishEvent).toHaveBeenCalledWith({ @@ -72,7 +79,7 @@ describe('publish', () => { publishSaveAndExitEvent(invalidPayload) ).rejects.toThrow( new ValidationError( - '"data.formId" must be a string. "data.email" is required. "data.state" is required', + '"data.form.id" must be a string. "data.form.slug" is required. "data.form.title" is required. "data.email" is required. "data.state" is required', [], {} ) From d5d65db76e4188f655adaa364d95399cc4201ef4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 12:52:20 +0100 Subject: [PATCH 06/27] Add saveAndExitExpiryDays config --- src/config/index.ts | 5 +++++ src/server/plugins/router.ts | 6 +++++- src/server/views/save-and-exit-confirmation.html | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 63c823cbd..d1efd1fd7 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -286,6 +286,11 @@ export const config = convict({ format: String, default: '', env: 'GOOGLE_ANALYTICS_TRACKING_ID' + }, + saveAndExitExpiryDays: { + format: Number, + default: 30, + env: 'SAVE_AND_EXIT_EXPIRY_IN_DAYS' } }) diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 21de46dd1..b04f43f1f 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -48,6 +48,7 @@ import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') export default { plugin: { @@ -402,7 +403,10 @@ export default { const email = messages[0] - return h.view('save-and-exit-confirmation', { email }) + return h.view('save-and-exit-confirmation', { + email, + saveAndExitExpiryDays + }) }, options: { validate: { diff --git a/src/server/views/save-and-exit-confirmation.html b/src/server/views/save-and-exit-confirmation.html index 7edeff255..d2f4dab85 100644 --- a/src/server/views/save-and-exit-confirmation.html +++ b/src/server/views/save-and-exit-confirmation.html @@ -12,7 +12,7 @@ }) }}

What happens next

- We have sent a one-off link to {{ email }} which you can use to resume this form within 28 days. + We have sent a one-off link to {{ email }} which you can use to resume this form within {{ saveAndExitExpiryDays }} days.

This link will only work once. From 2bdc75eb737ab43820980e5e96947e622d2865df Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 13:45:06 +0100 Subject: [PATCH 07/27] Use exported AnyFormRequest from forms-engine-plugin --- src/server/plugins/nunjucks/context.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index b22f50604..629ec8e04 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -20,7 +20,7 @@ const logger = createLogger() let webpackManifest /** - * @param {FormRequest | FormRequestPayload | null} request + * @param {AnyFormRequest | null} request */ export function context(request) { const manifestPath = join(config.get('publicDir'), 'assets-manifest.json') @@ -85,5 +85,5 @@ export function context(request) { /** * @import { ViewContext } from '~/src/server/plugins/nunjucks/types.js' - * @import { FormRequest, FormRequestPayload } from '@defra/forms-engine-plugin/types' + * @import { AnyFormRequest } from '@defra/forms-engine-plugin/types' */ From 824ee490d0ff7d71efb436d19b7183f0929c733e Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 15:13:15 +0100 Subject: [PATCH 08/27] Remove govuk-main-wrapper--l class from save and exit pages --- src/server/views/save-and-exit-confirmation.html | 2 -- src/server/views/save-and-exit-details.html | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/server/views/save-and-exit-confirmation.html b/src/server/views/save-and-exit-confirmation.html index d2f4dab85..c619e8840 100644 --- a/src/server/views/save-and-exit-confirmation.html +++ b/src/server/views/save-and-exit-confirmation.html @@ -2,8 +2,6 @@ {% from "govuk/components/panel/macro.njk" import govukPanel %} -{% set mainClasses = "govuk-main-wrapper--l" %} - {% block content %}

diff --git a/src/server/views/save-and-exit-details.html b/src/server/views/save-and-exit-details.html index 0caf52418..9f816182f 100644 --- a/src/server/views/save-and-exit-details.html +++ b/src/server/views/save-and-exit-details.html @@ -5,8 +5,6 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} -{% set mainClasses = "govuk-main-wrapper--l" %} - {% block content %}
From 142cb53d3dfc3b9f07fe43e4100e75e4599b53b1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 15:49:50 +0100 Subject: [PATCH 09/27] Add title to the save and exit pages --- src/server/models/save-and-exit.js | 40 ++++++++++++++++++++++----- src/server/plugins/router.ts | 43 +++++++++++++++++++----------- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 446a39066..2f71518c0 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -2,9 +2,11 @@ import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' import { SecurityQuestionsEnum, slugSchema } from '@defra/forms-model' import Joi from 'joi' +import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' -const pageTitle = 'Save your progress for later' +const detailsPageTitle = 'Save your progress for later' +const confirmationPageTitle = 'Your progress has been saved' // Field names/ids const email = 'email' @@ -13,6 +15,7 @@ const securityQuestion = 'securityQuestion' const securityAnswer = 'securityAnswer' const GOVUK_LABEL__M = 'govuk-label--m' +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') /** * @type { SecurityQuestion[]} @@ -228,14 +231,14 @@ export function getKey(slug) { } /** - * The save and exit form view model - * @param {SaveAndExitParams} params + * The save and exit details form view model + * @param {FormMetadata} metadata * @param {SaveAndExitPayload} [payload] * @param {FormStatus} [status] * @param {Error} [err] */ -export function viewModel(params, payload, status, err) { - const { slug } = params +export function detailsViewModel(metadata, payload, status, err) { + const { slug, title } = metadata const isPreview = !!status const formPath = isPreview ? `${FORM_PREFIX}/preview/${status}/${slug}` @@ -278,7 +281,9 @@ export function viewModel(params, payload, status, err) { } return { - pageTitle, + name: title, + serviceUrl: formPath, + pageTitle: detailsPageTitle, backLink, errors, fields, @@ -286,6 +291,28 @@ export function viewModel(params, payload, status, err) { } } +/** + * The save and exit confirmation form view model + * @param {FormMetadata} metadata + * @param {string} email + * @param {FormStatus} [status] + */ +export function confirmationViewModel(metadata, email, status) { + const { slug, title } = metadata + const isPreview = !!status + const formPath = isPreview + ? `${FORM_PREFIX}/preview/${status}/${slug}` + : `${FORM_PREFIX}/${slug}` + + return { + name: title, + serviceUrl: formPath, + pageTitle: confirmationPageTitle, + email, + saveAndExitExpiryDays + } +} + /** * @typedef {object} SecurityQuestion * @property {string} text - the question text @@ -311,5 +338,6 @@ export function viewModel(params, payload, status, err) { */ /** + * @import { FormMetadata } from '@defra/forms-model' * @import { FormStatus } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index b04f43f1f..b9b6f13c2 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -34,11 +34,12 @@ import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { + confirmationViewModel as saveAndExitConfirmationViewModel, + detailsViewModel as saveAndExitDetailsViewModel, getKey, paramsSchema as saveAndExitParamsSchema, payloadSchema as saveAndExitPayloadSchema, querySchema as saveAndExitQuerySchema, - viewModel as saveAndExitViewModel, type SaveAndExitParams, type SaveAndExitPayload, type SaveAndExitQuery @@ -48,7 +49,6 @@ import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] -const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') export default { plugin: { @@ -304,10 +304,12 @@ export default { }>({ method: 'GET', path: '/save-and-exit/{slug}', - handler(request, h) { + async handler(request, h) { const { params, query, payload } = request - const status = query.status - const model = saveAndExitViewModel(params, payload, status) + const { slug } = params + const { status } = query + const metadata = await getFormMetadata(slug) + const model = saveAndExitDetailsViewModel(metadata, payload, status) return h.view('save-and-exit-details', model) }, @@ -362,15 +364,19 @@ export default { request.yar.flash(getKey(slug), email) // Redirect to the save and exit confirmation page - return h.redirect(`/save-and-exit/${slug}/confirmation`) + return h.redirect( + `/save-and-exit/${slug}/confirmation${status ? `?status=${status}` : ''}` + ) }, options: { validate: { - failAction: (request, h, err) => { + async failAction(request, h, err) { const { params, query, payload } = request + const { slug } = params const { status } = query - const model = saveAndExitViewModel( - params as SaveAndExitParams, + const metadata = await getFormMetadata(slug) + const model = saveAndExitDetailsViewModel( + metadata, payload as SaveAndExitPayload, status as FormStatus, err @@ -390,9 +396,11 @@ export default { }>({ method: 'GET', path: '/save-and-exit/{slug}/confirmation', - handler(request, h) { - const { params } = request + async handler(request, h) { + const { params, query } = request const { slug } = params + const { status } = query + const metadata = await getFormMetadata(slug) // Get the flashed email const messages = request.yar.flash(getKey(slug)) @@ -402,15 +410,18 @@ export default { } const email = messages[0] - - return h.view('save-and-exit-confirmation', { + const model = saveAndExitConfirmationViewModel( + metadata, email, - saveAndExitExpiryDays - }) + status + ) + + return h.view('save-and-exit-confirmation', model) }, options: { validate: { - params: saveAndExitParamsSchema + params: saveAndExitParamsSchema, + query: saveAndExitQuerySchema } } }) From 8784477abf2432b38b6e43e1f6e689b78e8badb0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 4 Sep 2025 09:23:32 +0100 Subject: [PATCH 10/27] FormStatus as a route param not query --- src/server/index.ts | 2 +- src/server/models/save-and-exit.js | 27 ++++++++----------- src/server/plugins/router.ts | 43 +++++++++++------------------- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index ef478a44f..e2732b82f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -156,7 +156,7 @@ export const configureEnginePlugin = async ({ return h.redirect( !isPreview ? `/save-and-exit/${slug}` - : `/save-and-exit/${slug}?status=${state}` + : `/save-and-exit/${slug}/${state}` ) } } diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 2f71518c0..5ae7c0e2c 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -182,14 +182,12 @@ function buildSecurityAnswerField(payload, error) { /** * Save and exit params */ -export const paramsSchema = Joi.object().keys({ slug: slugSchema }).required() - -/** - * Save and exit query - */ -export const querySchema = Joi.object() - .keys({ status: stateSchema.optional() }) - .optional() +export const paramsSchema = Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() /** * Save and exit form payload schema @@ -224,10 +222,11 @@ export const payloadSchema = Joi.object() /** * Get save and exit session key - * @param { string } slug - the form slug + * @param {string} slug + * @param {FormStatus} [state] */ -export function getKey(slug) { - return `${slug}_save_and_exit` +export function getKey(slug, state) { + return `save-and-exit-${slug}-${state ?? ''}` } /** @@ -322,11 +321,7 @@ export function confirmationViewModel(metadata, email, status) { /** * @typedef {object} SaveAndExitParams * @property {string} slug - the form slug - */ - -/** - * @typedef {object} SaveAndExitQuery - * @property {FormStatus} [status] - the form status (draft/live) when in preview mode + * @property {FormStatus} [state] - the form status (draft/live) when in preview mode */ /** diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index b9b6f13c2..44438e6ca 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -39,10 +39,8 @@ import { getKey, paramsSchema as saveAndExitParamsSchema, payloadSchema as saveAndExitPayloadSchema, - querySchema as saveAndExitQuerySchema, type SaveAndExitParams, - type SaveAndExitPayload, - type SaveAndExitQuery + type SaveAndExitPayload } from '~/src/server/models/save-and-exit.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' @@ -299,15 +297,13 @@ export default { server.route<{ Params: SaveAndExitParams - Query: SaveAndExitQuery Payload: SaveAndExitPayload }>({ method: 'GET', - path: '/save-and-exit/{slug}', + path: '/save-and-exit/{slug}/{state?}', async handler(request, h) { - const { params, query, payload } = request - const { slug } = params - const { status } = query + const { params, payload } = request + const { slug, state: status } = params const metadata = await getFormMetadata(slug) const model = saveAndExitDetailsViewModel(metadata, payload, status) @@ -315,7 +311,6 @@ export default { }, options: { validate: { - query: saveAndExitQuerySchema, params: saveAndExitParamsSchema } } @@ -323,15 +318,13 @@ export default { server.route<{ Params: SaveAndExitParams - Query: SaveAndExitQuery Payload: SaveAndExitPayload }>({ method: 'POST', - path: '/save-and-exit/{slug}', + path: '/save-and-exit/{slug}/{state?}', async handler(request, h) { - const { params, query, payload } = request - const { slug } = params - const { status } = query + const { params, payload } = request + const { slug, state: status } = params const { email, securityQuestion, securityAnswer } = payload const metadata = await getFormMetadata(slug) const cacheService = getCacheService(request.server) @@ -361,19 +354,18 @@ export default { ) // Flash the email over to the confirmation page - request.yar.flash(getKey(slug), email) + request.yar.flash(getKey(slug, status), email) // Redirect to the save and exit confirmation page return h.redirect( - `/save-and-exit/${slug}/confirmation${status ? `?status=${status}` : ''}` + `/save-and-exit/${slug}/confirmation${status ? `/${status}` : ''}` ) }, options: { validate: { async failAction(request, h, err) { - const { params, query, payload } = request - const { slug } = params - const { status } = query + const { params, payload } = request + const { slug, state: status } = params const metadata = await getFormMetadata(slug) const model = saveAndExitDetailsViewModel( metadata, @@ -385,7 +377,6 @@ export default { return h.view('save-and-exit-details', model).takeover() }, params: saveAndExitParamsSchema, - query: saveAndExitQuerySchema, payload: saveAndExitPayloadSchema } } @@ -395,15 +386,14 @@ export default { Params: SaveAndExitParams }>({ method: 'GET', - path: '/save-and-exit/{slug}/confirmation', + path: '/save-and-exit/{slug}/confirmation/{state?}', async handler(request, h) { - const { params, query } = request - const { slug } = params - const { status } = query + const { params } = request + const { slug, state: status } = params const metadata = await getFormMetadata(slug) // Get the flashed email - const messages = request.yar.flash(getKey(slug)) + const messages = request.yar.flash(getKey(slug, status)) if (messages.length === 0) { return Boom.badRequest('No email found in flash cache') @@ -420,8 +410,7 @@ export default { }, options: { validate: { - params: saveAndExitParamsSchema, - query: saveAndExitQuerySchema + params: saveAndExitParamsSchema } } }) From 4c1bfd22e735817a40a35d65fb91f935173781ad Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 5 Sep 2025 16:04:54 +0100 Subject: [PATCH 11/27] Refactor outputService to use FormStatus from @defra/forms-engine-plugin --- src/server/services/outputService.test.js | 4 ++-- src/server/services/outputService.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index 5d12efefb..d8caf9191 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -1,8 +1,8 @@ import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { getFormatter } from '@defra/forms-engine-plugin/engine/outputFormatters/index.js' +import { FormStatus } from '@defra/forms-engine-plugin/types' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' -import { FormStatus } from '~/src/server/routes/types.js' import { OutputService, createOutputService @@ -504,5 +504,5 @@ describe('OutputService', () => { * @import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' * @import { DetailItem } from '@defra/forms-engine-plugin/engine/models/types.js' * @import { SubmitResponsePayload, FormMetadata } from '@defra/forms-model' - * @import { FormRequestPayload } from '~/src/server/routes/types.js' + * @import { FormRequestPayload } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 3e51a7114..69350226b 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -6,7 +6,10 @@ import { type FormAdapterSubmissionMessagePayload, type FormContext } from '@defra/forms-engine-plugin/engine/types.js' -import { type OutputService as IOutputService } from '@defra/forms-engine-plugin/types' +import { + type FormRequestPayload, + type OutputService as IOutputService +} from '@defra/forms-engine-plugin/types' import { type FormMetadata, type SubmitResponsePayload @@ -14,7 +17,6 @@ import { import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' const logger = createLogger() From 4678ddb8bf868bab295b6ac06e510b0935f56199 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 13:42:14 +0100 Subject: [PATCH 12/27] Update privacy notice for save and return (#906) --- src/server/plugins/router.ts | 3 ++- src/server/views/help/privacy-notice.html | 33 ++++++++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 44438e6ca..555946438 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -47,6 +47,7 @@ import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') export default { plugin: { @@ -138,7 +139,7 @@ export default { const { slug } = request.params const form = await getFormMetadata(slug) - return h.view('help/privacy-notice', { form }) + return h.view('help/privacy-notice', { form, saveAndExitExpiryDays }) }, options }) diff --git a/src/server/views/help/privacy-notice.html b/src/server/views/help/privacy-notice.html index 05f529e61..da7c06607 100644 --- a/src/server/views/help/privacy-notice.html +++ b/src/server/views/help/privacy-notice.html @@ -5,13 +5,13 @@ {% block content %}
-

{{ config.serviceName }} privacy notice

-

The {{ form.title }} form was created using ‘{{ config.serviceName }}’. This service is owned and operated by the Department for Environment, Food & Rural Affairs (Defra).

+

{{ config.serviceName }} - privacy notice

+

The {{ form.title }} form was created using the {{ config.serviceName }} service. This service is owned and operated by the Department for Environment, Food & Rural Affairs (Defra).

Who collects your personal data

-

The organisation that created the {{ form.title }} form using ‘{{ config.serviceName }}’ is the data controller of personal data they collect. If the data controller is outside the Defra legal entity, then Defra is the data processor.

+

The organisation that created the {{ form.title }} form using {{ config.serviceName }} is the controller of personal data they collect. If the controller is outside the Defra legal entity, then Defra is the processor.

Read the specific privacy notice for the {{ form.title }} form.

-

Defra also collects some data as a data controller. This privacy notice explains what personal data Defra collects and processes as a data controller through forms made with ‘{{ config.serviceName }}’.

+

Defra also collects some data as a controller. This privacy notice explains what personal data Defra collects and processes as a controller through forms made with {{ config.serviceName }}.

If you need further information about how Defra uses your personal data, email defraforms@defra.gov.uk.

If you want information and your associated rights you can email: data.protection@defra.gov.uk.

The data protection officer for Defra is responsible for checking that Defra complies with legislation. You can contact them at DefraGroupDataProtectionOfficer@defra.gov.uk.

@@ -19,24 +19,31 @@

Who collects your personal data

Data we collect from you and what we do with it

{% if config.googleAnalyticsTrackingId %} - -

If you give your consent, we use Google Analytics cookies to collect information about how you use ‘{{ config.serviceName }}’. Read the data privacy and security policy for Google Analytics.

+
+

If you give your consent, we use Google Analytics cookies to collect information about how you use {{ config.serviceName }}. Read the data privacy and security policy for Google Analytics.

Google Analytics processes information about:

  • your IP address
  • -
  • the pages you visit on ‘{{ config.serviceName }}’
  • -
  • how long you spend on each ‘{{ config.serviceName }}’ page
  • +
  • the pages you visit on {{ config.serviceName }}
  • +
  • how long you spend on each {{ config.serviceName }} page
  • how you got to the site
  • what you select while you’re visiting the site

Defra will make sure you cannot be directly identified by Google Analytics data. We do this by using Google Analytics’ IP address anonymisation feature and by removing any other personal data from the titles or URLs of the pages you visit.

Defra will not combine analytics information with other data sets in a way that would directly identify who you are.

- +
{% endif %} -

We use system logs to collect information about the usage of forms. The logs are stored in Amazon Web Services based in London.

-

We use the system logs {% if config.googleAnalyticsTrackingId %}and Google Analytics data{% endif %} to create anonymised reports about the performance of forms that use ‘{{ config.serviceName }}’. We use this data to improve forms, for example, if we discover a high number of drops offs at a certain point within a form. We may share this information with the data controller of the form.

-

If you email us feedback about a form that uses ‘{{ config.serviceName }}’, we’ll send your email address and any other personal information you choose to include in your email to the data controller for review. The data controller may use your personal information to reply to your query to update a form based on your feedback where it is appropriate.

+

We use system logs to collect information about the usage of forms.

+

We use the system logs {% if config.googleAnalyticsTrackingId %}and Google Analytics data{% endif %} to create anonymised reports about the performance of forms that use {{ config.serviceName }}. We use this data to improve forms, for example, if we discover a high number of incomplete forms.

+

If you email us feedback about a form that uses {{ config.serviceName }}, we’ll send your email address and any other personal information you choose to include in your email to the controller for review. The controller may use your personal data to reply to your query to update a form based on your feedback where it is appropriate.

+ +

Save and return to a form

+

You can save your form progress and return within {{ saveAndExitExpiryDays }} days. When saving your progress we ask you for:

+
    +
  • your email address
  • +
  • an answer to a security question
  • +

Lawful basis for processing your personal data

The lawful basis for processing your personal data is your consent.

@@ -68,7 +75,7 @@

Complaints

Personal information charter

Our personal information charter explains more about your rights over your personal data.

-

Last updated: 24 December 2024

+

Last updated: 29 August 2025

{% endblock %} From 25e9c058f2731bf2d44374b07d29f5c6d9678bbb Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:52:46 +0100 Subject: [PATCH 13/27] Feat/df 372 resume save and exit (#907) * import FormStatus from '@defra/forms-engine-plugin/types' * Update save and exit routes * Reworked with differnet flow * Stash * Corrected types Uses constructFormUrl for paths * Fix package-lock.json * Remove unnecessary classes * Update the save and exit routes and models * Add route param validation * Fix route typo * Route tests - more to follow * Bump @defra/forms-engine-plugin to v3.0.0 * SonarCloud issues * Extra coverage --------- Co-authored-by: David Stone --- package-lock.json | 1000 ++--------------- package.json | 5 +- src/server/helpers/error-helper.js | 21 + src/server/index.ts | 2 +- src/server/messaging/__stubs__/builder.js | 6 +- src/server/messaging/mappers/events.js | 10 +- src/server/messaging/mappers/events.test.js | 11 +- src/server/messaging/publish.js | 3 - src/server/messaging/publish.test.js | 9 +- src/server/models/save-and-exit.js | 215 +++- src/server/plugins/router.ts | 13 +- src/server/routes/index.ts | 1 + src/server/routes/save-and-exit.js | 290 +++++ src/server/routes/save-and-exit.test.js | 308 +++++ src/server/services/formsService.js | 79 +- src/server/types.ts | 17 + .../save-and-exit/resume-error-locked.html | 20 + .../views/save-and-exit/resume-error.html | 22 + .../views/save-and-exit/resume-password.html | 40 + .../views/save-and-exit/resume-success.html | 29 + 20 files changed, 1114 insertions(+), 987 deletions(-) create mode 100644 src/server/helpers/error-helper.js create mode 100644 src/server/routes/save-and-exit.js create mode 100644 src/server/routes/save-and-exit.test.js create mode 100644 src/server/views/save-and-exit/resume-error-locked.html create mode 100644 src/server/views/save-and-exit/resume-error.html create mode 100644 src/server/views/save-and-exit/resume-password.html create mode 100644 src/server/views/save-and-exit/resume-success.html diff --git a/package-lock.json b/package-lock.json index 430d37dfc..ec3da6f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,9 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@aws-sdk/client-s3": "^3.679.0", "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.546", + "@defra/forms-engine-plugin": "^3.0.0", + "@defra/forms-model": "^3.0.550", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -178,83 +177,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -380,425 +302,6 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.879.0.tgz", - "integrity": "sha512-1bD2Do/OdCIzl72ncHKYamDhPijUErLYpuLvciyYD4Ywt4cVLHjWtVIqb22XOOHYYHE3NqHMd4uRhvXMlsBRoQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/credential-provider-node": "3.879.0", - "@aws-sdk/middleware-bucket-endpoint": "3.873.0", - "@aws-sdk/middleware-expect-continue": "3.873.0", - "@aws-sdk/middleware-flexible-checksums": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-location-constraint": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-sdk-s3": "3.879.0", - "@aws-sdk/middleware-ssec": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/signature-v4-multi-region": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/eventstream-serde-browser": "^4.0.5", - "@smithy/eventstream-serde-config-resolver": "^4.1.3", - "@smithy/eventstream-serde-node": "^4.0.5", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-blob-browser": "^4.0.5", - "@smithy/hash-node": "^4.0.5", - "@smithy/hash-stream-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/md5-js": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.879.0.tgz", - "integrity": "sha512-+Pc3OYFpRYpKLKRreovPM63FPPud1/SF9vemwIJfz6KwsBCJdvg7vYD1xLSIp5DVZLeetgf4reCyAA5ImBfZuw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.879.0.tgz", - "integrity": "sha512-JgG7A8SSbr5IiCYL8kk39Y9chdSB5GPwBorDW8V8mr19G9L+qd6ohED4fAocoNFaDnYJ5wGAHhCfSJjzcsPBVQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.879.0.tgz", - "integrity": "sha512-2hM5ByLpyK+qORUexjtYyDZsgxVCCUiJQZRMGkNXFEGz6zTpbjfTIWoh3zRgWHEBiqyPIyfEy50eIF69WshcuA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.879.0.tgz", - "integrity": "sha512-07M8zfb73KmMBqVO5/V3Ea9kqDspMX0fO0kaI1bsjWI6ngnMye8jCE0/sIhmkVAI0aU709VA0g+Bzlopnw9EoQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/credential-provider-env": "3.879.0", - "@aws-sdk/credential-provider-http": "3.879.0", - "@aws-sdk/credential-provider-process": "3.879.0", - "@aws-sdk/credential-provider-sso": "3.879.0", - "@aws-sdk/credential-provider-web-identity": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.879.0.tgz", - "integrity": "sha512-FYaAqJbnSTrVL2iZkNDj2hj5087yMv2RN2GA8DJhe7iOJjzhzRojrtlfpWeJg6IhK0sBKDH+YXbdeexCzUJvtA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.879.0", - "@aws-sdk/credential-provider-http": "3.879.0", - "@aws-sdk/credential-provider-ini": "3.879.0", - "@aws-sdk/credential-provider-process": "3.879.0", - "@aws-sdk/credential-provider-sso": "3.879.0", - "@aws-sdk/credential-provider-web-identity": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.879.0.tgz", - "integrity": "sha512-7r360x1VyEt35Sm1JFOzww2WpnfJNBbvvnzoyLt7WRfK0S/AfsuWhu5ltJ80QvJ0R3AiSNbG+q/btG2IHhDYPQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.879.0.tgz", - "integrity": "sha512-gd27B0NsgtKlaPNARj4IX7F7US5NuU691rGm0EUSkDsM7TctvJULighKoHzPxDQlrDbVI11PW4WtKS/Zg5zPlQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.879.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/token-providers": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.879.0.tgz", - "integrity": "sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.879.0.tgz", - "integrity": "sha512-DDSV8228lQxeMAFKnigkd0fHzzn5aauZMYC3CSj6e5/qE7+9OwpkUcjHfb7HZ9KWG6L2/70aKZXHqiJ4xKhOZw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@smithy/core": "^3.9.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.879.0.tgz", - "integrity": "sha512-7+n9NpIz9QtKYnxmw1fHi9C8o0GrX8LbBR4D50c7bH6Iq5+XdSuL5AFOWWQ5cMD0JhqYYJhK/fJsVau3nUtC4g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.879.0.tgz", - "integrity": "sha512-47J7sCwXdnw9plRZNAGVkNEOlSiLb/kR2slnDIHRK9NB/ECKsoqgz5OZQJ9E2f0yqOs8zSNJjn3T01KxpgW8Qw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz", - "integrity": "sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.879.0.tgz", - "integrity": "sha512-A5KGc1S+CJRzYnuxJQQmH1BtGsz46AgyHkqReKfGiNQA8ET/9y9LQ5t2ABqnSBHHIh3+MiCcQSkUZ0S3rTodrQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@aws-sdk/client-sns": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.876.0.tgz", @@ -927,166 +430,12 @@ "node_modules/@aws-sdk/credential-provider-env": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.876.0.tgz", - "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", - "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", - "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", - "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-ini": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", - "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", - "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.876.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/token-providers": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", - "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.873.0.tgz", - "integrity": "sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.873.0.tgz", - "integrity": "sha512-GIqoc8WgRcf/opBOZXFLmplJQKwOMjiOMmDz9gQkaJ8FiVJoAp8EGVmK2TOWZMQUYsavvHYsHaor5R2xwPoGVg==", + "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/property-provider": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1094,64 +443,67 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.879.0.tgz", - "integrity": "sha512-U1rcWToy2rlQPQLsx5h73uTC1XYo/JpnlJGCc3Iw7b1qrK8Mke4+rgMPKCfnXELD5TTazGrbT03frxH4Y1Ycvw==", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", + "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.879.0", + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.1.4", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/property-provider": "^4.0.5", "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.4.10", "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", + "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", + "@aws-sdk/credential-provider-env": "3.876.0", + "@aws-sdk/credential-provider-http": "3.876.0", + "@aws-sdk/credential-provider-process": "3.876.0", + "@aws-sdk/credential-provider-sso": "3.876.0", + "@aws-sdk/credential-provider-web-identity": "3.876.0", + "@aws-sdk/nested-clients": "3.876.0", "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.7", "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", - "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", + "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/credential-provider-env": "3.876.0", + "@aws-sdk/credential-provider-http": "3.876.0", + "@aws-sdk/credential-provider-ini": "3.876.0", + "@aws-sdk/credential-provider-process": "3.876.0", + "@aws-sdk/credential-provider-sso": "3.876.0", + "@aws-sdk/credential-provider-web-identity": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1159,13 +511,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.873.0.tgz", - "integrity": "sha512-r+hIaORsW/8rq6wieDordXnA/eAu7xAPLue2InhoEX6ML7irP52BgiibHLpt9R0psiCzIHhju8qqKa4pJOrmiw==", + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", + "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1173,13 +528,18 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { + "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", - "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", + "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/client-sso": "3.876.0", + "@aws-sdk/core": "3.876.0", + "@aws-sdk/token-providers": "3.876.0", "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1187,14 +547,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", - "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", + "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", + "@aws-sdk/nested-clients": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/property-provider": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1202,64 +564,43 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.879.0.tgz", - "integrity": "sha512-ZTpLr2AbZcCsEzu18YCtB8Tp8tjAWHT0ccfwy3HiL6g9ncuSMW+7BVi1hDYmBidFwpPbnnIMtM0db3pDMR6/WA==", + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", + "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.879.0", "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", + "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-ssec": { + "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.873.0.tgz", - "integrity": "sha512-AF55J94BoiuzN7g3hahy0dXTVZahVi8XxRBLgzNp6yQf0KTng+hb/V9UQZVYY1GZaDczvvvnqC54RGe9OZZ9zQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", + "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1351,23 +692,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.879.0.tgz", - "integrity": "sha512-MDsw0EWOHyKac75X3gD8tLWtmPuRliS/s4IhWRhsdDCU13wewHIs5IlA5B65kT6ISf49yEIalEH3FHUSVqdmIQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/token-providers": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.876.0.tgz", @@ -1399,18 +723,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", - "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/util-endpoints": { "version": "3.873.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.873.0.tgz", @@ -3592,9 +2904,9 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-2.1.9.tgz", - "integrity": "sha512-VXLP/0JyTVy62jNqvaXqpqsfkapI1coLQ+pm8CJg6z0JHNkdoZBeOs+TbhZN2a6d1GZSAodqqd6AMG+NRtHs3g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.0.tgz", + "integrity": "sha512-LQGCiGfOcZFtpLZNW0FXiVbYb1nc5gQJS26IGTcrLE0oma6iEkVYmw3upGmZ/IMU5YZtu/3tJGusQK4/ASK4Ig==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -3661,9 +2973,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.546", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.546.tgz", - "integrity": "sha512-eYBXWxGl3wFluq0RUCDHOKiU1i5kSpAxGH9iELx5pzNNO1gpja7gI+3vE25DXW2Ppyl+anLaz/IZ3dM0OyfBGg==", + "version": "3.0.550", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.550.tgz", + "integrity": "sha512-Sy0inVCAbpIGDF//JqyKx8QB1IsoFw6KlV23EXFrFboC2KPAA5ohjmH6nhO10ypeNaPLNlaQJnE2U4OcZkEvOg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -5785,31 +5097,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/config-resolver": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", @@ -5877,76 +5164,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.5.tgz", - "integrity": "sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.5.tgz", - "integrity": "sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.3.tgz", - "integrity": "sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.5.tgz", - "integrity": "sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.5.tgz", - "integrity": "sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/fetch-http-handler": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", @@ -5963,21 +5180,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.5.tgz", - "integrity": "sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/hash-node": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", @@ -5993,20 +5195,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.5.tgz", - "integrity": "sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/invalid-dependency": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", @@ -6032,20 +5220,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", - "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/middleware-content-length": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", @@ -6506,20 +5680,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index fd3c9edeb..709a1a9e5 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.546", - "@aws-sdk/client-s3": "^3.679.0", + "@defra/forms-engine-plugin": "^3.0.0", + "@defra/forms-model": "^3.0.550", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/helpers/error-helper.js b/src/server/helpers/error-helper.js new file mode 100644 index 000000000..e3f53a7a7 --- /dev/null +++ b/src/server/helpers/error-helper.js @@ -0,0 +1,21 @@ +import Joi from 'joi' + +/** + * @param {string} fieldName + * @param {string} message + * @returns {Joi.ValidationError} + */ +export function createJoiError(fieldName, message) { + return new Joi.ValidationError( + message, + [ + { + message, + path: [fieldName], + type: 'custom', + context: { key: fieldName, label: fieldName } + } + ], + {} + ) +} diff --git a/src/server/index.ts b/src/server/index.ts index e2732b82f..0071063c9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -135,7 +135,7 @@ export const configureEnginePlugin = async ({ const pluginObject = { plugin, options: { - cacheName: 'session', + cache: 'session', nunjucks: { baseLayoutPath: 'layout.html', paths diff --git a/src/server/messaging/__stubs__/builder.js b/src/server/messaging/__stubs__/builder.js index be0607a89..6fbb18c72 100644 --- a/src/server/messaging/__stubs__/builder.js +++ b/src/server/messaging/__stubs__/builder.js @@ -18,10 +18,10 @@ export function buildSaveAndExitMessageData( return { form: { id: 'formId', - title: 'formId', - slug: 'my-form', + title: 'My First Form', isPreview: false, - status: FormStatus.Draft + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' }, security: { question: SecurityQuestionsEnum.MemorablePlace, diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index d53999e77..d225bc3de 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -6,9 +6,12 @@ import { SubmissionEventMessageType } from '@defra/forms-model' +import { config } from '~/src/config/index.js' + +const baseUrl = config.get('baseUrl') + /** * @param { string } formId - * @param { string } formSlug * @param { string } formTitle * @param { string } email * @param {{ question: SecurityQuestionsEnum, answer: string }} security @@ -18,7 +21,6 @@ import { */ export function saveAndExitMapper( formId, - formSlug, formTitle, email, security, @@ -29,10 +31,10 @@ export function saveAndExitMapper( const data = { form: { id: formId, - slug: formSlug, title: formTitle, status: status ?? FormStatus.Live, - isPreview: !!status + isPreview: !!status, + baseUrl }, email, security, diff --git a/src/server/messaging/mappers/events.test.js b/src/server/messaging/mappers/events.test.js index 631882c3c..d1e92987b 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -18,10 +18,10 @@ describe('runner-events', () => { const payload = { form: { id: 'formId', - title: 'formId', - slug: 'my-form', + title: 'My First Form', isPreview: true, - status: FormStatus.Draft + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' }, email: 'my-email@here.com', security: { @@ -37,7 +37,6 @@ describe('runner-events', () => { expect( saveAndExitMapper( payload.form.id, - payload.form.slug, payload.form.title, payload.email, payload.security, @@ -55,9 +54,9 @@ describe('runner-events', () => { form: { id: payload.form.id, title: payload.form.title, - slug: payload.form.slug, isPreview: payload.form.isPreview, - status: payload.form.status + status: payload.form.status, + baseUrl: 'http://localhost:3009' }, email: payload.email, security: { diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index 9dc0bc72a..6fcc89a1b 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -20,7 +20,6 @@ async function validateAndPublishEvent(saveAndExitMessage) { * Publish 'save and exit' event * The returned entityId will be a newly-generated guid * @param {string} formId - * @param {string} formSlug * @param {string} formTitle * @param {string} email * @param {{ question: SecurityQuestionsEnum, answer: string }} security @@ -29,7 +28,6 @@ async function validateAndPublishEvent(saveAndExitMessage) { */ export async function publishSaveAndExitEvent( formId, - formSlug, formTitle, email, security, @@ -38,7 +36,6 @@ export async function publishSaveAndExitEvent( ) { const message = saveAndExitMapper( formId, - formSlug, formTitle, email, security, diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index d6a8fcd3d..a99bc35bd 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -19,10 +19,10 @@ jest.mock('~/src/server/messaging/publish-base.js') const saveAndExitPayload = { form: { id: 'formId', - title: 'formId', - slug: 'my-form', + title: 'My First Form', isPreview: true, - status: FormStatus.Draft + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' }, email: 'my-email@here.com', security: { @@ -51,7 +51,6 @@ describe('publish', () => { it('should publish SAVE_AND_EXIT event', async () => { await publishSaveAndExitEvent( saveAndExitPayload.form.id, - saveAndExitPayload.form.slug, saveAndExitPayload.form.title, saveAndExitPayload.email, saveAndExitPayload.security, @@ -79,7 +78,7 @@ describe('publish', () => { publishSaveAndExitEvent(invalidPayload) ).rejects.toThrow( new ValidationError( - '"data.form.id" must be a string. "data.form.slug" is required. "data.form.title" is required. "data.email" is required. "data.state" is required', + '"data.form.id" must be a string. "data.form.title" is required. "data.email" is required. "data.state" is required', [], {} ) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 5ae7c0e2c..7aee34bf9 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -4,15 +4,19 @@ import Joi from 'joi' import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' +import { createJoiError } from '~/src/server/helpers/error-helper.js' const detailsPageTitle = 'Save your progress for later' const confirmationPageTitle = 'Your progress has been saved' +const MIN_PASSWORD_LENGTH = 3 +const MAX_PASSWORD_LENGTH = 40 + // Field names/ids const email = 'email' -const emailConfirmation = 'emailConfirmation' -const securityQuestion = 'securityQuestion' -const securityAnswer = 'securityAnswer' +const emailConfirmationFieldName = 'emailConfirmation' +const securityQuestionFieldName = 'securityQuestion' +const securityAnswerFieldName = 'securityAnswer' const GOVUK_LABEL__M = 'govuk-label--m' const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') @@ -48,13 +52,13 @@ function buildErrors(err) { const emailError = err.details.find((item) => item.path[0] === email) const emailConfirmationError = err.details.find( - (item) => item.path[0] === emailConfirmation + (item) => item.path[0] === emailConfirmationFieldName ) const securityQuestionError = err.details.find( - (item) => item.path[0] === securityQuestion + (item) => item.path[0] === securityQuestionFieldName ) const securityAnswerError = err.details.find( - (item) => item.path[0] === securityAnswer + (item) => item.path[0] === securityAnswerFieldName ) const errors = [] @@ -65,21 +69,21 @@ function buildErrors(err) { if (emailConfirmationError) { errors.push({ text: emailConfirmationError.message, - href: `#${emailConfirmation}` + href: `#${emailConfirmationFieldName}` }) } if (securityQuestionError) { errors.push({ text: securityQuestionError.message, - href: `#${securityQuestion}` + href: `#${securityQuestionFieldName}` }) } if (securityAnswerError) { errors.push({ text: securityAnswerError.message, - href: `#${securityAnswer}` + href: `#${securityAnswerFieldName}` }) } @@ -121,8 +125,8 @@ function buildEmailField(payload, error) { */ function buildEmailConfirmationField(payload, error) { return { - id: emailConfirmation, - name: emailConfirmation, + id: emailConfirmationFieldName, + name: emailConfirmationFieldName, label: { text: 'Confirm your email address', classes: GOVUK_LABEL__M, @@ -142,8 +146,8 @@ function buildEmailConfirmationField(payload, error) { */ function buildSecurityQuestionField(payload, error) { return { - id: securityQuestion, - name: securityQuestion, + id: securityQuestionFieldName, + name: securityQuestionFieldName, fieldset: { legend: { text: 'Choose a security question to answer', @@ -166,8 +170,8 @@ function buildSecurityQuestionField(payload, error) { */ function buildSecurityAnswerField(payload, error) { return { - id: securityAnswer, - name: securityAnswer, + id: securityAnswerFieldName, + name: securityAnswerFieldName, label: { text: 'Your answer to the security question', classes: GOVUK_LABEL__M @@ -189,6 +193,16 @@ export const paramsSchema = Joi.object() }) .required() +export const securityAnswerSchema = Joi.string() + .min(MIN_PASSWORD_LENGTH) + .max(MAX_PASSWORD_LENGTH) + .required() + .messages({ + 'string.min': 'Your answer must be between 3 and 40 characters long', + 'string.max': 'Your answer must be between 3 and 40 characters long', + '*': 'Enter an answer to the security question' + }) + /** * Save and exit form payload schema */ @@ -212,11 +226,7 @@ export const payloadSchema = Joi.object() .messages({ '*': 'Choose a security question to answer' }), - securityAnswer: Joi.string().min(3).max(40).required().messages({ - 'string.min': 'Your answer must be between 3 and 40 characters long', - 'string.max': 'Your answer must be between 3 and 40 characters long', - '*': 'Enter an answer to the security question' - }) + securityAnswer: securityAnswerSchema }) .required() @@ -232,16 +242,13 @@ export function getKey(slug, state) { /** * The save and exit details form view model * @param {FormMetadata} metadata - * @param {SaveAndExitPayload} [payload] * @param {FormStatus} [status] + * @param {SaveAndExitPayload} [payload] * @param {Error} [err] */ -export function detailsViewModel(metadata, payload, status, err) { +export function detailsViewModel(metadata, status, payload, err) { const { slug, title } = metadata - const isPreview = !!status - const formPath = isPreview - ? `${FORM_PREFIX}/preview/${status}/${slug}` - : `${FORM_PREFIX}/${slug}` + const formPath = constructFormUrl(slug, status) const backLink = { href: formPath @@ -258,15 +265,18 @@ export function detailsViewModel(metadata, payload, status, err) { // Model fields const fields = { [email]: buildEmailField(payload, emailError), - [emailConfirmation]: buildEmailConfirmationField( + [emailConfirmationFieldName]: buildEmailConfirmationField( payload, emailConfirmationError ), - [securityQuestion]: buildSecurityQuestionField( + [securityQuestionFieldName]: buildSecurityQuestionField( payload, securityQuestionError ), - [securityAnswer]: buildSecurityAnswerField(payload, securityAnswerError) + [securityAnswerFieldName]: buildSecurityAnswerField( + payload, + securityAnswerError + ) } // Model buttons @@ -298,10 +308,7 @@ export function detailsViewModel(metadata, payload, status, err) { */ export function confirmationViewModel(metadata, email, status) { const { slug, title } = metadata - const isPreview = !!status - const formPath = isPreview - ? `${FORM_PREFIX}/preview/${status}/${slug}` - : `${FORM_PREFIX}/${slug}` + const formPath = constructFormUrl(slug, status) return { name: title, @@ -312,6 +319,128 @@ export function confirmationViewModel(metadata, email, status) { } } +/** + * The save and exit password form view model + * @param {string} formTitle + * @param {SecurityQuestionsEnum} securityQuestion - the security question + * @param {SaveAndExitResumePasswordPayload} [payload] + * @param {Error} [err] + */ +export function saveAndExitPasswordViewModel( + formTitle, + securityQuestion, + payload, + err +) { + const pageTitle = 'Continue with your form' + const { errors, securityAnswerError } = buildErrors(err) + + // Model fields + const fields = { + [securityAnswerFieldName]: { + id: securityAnswerFieldName, + name: securityAnswerFieldName, + label: { + text: securityQuestions.find((x) => x.value === securityQuestion)?.text, + classes: GOVUK_LABEL__M + }, + value: payload?.securityAnswer ?? '', + errorMessage: securityAnswerError && { + text: securityAnswerError.message + } + } + } + + // Model buttons + const continueButton = { + text: 'Continue' + } + + return { + name: formTitle, + pageTitle, + errors, + fields, + buttons: { continueButton } + } +} + +/** + * The save and exit error form view model + * @param {{ slug: string }} payload + */ +export function saveAndExitResumeErrorViewModel(payload) { + const pageTitle = 'You cannot resume your form' + + // Model buttons + const continueButton = { + text: 'Start form again', + href: `/form/${payload.slug}` + } + + return { + pageTitle, + buttons: payload.slug ? { continueButton } : {} + } +} + +/** + * @param {number} attemptsRemaining + */ +export function createInvalidPasswordError(attemptsRemaining) { + return createJoiError( + securityAnswerFieldName, + `Your answer is incorrect. You have ${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} remaining.` + ) +} + +/** + * The save and exit form view model when user is locked out + * @param {FormMetadata} form + * @param {SaveAndExitResumeDetails} validatedLink + */ +export function saveAndExitLockedOutViewModel(form, validatedLink) { + return { + name: form.title, + buttons: { + continueButton: { + text: 'Start form again', + href: constructFormUrl(form.slug, validatedLink.form.status) + } + } + } +} + +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +export function constructFormUrl(slug, status) { + if (!status) { + return `${FORM_PREFIX}/${slug}` + } + + return `${FORM_PREFIX}/preview/${status}/${slug}` +} + +/** + * The save and exit success form view model + * @param {FormMetadata} form + * @param {FormStatus} [status] + */ +export function saveAndExitResumeSuccessViewModel(form, status) { + // Model buttons + const continueButton = { + text: 'Resume form', + href: constructFormUrl(form.slug, status) + } + + return { + name: form.title, + buttons: { continueButton } + } +} + /** * @typedef {object} SecurityQuestion * @property {string} text - the question text @@ -332,7 +461,27 @@ export function confirmationViewModel(metadata, email, status) { * @property {string} securityAnswer - the security answer */ +/** + * @typedef {object} SaveAndExitResumeParams + * @property {string} slug - the form slug + * @property {string} magicLinkId - the link parameter provided in the magic link + */ + +/** + * @typedef {object} SaveAndExitResumePasswordParams + * @property {string} formId - the form id answer + * @property {string} magicLinkId - the magic link id + * @property {string} slug - the form slug + * @property {FormStatus} [state] - the form status + */ + +/** + * @typedef {object} SaveAndExitResumePasswordPayload + * @property {string} securityAnswer - the security answer + */ + /** * @import { FormMetadata } from '@defra/forms-model' * @import { FormStatus } from '@defra/forms-engine-plugin/types' + * @import { SaveAndExitResumeDetails } from '~/src/server/types.js' */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 555946438..40e310975 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -43,7 +43,11 @@ import { type SaveAndExitPayload } from '~/src/server/models/save-and-exit.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' -import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' +import { + healthRoute, + publicRoutes, + saveAndExitRoutes +} from '~/src/server/routes/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] @@ -54,6 +58,7 @@ export default { name: 'router', register: (server) => { server.route(routes) + server.route(saveAndExitRoutes as ServerRoute[]) // /preview/{state}/{slug} -> {FORM_PREFIX}/preview/{state}/{slug} server.route({ @@ -298,15 +303,14 @@ export default { server.route<{ Params: SaveAndExitParams - Payload: SaveAndExitPayload }>({ method: 'GET', path: '/save-and-exit/{slug}/{state?}', async handler(request, h) { - const { params, payload } = request + const { params } = request const { slug, state: status } = params const metadata = await getFormMetadata(slug) - const model = saveAndExitDetailsViewModel(metadata, payload, status) + const model = saveAndExitDetailsViewModel(metadata, status) return h.view('save-and-exit-details', model) }, @@ -341,7 +345,6 @@ export default { await publishSaveAndExitEvent( metadata.id, - metadata.slug, metadata.title, email, security, diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 188dbaca0..356adf642 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,2 +1,3 @@ export { default as publicRoutes } from '~/src/server/routes/public.js' export { default as healthRoute } from '~/src/server/routes/health.js' +export { default as saveAndExitRoutes } from '~/src/server/routes/save-and-exit.js' diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js new file mode 100644 index 000000000..fe09cc5a9 --- /dev/null +++ b/src/server/routes/save-and-exit.js @@ -0,0 +1,290 @@ +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { slugSchema } from '@defra/forms-model' +import { StatusCodes } from 'http-status-codes' +import Joi from 'joi' + +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { + createInvalidPasswordError, + saveAndExitLockedOutViewModel, + saveAndExitPasswordViewModel, + saveAndExitResumeErrorViewModel, + saveAndExitResumeSuccessViewModel, + securityAnswerSchema +} from '~/src/server/models/save-and-exit.js' +import { + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails, + validateSaveAndExitCredentials +} from '~/src/server/services/formsService.js' + +const logger = createLogger() + +const maxInvalidPasswordAttempts = 3 + +const validateSaveAndExitSchema = Joi.object().keys({ + crumb: crumbSchema, + securityAnswer: securityAnswerSchema +}) + +const saveAndExitParamsSchema = Joi.object() + .keys({ + formId: Joi.string().required(), + magicLinkId: Joi.string().uuid().required(), + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + +// View paths +const ERROR_BASE_URL = '/save-and-exit-resume-error' +const RESUME_ERROR = 'save-and-exit/resume-error' +const RESUME_ERROR_LOCKED = 'save-and-exit/resume-error-locked' +const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password' +const RESUME_SUCCESS = 'save-and-exit/resume-success' + +export default [ + /** + * @satisfies {ServerRoute<{ Params: { formId: string, magicLinkId: string } }>} + */ + ({ + method: 'GET', + path: '/save-and-exit-resume/{formId}/{magicLinkId}', + async handler(request, h) { + const { params } = request + const { formId, magicLinkId } = params + + // Check form id + let form + try { + form = await getFormMetadataById(formId) + } catch (err) { + logger.error( + err, + `Invalid formId ${formId} in magic link id ${magicLinkId}` + ) + return h.redirect(ERROR_BASE_URL).code(StatusCodes.SEE_OTHER) + } + + // Check magic link id + let linkDetails + try { + linkDetails = await getSaveAndExitDetails(magicLinkId) + + if (!linkDetails) { + throw new Error('No link found') + } + } catch (err) { + logger.error( + err, + `Invalid magic link id ${magicLinkId} with form id ${formId}` + ) + return h + .redirect(`${ERROR_BASE_URL}/${form.slug}`) + .code(StatusCodes.SEE_OTHER) + } + + const { isPreview, status } = linkDetails.form + + const slugAndState = isPreview ? `/${status}` : '' + + return h.redirect( + `/save-and-exit-resume-verify/${formId}/${magicLinkId}/${form.slug}${slugAndState}` + ) + }, + options: { + validate: { + params: Joi.object() + .keys({ + formId: Joi.string().required(), + magicLinkId: Joi.string().uuid().required() + }) + .required() + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { formId: string, magicLinkId: string, slug: string, state?: string } }>} + */ + ({ + method: 'GET', + path: '/save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { formId, magicLinkId } = params + const resumeDetails = await getSaveAndExitDetails(magicLinkId) + + if (!resumeDetails) { + return h.redirect(ERROR_BASE_URL) + } + + // Check form id + let form + try { + form = await getFormMetadataById(resumeDetails.form.id) + } catch (err) { + logger.error( + err, + `Invalid formId ${formId} in magic link id ${magicLinkId}` + ) + return h.redirect(ERROR_BASE_URL) + } + + const model = saveAndExitPasswordViewModel( + form.title, + resumeDetails.question + ) + + return h.view(RESUME_PASSWORD_PATH, model) + }, + options: { + validate: { + params: saveAndExitParamsSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { slug: string } }>} + */ + ({ + method: 'GET', + path: '/save-and-exit-resume-error/{slug?}', + handler(request, h) { + const { params } = request + const { slug } = params + const model = saveAndExitResumeErrorViewModel({ slug }) + + return h.view(RESUME_ERROR, model) + }, + options: { + validate: { + params: Joi.object() + .keys({ + slug: slugSchema.optional() + }) + .required() + } + } + }), + /** + * @satisfies {ServerRoute<{ Payload: SaveAndExitResumePasswordPayload, Params: SaveAndExitResumePasswordParams }>} + */ + ({ + method: 'POST', + path: '/save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/{state?}', + async handler(request, h) { + const { params, payload } = request + const { formId, magicLinkId } = params + const { securityAnswer } = payload + + // Validate the security answer + const validatedLink = await validateSaveAndExitCredentials( + magicLinkId, + securityAnswer + ) + + // Reload form title in case it has changed + const form = await getFormMetadataById(formId) + + if (validatedLink.validPassword) { + // Restore state + const cacheService = getCacheService(request.server) + await cacheService.setState(request, validatedLink.state) + + const { isPreview, status } = validatedLink.form + + const slugAndState = isPreview ? `/${status}` : '' + + return h.redirect( + `/save-and-exit-resume-success/${form.slug}${slugAndState}` + ) + } + + const attemptsRemaining = + maxInvalidPasswordAttempts - validatedLink.invalidPasswordAttempts + if (attemptsRemaining > 0) { + // User has more password attempts left + logger.info( + `Invalid password attempt for form id ${validatedLink.form.id}` + ) + const error = createInvalidPasswordError(attemptsRemaining) + + const model = saveAndExitPasswordViewModel( + form.title, + validatedLink.question, + undefined, + error + ) + + return h.view(RESUME_PASSWORD_PATH, model) + } else { + // Locked out + const model = saveAndExitLockedOutViewModel(form, validatedLink) + return h.view(RESUME_ERROR_LOCKED, model) + } + }, + options: { + validate: { + params: saveAndExitParamsSchema, + payload: validateSaveAndExitSchema, + failAction: async (request, h, error) => { + const params = /** @type {SaveAndExitResumePasswordParams} */ ( + request.params + ) + const payload = /** @type {SaveAndExitResumePasswordPayload} */ ( + request.payload + ) + const resumeDetails = await getSaveAndExitDetails(params.magicLinkId) + + if (!resumeDetails) { + return h.redirect(ERROR_BASE_URL).takeover() + } + + const form = await getFormMetadataById(resumeDetails.form.id) + + const model = saveAndExitPasswordViewModel( + form.title, + resumeDetails.question, + payload, + error + ) + + return h.view(RESUME_PASSWORD_PATH, model).takeover() + } + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { slug: string, state?: string} }>} + */ + ({ + method: 'GET', + path: '/save-and-exit-resume-success/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state } = params + const form = await getFormMetadata(slug) + const model = saveAndExitResumeSuccessViewModel(form, state) + + return h.view(RESUME_SUCCESS, model) + }, + options: { + validate: { + params: Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + } + } + }) +] + +/** + * @import { ServerRoute } from '@hapi/hapi' + * @import { FormStatus } from '@defra/forms-model' + * @import { SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' + */ diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js new file mode 100644 index 000000000..6219535d0 --- /dev/null +++ b/src/server/routes/save-and-exit.test.js @@ -0,0 +1,308 @@ +import { StatusCodes } from 'http-status-codes' + +import { createServer } from '~/src/server/index.js' +import { + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails +} from '~/src/server/services/formsService.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formsService.js') + +describe('Save-and-exit check routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer() + await server.initialize() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' + const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' + + describe('GET /save-and-exit-resume/{formId}/{magicLinkId}', () => { + test('/route forwards correctly on success', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + ) + }) + + test('/route forwards correctly on invalid form error', async () => { + jest.mocked(getFormMetadataById).mockImplementationOnce(() => { + throw new Error('form not found') + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/save-and-exit-resume-error') + }) + + test('/route forwards correctly on magic link error', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(() => { + throw new Error('magic link not found') + }) + + const options = { + method: 'GET', + url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/save-and-exit-resume-error/my-form-to-resume' + ) + }) + + test('/route forwards correctly on magic link error 2', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce(undefined) + + const options = { + method: 'GET', + url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/save-and-exit-resume-error/my-form-to-resume' + ) + }) + }) + + describe('GET /save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/state?}', () => { + test('/route renders page', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + }) + + test('/route forwards correctly on invalid form error', async () => { + jest.mocked(getFormMetadataById).mockImplementationOnce(() => { + throw new Error('form not found') + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/save-and-exit-resume-error') + }) + + test('/route forwards correctly on magic link error', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(undefined) + + const options = { + method: 'GET', + url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/save-and-exit-resume-error') + }) + }) + + describe('GET /save-and-exit-resume-error', () => { + test('/route renders page without slug', async () => { + const options = { + method: 'GET', + url: '/save-and-exit-resume-error' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + + const $button = container.queryByRole('button', { + name: 'Start form again' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).not.toBeInTheDocument() + }) + + test('/route renders page with slug', async () => { + const options = { + method: 'GET', + url: '/save-and-exit-resume-error/my-slug' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + + const $button = container.queryByRole('button', { + name: 'Start form again' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute('href', '/form/my-slug') + }) + }) + + describe('GET /save-and-exit-resume-success', () => { + test('/route renders page without state', async () => { + jest + .mocked(getFormMetadata) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + + const options = { + method: 'GET', + url: '/save-and-exit-resume-success/my-slug' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Welcome back to your form') + + const $button = container.queryByRole('button', { + name: 'Resume form' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute('href', '/form/my-form-to-resume') + }) + + test('/route renders page with slug', async () => { + jest + .mocked(getFormMetadata) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + + const options = { + method: 'GET', + url: '/save-and-exit-resume-success/my-slug/draft' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Welcome back to your form') + + const $button = container.queryByRole('button', { + name: 'Resume form' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute( + 'href', + '/form/preview/draft/my-form-to-resume' + ) + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index e43ac804f..5ee0599d5 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -2,17 +2,41 @@ import { FormStatus } from '@defra/forms-engine-plugin/types' import { formMetadataSchema } from '@defra/forms-model' import { config } from '~/src/config/index.js' -import { getJson } from '~/src/server/services/httpService.js' +import { getJson, postJson } from '~/src/server/services/httpService.js' + +const managerUrl = config.get('managerUrl') +const submissionUrl = config.get('submissionUrl') /** - * Retrieves a form definition from the form manager for a given slug + * Retrieves a form metadata from the form manager for a given slug * @param {string} slug - the slug of the form */ export async function getFormMetadata(slug) { const getJsonByType = /** @type {typeof getJson} */ (getJson) const { payload: metadata } = await getJsonByType( - `${config.get('managerUrl')}/forms/slug/${slug}` + `${managerUrl}/forms/slug/${slug}` + ) + + // Run it through the schema to coerce dates + const result = formMetadataSchema.validate(metadata) + + if (result.error) { + throw result.error + } + + return result.value +} + +/** + * Retrieves a form metadata from the form manager for a given form id + * @param {string} formId - the slug of the form + */ +export async function getFormMetadataById(formId) { + const getJsonByType = /** @type {typeof getJson} */ (getJson) + + const { payload: metadata } = await getJsonByType( + `${managerUrl}/forms/${formId}` ) // Run it through the schema to coerce dates @@ -35,12 +59,59 @@ export async function getFormDefinition(id, state) { const suffix = state === FormStatus.Draft ? `/${state}` : '' const { payload: definition } = await getJsonByType( - `${config.get('managerUrl')}/forms/${id}/definition${suffix}` + `${managerUrl}/forms/${id}/definition${suffix}` ) return definition } +/** + * Retrieves a save-and-exit record from the form submission api for a given magic link + * @param {string} magicLinkId - the id of the magic link + */ +export async function getSaveAndExitDetails(magicLinkId) { + const getJsonByType = /** @type {typeof getJson} */ ( + getJson + ) + + const { payload: results } = await getJsonByType( + `${submissionUrl}/save-and-exit/${magicLinkId}` + ) + + return results +} + +/** + * Retrieves a save-and-exit record from the form submission api for a given magic link + * @param {string} magicLinkId - the id of the magic link + * @param {string} securityAnswer - the security answer provided by the user + */ +export async function validateSaveAndExitCredentials( + magicLinkId, + securityAnswer +) { + const postJsonByType = + /** @type {typeof postJson} */ (postJson) + + const { payload: results } = await postJsonByType( + `${submissionUrl}/save-and-exit/${magicLinkId}`, + { + payload: { + securityAnswer + } + } + ) + + if (!results) { + throw new Error( + 'Unexpected empty response in validateSaveAndExitCredentials' + ) + } + + return results +} + /** * @import { FormDefinition, FormMetadata } from '@defra/forms-model' + * @import { SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' */ diff --git a/src/server/types.ts b/src/server/types.ts index d212f88a4..2569304f0 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -8,6 +8,7 @@ import { import { type FormDefinition, type FormMetadata, + type SecurityQuestionsEnum, type SubmitPayload, type SubmitResponsePayload } from '@defra/forms-model' @@ -51,3 +52,19 @@ export interface OutputService { formMetadata?: FormMetadata ) => Promise } + +export interface SaveAndExitDetails { + form: { + id: string + status: FormStatus + isPreview: boolean + baseUrl: string + } + question: SecurityQuestionsEnum + invalidPasswordAttempts: number + state: object +} + +export interface SaveAndExitResumeDetails extends SaveAndExitDetails { + validPassword: boolean +} diff --git a/src/server/views/save-and-exit/resume-error-locked.html b/src/server/views/save-and-exit/resume-error-locked.html new file mode 100644 index 000000000..2abf88a1b --- /dev/null +++ b/src/server/views/save-and-exit/resume-error-locked.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+

You cannot resume your form

+

+ The answer to your security question was incorrect 3 times. You have run out of attempts to resume your form. +

+

+ You will need to start the form again. Your information will be securely deleted. +

+
+ {{ govukButton(buttons.continueButton) }} +
+
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-error.html b/src/server/views/save-and-exit/resume-error.html new file mode 100644 index 000000000..1c820f962 --- /dev/null +++ b/src/server/views/save-and-exit/resume-error.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+

{{ pageTitle }}

+

+ The link we emailed to you has expired or is invalid. Your information will be securely deleted. +

+

+ You will need to start the form again. +

+ {% if buttons.continueButton %} +
+ {{ govukButton(buttons.continueButton) }} +
+ {% endif %} +
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-password.html b/src/server/views/save-and-exit/resume-password.html new file mode 100644 index 000000000..3da5d928c --- /dev/null +++ b/src/server/views/save-and-exit/resume-password.html @@ -0,0 +1,40 @@ +{% extends "layout.html" %} + +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% block content %} +
+
+ {% if errors | length %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errors + }) }} + {% endif %} + +

{{ pageTitle }}

+

+ Enter the answer to your security question to retrieve your information and continue with your form. +

+

+ You have 3 attempts to enter your answer. Make sure your answer matches the exact style and format when used when saving your progress. +

+
+ + {{ govukInput(fields.securityAnswer) }} + + {{ govukWarningText({ + text: "This will use up your save link. To save progress again, you will need to repeat the save process and generate another link.", + iconFallbackText: "Warning" + }) }} + +
+ {{ govukButton(buttons.continueButton) }} +
+
+
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-success.html b/src/server/views/save-and-exit/resume-success.html new file mode 100644 index 000000000..c413da6a0 --- /dev/null +++ b/src/server/views/save-and-exit/resume-success.html @@ -0,0 +1,29 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% block content %} +
+
+

Welcome back to your form

+

+ You will return to the page where you saved your progress unless new questions have been added to the form. +

+ + {{ govukWarningText({ + text: "If new questions have been added to the form, you will be taken to the earliest new question.", + iconFallbackText: "Warning" + }) }} + +

+ You will need to repeat the save process again and generate a new link if you want to save your progress in the future. +

+
+
+ {{ govukButton(buttons.continueButton) }} +
+
+
+
+{% endblock %} From 493ddfa0ae3c52b84c1dced6d2ea4bf0c9d85567 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 14:54:07 +0100 Subject: [PATCH 14/27] Update JSDoc function description --- src/server/services/formsService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 5ee0599d5..149a368a6 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -82,7 +82,7 @@ export async function getSaveAndExitDetails(magicLinkId) { } /** - * Retrieves a save-and-exit record from the form submission api for a given magic link + * Validates correct password for a save-and-exit record from the form submission api for a given magic link * @param {string} magicLinkId - the id of the magic link * @param {string} securityAnswer - the security answer provided by the user */ From f220e24071a9544adba28b7907086b3d637767e6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:00:01 +0100 Subject: [PATCH 15/27] Sonar fixes (Nested template literals) --- src/server/plugins/router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 40e310975..607690c19 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -361,9 +361,9 @@ export default { request.yar.flash(getKey(slug, status), email) // Redirect to the save and exit confirmation page - return h.redirect( - `/save-and-exit/${slug}/confirmation${status ? `/${status}` : ''}` - ) + const statusPath = status ? `/${status}` : '' + + return h.redirect(`/save-and-exit/${slug}/confirmation${statusPath}`) }, options: { validate: { From 6656d1249f12be5d88eccb843938376b92141e93 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:00:20 +0100 Subject: [PATCH 16/27] Sonar fixes (Variable declared in the upper scope) --- src/server/models/save-and-exit.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 7aee34bf9..6bdd53fcb 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -13,7 +13,7 @@ const MIN_PASSWORD_LENGTH = 3 const MAX_PASSWORD_LENGTH = 40 // Field names/ids -const email = 'email' +const emailFieldName = 'email' const emailConfirmationFieldName = 'emailConfirmation' const securityQuestionFieldName = 'securityQuestion' const securityAnswerFieldName = 'securityAnswer' @@ -50,7 +50,7 @@ function buildErrors(err) { return {} } - const emailError = err.details.find((item) => item.path[0] === email) + const emailError = err.details.find((item) => item.path[0] === emailFieldName) const emailConfirmationError = err.details.find( (item) => item.path[0] === emailConfirmationFieldName ) @@ -63,7 +63,7 @@ function buildErrors(err) { const errors = [] if (emailError) { - errors.push({ text: emailError.message, href: `#${email}` }) + errors.push({ text: emailError.message, href: `#${emailFieldName}` }) } if (emailConfirmationError) { @@ -103,8 +103,8 @@ function buildErrors(err) { */ function buildEmailField(payload, error) { return { - id: email, - name: email, + id: emailFieldName, + name: emailFieldName, label: { text: 'Your email address', classes: GOVUK_LABEL__M, @@ -264,7 +264,7 @@ export function detailsViewModel(metadata, status, payload, err) { // Model fields const fields = { - [email]: buildEmailField(payload, emailError), + [emailFieldName]: buildEmailField(payload, emailError), [emailConfirmationFieldName]: buildEmailConfirmationField( payload, emailConfirmationError From ed35d88a82b524a20d61ed6b5f85fc38d830cf35 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:16:34 +0100 Subject: [PATCH 17/27] Rename /save-and-exit-resume to /resume-form --- src/server/routes/save-and-exit.js | 22 +++++++------ src/server/routes/save-and-exit.test.js | 42 ++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index fe09cc5a9..418b00bfa 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -38,8 +38,9 @@ const saveAndExitParamsSchema = Joi.object() }) .required() +const ERROR_BASE_URL = '/resume-form-error' + // View paths -const ERROR_BASE_URL = '/save-and-exit-resume-error' const RESUME_ERROR = 'save-and-exit/resume-error' const RESUME_ERROR_LOCKED = 'save-and-exit/resume-error-locked' const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password' @@ -51,7 +52,7 @@ export default [ */ ({ method: 'GET', - path: '/save-and-exit-resume/{formId}/{magicLinkId}', + path: '/resume-form/{formId}/{magicLinkId}', async handler(request, h) { const { params } = request const { formId, magicLinkId } = params @@ -81,6 +82,9 @@ export default [ err, `Invalid magic link id ${magicLinkId} with form id ${formId}` ) + } + + if (!linkDetails || form.id !== linkDetails.form.id) { return h .redirect(`${ERROR_BASE_URL}/${form.slug}`) .code(StatusCodes.SEE_OTHER) @@ -91,7 +95,7 @@ export default [ const slugAndState = isPreview ? `/${status}` : '' return h.redirect( - `/save-and-exit-resume-verify/${formId}/${magicLinkId}/${form.slug}${slugAndState}` + `/resume-form-verify/${formId}/${magicLinkId}/${form.slug}${slugAndState}` ) }, options: { @@ -110,7 +114,7 @@ export default [ */ ({ method: 'GET', - path: '/save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/{state?}', + path: '/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', async handler(request, h) { const { params } = request const { formId, magicLinkId } = params @@ -150,7 +154,7 @@ export default [ */ ({ method: 'GET', - path: '/save-and-exit-resume-error/{slug?}', + path: '/resume-form-error/{slug?}', handler(request, h) { const { params } = request const { slug } = params @@ -173,7 +177,7 @@ export default [ */ ({ method: 'POST', - path: '/save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/{state?}', + path: '/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', async handler(request, h) { const { params, payload } = request const { formId, magicLinkId } = params @@ -197,9 +201,7 @@ export default [ const slugAndState = isPreview ? `/${status}` : '' - return h.redirect( - `/save-and-exit-resume-success/${form.slug}${slugAndState}` - ) + return h.redirect(`/resume-form-success/${form.slug}${slugAndState}`) } const attemptsRemaining = @@ -261,7 +263,7 @@ export default [ */ ({ method: 'GET', - path: '/save-and-exit-resume-success/{slug}/{state?}', + path: '/resume-form-success/{slug}/{state?}', async handler(request, h) { const { params } = request const { slug, state } = params diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 6219535d0..4c459093d 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -26,7 +26,7 @@ describe('Save-and-exit check routes', () => { const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' - describe('GET /save-and-exit-resume/{formId}/{magicLinkId}', () => { + describe('GET /resume-form/{formId}/{magicLinkId}', () => { test('/route forwards correctly on success', async () => { jest .mocked(getFormMetadataById) @@ -42,14 +42,14 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) expect(response.headers.location).toBe( - `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` ) }) @@ -67,13 +67,13 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(response.headers.location).toBe('/save-and-exit-resume-error') + expect(response.headers.location).toBe('/resume-form-error') }) test('/route forwards correctly on magic link error', async () => { @@ -87,14 +87,14 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) expect(response.headers.location).toBe( - '/save-and-exit-resume-error/my-form-to-resume' + '/resume-form-error/my-form-to-resume' ) }) @@ -107,19 +107,19 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume/${FORM_ID}/${MAGIC_LINK_ID}` + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) expect(response.headers.location).toBe( - '/save-and-exit-resume-error/my-form-to-resume' + '/resume-form-error/my-form-to-resume' ) }) }) - describe('GET /save-and-exit-resume-verify/{formId}/{magicLinkId}/{slug}/state?}', () => { + describe('GET /resume-form-verify/{formId}/{magicLinkId}/{slug}/state?}', () => { test('/route renders page', async () => { jest .mocked(getFormMetadataById) @@ -138,7 +138,7 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` } const { response, container } = await renderResponse(server, options) @@ -163,13 +163,13 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe('/save-and-exit-resume-error') + expect(response.headers.location).toBe('/resume-form-error') }) test('/route forwards correctly on magic link error', async () => { @@ -182,21 +182,21 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-resume-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` } const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe('/save-and-exit-resume-error') + expect(response.headers.location).toBe('/resume-form-error') }) }) - describe('GET /save-and-exit-resume-error', () => { + describe('GET /resume-form-error', () => { test('/route renders page without slug', async () => { const options = { method: 'GET', - url: '/save-and-exit-resume-error' + url: '/resume-form-error' } const { response, container } = await renderResponse(server, options) @@ -218,7 +218,7 @@ describe('Save-and-exit check routes', () => { test('/route renders page with slug', async () => { const options = { method: 'GET', - url: '/save-and-exit-resume-error/my-slug' + url: '/resume-form-error/my-slug' } const { response, container } = await renderResponse(server, options) @@ -239,7 +239,7 @@ describe('Save-and-exit check routes', () => { }) }) - describe('GET /save-and-exit-resume-success', () => { + describe('GET /resume-form-success', () => { test('/route renders page without state', async () => { jest .mocked(getFormMetadata) @@ -251,7 +251,7 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: '/save-and-exit-resume-success/my-slug' + url: '/resume-form-success/my-slug' } const { response, container } = await renderResponse(server, options) @@ -280,7 +280,7 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: '/save-and-exit-resume-success/my-slug/draft' + url: '/resume-form-success/my-slug/draft' } const { response, container } = await renderResponse(server, options) From 7bd2ae00797cc1db66fc0cd20d69f76a1f731b55 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:50:52 +0100 Subject: [PATCH 18/27] Combine save and exit routes into 1 file --- src/server/models/save-and-exit.js | 42 ++++++--- src/server/plugins/router.ts | 135 +------------------------- src/server/routes/save-and-exit.js | 147 ++++++++++++++++++++++++----- 3 files changed, 156 insertions(+), 168 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 6bdd53fcb..3e1100417 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -183,16 +183,6 @@ function buildSecurityAnswerField(payload, error) { } } -/** - * Save and exit params - */ -export const paramsSchema = Joi.object() - .keys({ - slug: slugSchema, - state: stateSchema.optional() - }) - .required() - export const securityAnswerSchema = Joi.string() .min(MIN_PASSWORD_LENGTH) .max(MAX_PASSWORD_LENGTH) @@ -203,6 +193,16 @@ export const securityAnswerSchema = Joi.string() '*': 'Enter an answer to the security question' }) +/** + * Save and exit params schema + */ +export const paramsSchema = Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + /** * Save and exit form payload schema */ @@ -230,6 +230,26 @@ export const payloadSchema = Joi.object() }) .required() +/** + * Save and exit resume params schema + */ +export const resumeParamsSchema = Joi.object() + .keys({ + formId: Joi.string().required(), + magicLinkId: Joi.string().uuid().required(), + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + +/** + * Save and exit validate payload schema + */ +export const validatePayloadSchema = Joi.object().keys({ + crumb: crumbSchema, + securityAnswer: securityAnswerSchema +}) + /** * Get save and exit session key * @param {string} slug @@ -457,7 +477,7 @@ export function saveAndExitResumeSuccessViewModel(form, status) { * @typedef {object} SaveAndExitPayload * @property {string} email - email * @property {string} emailConfirmation - email confirmation - * @property {string} securityQuestion - the security question + * @property {SecurityQuestionsEnum} securityQuestion - the security question * @property {string} securityAnswer - the security answer */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 607690c19..7e932f178 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -1,5 +1,4 @@ import { - getCacheService, handleLegacyRedirect, isPathRelative } from '@defra/forms-engine-plugin/engine/helpers.js' @@ -9,11 +8,7 @@ import { pathSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' -import { - type FormRequestPayload, - type FormStatus -} from '@defra/forms-engine-plugin/types' -import { slugSchema, type SecurityQuestionsEnum } from '@defra/forms-model' +import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' import { type Request, @@ -32,16 +27,6 @@ import { import { type CookieConsent } from '~/src/common/types.js' import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' -import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' -import { - confirmationViewModel as saveAndExitConfirmationViewModel, - detailsViewModel as saveAndExitDetailsViewModel, - getKey, - paramsSchema as saveAndExitParamsSchema, - payloadSchema as saveAndExitPayloadSchema, - type SaveAndExitParams, - type SaveAndExitPayload -} from '~/src/server/models/save-and-exit.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { healthRoute, @@ -300,124 +285,6 @@ export default { } } }) - - server.route<{ - Params: SaveAndExitParams - }>({ - method: 'GET', - path: '/save-and-exit/{slug}/{state?}', - async handler(request, h) { - const { params } = request - const { slug, state: status } = params - const metadata = await getFormMetadata(slug) - const model = saveAndExitDetailsViewModel(metadata, status) - - return h.view('save-and-exit-details', model) - }, - options: { - validate: { - params: saveAndExitParamsSchema - } - } - }) - - server.route<{ - Params: SaveAndExitParams - Payload: SaveAndExitPayload - }>({ - method: 'POST', - path: '/save-and-exit/{slug}/{state?}', - async handler(request, h) { - const { params, payload } = request - const { slug, state: status } = params - const { email, securityQuestion, securityAnswer } = payload - const metadata = await getFormMetadata(slug) - const cacheService = getCacheService(request.server) - - // Publish topic message - const security = { - question: securityQuestion as SecurityQuestionsEnum, - answer: securityAnswer - } - const state = await cacheService.getState( - request as unknown as FormRequestPayload - ) - - await publishSaveAndExitEvent( - metadata.id, - metadata.title, - email, - security, - state, - status - ) - - // Clear all form data - await cacheService.clearState( - request as unknown as FormRequestPayload - ) - - // Flash the email over to the confirmation page - request.yar.flash(getKey(slug, status), email) - - // Redirect to the save and exit confirmation page - const statusPath = status ? `/${status}` : '' - - return h.redirect(`/save-and-exit/${slug}/confirmation${statusPath}`) - }, - options: { - validate: { - async failAction(request, h, err) { - const { params, payload } = request - const { slug, state: status } = params - const metadata = await getFormMetadata(slug) - const model = saveAndExitDetailsViewModel( - metadata, - payload as SaveAndExitPayload, - status as FormStatus, - err - ) - - return h.view('save-and-exit-details', model).takeover() - }, - params: saveAndExitParamsSchema, - payload: saveAndExitPayloadSchema - } - } - }) - - server.route<{ - Params: SaveAndExitParams - }>({ - method: 'GET', - path: '/save-and-exit/{slug}/confirmation/{state?}', - async handler(request, h) { - const { params } = request - const { slug, state: status } = params - const metadata = await getFormMetadata(slug) - - // Get the flashed email - const messages = request.yar.flash(getKey(slug, status)) - - if (messages.length === 0) { - return Boom.badRequest('No email found in flash cache') - } - - const email = messages[0] - const model = saveAndExitConfirmationViewModel( - metadata, - email, - status - ) - - return h.view('save-and-exit-confirmation', model) - }, - options: { - validate: { - params: saveAndExitParamsSchema - } - } - }) } } } satisfies ServerRegisterPluginObject diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 418b00bfa..4a090510c 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,17 +1,25 @@ import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' -import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { stateSchema } from '@defra/forms-engine-plugin/schema.js' import { slugSchema } from '@defra/forms-model' +import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { + confirmationViewModel as saveAndExitConfirmationViewModel, createInvalidPasswordError, + detailsViewModel as saveAndExitDetailsViewModel, + getKey, + paramsSchema, + payloadSchema, + resumeParamsSchema, saveAndExitLockedOutViewModel, saveAndExitPasswordViewModel, saveAndExitResumeErrorViewModel, saveAndExitResumeSuccessViewModel, - securityAnswerSchema + validatePayloadSchema } from '~/src/server/models/save-and-exit.js' import { getFormMetadata, @@ -19,25 +27,10 @@ import { getSaveAndExitDetails, validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' - const logger = createLogger() const maxInvalidPasswordAttempts = 3 -const validateSaveAndExitSchema = Joi.object().keys({ - crumb: crumbSchema, - securityAnswer: securityAnswerSchema -}) - -const saveAndExitParamsSchema = Joi.object() - .keys({ - formId: Joi.string().required(), - magicLinkId: Joi.string().uuid().required(), - slug: slugSchema, - state: stateSchema.optional() - }) - .required() - const ERROR_BASE_URL = '/resume-form-error' // View paths @@ -47,6 +40,115 @@ const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password' const RESUME_SUCCESS = 'save-and-exit/resume-success' export default [ + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} + */ + ({ + method: 'GET', + path: '/save-and-exit/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + const model = saveAndExitDetailsViewModel(metadata, status) + + return h.view('save-and-exit-details', model) + }, + options: { + validate: { + params: paramsSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams, Payload: SaveAndExitPayload }>} + */ + ({ + method: 'POST', + path: '/save-and-exit/{slug}/{state?}', + async handler(request, h) { + const { params, payload } = request + const { slug, state: status } = params + const { email, securityQuestion, securityAnswer } = payload + const metadata = await getFormMetadata(slug) + const cacheService = getCacheService(request.server) + + // Publish topic message + const security = { + question: securityQuestion, + answer: securityAnswer + } + const state = await cacheService.getState(request) + + await publishSaveAndExitEvent( + metadata.id, + metadata.title, + email, + security, + state, + status + ) + + // Clear all form data + await cacheService.clearState(request) + + // Flash the email over to the confirmation page + request.yar.flash(getKey(slug, status), email) + + // Redirect to the save and exit confirmation page + const statusPath = status ? `/${status}` : '' + + return h.redirect(`/save-and-exit/${slug}/confirmation${statusPath}`) + }, + options: { + validate: { + async failAction(request, h, err) { + const { params, payload } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + const model = saveAndExitDetailsViewModel( + metadata, + payload, + status, + err + ) + + return h.view('save-and-exit-details', model).takeover() + }, + params: paramsSchema, + payload: payloadSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} + */ + ({ + method: 'GET', + path: '/save-and-exit/{slug}/confirmation/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + + // Get the flashed email + const messages = request.yar.flash(getKey(slug, status)) + + if (messages.length === 0) { + return Boom.badRequest('No email found in flash cache') + } + + const email = messages[0] + const model = saveAndExitConfirmationViewModel(metadata, email, status) + + return h.view('save-and-exit-confirmation', model) + }, + options: { + validate: { + params: paramsSchema + } + } + }), /** * @satisfies {ServerRoute<{ Params: { formId: string, magicLinkId: string } }>} */ @@ -110,7 +212,7 @@ export default [ } }), /** - * @satisfies {ServerRoute<{ Params: { formId: string, magicLinkId: string, slug: string, state?: string } }>} + * @satisfies {ServerRoute<{ Params: SaveAndExitResumePasswordParams }>} */ ({ method: 'GET', @@ -145,7 +247,7 @@ export default [ }, options: { validate: { - params: saveAndExitParamsSchema + params: resumeParamsSchema } } }), @@ -229,8 +331,8 @@ export default [ }, options: { validate: { - params: saveAndExitParamsSchema, - payload: validateSaveAndExitSchema, + params: resumeParamsSchema, + payload: validatePayloadSchema, failAction: async (request, h, error) => { const params = /** @type {SaveAndExitResumePasswordParams} */ ( request.params @@ -287,6 +389,5 @@ export default [ /** * @import { ServerRoute } from '@hapi/hapi' - * @import { FormStatus } from '@defra/forms-model' - * @import { SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' + * @import { SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' */ From 2b16c4127268d52e586b0599f52d2de3e34c2838 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:52:43 +0100 Subject: [PATCH 19/27] Remove import alias --- src/server/routes/save-and-exit.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 4a090510c..f126ae962 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -8,9 +8,9 @@ import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { - confirmationViewModel as saveAndExitConfirmationViewModel, + confirmationViewModel, createInvalidPasswordError, - detailsViewModel as saveAndExitDetailsViewModel, + detailsViewModel, getKey, paramsSchema, payloadSchema, @@ -50,7 +50,7 @@ export default [ const { params } = request const { slug, state: status } = params const metadata = await getFormMetadata(slug) - const model = saveAndExitDetailsViewModel(metadata, status) + const model = detailsViewModel(metadata, status) return h.view('save-and-exit-details', model) }, @@ -106,12 +106,7 @@ export default [ const { params, payload } = request const { slug, state: status } = params const metadata = await getFormMetadata(slug) - const model = saveAndExitDetailsViewModel( - metadata, - payload, - status, - err - ) + const model = detailsViewModel(metadata, payload, status, err) return h.view('save-and-exit-details', model).takeover() }, @@ -139,7 +134,7 @@ export default [ } const email = messages[0] - const model = saveAndExitConfirmationViewModel(metadata, email, status) + const model = confirmationViewModel(metadata, email, status) return h.view('save-and-exit-confirmation', model) }, From 1cc65799eac4774981ea3df8cf63bff5dd39212f Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 15:55:56 +0100 Subject: [PATCH 20/27] TS in JSDoc imports at the bottom --- src/server/messaging/publish.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index a99bc35bd..8f1b7e341 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -14,7 +14,7 @@ import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' jest.mock('~/src/server/messaging/publish-base.js') /** - * @type {import('@defra/forms-model').SaveAndExitMessageData} + * @type {SaveAndExitMessageData} */ const saveAndExitPayload = { form: { @@ -86,3 +86,7 @@ describe('publish', () => { }) }) }) + +/** + * @import { SaveAndExitMessageData } from '@defra/forms-model' + */ From c4d7e3eab14271dda0b98ebcc4b3a883d313fef5 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 8 Sep 2025 16:13:00 +0100 Subject: [PATCH 21/27] Extra coverage --- src/server/routes/save-and-exit.test.js | 117 +++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 4c459093d..0dd45dd0b 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -1,14 +1,17 @@ import { StatusCodes } from 'http-status-codes' +import { createJoiError } from '~/src/server/helpers/error-helper.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata, getFormMetadataById, - getSaveAndExitDetails + getSaveAndExitDetails, + validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' import { renderResponse } from '~/test/helpers/component-helpers.js' jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/helpers/error-helper.js') describe('Save-and-exit check routes', () => { /** @type {Server} */ @@ -301,6 +304,118 @@ describe('Save-and-exit check routes', () => { ) }) }) + + describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { + test('/route handles valid password', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: true, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'valid' + } + } + + const { response } = await renderResponse(server, options) + + // TODO - fix test + expect(response.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR) + // expect(response.headers.location).toBe( + // `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + // ) + }) + + test('/route handles invalid password', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'invalid' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + expect(createJoiError).toHaveBeenCalledWith( + 'securityAnswer', + 'Your answer is incorrect. You have 2 attempts remaining.' + ) + }) + + test('/route handles lockout', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 3, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'invalid' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + expect($mastheadHeading).toBeInTheDocument() + const $errorMessage = container.getByText( + 'The answer to your security question was incorrect 3 times. You have run out of attempts to resume your form.' + ) + expect($errorMessage).toBeInTheDocument() + }) + }) }) /** From bc8777a7adec728eba9b5092c3fa77c179ca4aca Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Sep 2025 16:14:31 +0100 Subject: [PATCH 22/27] Sonar fixes (parameters order) --- src/server/routes/save-and-exit.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index f126ae962..b2402afe4 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -106,7 +106,12 @@ export default [ const { params, payload } = request const { slug, state: status } = params const metadata = await getFormMetadata(slug) - const model = detailsViewModel(metadata, payload, status, err) + const model = detailsViewModel( + metadata, + status, + /** @type {SaveAndExitPayload} */ (payload), + err + ) return h.view('save-and-exit-details', model).takeover() }, From 1c1ce99a3e96a10e857a0105ebc97137276b8f63 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 8 Sep 2025 16:29:48 +0100 Subject: [PATCH 23/27] Renamed models Extra tests --- src/server/models/save-and-exit.js | 13 +++----- src/server/routes/save-and-exit.js | 23 ++++++-------- src/server/routes/save-and-exit.test.js | 41 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 3e1100417..58520d431 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -346,12 +346,7 @@ export function confirmationViewModel(metadata, email, status) { * @param {SaveAndExitResumePasswordPayload} [payload] * @param {Error} [err] */ -export function saveAndExitPasswordViewModel( - formTitle, - securityQuestion, - payload, - err -) { +export function passwordViewModel(formTitle, securityQuestion, payload, err) { const pageTitle = 'Continue with your form' const { errors, securityAnswerError } = buildErrors(err) @@ -389,7 +384,7 @@ export function saveAndExitPasswordViewModel( * The save and exit error form view model * @param {{ slug: string }} payload */ -export function saveAndExitResumeErrorViewModel(payload) { +export function resumeErrorViewModel(payload) { const pageTitle = 'You cannot resume your form' // Model buttons @@ -419,7 +414,7 @@ export function createInvalidPasswordError(attemptsRemaining) { * @param {FormMetadata} form * @param {SaveAndExitResumeDetails} validatedLink */ -export function saveAndExitLockedOutViewModel(form, validatedLink) { +export function lockedOutViewModel(form, validatedLink) { return { name: form.title, buttons: { @@ -448,7 +443,7 @@ export function constructFormUrl(slug, status) { * @param {FormMetadata} form * @param {FormStatus} [status] */ -export function saveAndExitResumeSuccessViewModel(form, status) { +export function resumeSuccessViewModel(form, status) { // Model buttons const continueButton = { text: 'Resume form', diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index b2402afe4..4d94655ab 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -12,13 +12,13 @@ import { createInvalidPasswordError, detailsViewModel, getKey, + lockedOutViewModel, paramsSchema, + passwordViewModel, payloadSchema, + resumeErrorViewModel, resumeParamsSchema, - saveAndExitLockedOutViewModel, - saveAndExitPasswordViewModel, - saveAndExitResumeErrorViewModel, - saveAndExitResumeSuccessViewModel, + resumeSuccessViewModel, validatePayloadSchema } from '~/src/server/models/save-and-exit.js' import { @@ -238,10 +238,7 @@ export default [ return h.redirect(ERROR_BASE_URL) } - const model = saveAndExitPasswordViewModel( - form.title, - resumeDetails.question - ) + const model = passwordViewModel(form.title, resumeDetails.question) return h.view(RESUME_PASSWORD_PATH, model) }, @@ -260,7 +257,7 @@ export default [ handler(request, h) { const { params } = request const { slug } = params - const model = saveAndExitResumeErrorViewModel({ slug }) + const model = resumeErrorViewModel({ slug }) return h.view(RESUME_ERROR, model) }, @@ -315,7 +312,7 @@ export default [ ) const error = createInvalidPasswordError(attemptsRemaining) - const model = saveAndExitPasswordViewModel( + const model = passwordViewModel( form.title, validatedLink.question, undefined, @@ -325,7 +322,7 @@ export default [ return h.view(RESUME_PASSWORD_PATH, model) } else { // Locked out - const model = saveAndExitLockedOutViewModel(form, validatedLink) + const model = lockedOutViewModel(form, validatedLink) return h.view(RESUME_ERROR_LOCKED, model) } }, @@ -348,7 +345,7 @@ export default [ const form = await getFormMetadataById(resumeDetails.form.id) - const model = saveAndExitPasswordViewModel( + const model = passwordViewModel( form.title, resumeDetails.question, payload, @@ -370,7 +367,7 @@ export default [ const { params } = request const { slug, state } = params const form = await getFormMetadata(slug) - const model = saveAndExitResumeSuccessViewModel(form, state) + const model = resumeSuccessViewModel(form, state) return h.view(RESUME_SUCCESS, model) }, diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 0dd45dd0b..899624e0c 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -415,6 +415,47 @@ describe('Save-and-exit check routes', () => { ) expect($errorMessage).toBeInTheDocument() }) + + test('/route handles missing password', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: '' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + expect(createJoiError).not.toHaveBeenCalled() + }) }) }) From 97ace175b3efb0b21e7b1fd66aa641a61d07c21e Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 8 Sep 2025 16:37:33 +0100 Subject: [PATCH 24/27] Extra coverage --- src/server/services/formsService.test.js | 92 +++++++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/src/server/services/formsService.test.js b/src/server/services/formsService.test.js index f30d81ea1..8999acefc 100644 --- a/src/server/services/formsService.test.js +++ b/src/server/services/formsService.test.js @@ -3,12 +3,17 @@ import { StatusCodes } from 'http-status-codes' import { getFormDefinition, - getFormMetadata + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails, + validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' -import { getJson } from '~/src/server/services/httpService.js' +import { getJson, postJson } from '~/src/server/services/httpService.js' import * as fixtures from '~/test/fixtures/index.js' -const { MANAGER_URL } = process.env +const { MANAGER_URL, SUBMISSION_URL } = process.env + +const magicLinkId = '7ac201b2-bea3-490d-8ccb-2734b2794f7b' jest.mock('~/src/server/services/httpService') @@ -57,6 +62,48 @@ describe('Forms service', () => { }) }) + describe('getFormMetadataById', () => { + beforeEach(() => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: metadata + }) + }) + + it('requests JSON via form slug', async () => { + await getFormMetadataById(metadata.id) + + expect(getJson).toHaveBeenCalledWith( + `${MANAGER_URL}/forms/${metadata.id}` + ) + }) + + it('coerces timestamps from string to Date', async () => { + const payload = { + ...structuredClone(metadata), + + // JSON payload uses string dates in transit + createdAt: metadata.createdAt.toISOString(), + updatedAt: metadata.updatedAt.toISOString() + } + + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload + }) + + await expect(getFormMetadataById(metadata.id)).resolves.toEqual({ + ...metadata, + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + }) + }) + describe('getFormDefinition', () => { beforeEach(() => { jest.mocked(getJson).mockResolvedValue({ @@ -83,6 +130,45 @@ describe('Forms service', () => { ) }) }) + + describe('getSaveAndExitDetails', () => { + beforeEach(() => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: definition + }) + }) + + it('requests JSON via form ID (draft)', async () => { + await getSaveAndExitDetails(magicLinkId) + + expect(getJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/save-and-exit/${magicLinkId}` + ) + }) + }) + + describe('validateSaveAndExitCredentials', () => { + beforeEach(() => { + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: definition + }) + }) + + it('requests JSON via form ID (draft)', async () => { + await validateSaveAndExitCredentials(magicLinkId, 'answer') + + expect(postJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/save-and-exit/${magicLinkId}`, + { payload: { securityAnswer: 'answer' } } + ) + }) + }) }) /** From 4ae8b74b1e9834b93770bbd59d031ee1e5880018 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 9 Sep 2025 08:36:13 +0100 Subject: [PATCH 25/27] Extra coverage and fixed test --- .../routes/save-and-exit-with-cache.test.js | 112 ++++++++++++++++++ src/server/routes/save-and-exit.test.js | 66 +++++------ 2 files changed, 144 insertions(+), 34 deletions(-) create mode 100644 src/server/routes/save-and-exit-with-cache.test.js diff --git a/src/server/routes/save-and-exit-with-cache.test.js b/src/server/routes/save-and-exit-with-cache.test.js new file mode 100644 index 000000000..f45054392 --- /dev/null +++ b/src/server/routes/save-and-exit-with-cache.test.js @@ -0,0 +1,112 @@ +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { StatusCodes } from 'http-status-codes' + +import { createServer } from '~/src/server/index.js' +import { + getFormMetadataById, + validateSaveAndExitCredentials +} from '~/src/server/services/formsService.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/helpers/error-helper.js') +jest.mock('@defra/forms-engine-plugin/engine/helpers.js') + +describe('Save-and-exit check routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer() + await server.initialize() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' + const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' + + describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { + test('/route handles valid password with no supplied form status', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: true, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + setState: jest.fn() + })) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'valid' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/resume-form-success/my-form-to-resume` + ) + }) + + test('/route handles valid password with draft preview', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: true, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID, + status: 'draft', + isPreview: true + } + }) + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + setState: jest.fn() + })) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'valid' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/resume-form-success/my-form-to-resume/draft` + ) + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 899624e0c..679162f9f 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -306,40 +306,6 @@ describe('Save-and-exit check routes', () => { }) describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { - test('/route handles valid password', async () => { - jest - .mocked(getFormMetadataById) - // @ts-expect-error - allow partial objects for tests - .mockResolvedValueOnce({ - slug: 'my-form-to-resume', - title: 'My Form To Resume' - }) - jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ - validPassword: true, - invalidPasswordAttempts: 1, - // @ts-expect-error - allow partial objects for tests - form: { - id: FORM_ID - } - }) - - const options = { - method: 'POST', - url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, - payload: { - securityAnswer: 'valid' - } - } - - const { response } = await renderResponse(server, options) - - // TODO - fix test - expect(response.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR) - // expect(response.headers.location).toBe( - // `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` - // ) - }) - test('/route handles invalid password', async () => { jest .mocked(getFormMetadataById) @@ -456,6 +422,38 @@ describe('Save-and-exit check routes', () => { expect($mastheadHeading).toBeInTheDocument() expect(createJoiError).not.toHaveBeenCalled() }) + + test('/route handles missing password and invalid url', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce(undefined) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: '' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/resume-form-error') + }) }) }) From 370ca076ec951405cb4d3948ef23d16d626c56a5 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 9 Sep 2025 08:46:53 +0100 Subject: [PATCH 26/27] Max attempts increased to 5 Boilerplate includes attempts --- src/server/models/save-and-exit.js | 14 ++++++++-- src/server/routes/save-and-exit.js | 28 +++++++++++++++---- src/server/routes/save-and-exit.test.js | 6 ++-- .../save-and-exit/resume-error-locked.html | 2 +- .../views/save-and-exit/resume-password.html | 2 +- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 58520d431..039bb7a3e 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -343,10 +343,17 @@ export function confirmationViewModel(metadata, email, status) { * The save and exit password form view model * @param {string} formTitle * @param {SecurityQuestionsEnum} securityQuestion - the security question + * @param {number} attemptsLeft * @param {SaveAndExitResumePasswordPayload} [payload] * @param {Error} [err] */ -export function passwordViewModel(formTitle, securityQuestion, payload, err) { +export function passwordViewModel( + formTitle, + securityQuestion, + attemptsLeft, + payload, + err +) { const pageTitle = 'Continue with your form' const { errors, securityAnswerError } = buildErrors(err) @@ -376,6 +383,7 @@ export function passwordViewModel(formTitle, securityQuestion, payload, err) { pageTitle, errors, fields, + attemptsLeft, buttons: { continueButton } } } @@ -413,10 +421,12 @@ export function createInvalidPasswordError(attemptsRemaining) { * The save and exit form view model when user is locked out * @param {FormMetadata} form * @param {SaveAndExitResumeDetails} validatedLink + * @param {number} maxPasswordAttempts */ -export function lockedOutViewModel(form, validatedLink) { +export function lockedOutViewModel(form, validatedLink, maxPasswordAttempts) { return { name: form.title, + maxPasswordAttempts, buttons: { continueButton: { text: 'Start form again', diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 4d94655ab..ba9954319 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -29,7 +29,7 @@ import { } from '~/src/server/services/formsService.js' const logger = createLogger() -const maxInvalidPasswordAttempts = 3 +const maxInvalidPasswordAttempts = 5 const ERROR_BASE_URL = '/resume-form-error' @@ -39,6 +39,13 @@ const RESUME_ERROR_LOCKED = 'save-and-exit/resume-error-locked' const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password' const RESUME_SUCCESS = 'save-and-exit/resume-success' +/** + * @param {number} attemptsSoFar + */ +export function getPasswordAttemptsLeft(attemptsSoFar) { + return maxInvalidPasswordAttempts - attemptsSoFar +} + export default [ /** * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} @@ -238,7 +245,11 @@ export default [ return h.redirect(ERROR_BASE_URL) } - const model = passwordViewModel(form.title, resumeDetails.question) + const model = passwordViewModel( + form.title, + resumeDetails.question, + getPasswordAttemptsLeft(resumeDetails.invalidPasswordAttempts) + ) return h.view(RESUME_PASSWORD_PATH, model) }, @@ -303,8 +314,9 @@ export default [ return h.redirect(`/resume-form-success/${form.slug}${slugAndState}`) } - const attemptsRemaining = - maxInvalidPasswordAttempts - validatedLink.invalidPasswordAttempts + const attemptsRemaining = getPasswordAttemptsLeft( + validatedLink.invalidPasswordAttempts + ) if (attemptsRemaining > 0) { // User has more password attempts left logger.info( @@ -315,6 +327,7 @@ export default [ const model = passwordViewModel( form.title, validatedLink.question, + attemptsRemaining, undefined, error ) @@ -322,7 +335,11 @@ export default [ return h.view(RESUME_PASSWORD_PATH, model) } else { // Locked out - const model = lockedOutViewModel(form, validatedLink) + const model = lockedOutViewModel( + form, + validatedLink, + maxInvalidPasswordAttempts + ) return h.view(RESUME_ERROR_LOCKED, model) } }, @@ -348,6 +365,7 @@ export default [ const model = passwordViewModel( form.title, resumeDetails.question, + getPasswordAttemptsLeft(resumeDetails.invalidPasswordAttempts), payload, error ) diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 679162f9f..65438f360 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -339,7 +339,7 @@ describe('Save-and-exit check routes', () => { expect($mastheadHeading).toBeInTheDocument() expect(createJoiError).toHaveBeenCalledWith( 'securityAnswer', - 'Your answer is incorrect. You have 2 attempts remaining.' + 'Your answer is incorrect. You have 4 attempts remaining.' ) }) @@ -353,7 +353,7 @@ describe('Save-and-exit check routes', () => { }) jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ validPassword: false, - invalidPasswordAttempts: 3, + invalidPasswordAttempts: 5, // @ts-expect-error - allow partial objects for tests form: { id: FORM_ID @@ -377,7 +377,7 @@ describe('Save-and-exit check routes', () => { ) expect($mastheadHeading).toBeInTheDocument() const $errorMessage = container.getByText( - 'The answer to your security question was incorrect 3 times. You have run out of attempts to resume your form.' + 'The answer to your security question was incorrect 5 times. You have run out of attempts to resume your form.' ) expect($errorMessage).toBeInTheDocument() }) diff --git a/src/server/views/save-and-exit/resume-error-locked.html b/src/server/views/save-and-exit/resume-error-locked.html index 2abf88a1b..4ed6495a2 100644 --- a/src/server/views/save-and-exit/resume-error-locked.html +++ b/src/server/views/save-and-exit/resume-error-locked.html @@ -7,7 +7,7 @@

You cannot resume your form

- The answer to your security question was incorrect 3 times. You have run out of attempts to resume your form. + The answer to your security question was incorrect {{ maxPasswordAttempts }} times. You have run out of attempts to resume your form.

You will need to start the form again. Your information will be securely deleted. diff --git a/src/server/views/save-and-exit/resume-password.html b/src/server/views/save-and-exit/resume-password.html index 3da5d928c..fca4d6710 100644 --- a/src/server/views/save-and-exit/resume-password.html +++ b/src/server/views/save-and-exit/resume-password.html @@ -20,7 +20,7 @@

{{ pageTitle }}

Enter the answer to your security question to retrieve your information and continue with your form.

- You have 3 attempts to enter your answer. Make sure your answer matches the exact style and format when used when saving your progress. + You have {{ attemptsLeft }} attempts to enter your answer. Make sure your answer matches the exact style and format when used when saving your progress.

From 76dbd42bc07426c86ac0355665d226af3c2b0002 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 9 Sep 2025 09:24:16 +0100 Subject: [PATCH 27/27] Remove empty import --- src/typings/hapi/index.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index 473799461..4b976d1a7 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,7 +5,6 @@ import { type Plugin } from '@hapi/hapi' import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' -import {} from '~/src/server/routes/types.js' import { type CacheService } from '~/src/server/services/index.js' declare module '@hapi/hapi' {