From 9ffb785832bcaf824e552e1692fdf42aa7c8021d Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 7 Sep 2026 15:05:32 +0100 Subject: [PATCH 1/9] Save-and-exit when logged in --- src/server/i18n/translations/cy.json | 5 +- src/server/i18n/translations/en-GB.json | 5 +- src/server/index.ts | 8 +- src/server/messaging/mappers/events.js | 39 ++++- src/server/messaging/mappers/events.test.js | 68 +++++++- src/server/messaging/publish.js | 39 ++++- src/server/messaging/publish.test.js | 59 ++++++- src/server/models/save-and-exit-v2.js | 81 +++++++++ src/server/plugins/router.ts | 4 +- src/server/routes/index.ts | 1 + src/server/routes/save-and-exit-v2.js | 165 ++++++++++++++++++ src/server/routes/save-and-exit-v2.test.js | 121 +++++++++++++ src/server/routes/save-and-exit.js | 4 +- .../views/save-and-exit-v2/confirmation.html | 33 ++++ 14 files changed, 603 insertions(+), 29 deletions(-) create mode 100644 src/server/models/save-and-exit-v2.js create mode 100644 src/server/routes/save-and-exit-v2.js create mode 100644 src/server/routes/save-and-exit-v2.test.js create mode 100644 src/server/views/save-and-exit-v2/confirmation.html diff --git a/src/server/i18n/translations/cy.json b/src/server/i18n/translations/cy.json index 49320cd8d..f9191cfdf 100644 --- a/src/server/i18n/translations/cy.json +++ b/src/server/i18n/translations/cy.json @@ -187,7 +187,10 @@ "linkSent": "Rydym wedi anfon dolen i [[email]] y gallwch ei defnyddio i ailafael yn y ffurflen hon o fewn [[saveAndExitExpiryDays]] diwrnod.", "repeatSave": "Os hoffech gadw eich cynnydd eto ar ôl ailafael yn eich ffurflen, bydd angen i chi ailadrodd y broses gadw er mwyn cynhyrchu dolen newydd.", "checkSpam": "Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.", - "closeWindow": "Gallwch gau'r ffenestr hon." + "closeWindow": "Gallwch gau'r ffenestr hon.", + "answersSaved": "Mae eich atebion wedi'u cadw am [[days]] o ddiwrnodau.", + "emailedLinkForSignIn": "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen '[[formName]]' yn ddiweddarach.", + "signin": "Mewngofnodi" }, "resumeError": { "pageTitle": "Ni allwch ailafael yn eich ffurflen", diff --git a/src/server/i18n/translations/en-GB.json b/src/server/i18n/translations/en-GB.json index cbe4bb3e1..4dd679d4c 100644 --- a/src/server/i18n/translations/en-GB.json +++ b/src/server/i18n/translations/en-GB.json @@ -189,7 +189,10 @@ "linkSent": "We have sent a link to [[email]] which you can use to resume this form within [[saveAndExitExpiryDays]] days.", "repeatSave": "If you want to save your progress again after resuming your form, you will need to repeat the save process to generate a new link.", "checkSpam": "Check your spam folder if you have not received an email after a few minutes.", - "closeWindow": "You can close this window." + "closeWindow": "You can close this window.", + "answersSaved": "Your answers have been saved for [[days]] days.", + "emailedLinkForSignIn": "We’ve emailed you a link so you can sign in and continue your '[[formName]]' form later.", + "signin": "Sign in" }, "resumeError": { "pageTitle": "You cannot resume your form", diff --git a/src/server/index.ts b/src/server/index.ts index caeee20a4..d42f524b3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -178,10 +178,14 @@ export const configureEnginePlugin = async ({ yar.flash(SAVE_AND_EXIT_PAYLOAD, pagePayload, true) + const saveAndExitBase = config.get('useSignInFeature') + ? 'save-and-exit-v2' + : 'save-and-exit' + return h.redirect( !isPreview - ? `/save-and-exit/${slug}` - : `/save-and-exit/${slug}/${state}` + ? `/${saveAndExitBase}/${slug}` + : `/${saveAndExitBase}/${slug}/${state}` ) }, controllers: { diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index b94ede247..ca35f1f95 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -12,6 +12,7 @@ import { config } from '~/src/config/index.js' const baseUrl = config.get('baseUrl') /** + * For legacy V1 save-and-exit * @param { string } formId * @param { string } formTitle * @param { string } email @@ -20,7 +21,7 @@ const baseUrl = config.get('baseUrl') * @param { FormStatus } [status] * @returns {SaveAndExitMessage} */ -export function saveAndExitMapper( +export function saveAndExitV1Mapper( formId, formTitle, email, @@ -60,6 +61,40 @@ export function saveAndExitMapper( } /** - * @import { SaveAndExitMessage, SaveAndExitMessageData, SecurityQuestionsEnum } from '@defra/forms-model' + * For V2 save-and-exit + * @param { string } formId + * @param { string } formTitle + * @param { string } email + * @param { FormState } state + * @param { FormStatus } [status] + * @returns {SaveAndExitV2Message} + */ +export function saveAndExitV2Mapper(formId, formTitle, email, state, status) { + /** @type {SaveAndExitV2MessageData} */ + const data = { + form: { + id: formId, + title: formTitle, + status: status ?? FormStatus.Live, + isPreview: !!status, + baseUrl + }, + email, + state + } + const now = new Date() + return { + schemaVersion: SubmissionEventMessageSchemaVersion.V1, + category: SubmissionEventMessageCategory.RUNNER, + source: SubmissionEventMessageSource.FORMS_RUNNER, + type: SubmissionEventMessageType.RUNNER_SAVE_AND_EXIT_V2, + createdAt: now, + data, + messageCreatedAt: now + } +} + +/** + * @import { SaveAndExitMessage, SaveAndExitV2Message, SaveAndExitMessageData, SaveAndExitV2MessageData, 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 f4e40701e..b854b9caf 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -8,11 +8,14 @@ import { SubmissionEventMessageType } from '@defra/forms-model' -import { saveAndExitMapper } from '~/src/server/messaging/mappers/events.js' +import { + saveAndExitV1Mapper, + saveAndExitV2Mapper +} from '~/src/server/messaging/mappers/events.js' describe('runner-events', () => { - describe('saveAndExitMapper', () => { - it('should map a payload into a SAVE_AND_EXIT event', () => { + describe('saveAndExitV1Mapper', () => { + it('should map a payload into a SAVE_AND_EXIT V1 event', () => { /** * @type {SaveAndExitMessageData} */ @@ -36,7 +39,7 @@ describe('runner-events', () => { } expect( - saveAndExitMapper( + saveAndExitV1Mapper( payload.form.id, payload.form.title, payload.email, @@ -69,7 +72,7 @@ describe('runner-events', () => { }) }) - it('should map a payload into a SAVE_AND_EXIT event with magicLinkGroupId', () => { + it('should map a payload into a SAVE_AND_EXIT V1 event with magicLinkGroupId', () => { /** * @type {SaveAndExitMessageData} */ @@ -94,7 +97,7 @@ describe('runner-events', () => { } expect( - saveAndExitMapper( + saveAndExitV1Mapper( payload.form.id, payload.form.title, payload.email, @@ -128,7 +131,58 @@ describe('runner-events', () => { }) }) }) + + describe('saveAndExitV2Mapper', () => { + it('should map a payload into a SAVE_AND_EXIT V2 event', () => { + /** + * @type {SaveAndExitV2MessageData} + */ + const payload = { + form: { + id: 'formId', + title: 'My First Form', + isPreview: true, + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' + }, + email: 'my-email@here.com', + state: { + formVal1: '123', + formVal2: '456' + } + } + + expect( + saveAndExitV2Mapper( + payload.form.id, + payload.form.title, + payload.email, + payload.state, + payload.form.status + ) + ).toEqual({ + schemaVersion: SubmissionEventMessageSchemaVersion.V1, + category: SubmissionEventMessageCategory.RUNNER, + source: SubmissionEventMessageSource.FORMS_RUNNER, + type: SubmissionEventMessageType.RUNNER_SAVE_AND_EXIT_V2, + createdAt: expect.any(Date), + messageCreatedAt: expect.any(Date), + data: { + form: { + id: payload.form.id, + title: payload.form.title, + isPreview: payload.form.isPreview, + status: payload.form.status, + baseUrl: 'http://localhost:3009' + }, + email: payload.email, + state: payload.state + } + }) + }) + }) }) + /** - * @import { SaveAndExitMessageData } from '@defra/forms-model' + * @import { SaveAndExitMessageData, SaveAndExitV2MessageData } from '@defra/forms-model' */ diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index 6fcc89a1b..b867944bf 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -1,12 +1,15 @@ import { submissionMessageSchema } from '@defra/forms-model' import Joi from 'joi' -import { saveAndExitMapper } from '~/src/server/messaging/mappers/events.js' +import { + saveAndExitV1Mapper, + saveAndExitV2Mapper +} from '~/src/server/messaging/mappers/events.js' import { publishEvent } from '~/src/server/messaging/publish-base.js' /** * Helper to validate and publish an event - * @param {SaveAndExitMessage} saveAndExitMessage + * @param { SaveAndExitMessage | SaveAndExitV2Message } saveAndExitMessage */ async function validateAndPublishEvent(saveAndExitMessage) { const value = Joi.attempt(saveAndExitMessage, submissionMessageSchema, { @@ -17,8 +20,9 @@ async function validateAndPublishEvent(saveAndExitMessage) { } /** - * Publish 'save and exit' event - * The returned entityId will be a newly-generated guid + * Publish 'save and exit' event (v1) + * The returned entityId will be a newly-generated guid. + * V1 save-and-exit stores a magic link for user-retrieval of state. * @param {string} formId * @param {string} formTitle * @param {string} email @@ -26,7 +30,7 @@ async function validateAndPublishEvent(saveAndExitMessage) { * @param {FormState} state * @param {FormStatus} [status] */ -export async function publishSaveAndExitEvent( +export async function publishSaveAndExitV1Event( formId, formTitle, email, @@ -34,7 +38,7 @@ export async function publishSaveAndExitEvent( state, status ) { - const message = saveAndExitMapper( + const message = saveAndExitV1Mapper( formId, formTitle, email, @@ -46,7 +50,28 @@ export async function publishSaveAndExitEvent( return validateAndPublishEvent(message) } +/** + * Publish 'save and exit' event (v2) + * V2 save-and-exit stores state against the logged-in user. + * @param {string} formId + * @param {string} formTitle + * @param {string} email + * @param {FormState} state + * @param {FormStatus} [status] + */ +export async function publishSaveAndExitV2Event( + formId, + formTitle, + email, + state, + status +) { + const message = saveAndExitV2Mapper(formId, formTitle, email, state, status) + + return validateAndPublishEvent(message) +} + /** * @import { FormState } from '@defra/forms-engine-plugin/engine/types.js' - * @import { FormStatus, SaveAndExitMessage, SecurityQuestionsEnum } from '@defra/forms-model' + * @import { FormStatus, SaveAndExitMessage, SaveAndExitV2Message, SecurityQuestionsEnum } from '@defra/forms-model' */ diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index 8f1b7e341..21ace9049 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -9,7 +9,10 @@ import { import { ValidationError } from 'joi' import { publishEvent } from '~/src/server/messaging/publish-base.js' -import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' +import { + publishSaveAndExitV1Event, + publishSaveAndExitV2Event +} from '~/src/server/messaging/publish.js' jest.mock('~/src/server/messaging/publish-base.js') @@ -47,9 +50,9 @@ describe('publish', () => { jest.resetAllMocks() }) - describe('publishSaveAndExitEvent', () => { - it('should publish SAVE_AND_EXIT event', async () => { - await publishSaveAndExitEvent( + describe('publishSaveAndExitV1Event', () => { + it('should publish SAVE_AND_EXIT V1 event', async () => { + await publishSaveAndExitV1Event( saveAndExitPayload.form.id, saveAndExitPayload.form.title, saveAndExitPayload.email, @@ -75,7 +78,51 @@ describe('publish', () => { await expect( // @ts-expect-error - invalid schema - publishSaveAndExitEvent(invalidPayload) + publishSaveAndExitV1Event(invalidPayload) + ).rejects.toThrow( + new ValidationError( + '"data.form.id" must be a string. "data.form.title" is required. "data.email" is required. "data.state" is required', + [], + {} + ) + ) + }) + }) + + describe('publishSaveAndExitV2Event', () => { + const saveAndExitPayloadTemp = structuredClone(saveAndExitPayload) + // @ts-expect-error - remove property that V2 doesn't have + delete saveAndExitPayloadTemp.security + /** @type {SaveAndExitV2MessageData} */ + const saveAndExitV2Payload = saveAndExitPayloadTemp + + it('should publish SAVE_AND_EXIT V2 event', async () => { + await publishSaveAndExitV2Event( + saveAndExitV2Payload.form.id, + saveAndExitV2Payload.form.title, + saveAndExitV2Payload.email, + saveAndExitV2Payload.state, + saveAndExitV2Payload.form.status + ) + + expect(publishEvent).toHaveBeenCalledWith({ + source: SubmissionEventMessageSource.FORMS_RUNNER, + messageCreatedAt: expect.any(Date), + schemaVersion: SubmissionEventMessageSchemaVersion.V1, + category: SubmissionEventMessageCategory.RUNNER, + type: SubmissionEventMessageType.RUNNER_SAVE_AND_EXIT_V2, + createdAt: expect.any(Date), + data: saveAndExitV2Payload + }) + }) + + it('should not publish the event if the schema is incorrect', async () => { + jest.mocked(publishEvent).mockRejectedValue(new Error('rejected')) + const invalidPayload = {} + + await expect( + // @ts-expect-error - invalid schema + publishSaveAndExitV2Event(invalidPayload) ).rejects.toThrow( new ValidationError( '"data.form.id" must be a string. "data.form.title" is required. "data.email" is required. "data.state" is required', @@ -88,5 +135,5 @@ describe('publish', () => { }) /** - * @import { SaveAndExitMessageData } from '@defra/forms-model' + * @import { SaveAndExitMessageData, SaveAndExitV2MessageData } from '@defra/forms-model' */ diff --git a/src/server/models/save-and-exit-v2.js b/src/server/models/save-and-exit-v2.js new file mode 100644 index 000000000..59dcdf9b2 --- /dev/null +++ b/src/server/models/save-and-exit-v2.js @@ -0,0 +1,81 @@ +import { stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { slugSchema } from '@defra/forms-model' +import Joi from 'joi' + +import { config } from '~/src/config/index.js' +import { FORM_PREFIX, HOMEPAGE_PREFIX } from '~/src/server/constants.js' +import { getFeedbackFormLink } from '~/src/server/utils/utils.js' + +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') + +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +function constructFormUrl(slug, status) { + if (!status) { + return `${FORM_PREFIX}/${slug}` + } + + return `${FORM_PREFIX}/preview/${status}/${slug}` +} + +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +function constructSigninUrl(slug, status) { + if (!status) { + return `${HOMEPAGE_PREFIX}/${slug}` + } + + return `${HOMEPAGE_PREFIX}/preview/${status}/${slug}` +} + +/** + * Save and exit params schema + */ +export const paramsSchema = Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + +/** + * Get save and exit session key + * @param {string} slug + * @param {FormStatus} [state] + */ +export function getKey(slug, state) { + return `save-and-exit-v2-${slug}-${state ?? ''}` +} + +/** + * The save and exit confirmation form view model + * @param {FormMetadata} metadata + * @param {Translator} translator + * @param {FormStatus} [status] + */ +export function confirmationViewModel(metadata, translator, status) { + const { slug, title, id } = metadata + const formPath = constructFormUrl(slug, status) + const signinLink = constructSigninUrl(slug, status) + const { t } = translator + + return { + name: title, + serviceUrl: formPath, + signinLink, + pageTitle: t('saveAndExit.confirmation.pageTitle'), + saveAndExitExpiryDays, + context: { translator }, + ...getFeedbackFormLink(id) + } +} + +/** + * @import { FormMetadata } from '@defra/forms-model' + * @import { FormStatus } from '@defra/forms-engine-plugin/types' + * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' + */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index b07366352..f8163bb07 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -36,7 +36,8 @@ import { healthRoute, homepageRoutes, publicRoutes, - saveAndExitRoutes + saveAndExitRoutes, + saveAndExitV2Routes } from '~/src/server/routes/index.js' import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' import { getFormMetadataWithoutGuard } from '~/src/server/services/formMetadataGuards.js' @@ -78,6 +79,7 @@ export default { if (config.get('useSignInFeature')) { server.route(authRoutes as ServerRoute[]) server.route(homepageRoutes) + server.route(saveAndExitV2Routes as ServerRoute[]) } server.route(routes) diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 1d2f52760..908ea9e88 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -3,3 +3,4 @@ export { default as homepageRoutes } from '~/src/server/routes/homepage.js' 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' +export { default as saveAndExitV2Routes } from '~/src/server/routes/save-and-exit-v2.js' diff --git a/src/server/routes/save-and-exit-v2.js b/src/server/routes/save-and-exit-v2.js new file mode 100644 index 000000000..23d93021a --- /dev/null +++ b/src/server/routes/save-and-exit-v2.js @@ -0,0 +1,165 @@ +import { + CURRENT_PAGE_PATH_KEY, + STATE_NOT_YET_VALIDATED +} from '@defra/forms-engine-plugin' +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { FormStatus } from '@defra/forms-model' +import * as Hoek from '@hapi/hoek' + +import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' +import { EN_GB } from '~/src/server/constants.js' +import { getCachedFormTranslatorExternalRoutes } from '~/src/server/i18n/form.js' +import { publishSaveAndExitV2Event } from '~/src/server/messaging/publish.js' +import { + confirmationViewModel, + getKey, + paramsSchema +} from '~/src/server/models/save-and-exit-v2.js' +import { + getPayloadFromFlash, + hasState +} from '~/src/server/routes/save-and-exit-helper.js' +import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' +import { getFormDefinitionWithFallback } from '~/src/server/services/helpers/formsServiceHelper.js' +import { + isLanguageSupported, + resolveLanguage +} from '~/src/server/utils/utils.js' + +// Route base +const SAVE_AND_EXIT_ROUTE_BASE = 'save-and-exit-v2' + +// View paths +const SAVE_AND_EXIT_CONFIRMATION = 'save-and-exit-v2/confirmation' + +/** + * + * @param {{ query: RequestQuery, yar: Yar }} request + * @param {FormMetadata} metadata - the metadata of the form + * @param {FormStatus} status + * @returns {Promise<{ translator: Translator, language: string }>} + */ +export async function getFormTranslator( + request, + metadata, + status = metadata.live ? FormStatus.Live : FormStatus.Draft +) { + let language = resolveLanguage(request.query, request.yar) + + if (language !== EN_GB) { + const definition = await getFormDefinitionWithFallback(metadata.id, status) + + if (!isLanguageSupported(language, definition)) { + // If not translations defined in the FormDefinition, always default to English + language = EN_GB + } + } + + const translator = await getCachedFormTranslatorExternalRoutes( + metadata, + status, + language + ) + + return { translator, language } +} + +export default [ + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} + */ + ({ + method: 'GET', + path: `/${SAVE_AND_EXIT_ROUTE_BASE}/{slug}/{state?}`, + async handler(request, h) { + const { params, auth } = request + const { slug, state: status } = params + const metadata = await getFormMetadataWithGuard(slug, status) + const { translator } = await getFormTranslator(request, metadata, status) + + const model = confirmationViewModel(metadata, translator, status) + + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). + // The current page state may be invalid so we don't want to push into the cache as normal properties. + const cacheService = getCacheService( + /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server + ) + const formState = await cacheService.getState( + /** @type {CacheRequest} */ (request) + ) + + // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared + // so just show the form from the start + if (!hasState(formState)) { + return h.redirect(model.serviceUrl) + } + + const pagePayload = getPayloadFromFlash(request) + const currentPagePayload = Array.isArray(pagePayload) + ? {} + : /** @type { FormPayload | undefined } */ (pagePayload) + const currentPagePath = + currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload + ? currentPagePayload[CURRENT_PAGE_PATH_KEY] + : undefined + + if (currentPagePath) { + const combinedState = Hoek.merge( + formState, + { + [STATE_NOT_YET_VALIDATED]: { + ...currentPagePayload, + [CURRENT_PAGE_PATH_KEY]: currentPagePath + } + }, + { + mergeArrays: false + } + ) + await cacheService.setState( + /** @type {CacheRequest} */ (request), + combinedState + ) + } + + if (!auth.credentials.email) { + throw new Error('User not logged in') + } + + await publishSaveAndExitV2Event( + metadata.id, + metadata.title, + auth.credentials.email, + await cacheService.getState(/** @type {CacheRequest} */ (request)), + status + ) + + // Clear any previous save and exit session state + request.yar.clear(getKey(slug, status)) + await cacheService.clearState(request) + + // Log out? + // TODO + + return h + .view(SAVE_AND_EXIT_CONFIRMATION, model) + .header('Cache-Control', 'no-cache, no-store, must-revalidate') + }, + options: { + auth: { mode: 'required', strategy: CITIZEN_SESSION }, + validate: { + params: paramsSchema + } + } + }) +] + +/** + * @import { ServerRoute, RequestQuery } from '@hapi/hapi' + * @import { Yar } from '@hapi/yar' + * @import { FormMetadata } from '@defra/forms-model' + * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' + * @import { AnyRequest, CacheRequest, FormPayload } from '@defra/forms-engine-plugin/engine/types.js' + * @import { SaveAndExitParams } from '~/src/server/models/save-and-exit.js' + */ diff --git a/src/server/routes/save-and-exit-v2.test.js b/src/server/routes/save-and-exit-v2.test.js new file mode 100644 index 000000000..eb2419060 --- /dev/null +++ b/src/server/routes/save-and-exit-v2.test.js @@ -0,0 +1,121 @@ +import { + checkFormStatus, + getCacheService +} from '@defra/forms-engine-plugin/engine/helpers.js' +import { FormStatus } from '@defra/forms-model' +import { StatusCodes } from 'http-status-codes' + +import { config } from '~/src/config/index.js' +import { logger } from '~/src/server/common/helpers/logging/logger.js' +import { createServer } from '~/src/server/index.js' +import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formMetadataGuards.js') +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/helpers/error-helper.js') +jest.mock('@defra/forms-engine-plugin/engine/form-availability.js') +jest.mock('@defra/forms-engine-plugin/engine/helpers.js') +jest.mock('~/src/server/messaging/publish.js') + +const DRAFT_STATE = 'draft' + +/** A citizen who has signed in, as the citizen-session scheme presents them */ +const credentials = { + iss: 'http://localhost:3011', + sub: 'sub-1', + email: 'citizen@example.com', + idToken: 'header.payload.signature' +} + +describe('Save-and-exit check routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + config.set('useSignInFeature', true) + + server = await createServer({ + enforceCsrf: false + }) + + await server.initialize() + }) + + afterAll(async () => { + await server.stop() + config.set('useSignInFeature', false) + }) + + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(logger, 'error').mockImplementation(() => { + /* mock */ + }) + jest.spyOn(logger, 'info').mockImplementation(() => { + /* mock */ + }) + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + getState: jest.fn().mockResolvedValueOnce({ key: 'val' }), + clearState: jest.fn() + })) + jest + .mocked(checkFormStatus) + .mockReturnValueOnce({ isPreview: true, state: FormStatus.Draft }) + }) + + const FORM_SLUG = 'my-form-slug' + + const testMetadata = { + slug: FORM_SLUG, + id: '9a48c529-16eb-4ff7-98be-e8aec801b4ed', + title: 'My test form' + } + + describe('GET /save-and-exit-v2/{slug}/{state?}', () => { + it('sends a signed-out citizen to sign in first', async () => { + const response = await server.inject({ + method: 'GET', + url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + '/auth/sign-in?returnUrl=%2Fsave-and-exit-v2%2Fmy-form-slug%2Fdraft' + ) + }) + + test('route renders view on success when logged in', async () => { + jest + .mocked(getFormMetadataWithGuard) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce(testMetadata) + + const options = { + method: 'GET', + url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'Your progress has been saved' + ) + + const $button = container.queryByRole('link', { + name: 'Sign in' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).not.toBeInTheDocument() + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 95077d440..4fa421bfa 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -19,7 +19,7 @@ import { getCachedFormTranslatorBasic, getCachedFormTranslatorExternalRoutes } from '~/src/server/i18n/form.js' -import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' +import { publishSaveAndExitV1Event } from '~/src/server/messaging/publish.js' import { confirmationViewModel, createInvalidPasswordError, @@ -244,7 +244,7 @@ export default [ return h.view(SAVE_AND_EXIT_DETAILS, model).takeover() } - await publishSaveAndExitEvent( + await publishSaveAndExitV1Event( metadata.id, metadata.title, email, diff --git a/src/server/views/save-and-exit-v2/confirmation.html b/src/server/views/save-and-exit-v2/confirmation.html new file mode 100644 index 000000000..5c2810724 --- /dev/null +++ b/src/server/views/save-and-exit-v2/confirmation.html @@ -0,0 +1,33 @@ +{% extends "layout.html" %} + +{% from "govuk/components/panel/macro.njk" import govukPanel %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+ {{ govukPanel({ + titleText: pageTitle + }) }} +

{{ tR('saveAndExit.confirmation.whatHappensNext') }}

+

+ {{ tR('saveAndExit.confirmation.answersSaved', { days: saveAndExitExpiryDays }) }} +

+

+ {{ tR('saveAndExit.confirmation.emailedLinkForSignIn', { formName: name }) }} +

+

+ {{ tR('saveAndExit.confirmation.repeatSave') }} +

+

+ {{ tR('saveAndExit.confirmation.checkSpam') }} +

+ + {{ govukButton({ + text: tR('saveAndExit.confirmation.signin'), + href: signinLink + }) + }} +
+
+{% endblock %} From dd3824676e30e1ef92e93b2a054e9ef6d10db110 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 7 Sep 2026 15:49:31 +0100 Subject: [PATCH 2/9] Refactored to avoid duplication --- .../routes/save-and-exit-state-handler.js | 70 +++++++++++++++ src/server/routes/save-and-exit-v2.js | 89 ++----------------- src/server/routes/save-and-exit-v2.test.js | 2 +- src/server/routes/save-and-exit.js | 52 +---------- 4 files changed, 80 insertions(+), 133 deletions(-) create mode 100644 src/server/routes/save-and-exit-state-handler.js diff --git a/src/server/routes/save-and-exit-state-handler.js b/src/server/routes/save-and-exit-state-handler.js new file mode 100644 index 000000000..2b1830d92 --- /dev/null +++ b/src/server/routes/save-and-exit-state-handler.js @@ -0,0 +1,70 @@ +import { + CURRENT_PAGE_PATH_KEY, + STATE_NOT_YET_VALIDATED +} from '@defra/forms-engine-plugin' +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import * as Hoek from '@hapi/hoek' + +import { + getPayloadFromFlash, + hasState +} from '~/src/server/routes/save-and-exit-helper.js' + +/** + * Check the cached form state and preserve any unvalidated page payload. + * @param {Request<{ Params: SaveAndExitParams }>} request + * @returns {Promise} true when no state exists and the user should be redirected to the form + */ +export async function stateHandler(request) { + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). + // The current page state may be invalid so we don't want to push into the cache as normal properties. + const cacheService = getCacheService( + /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server + ) + const formState = await cacheService.getState( + /** @type {CacheRequest} */ (request) + ) + + // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared + // so just show the form from the start + if (!hasState(formState)) { + return true + } + + const pagePayload = getPayloadFromFlash(request) + const currentPagePayload = Array.isArray(pagePayload) + ? {} + : /** @type { FormPayload | undefined } */ (pagePayload) + const currentPagePath = + currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload + ? currentPagePayload[CURRENT_PAGE_PATH_KEY] + : undefined + + if (currentPagePath) { + const combinedState = Hoek.merge( + formState, + { + [STATE_NOT_YET_VALIDATED]: { + ...currentPagePayload, + [CURRENT_PAGE_PATH_KEY]: currentPagePath + } + }, + { + mergeArrays: false + } + ) + await cacheService.setState( + /** @type {CacheRequest} */ (request), + combinedState + ) + } + return false +} + +/** + * @import { Request } from '@hapi/hapi' + * @import { SaveAndExitParams } from '~/src/server/models/save-and-exit.js' + * @import { FormSubmissionState } from '@defra/forms-engine-plugin/engine/types.js' + * @import { AnyRequest, CacheRequest, FormPayload } from '@defra/forms-engine-plugin/engine/types.js' + */ diff --git a/src/server/routes/save-and-exit-v2.js b/src/server/routes/save-and-exit-v2.js index 23d93021a..e70115b81 100644 --- a/src/server/routes/save-and-exit-v2.js +++ b/src/server/routes/save-and-exit-v2.js @@ -1,30 +1,15 @@ -import { - CURRENT_PAGE_PATH_KEY, - STATE_NOT_YET_VALIDATED -} from '@defra/forms-engine-plugin' import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' -import { FormStatus } from '@defra/forms-model' -import * as Hoek from '@hapi/hoek' import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' -import { EN_GB } from '~/src/server/constants.js' -import { getCachedFormTranslatorExternalRoutes } from '~/src/server/i18n/form.js' import { publishSaveAndExitV2Event } from '~/src/server/messaging/publish.js' import { confirmationViewModel, getKey, paramsSchema } from '~/src/server/models/save-and-exit-v2.js' -import { - getPayloadFromFlash, - hasState -} from '~/src/server/routes/save-and-exit-helper.js' +import { stateHandler } from '~/src/server/routes/save-and-exit-state-handler.js' +import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' -import { getFormDefinitionWithFallback } from '~/src/server/services/helpers/formsServiceHelper.js' -import { - isLanguageSupported, - resolveLanguage -} from '~/src/server/utils/utils.js' // Route base const SAVE_AND_EXIT_ROUTE_BASE = 'save-and-exit-v2' @@ -32,38 +17,6 @@ const SAVE_AND_EXIT_ROUTE_BASE = 'save-and-exit-v2' // View paths const SAVE_AND_EXIT_CONFIRMATION = 'save-and-exit-v2/confirmation' -/** - * - * @param {{ query: RequestQuery, yar: Yar }} request - * @param {FormMetadata} metadata - the metadata of the form - * @param {FormStatus} status - * @returns {Promise<{ translator: Translator, language: string }>} - */ -export async function getFormTranslator( - request, - metadata, - status = metadata.live ? FormStatus.Live : FormStatus.Draft -) { - let language = resolveLanguage(request.query, request.yar) - - if (language !== EN_GB) { - const definition = await getFormDefinitionWithFallback(metadata.id, status) - - if (!isLanguageSupported(language, definition)) { - // If not translations defined in the FormDefinition, always default to English - language = EN_GB - } - } - - const translator = await getCachedFormTranslatorExternalRoutes( - metadata, - status, - language - ) - - return { translator, language } -} - export default [ /** * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} @@ -79,50 +32,18 @@ export default [ const model = confirmationViewModel(metadata, translator, status) - // Store any outstanding data from the current page in a special attribute - // (in case the current page wasn't yet validated and saved). - // The current page state may be invalid so we don't want to push into the cache as normal properties. const cacheService = getCacheService( /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server ) - const formState = await cacheService.getState( - /** @type {CacheRequest} */ (request) - ) + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared // so just show the form from the start - if (!hasState(formState)) { + if (await stateHandler(request)) { return h.redirect(model.serviceUrl) } - const pagePayload = getPayloadFromFlash(request) - const currentPagePayload = Array.isArray(pagePayload) - ? {} - : /** @type { FormPayload | undefined } */ (pagePayload) - const currentPagePath = - currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload - ? currentPagePayload[CURRENT_PAGE_PATH_KEY] - : undefined - - if (currentPagePath) { - const combinedState = Hoek.merge( - formState, - { - [STATE_NOT_YET_VALIDATED]: { - ...currentPagePayload, - [CURRENT_PAGE_PATH_KEY]: currentPagePath - } - }, - { - mergeArrays: false - } - ) - await cacheService.setState( - /** @type {CacheRequest} */ (request), - combinedState - ) - } - if (!auth.credentials.email) { throw new Error('User not logged in') } diff --git a/src/server/routes/save-and-exit-v2.test.js b/src/server/routes/save-and-exit-v2.test.js index eb2419060..c9b35fb30 100644 --- a/src/server/routes/save-and-exit-v2.test.js +++ b/src/server/routes/save-and-exit-v2.test.js @@ -56,7 +56,7 @@ describe('Save-and-exit check routes', () => { /* mock */ }) // @ts-expect-error - not all method mocked - jest.mocked(getCacheService).mockImplementationOnce(() => ({ + jest.mocked(getCacheService).mockImplementation(() => ({ getState: jest.fn().mockResolvedValueOnce({ key: 'val' }), clearState: jest.fn() })) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 4fa421bfa..e026f4480 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,14 +1,8 @@ -import { - CURRENT_PAGE_PATH_KEY, - MAGIC_LINK_GROUP_ID, - STATE_NOT_YET_VALIDATED, - isOfflineBoom -} from '@defra/forms-engine-plugin' +import { MAGIC_LINK_GROUP_ID, isOfflineBoom } from '@defra/forms-engine-plugin' import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' import { stateSchema } from '@defra/forms-engine-plugin/schema.js' import { FormStatus, slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' -import * as Hoek from '@hapi/hoek' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' @@ -34,10 +28,8 @@ import { resumeSuccessViewModel, validatePayloadSchema } from '~/src/server/models/save-and-exit.js' -import { - getPayloadFromFlash, - hasState -} from '~/src/server/routes/save-and-exit-helper.js' +import { hasState } from '~/src/server/routes/save-and-exit-helper.js' +import { stateHandler } from '~/src/server/routes/save-and-exit-state-handler.js' import { getFormMetadataById, getFormMetadataWithGuard @@ -138,48 +130,12 @@ export default [ // Store any outstanding data from the current page in a special attribute // (in case the current page wasn't yet validated and saved). - // The current page state may be invalid so we don't want to push into the cache as normal properties. - const cacheService = getCacheService( - /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server - ) - const formState = await cacheService.getState( - /** @type {CacheRequest} */ (request) - ) - // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared // so just show the form from the start - if (!hasState(formState)) { + if (await stateHandler(request)) { return h.redirect(model.serviceUrl) } - const pagePayload = getPayloadFromFlash(request) - const currentPagePayload = Array.isArray(pagePayload) - ? {} - : /** @type { FormPayload | undefined } */ (pagePayload) - const currentPagePath = - currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload - ? currentPagePayload[CURRENT_PAGE_PATH_KEY] - : undefined - - if (currentPagePath) { - const combinedState = Hoek.merge( - formState, - { - [STATE_NOT_YET_VALIDATED]: { - ...currentPagePayload, - [CURRENT_PAGE_PATH_KEY]: currentPagePath - } - }, - { - mergeArrays: false - } - ) - await cacheService.setState( - /** @type {CacheRequest} */ (request), - combinedState - ) - } - // Clear any previous save and exit session state request.yar.clear(getKey(slug, status)) From 3ae080f2c75bca2320212c943e77d03ded0beb18 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 7 Sep 2026 16:11:11 +0100 Subject: [PATCH 3/9] Bump editorconfig + audit fix --- package-lock.json | 94 +++++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d4c8da14..70262da18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "copy-webpack-plugin": "^14.0.0", "cssnano": "^8.0.2", "cssnano-preset-default": "^8.0.2", - "editorconfig-checker": "^6.1.1", + "editorconfig-checker": "^6.2.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import-x": "^4.17.1", @@ -2492,9 +2492,9 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "5.0.0-alpha.21", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-5.0.0-alpha.21.tgz", - "integrity": "sha512-rIh67XQNAD/5i/yHQIF+G0y+NxHLyYNeGn+qK2H7MxnHFZBSara/QnCrG0nwexuwOETpmxarZRaPsd2AQxtMjw==", + "version": "5.0.0-alpha.22", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-5.0.0-alpha.22.tgz", + "integrity": "sha512-RvBiWL66qi8ahtainewL2dTFf4yBtpsZo0QHjvAgjupOl7cSse7J8FXLY/sbl3+yx9NeFUdyUHMMK/3uDStESA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -3961,29 +3961,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/gitignore-to-minimatch": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", @@ -8677,9 +8691,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -8781,9 +8795,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -8800,11 +8814,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -8927,9 +8941,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001802", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", - "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -10193,9 +10207,9 @@ } }, "node_modules/editorconfig-checker": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/editorconfig-checker/-/editorconfig-checker-6.1.1.tgz", - "integrity": "sha512-kiOb6qaWpMNt7Z/43ba0Pa1Inhr2/t9nKbvEKtCeXJ5AesztoM9AgLOOQVB4QUv/nGjgz3xkbx4pcogVRD2NWw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/editorconfig-checker/-/editorconfig-checker-6.2.0.tgz", + "integrity": "sha512-5zrNwlxUWyOvAcrSK4mPIGNYrf1KK5pH2D3VXDB1AcDsDdBfLNPOjBL5jHgYh+JykOIuXlQHMeYvXPrZwGNmHA==", "dev": true, "license": "MIT", "bin": { @@ -10211,9 +10225,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.387", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", - "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "license": "ISC" }, "node_modules/emittery": { @@ -11158,9 +11172,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -15508,9 +15522,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "license": "MIT", "engines": { "node": ">=18" @@ -20661,9 +20675,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 3bd460389..8f5cde531 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "copy-webpack-plugin": "^14.0.0", "cssnano": "^8.0.2", "cssnano-preset-default": "^8.0.2", - "editorconfig-checker": "^6.1.1", + "editorconfig-checker": "^6.2.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import-x": "^4.17.1", From e6f96fcbfdbebe1a04260bf88daa72e85a492cd1 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 7 Sep 2026 16:20:12 +0100 Subject: [PATCH 4/9] Extra coverage --- src/server/models/save-and-exit-v2.js | 4 +-- src/server/models/save-and-exit-v2.test.js | 32 ++++++++++++++++++++++ src/server/routes/save-and-exit-v2.js | 6 +--- 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 src/server/models/save-and-exit-v2.test.js diff --git a/src/server/models/save-and-exit-v2.js b/src/server/models/save-and-exit-v2.js index 59dcdf9b2..2de04ca1d 100644 --- a/src/server/models/save-and-exit-v2.js +++ b/src/server/models/save-and-exit-v2.js @@ -12,7 +12,7 @@ const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') * @param {string} slug * @param {FormStatus} [status] */ -function constructFormUrl(slug, status) { +export function constructFormUrl(slug, status) { if (!status) { return `${FORM_PREFIX}/${slug}` } @@ -24,7 +24,7 @@ function constructFormUrl(slug, status) { * @param {string} slug * @param {FormStatus} [status] */ -function constructSigninUrl(slug, status) { +export function constructSigninUrl(slug, status) { if (!status) { return `${HOMEPAGE_PREFIX}/${slug}` } diff --git a/src/server/models/save-and-exit-v2.test.js b/src/server/models/save-and-exit-v2.test.js new file mode 100644 index 000000000..0f91e9da4 --- /dev/null +++ b/src/server/models/save-and-exit-v2.test.js @@ -0,0 +1,32 @@ +import { FormStatus } from '@defra/forms-model' + +import { + constructFormUrl, + constructSigninUrl +} from '~/src/server/models/save-and-exit-v2.js' + +describe('Save and exit v2 models', () => { + describe('constructFormUrl', () => { + test('should construct form url', () => { + expect(constructFormUrl('my-form-slug', FormStatus.Draft)).toBe( + '/form/preview/draft/my-form-slug' + ) + expect(constructFormUrl('my-form-slug', FormStatus.Live)).toBe( + '/form/preview/live/my-form-slug' + ) + expect(constructFormUrl('my-form-slug')).toBe('/form/my-form-slug') + }) + }) + + describe('constructSigninUrl', () => { + test('should construct sign-in url', () => { + expect(constructSigninUrl('my-form-slug', FormStatus.Draft)).toBe( + '/homepage/preview/draft/my-form-slug' + ) + expect(constructSigninUrl('my-form-slug', FormStatus.Live)).toBe( + '/homepage/preview/live/my-form-slug' + ) + expect(constructSigninUrl('my-form-slug')).toBe('/homepage/my-form-slug') + }) + }) +}) diff --git a/src/server/routes/save-and-exit-v2.js b/src/server/routes/save-and-exit-v2.js index e70115b81..3e980c040 100644 --- a/src/server/routes/save-and-exit-v2.js +++ b/src/server/routes/save-and-exit-v2.js @@ -44,14 +44,10 @@ export default [ return h.redirect(model.serviceUrl) } - if (!auth.credentials.email) { - throw new Error('User not logged in') - } - await publishSaveAndExitV2Event( metadata.id, metadata.title, - auth.credentials.email, + /** @type {string} */ (auth.credentials.email), await cacheService.getState(/** @type {CacheRequest} */ (request)), status ) From abed491756a3d80661529c31a392feb25ccee64a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 8 Sep 2026 08:58:54 +0100 Subject: [PATCH 5/9] Proves english and welsh in confirmation page --- src/server/routes/save-and-exit-v2.test.js | 75 +++++++++++++++++++ .../views/save-and-exit-v2/confirmation.html | 5 +- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/server/routes/save-and-exit-v2.test.js b/src/server/routes/save-and-exit-v2.test.js index c9b35fb30..4afe6f547 100644 --- a/src/server/routes/save-and-exit-v2.test.js +++ b/src/server/routes/save-and-exit-v2.test.js @@ -9,10 +9,12 @@ import { config } from '~/src/config/index.js' import { logger } from '~/src/server/common/helpers/logging/logger.js' import { createServer } from '~/src/server/index.js' import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' +import { getFormDefinitionWithFallback } from '~/src/server/services/helpers/formsServiceHelper.js' import { renderResponse } from '~/test/helpers/component-helpers.js' jest.mock('~/src/server/services/formMetadataGuards.js') jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/services/helpers/formsServiceHelper.js') jest.mock('~/src/server/helpers/error-helper.js') jest.mock('@defra/forms-engine-plugin/engine/form-availability.js') jest.mock('@defra/forms-engine-plugin/engine/helpers.js') @@ -106,14 +108,87 @@ describe('Save-and-exit check routes', () => { 'Your progress has been saved' ) + const $title = container.getByText('What happens next') + + const $savedFor = container.getByText( + 'Your answers have been saved for 28 days.' + ) + + const $emailedLink = container.getByText( + "We’ve emailed you a link so you can sign in and continue your 'My test form' form later." + ) + + const $checkSpam = container.getByText( + 'Check your spam folder if you have not received an email after a few minutes.' + ) + const $button = container.queryByRole('link', { name: 'Sign in' }) expect($mastheadHeading).toBeInTheDocument() + expect($title).toBeInTheDocument() + expect($savedFor).toBeInTheDocument() + expect($emailedLink).toBeInTheDocument() + expect($checkSpam).toBeInTheDocument() expect($button).not.toBeInTheDocument() }) }) + + test('route renders view on success when logged in (in Welsh)', async () => { + jest + .mocked(getFormMetadataWithGuard) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce(testMetadata) + jest.mocked(getFormDefinitionWithFallback).mockResolvedValue( + // @ts-expect-error - partial mock of test data + { + metadata: { + translations: { + cy: { + dummy: 'test' + } + } + } + } + ) + const options = { + method: 'GET', + url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}?language=cy`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.queryByText( + "Mae eich cynnydd wedi'i gadw" + ) + + const $title = container.queryByText("Beth sy'n digwydd nesaf") + + const $savedFor = container.queryByText( + "Mae eich atebion wedi'u cadw am 28 o ddiwrnodau." + ) + + const $emailedLink = container.queryByText( + "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen 'My test form' yn ddiweddarach." + ) + + const $checkSpam = container.queryByText( + 'Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.' + ) + + const $button = container.getByTestId('signin-button') + + expect($mastheadHeading).toBeInTheDocument() + expect($title).toBeInTheDocument() + expect($savedFor).toBeInTheDocument() + expect($emailedLink).toBeInTheDocument() + expect($checkSpam).toBeInTheDocument() + expect($button.textContent.trim()).toBe('Mewngofnodi') + }) }) /** diff --git a/src/server/views/save-and-exit-v2/confirmation.html b/src/server/views/save-and-exit-v2/confirmation.html index 5c2810724..7671806c5 100644 --- a/src/server/views/save-and-exit-v2/confirmation.html +++ b/src/server/views/save-and-exit-v2/confirmation.html @@ -25,7 +25,10 @@

{{ tR('saveAndExit.confirmation.whatHappensNext') }} {{ govukButton({ text: tR('saveAndExit.confirmation.signin'), - href: signinLink + href: signinLink, + attributes: { + 'data-testid': 'signin-button' + } }) }} From d954571972547d2803b5904e82fbd1778e3c2528 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 8 Sep 2026 13:18:12 +0100 Subject: [PATCH 6/9] Rework after review --- src/server/index.ts | 8 +- src/server/messaging/mappers/events.js | 6 +- src/server/messaging/mappers/events.test.js | 9 +- src/server/messaging/publish.js | 6 +- src/server/messaging/publish.test.js | 31 +++- src/server/models/save-and-exit-v2.js | 20 +-- src/server/models/save-and-exit-v2.test.js | 17 +-- src/server/plugins/router.ts | 4 +- src/server/routes/index.ts | 1 - src/server/routes/save-and-exit-v2.js | 82 ----------- src/server/routes/save-and-exit-v2.test.js | 41 +++--- src/server/routes/save-and-exit.js | 132 +++++++++++++----- .../views/save-and-exit-v2/confirmation.html | 9 -- 13 files changed, 164 insertions(+), 202 deletions(-) delete mode 100644 src/server/routes/save-and-exit-v2.js diff --git a/src/server/index.ts b/src/server/index.ts index d42f524b3..caeee20a4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -178,14 +178,10 @@ export const configureEnginePlugin = async ({ yar.flash(SAVE_AND_EXIT_PAYLOAD, pagePayload, true) - const saveAndExitBase = config.get('useSignInFeature') - ? 'save-and-exit-v2' - : 'save-and-exit' - return h.redirect( !isPreview - ? `/${saveAndExitBase}/${slug}` - : `/${saveAndExitBase}/${slug}/${state}` + ? `/save-and-exit/${slug}` + : `/save-and-exit/${slug}/${state}` ) }, controllers: { diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index ca35f1f95..eeba4f044 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -64,12 +64,12 @@ export function saveAndExitV1Mapper( * For V2 save-and-exit * @param { string } formId * @param { string } formTitle - * @param { string } email + * @param {{ sub: string, issuer: string }} auth * @param { FormState } state * @param { FormStatus } [status] * @returns {SaveAndExitV2Message} */ -export function saveAndExitV2Mapper(formId, formTitle, email, state, status) { +export function saveAndExitV2Mapper(formId, formTitle, auth, state, status) { /** @type {SaveAndExitV2MessageData} */ const data = { form: { @@ -79,7 +79,7 @@ export function saveAndExitV2Mapper(formId, formTitle, email, state, status) { isPreview: !!status, baseUrl }, - email, + auth, state } const now = new Date() diff --git a/src/server/messaging/mappers/events.test.js b/src/server/messaging/mappers/events.test.js index b854b9caf..f632b2247 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -145,7 +145,10 @@ describe('runner-events', () => { status: FormStatus.Draft, baseUrl: 'http://localhost:3009' }, - email: 'my-email@here.com', + auth: { + sub: 'auth-sub', + issuer: 'auth-issuer' + }, state: { formVal1: '123', formVal2: '456' @@ -156,7 +159,7 @@ describe('runner-events', () => { saveAndExitV2Mapper( payload.form.id, payload.form.title, - payload.email, + payload.auth, payload.state, payload.form.status ) @@ -175,7 +178,7 @@ describe('runner-events', () => { status: payload.form.status, baseUrl: 'http://localhost:3009' }, - email: payload.email, + auth: payload.auth, state: payload.state } }) diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index b867944bf..d5f38aaf7 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -55,18 +55,18 @@ export async function publishSaveAndExitV1Event( * V2 save-and-exit stores state against the logged-in user. * @param {string} formId * @param {string} formTitle - * @param {string} email + * @param {{ sub: string, issuer: string }} auth * @param {FormState} state * @param {FormStatus} [status] */ export async function publishSaveAndExitV2Event( formId, formTitle, - email, + auth, state, status ) { - const message = saveAndExitV2Mapper(formId, formTitle, email, state, status) + const message = saveAndExitV2Mapper(formId, formTitle, auth, state, status) return validateAndPublishEvent(message) } diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index 21ace9049..c07464167 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -38,6 +38,27 @@ const saveAndExitPayload = { } } +/** + * @type {SaveAndExitV2MessageData} + */ +const saveAndExitPayloadv2 = { + form: { + id: 'formId', + title: 'My First Form', + isPreview: true, + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' + }, + auth: { + sub: 'auth-sub', + issuer: 'auth-issuer' + }, + state: { + formVal1: '123', + formVal2: '456' + } +} + describe('publish', () => { beforeEach(() => { jest.mocked(publishEvent).mockResolvedValue({ @@ -90,17 +111,13 @@ describe('publish', () => { }) describe('publishSaveAndExitV2Event', () => { - const saveAndExitPayloadTemp = structuredClone(saveAndExitPayload) - // @ts-expect-error - remove property that V2 doesn't have - delete saveAndExitPayloadTemp.security - /** @type {SaveAndExitV2MessageData} */ - const saveAndExitV2Payload = saveAndExitPayloadTemp + const saveAndExitV2Payload = structuredClone(saveAndExitPayloadv2) it('should publish SAVE_AND_EXIT V2 event', async () => { await publishSaveAndExitV2Event( saveAndExitV2Payload.form.id, saveAndExitV2Payload.form.title, - saveAndExitV2Payload.email, + saveAndExitV2Payload.auth, saveAndExitV2Payload.state, saveAndExitV2Payload.form.status ) @@ -125,7 +142,7 @@ describe('publish', () => { publishSaveAndExitV2Event(invalidPayload) ).rejects.toThrow( new ValidationError( - '"data.form.id" must be a string. "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.state" is required', [], {} ) diff --git a/src/server/models/save-and-exit-v2.js b/src/server/models/save-and-exit-v2.js index 2de04ca1d..46dfbfafd 100644 --- a/src/server/models/save-and-exit-v2.js +++ b/src/server/models/save-and-exit-v2.js @@ -3,7 +3,7 @@ import { slugSchema } from '@defra/forms-model' import Joi from 'joi' import { config } from '~/src/config/index.js' -import { FORM_PREFIX, HOMEPAGE_PREFIX } from '~/src/server/constants.js' +import { FORM_PREFIX } from '~/src/server/constants.js' import { getFeedbackFormLink } from '~/src/server/utils/utils.js' const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') @@ -20,18 +20,6 @@ export function constructFormUrl(slug, status) { return `${FORM_PREFIX}/preview/${status}/${slug}` } -/** - * @param {string} slug - * @param {FormStatus} [status] - */ -export function constructSigninUrl(slug, status) { - if (!status) { - return `${HOMEPAGE_PREFIX}/${slug}` - } - - return `${HOMEPAGE_PREFIX}/preview/${status}/${slug}` -} - /** * Save and exit params schema */ @@ -48,7 +36,7 @@ export const paramsSchema = Joi.object() * @param {FormStatus} [state] */ export function getKey(slug, state) { - return `save-and-exit-v2-${slug}-${state ?? ''}` + return `save-and-exit-${slug}-${state ?? ''}` } /** @@ -57,16 +45,14 @@ export function getKey(slug, state) { * @param {Translator} translator * @param {FormStatus} [status] */ -export function confirmationViewModel(metadata, translator, status) { +export function confirmationViewModelv2(metadata, translator, status) { const { slug, title, id } = metadata const formPath = constructFormUrl(slug, status) - const signinLink = constructSigninUrl(slug, status) const { t } = translator return { name: title, serviceUrl: formPath, - signinLink, pageTitle: t('saveAndExit.confirmation.pageTitle'), saveAndExitExpiryDays, context: { translator }, diff --git a/src/server/models/save-and-exit-v2.test.js b/src/server/models/save-and-exit-v2.test.js index 0f91e9da4..50892f66f 100644 --- a/src/server/models/save-and-exit-v2.test.js +++ b/src/server/models/save-and-exit-v2.test.js @@ -1,9 +1,6 @@ import { FormStatus } from '@defra/forms-model' -import { - constructFormUrl, - constructSigninUrl -} from '~/src/server/models/save-and-exit-v2.js' +import { constructFormUrl } from '~/src/server/models/save-and-exit-v2.js' describe('Save and exit v2 models', () => { describe('constructFormUrl', () => { @@ -17,16 +14,4 @@ describe('Save and exit v2 models', () => { expect(constructFormUrl('my-form-slug')).toBe('/form/my-form-slug') }) }) - - describe('constructSigninUrl', () => { - test('should construct sign-in url', () => { - expect(constructSigninUrl('my-form-slug', FormStatus.Draft)).toBe( - '/homepage/preview/draft/my-form-slug' - ) - expect(constructSigninUrl('my-form-slug', FormStatus.Live)).toBe( - '/homepage/preview/live/my-form-slug' - ) - expect(constructSigninUrl('my-form-slug')).toBe('/homepage/my-form-slug') - }) - }) }) diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index f8163bb07..b07366352 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -36,8 +36,7 @@ import { healthRoute, homepageRoutes, publicRoutes, - saveAndExitRoutes, - saveAndExitV2Routes + saveAndExitRoutes } from '~/src/server/routes/index.js' import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' import { getFormMetadataWithoutGuard } from '~/src/server/services/formMetadataGuards.js' @@ -79,7 +78,6 @@ export default { if (config.get('useSignInFeature')) { server.route(authRoutes as ServerRoute[]) server.route(homepageRoutes) - server.route(saveAndExitV2Routes as ServerRoute[]) } server.route(routes) diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 908ea9e88..1d2f52760 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -3,4 +3,3 @@ export { default as homepageRoutes } from '~/src/server/routes/homepage.js' 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' -export { default as saveAndExitV2Routes } from '~/src/server/routes/save-and-exit-v2.js' diff --git a/src/server/routes/save-and-exit-v2.js b/src/server/routes/save-and-exit-v2.js deleted file mode 100644 index 3e980c040..000000000 --- a/src/server/routes/save-and-exit-v2.js +++ /dev/null @@ -1,82 +0,0 @@ -import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' - -import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' -import { publishSaveAndExitV2Event } from '~/src/server/messaging/publish.js' -import { - confirmationViewModel, - getKey, - paramsSchema -} from '~/src/server/models/save-and-exit-v2.js' -import { stateHandler } from '~/src/server/routes/save-and-exit-state-handler.js' -import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' -import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' - -// Route base -const SAVE_AND_EXIT_ROUTE_BASE = 'save-and-exit-v2' - -// View paths -const SAVE_AND_EXIT_CONFIRMATION = 'save-and-exit-v2/confirmation' - -export default [ - /** - * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} - */ - ({ - method: 'GET', - path: `/${SAVE_AND_EXIT_ROUTE_BASE}/{slug}/{state?}`, - async handler(request, h) { - const { params, auth } = request - const { slug, state: status } = params - const metadata = await getFormMetadataWithGuard(slug, status) - const { translator } = await getFormTranslator(request, metadata, status) - - const model = confirmationViewModel(metadata, translator, status) - - const cacheService = getCacheService( - /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server - ) - - // Store any outstanding data from the current page in a special attribute - // (in case the current page wasn't yet validated and saved). - // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared - // so just show the form from the start - if (await stateHandler(request)) { - return h.redirect(model.serviceUrl) - } - - await publishSaveAndExitV2Event( - metadata.id, - metadata.title, - /** @type {string} */ (auth.credentials.email), - await cacheService.getState(/** @type {CacheRequest} */ (request)), - status - ) - - // Clear any previous save and exit session state - request.yar.clear(getKey(slug, status)) - await cacheService.clearState(request) - - // Log out? - // TODO - - return h - .view(SAVE_AND_EXIT_CONFIRMATION, model) - .header('Cache-Control', 'no-cache, no-store, must-revalidate') - }, - options: { - auth: { mode: 'required', strategy: CITIZEN_SESSION }, - validate: { - params: paramsSchema - } - } - }) -] - -/** - * @import { ServerRoute, RequestQuery } from '@hapi/hapi' - * @import { Yar } from '@hapi/yar' - * @import { FormMetadata } from '@defra/forms-model' - * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' - * @import { AnyRequest, CacheRequest, FormPayload } from '@defra/forms-engine-plugin/engine/types.js' - * @import { SaveAndExitParams } from '~/src/server/models/save-and-exit.js' - */ diff --git a/src/server/routes/save-and-exit-v2.test.js b/src/server/routes/save-and-exit-v2.test.js index 4afe6f547..da3e4f4e8 100644 --- a/src/server/routes/save-and-exit-v2.test.js +++ b/src/server/routes/save-and-exit-v2.test.js @@ -5,7 +5,6 @@ import { import { FormStatus } from '@defra/forms-model' import { StatusCodes } from 'http-status-codes' -import { config } from '~/src/config/index.js' import { logger } from '~/src/server/common/helpers/logging/logger.js' import { createServer } from '~/src/server/index.js' import { getFormMetadataWithGuard } from '~/src/server/services/formMetadataGuards.js' @@ -20,6 +19,15 @@ jest.mock('@defra/forms-engine-plugin/engine/form-availability.js') jest.mock('@defra/forms-engine-plugin/engine/helpers.js') jest.mock('~/src/server/messaging/publish.js') +// Enable sign-in routes while retaining the real values for every other setting. +jest.mock('~/src/config/index', () => { + const actual = jest.requireActual('~/src/config/index') + + actual.config.set('useSignInFeature', true) + + return actual +}) + const DRAFT_STATE = 'draft' /** A citizen who has signed in, as the citizen-session scheme presents them */ @@ -35,8 +43,6 @@ describe('Save-and-exit check routes', () => { let server beforeAll(async () => { - config.set('useSignInFeature', true) - server = await createServer({ enforceCsrf: false }) @@ -46,7 +52,6 @@ describe('Save-and-exit check routes', () => { afterAll(async () => { await server.stop() - config.set('useSignInFeature', false) }) beforeEach(() => { @@ -59,12 +64,13 @@ describe('Save-and-exit check routes', () => { }) // @ts-expect-error - not all method mocked jest.mocked(getCacheService).mockImplementation(() => ({ - getState: jest.fn().mockResolvedValueOnce({ key: 'val' }), + getState: jest.fn().mockResolvedValue({ key: 'val' }), + setState: jest.fn(), clearState: jest.fn() })) jest .mocked(checkFormStatus) - .mockReturnValueOnce({ isPreview: true, state: FormStatus.Draft }) + .mockReturnValue({ isPreview: true, state: FormStatus.Draft }) }) const FORM_SLUG = 'my-form-slug' @@ -75,16 +81,21 @@ describe('Save-and-exit check routes', () => { title: 'My test form' } - describe('GET /save-and-exit-v2/{slug}/{state?}', () => { + describe('GET /save-and-exit/{slug}/{state?}', () => { it('sends a signed-out citizen to sign in first', async () => { + jest + .mocked(getFormMetadataWithGuard) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce(testMetadata) + const response = await server.inject({ method: 'GET', - url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}` + url: `/save-and-exit/${FORM_SLUG}/${DRAFT_STATE}` }) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) expect(response.headers.location).toBe( - '/auth/sign-in?returnUrl=%2Fsave-and-exit-v2%2Fmy-form-slug%2Fdraft' + '/auth/sign-in?returnUrl=%2Fsave-and-exit%2Fmy-form-slug%2Fdraft' ) }) @@ -96,7 +107,7 @@ describe('Save-and-exit check routes', () => { const options = { method: 'GET', - url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}`, + url: `/save-and-exit/${FORM_SLUG}/${DRAFT_STATE}`, auth: { strategy: 'citizen-session', credentials } } @@ -122,16 +133,11 @@ describe('Save-and-exit check routes', () => { 'Check your spam folder if you have not received an email after a few minutes.' ) - const $button = container.queryByRole('link', { - name: 'Sign in' - }) - expect($mastheadHeading).toBeInTheDocument() expect($title).toBeInTheDocument() expect($savedFor).toBeInTheDocument() expect($emailedLink).toBeInTheDocument() expect($checkSpam).toBeInTheDocument() - expect($button).not.toBeInTheDocument() }) }) @@ -154,7 +160,7 @@ describe('Save-and-exit check routes', () => { ) const options = { method: 'GET', - url: `/save-and-exit-v2/${FORM_SLUG}/${DRAFT_STATE}?language=cy`, + url: `/save-and-exit/${FORM_SLUG}/${DRAFT_STATE}?language=cy`, auth: { strategy: 'citizen-session', credentials } } @@ -180,14 +186,11 @@ describe('Save-and-exit check routes', () => { 'Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.' ) - const $button = container.getByTestId('signin-button') - expect($mastheadHeading).toBeInTheDocument() expect($title).toBeInTheDocument() expect($savedFor).toBeInTheDocument() expect($emailedLink).toBeInTheDocument() expect($checkSpam).toBeInTheDocument() - expect($button.textContent.trim()).toBe('Mewngofnodi') }) }) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index e026f4480..eb6e37c38 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -6,6 +6,8 @@ import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' +import { config } from '~/src/config/index.js' +import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' import { logger } from '~/src/server/common/helpers/logging/logger.js' import { EN_GB } from '~/src/server/constants.js' import { createJoiError } from '~/src/server/helpers/error-helper.js' @@ -13,7 +15,11 @@ import { getCachedFormTranslatorBasic, getCachedFormTranslatorExternalRoutes } from '~/src/server/i18n/form.js' -import { publishSaveAndExitV1Event } from '~/src/server/messaging/publish.js' +import { + publishSaveAndExitV1Event, + publishSaveAndExitV2Event +} from '~/src/server/messaging/publish.js' +import { confirmationViewModelv2 } from '~/src/server/models/save-and-exit-v2.js' import { confirmationViewModel, createInvalidPasswordError, @@ -54,6 +60,7 @@ 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' +const SAVE_AND_EXIT_CONFIRMATION = 'save-and-exit-v2/confirmation' /** * @param {number} attemptsSoFar @@ -107,6 +114,86 @@ export async function getFormTranslator( return { translator, language } } +/** + * Handle V1 save-and exit initial route + * @param {Request<{ Params: SaveAndExitParams }>} request + * @param {ResponseToolkit<{ Params: SaveAndExitParams }>} h + */ +async function v1Handler(request, h) { + const { params } = request + const { slug, state: status } = params + const metadata = await getFormMetadataWithGuard(slug, status) + const { translator } = await getFormTranslator(request, metadata, status) + + const model = detailsViewModel( + metadata, + translator, + status, + undefined, + undefined + ) + + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). + // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared + // so just show the form from the start + if (await stateHandler(request)) { + return h.redirect(model.serviceUrl) + } + + // Clear any previous save and exit session state + request.yar.clear(getKey(slug, status)) + + return h + .view(SAVE_AND_EXIT_DETAILS, model) + .header('Cache-Control', 'no-cache, no-store, must-revalidate') +} + +/** + * Handle V2 save-and exit route + * @param {Request<{ Params: SaveAndExitParams }>} request + * @param {ResponseToolkit<{ Params: SaveAndExitParams }>} h + */ +async function v2Handler(request, h) { + const { params, auth } = request + const { slug, state: status } = params + const metadata = await getFormMetadataWithGuard(slug, status) + const { translator } = await getFormTranslator(request, metadata, status) + + const model = confirmationViewModelv2(metadata, translator, status) + + const cacheService = getCacheService( + /** @type {AnyRequest} */ (/** @type {unknown} */ (request)).server + ) + + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). + // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared + // so just show the form from the start + if (await stateHandler(request)) { + return h.redirect(model.serviceUrl) + } + + await publishSaveAndExitV2Event( + metadata.id, + metadata.title, + { + sub: /** @type {string} */ (auth.credentials.sub), + issuer: /** @type {string} */ (auth.credentials.iss) + }, + await cacheService.getState(/** @type {CacheRequest} */ (request)), + status + ) + + // Clear any previous save and exit session state + request.yar.clear(getKey(slug, status)) + await cacheService.clearState(request) + + return h + .view(SAVE_AND_EXIT_CONFIRMATION, model) + .header('Cache-Control', 'no-cache, no-store, must-revalidate') +} + export default [ /** * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} @@ -114,39 +201,18 @@ export default [ ({ method: 'GET', path: '/save-and-exit/{slug}/{state?}', - async handler(request, h) { - const { params } = request - const { slug, state: status } = params - const metadata = await getFormMetadataWithGuard(slug, status) - const { translator } = await getFormTranslator(request, metadata, status) - - const model = detailsViewModel( - metadata, - translator, - status, - undefined, - undefined - ) - - // Store any outstanding data from the current page in a special attribute - // (in case the current page wasn't yet validated and saved). - // Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared - // so just show the form from the start - if (await stateHandler(request)) { - return h.redirect(model.serviceUrl) - } - - // Clear any previous save and exit session state - request.yar.clear(getKey(slug, status)) - - return h - .view(SAVE_AND_EXIT_DETAILS, model) - .header('Cache-Control', 'no-cache, no-store, must-revalidate') + handler(request, h) { + return config.get('useSignInFeature') + ? v2Handler(request, h) + : v1Handler(request, h) }, options: { validate: { params: paramsSchema - } + }, + ...(config.get('useSignInFeature') + ? { auth: { mode: 'required', strategy: CITIZEN_SESSION } } + : {}) } }), /** @@ -627,10 +693,10 @@ export default [ ] /** - * @import { ServerRoute, RequestQuery } from '@hapi/hapi' + * @import { ServerRoute, ResponseToolkit, Request, RequestQuery } from '@hapi/hapi' * @import { Yar } from '@hapi/yar' - * @import { FormDefinition, FormMetadata } from '@defra/forms-model' + * @import { FormMetadata } from '@defra/forms-model' * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' - * @import { AnyRequest, CacheRequest, FormPayload } from '@defra/forms-engine-plugin/engine/types.js' + * @import { AnyRequest, CacheRequest } from '@defra/forms-engine-plugin/engine/types.js' * @import { BoomErrorCustomSaveAndExit, SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' */ diff --git a/src/server/views/save-and-exit-v2/confirmation.html b/src/server/views/save-and-exit-v2/confirmation.html index 7671806c5..145716fc1 100644 --- a/src/server/views/save-and-exit-v2/confirmation.html +++ b/src/server/views/save-and-exit-v2/confirmation.html @@ -22,15 +22,6 @@

{{ tR('saveAndExit.confirmation.whatHappensNext') }}

{{ tR('saveAndExit.confirmation.checkSpam') }}

- - {{ govukButton({ - text: tR('saveAndExit.confirmation.signin'), - href: signinLink, - attributes: { - 'data-testid': 'signin-button' - } - }) - }} {% endblock %} From fe5786143a22f1d8471f6f1aadd9e8ba68d60896 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 8 Sep 2026 13:22:06 +0100 Subject: [PATCH 7/9] Method name change --- src/server/i18n/translations/cy.json | 3 +-- src/server/i18n/translations/en-GB.json | 3 +-- src/server/routes/save-and-exit.js | 8 ++++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/server/i18n/translations/cy.json b/src/server/i18n/translations/cy.json index f9191cfdf..08907cbda 100644 --- a/src/server/i18n/translations/cy.json +++ b/src/server/i18n/translations/cy.json @@ -189,8 +189,7 @@ "checkSpam": "Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.", "closeWindow": "Gallwch gau'r ffenestr hon.", "answersSaved": "Mae eich atebion wedi'u cadw am [[days]] o ddiwrnodau.", - "emailedLinkForSignIn": "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen '[[formName]]' yn ddiweddarach.", - "signin": "Mewngofnodi" + "emailedLinkForSignIn": "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen '[[formName]]' yn ddiweddarach." }, "resumeError": { "pageTitle": "Ni allwch ailafael yn eich ffurflen", diff --git a/src/server/i18n/translations/en-GB.json b/src/server/i18n/translations/en-GB.json index 4dd679d4c..daaa15747 100644 --- a/src/server/i18n/translations/en-GB.json +++ b/src/server/i18n/translations/en-GB.json @@ -191,8 +191,7 @@ "checkSpam": "Check your spam folder if you have not received an email after a few minutes.", "closeWindow": "You can close this window.", "answersSaved": "Your answers have been saved for [[days]] days.", - "emailedLinkForSignIn": "We’ve emailed you a link so you can sign in and continue your '[[formName]]' form later.", - "signin": "Sign in" + "emailedLinkForSignIn": "We’ve emailed you a link so you can sign in and continue your '[[formName]]' form later." }, "resumeError": { "pageTitle": "You cannot resume your form", diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index eb6e37c38..1736c1d4f 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -119,7 +119,7 @@ export async function getFormTranslator( * @param {Request<{ Params: SaveAndExitParams }>} request * @param {ResponseToolkit<{ Params: SaveAndExitParams }>} h */ -async function v1Handler(request, h) { +async function handlerMemorableWordSaveAndExit(request, h) { const { params } = request const { slug, state: status } = params const metadata = await getFormMetadataWithGuard(slug, status) @@ -154,7 +154,7 @@ async function v1Handler(request, h) { * @param {Request<{ Params: SaveAndExitParams }>} request * @param {ResponseToolkit<{ Params: SaveAndExitParams }>} h */ -async function v2Handler(request, h) { +async function handleAuthenticatedSaveAndExit(request, h) { const { params, auth } = request const { slug, state: status } = params const metadata = await getFormMetadataWithGuard(slug, status) @@ -203,8 +203,8 @@ export default [ path: '/save-and-exit/{slug}/{state?}', handler(request, h) { return config.get('useSignInFeature') - ? v2Handler(request, h) - : v1Handler(request, h) + ? handleAuthenticatedSaveAndExit(request, h) + : handlerMemorableWordSaveAndExit(request, h) }, options: { validate: { From 22a024f7ddcd649131355b59fd946d7b9f86eb98 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 8 Sep 2026 14:25:24 +0100 Subject: [PATCH 8/9] Re-instated button in save-and-exit confirmation --- src/server/i18n/translations/cy.json | 3 ++- src/server/i18n/translations/en-GB.json | 3 ++- src/server/models/save-and-exit-v2.js | 16 +++++++++++++++- src/server/models/save-and-exit-v2.test.js | 17 ++++++++++++++++- src/server/routes/save-and-exit-v2.test.js | 16 +++++++++++----- .../views/save-and-exit-v2/confirmation.html | 9 +++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/server/i18n/translations/cy.json b/src/server/i18n/translations/cy.json index 08907cbda..7eb765845 100644 --- a/src/server/i18n/translations/cy.json +++ b/src/server/i18n/translations/cy.json @@ -189,7 +189,8 @@ "checkSpam": "Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.", "closeWindow": "Gallwch gau'r ffenestr hon.", "answersSaved": "Mae eich atebion wedi'u cadw am [[days]] o ddiwrnodau.", - "emailedLinkForSignIn": "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen '[[formName]]' yn ddiweddarach." + "emailedLinkForSignIn": "Rydym wedi anfon dolen atoch drwy e-bost fel y gallwch fewngofnodi a pharhau â'ch ffurflen '[[formName]]' yn ddiweddarach.", + "back": "Yn ôl i reoli eich ffurflen" }, "resumeError": { "pageTitle": "Ni allwch ailafael yn eich ffurflen", diff --git a/src/server/i18n/translations/en-GB.json b/src/server/i18n/translations/en-GB.json index daaa15747..2a0b12474 100644 --- a/src/server/i18n/translations/en-GB.json +++ b/src/server/i18n/translations/en-GB.json @@ -191,7 +191,8 @@ "checkSpam": "Check your spam folder if you have not received an email after a few minutes.", "closeWindow": "You can close this window.", "answersSaved": "Your answers have been saved for [[days]] days.", - "emailedLinkForSignIn": "We’ve emailed you a link so you can sign in and continue your '[[formName]]' form later." + "emailedLinkForSignIn": "We’ve emailed you a link so you can sign in and continue your '[[formName]]' form later.", + "back": "Back to manage your form" }, "resumeError": { "pageTitle": "You cannot resume your form", diff --git a/src/server/models/save-and-exit-v2.js b/src/server/models/save-and-exit-v2.js index 46dfbfafd..8874353e9 100644 --- a/src/server/models/save-and-exit-v2.js +++ b/src/server/models/save-and-exit-v2.js @@ -3,7 +3,7 @@ import { slugSchema } from '@defra/forms-model' import Joi from 'joi' import { config } from '~/src/config/index.js' -import { FORM_PREFIX } from '~/src/server/constants.js' +import { FORM_PREFIX, HOMEPAGE_PREFIX } from '~/src/server/constants.js' import { getFeedbackFormLink } from '~/src/server/utils/utils.js' const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') @@ -20,6 +20,18 @@ export function constructFormUrl(slug, status) { return `${FORM_PREFIX}/preview/${status}/${slug}` } +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +export function constructBackLink(slug, status) { + if (!status) { + return `${HOMEPAGE_PREFIX}/${slug}` + } + + return `${HOMEPAGE_PREFIX}/preview/${status}/${slug}` +} + /** * Save and exit params schema */ @@ -48,12 +60,14 @@ export function getKey(slug, state) { export function confirmationViewModelv2(metadata, translator, status) { const { slug, title, id } = metadata const formPath = constructFormUrl(slug, status) + const backLink = constructBackLink(slug, status) const { t } = translator return { name: title, serviceUrl: formPath, pageTitle: t('saveAndExit.confirmation.pageTitle'), + backLink, saveAndExitExpiryDays, context: { translator }, ...getFeedbackFormLink(id) diff --git a/src/server/models/save-and-exit-v2.test.js b/src/server/models/save-and-exit-v2.test.js index 50892f66f..e0a924cd5 100644 --- a/src/server/models/save-and-exit-v2.test.js +++ b/src/server/models/save-and-exit-v2.test.js @@ -1,6 +1,9 @@ import { FormStatus } from '@defra/forms-model' -import { constructFormUrl } from '~/src/server/models/save-and-exit-v2.js' +import { + constructBackLink, + constructFormUrl +} from '~/src/server/models/save-and-exit-v2.js' describe('Save and exit v2 models', () => { describe('constructFormUrl', () => { @@ -14,4 +17,16 @@ describe('Save and exit v2 models', () => { expect(constructFormUrl('my-form-slug')).toBe('/form/my-form-slug') }) }) + + describe('constructBackLink', () => { + test('should construct backlink', () => { + expect(constructBackLink('my-form-slug', FormStatus.Draft)).toBe( + '/homepage/preview/draft/my-form-slug' + ) + expect(constructBackLink('my-form-slug', FormStatus.Live)).toBe( + '/homepage/preview/live/my-form-slug' + ) + expect(constructBackLink('my-form-slug')).toBe('/homepage/my-form-slug') + }) + }) }) diff --git a/src/server/routes/save-and-exit-v2.test.js b/src/server/routes/save-and-exit-v2.test.js index da3e4f4e8..c15c510e3 100644 --- a/src/server/routes/save-and-exit-v2.test.js +++ b/src/server/routes/save-and-exit-v2.test.js @@ -115,29 +115,32 @@ describe('Save-and-exit check routes', () => { expect(response.statusCode).toBe(StatusCodes.OK) - const $mastheadHeading = container.getByText( + const $mastheadHeading = container.queryByText( 'Your progress has been saved' ) - const $title = container.getByText('What happens next') + const $title = container.queryByText('What happens next') - const $savedFor = container.getByText( + const $savedFor = container.queryByText( 'Your answers have been saved for 28 days.' ) - const $emailedLink = container.getByText( + const $emailedLink = container.queryByText( "We’ve emailed you a link so you can sign in and continue your 'My test form' form later." ) - const $checkSpam = container.getByText( + const $checkSpam = container.queryByText( 'Check your spam folder if you have not received an email after a few minutes.' ) + const $button = container.queryByTestId('back-button') + expect($mastheadHeading).toBeInTheDocument() expect($title).toBeInTheDocument() expect($savedFor).toBeInTheDocument() expect($emailedLink).toBeInTheDocument() expect($checkSpam).toBeInTheDocument() + expect($button?.textContent.trim()).toBe('Back to manage your form') }) }) @@ -186,11 +189,14 @@ describe('Save-and-exit check routes', () => { 'Gwiriwch eich ffolder sbam os na fyddwch wedi cael e-bost ar ôl ychydig funudau.' ) + const $button = container.queryByTestId('back-button') + expect($mastheadHeading).toBeInTheDocument() expect($title).toBeInTheDocument() expect($savedFor).toBeInTheDocument() expect($emailedLink).toBeInTheDocument() expect($checkSpam).toBeInTheDocument() + expect($button?.textContent.trim()).toBe('Yn ôl i reoli eich ffurflen') }) }) diff --git a/src/server/views/save-and-exit-v2/confirmation.html b/src/server/views/save-and-exit-v2/confirmation.html index 145716fc1..cb192e29d 100644 --- a/src/server/views/save-and-exit-v2/confirmation.html +++ b/src/server/views/save-and-exit-v2/confirmation.html @@ -22,6 +22,15 @@

{{ tR('saveAndExit.confirmation.whatHappensNext') }}

{{ tR('saveAndExit.confirmation.checkSpam') }}

+ + {{ govukButton({ + text: tR('saveAndExit.confirmation.back'), + href: backLink, + attributes: { + 'data-testid': 'back-button' + } + }) + }} {% endblock %} From 3d9fa1a86ae68c8b8e80fe97e38ffc4c5a6a372a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 8 Sep 2026 14:57:54 +0100 Subject: [PATCH 9/9] Added email in payloads --- src/server/messaging/mappers/events.js | 11 ++++++++++- src/server/messaging/mappers/events.test.js | 3 +++ src/server/messaging/publish.js | 11 ++++++++++- src/server/messaging/publish.test.js | 4 +++- src/server/routes/save-and-exit.js | 1 + 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index eeba4f044..114da9b72 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -64,12 +64,20 @@ export function saveAndExitV1Mapper( * For V2 save-and-exit * @param { string } formId * @param { string } formTitle + * @param { string } email * @param {{ sub: string, issuer: string }} auth * @param { FormState } state * @param { FormStatus } [status] * @returns {SaveAndExitV2Message} */ -export function saveAndExitV2Mapper(formId, formTitle, auth, state, status) { +export function saveAndExitV2Mapper( + formId, + formTitle, + email, + auth, + state, + status +) { /** @type {SaveAndExitV2MessageData} */ const data = { form: { @@ -79,6 +87,7 @@ export function saveAndExitV2Mapper(formId, formTitle, auth, state, status) { isPreview: !!status, baseUrl }, + email, auth, state } diff --git a/src/server/messaging/mappers/events.test.js b/src/server/messaging/mappers/events.test.js index f632b2247..2e5841932 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -145,6 +145,7 @@ describe('runner-events', () => { status: FormStatus.Draft, baseUrl: 'http://localhost:3009' }, + email: 'my-email@here.com', auth: { sub: 'auth-sub', issuer: 'auth-issuer' @@ -159,6 +160,7 @@ describe('runner-events', () => { saveAndExitV2Mapper( payload.form.id, payload.form.title, + payload.email, payload.auth, payload.state, payload.form.status @@ -178,6 +180,7 @@ describe('runner-events', () => { status: payload.form.status, baseUrl: 'http://localhost:3009' }, + email: payload.email, auth: payload.auth, state: payload.state } diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index d5f38aaf7..28caa26dc 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -55,6 +55,7 @@ export async function publishSaveAndExitV1Event( * V2 save-and-exit stores state against the logged-in user. * @param {string} formId * @param {string} formTitle + * @param {string} email * @param {{ sub: string, issuer: string }} auth * @param {FormState} state * @param {FormStatus} [status] @@ -62,11 +63,19 @@ export async function publishSaveAndExitV1Event( export async function publishSaveAndExitV2Event( formId, formTitle, + email, auth, state, status ) { - const message = saveAndExitV2Mapper(formId, formTitle, auth, state, status) + const message = saveAndExitV2Mapper( + formId, + formTitle, + email, + auth, + state, + status + ) return validateAndPublishEvent(message) } diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index c07464167..b8e9615d2 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -49,6 +49,7 @@ const saveAndExitPayloadv2 = { status: FormStatus.Draft, baseUrl: 'http://localhost:3009' }, + email: 'my-email@here.com', auth: { sub: 'auth-sub', issuer: 'auth-issuer' @@ -117,6 +118,7 @@ describe('publish', () => { await publishSaveAndExitV2Event( saveAndExitV2Payload.form.id, saveAndExitV2Payload.form.title, + saveAndExitV2Payload.email, saveAndExitV2Payload.auth, saveAndExitV2Payload.state, saveAndExitV2Payload.form.status @@ -142,7 +144,7 @@ describe('publish', () => { publishSaveAndExitV2Event(invalidPayload) ).rejects.toThrow( new ValidationError( - '"data.form.id" must be a string. "data.form.title" 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/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 1736c1d4f..6164740fd 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -177,6 +177,7 @@ async function handleAuthenticatedSaveAndExit(request, h) { await publishSaveAndExitV2Event( metadata.id, metadata.title, + /** @type {string} */ (auth.credentials.email), { sub: /** @type {string} */ (auth.credentials.sub), issuer: /** @type {string} */ (auth.credentials.iss)