From 4ede2466f8275c5bdf04f844b61cd21402fbae3b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 1 Apr 2026 14:32:38 +0100 Subject: [PATCH 1/4] Redirects to latest link when old link --- src/server/models/save-and-exit.js | 12 +++++++++++ src/server/routes/save-and-exit.js | 18 ++++++++++++++++- src/server/routes/save-and-exit.test.js | 27 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index d1d2791de..752d138e7 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -543,8 +543,20 @@ export function resumeSuccessViewModel(form, status) { * @property {string} securityAnswer - the security answer */ +/** + * @typedef {object} CustomErrorPayload + * @property {{ latestId?: string }} [custom] - custom payload + */ + +/** + * @typedef {object} BoomErrorCustomSaveAndExit + * @property {{ statusCode?: StatusCodes, payload?: CustomErrorPayload }} [output] - contains status code and payload + * @property {{ payload?: CustomErrorPayload }} [data] - custom payload for save-and-exit + */ + /** * @import { FormMetadata } from '@defra/forms-model' + * @import { StatusCodes } from 'http-status-codes' * @import { FormStatus } from '@defra/forms-engine-plugin/types' * @import { SaveAndExitResumeDetails } from '~/src/server/types.js' */ diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index f9da99e96..9fb0f0c82 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -270,6 +270,22 @@ export default [ throw new Error('No link found') } } catch (err) { + const error = /** @type {BoomErrorCustomSaveAndExit} */ (err) + if (error.output?.statusCode === StatusCodes.GONE) { + const latestLinkId = + error.data?.payload?.custom?.latestId ?? + error.output.payload?.custom?.latestId + if (latestLinkId) { + logger.info( + `Old link ${magicLinkId} used but redirected to ${latestLinkId}` + ) + return h + .redirect(`/resume-form/${formId}/${latestLinkId}`) + .code(StatusCodes.SEE_OTHER) + } else { + throw new Error('Consumed link found but then no latest in group') + } + } logger.error( err, `Invalid magic link id ${magicLinkId} with form id ${formId}` @@ -497,5 +513,5 @@ export default [ /** * @import { ServerRoute } from '@hapi/hapi' * @import { FormPayload } from '@defra/forms-engine-plugin/engine/types.js' - * @import { SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' + * @import { BoomErrorCustomSaveAndExit, SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' */ diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 637d7c449..9e8c4c43c 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -1,3 +1,4 @@ +import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import { createJoiError } from '~/src/server/helpers/error-helper.js' @@ -104,6 +105,32 @@ describe('Save-and-exit check routes', () => { ) }) + test('/route forwards correctly on magic link consumed but redirects to latest link', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(() => { + const boomError = Boom.resourceGone('magic link consumed') + boomError.output.payload.custom = { + latestId: 'latest-link-id' + } + throw boomError + }) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/resume-form/eab6ac6c-79b6-439f-bd94-d93eb121b3f1/latest-link-id' + ) + }) + test('/route forwards correctly on magic link error - wrong form id', async () => { jest .mocked(getFormMetadataById) From f8c8ded67310efa5c07578f7ee621c27f37101b2 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 1 Apr 2026 14:39:12 +0100 Subject: [PATCH 2/4] Extra coverage --- src/server/routes/save-and-exit.js | 4 +++- src/server/routes/save-and-exit.test.js | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 9fb0f0c82..01dcfddee 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -283,7 +283,9 @@ export default [ .redirect(`/resume-form/${formId}/${latestLinkId}`) .code(StatusCodes.SEE_OTHER) } else { - throw new Error('Consumed link found but then no latest in group') + return h + .redirect(`${ERROR_BASE_URL}/${form.slug}`) + .code(StatusCodes.SEE_OTHER) } } logger.error( diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 9e8c4c43c..7dcd81e04 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -131,6 +131,32 @@ describe('Save-and-exit check routes', () => { ) }) + test('/rthrows if trying to redirect to latest in group, but none found', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(() => { + const boomError = Boom.resourceGone('magic link consumed') + boomError.output.payload.custom = { + latestId: undefined + } + throw boomError + }) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/resume-form-error/my-form-to-resume' + ) + }) + test('/route forwards correctly on magic link error - wrong form id', async () => { jest .mocked(getFormMetadataById) From 41621aeb24996f12a18d4a4d67b918e07ac3729f Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 1 Apr 2026 15:47:13 +0100 Subject: [PATCH 3/4] Change message content + fixed test --- src/server/routes/save-and-exit.js | 4 +- src/server/routes/save-and-exit.test.js | 44 ++++++++++--------- .../views/save-and-exit/resume-password.html | 2 +- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 01dcfddee..b45bc964b 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -272,9 +272,7 @@ export default [ } catch (err) { const error = /** @type {BoomErrorCustomSaveAndExit} */ (err) if (error.output?.statusCode === StatusCodes.GONE) { - const latestLinkId = - error.data?.payload?.custom?.latestId ?? - error.output.payload?.custom?.latestId + const latestLinkId = error.data?.payload?.custom?.latestId if (latestLinkId) { logger.info( `Old link ${magicLinkId} used but redirected to ${latestLinkId}` diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 7dcd81e04..2f1c87e80 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -34,7 +34,7 @@ describe('Save-and-exit check routes', () => { const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' describe('GET /resume-form/{formId}/{magicLinkId}', () => { - test('/route forwards correctly on success', async () => { + test('route forwards correctly on success', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -60,7 +60,7 @@ describe('Save-and-exit check routes', () => { ) }) - test('/route forwards correctly on invalid form error', async () => { + test('route forwards correctly on invalid form error', async () => { jest.mocked(getFormMetadataById).mockImplementationOnce(() => { throw new Error('form not found') }) @@ -83,7 +83,7 @@ describe('Save-and-exit check routes', () => { expect(response.headers.location).toBe('/resume-form-error') }) - test('/route forwards correctly on magic link error', async () => { + test('route forwards correctly on magic link error', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -105,15 +105,19 @@ describe('Save-and-exit check routes', () => { ) }) - test('/route forwards correctly on magic link consumed but redirects to latest link', async () => { + test('route forwards correctly on magic link consumed but redirects to latest link', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) jest.mocked(getSaveAndExitDetails).mockImplementationOnce(() => { const boomError = Boom.resourceGone('magic link consumed') - boomError.output.payload.custom = { - latestId: 'latest-link-id' + boomError.data = { + payload: { + custom: { + latestId: 'latest-link-id' + } + } } throw boomError }) @@ -131,7 +135,7 @@ describe('Save-and-exit check routes', () => { ) }) - test('/rthrows if trying to redirect to latest in group, but none found', async () => { + test('throws if trying to redirect to latest in group, but none found', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -157,7 +161,7 @@ describe('Save-and-exit check routes', () => { ) }) - test('/route forwards correctly on magic link error - wrong form id', async () => { + test('route forwards correctly on magic link error - wrong form id', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -180,7 +184,7 @@ describe('Save-and-exit check routes', () => { ) }) - test('/route forwards correctly on magic link error 2', async () => { + test('route forwards correctly on magic link error 2', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -202,7 +206,7 @@ describe('Save-and-exit check routes', () => { }) describe('GET /resume-form-verify/{formId}/{magicLinkId}/{slug}/state?}', () => { - test('/route renders page', async () => { + test('route renders page', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -231,7 +235,7 @@ describe('Save-and-exit check routes', () => { expect($mastheadHeading).toBeInTheDocument() }) - test('/route forwards correctly on invalid form error', async () => { + test('route forwards correctly on invalid form error', async () => { jest.mocked(getFormMetadataById).mockImplementationOnce(() => { throw new Error('form not found') }) @@ -254,7 +258,7 @@ describe('Save-and-exit check routes', () => { expect(response.headers.location).toBe('/resume-form-error') }) - test('/route forwards correctly on magic link error', async () => { + test('route forwards correctly on magic link error', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -275,7 +279,7 @@ describe('Save-and-exit check routes', () => { }) describe('GET /resume-form-error', () => { - test('/route renders page without slug', async () => { + test('route renders page without slug', async () => { const options = { method: 'GET', url: '/resume-form-error' @@ -297,7 +301,7 @@ describe('Save-and-exit check routes', () => { expect($button).not.toBeInTheDocument() }) - test('/route renders page with slug', async () => { + test('route renders page with slug', async () => { const options = { method: 'GET', url: '/resume-form-error/my-slug' @@ -322,7 +326,7 @@ describe('Save-and-exit check routes', () => { }) describe('GET /resume-form-success', () => { - test('/route renders page without state', async () => { + test('route renders page without state', async () => { jest .mocked(getFormMetadata) // @ts-expect-error - allow partial objects for tests @@ -351,7 +355,7 @@ describe('Save-and-exit check routes', () => { expect($button).toHaveAttribute('href', '/form/my-form-to-resume/summary') }) - test('/route renders page with slug', async () => { + test('route renders page with slug', async () => { jest .mocked(getFormMetadata) // @ts-expect-error - allow partial objects for tests @@ -385,7 +389,7 @@ describe('Save-and-exit check routes', () => { }) describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { - test('/route handles invalid password', async () => { + test('route handles invalid password', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -422,7 +426,7 @@ describe('Save-and-exit check routes', () => { ) }) - test('/route handles lockout', async () => { + test('route handles lockout', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -461,7 +465,7 @@ describe('Save-and-exit check routes', () => { expect($errorMessage).toBeInTheDocument() }) - test('/route handles missing password', async () => { + test('route handles missing password', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -502,7 +506,7 @@ describe('Save-and-exit check routes', () => { expect(createJoiError).not.toHaveBeenCalled() }) - test('/route handles missing password and invalid url', async () => { + test('route handles missing password and invalid url', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests diff --git a/src/server/views/save-and-exit/resume-password.html b/src/server/views/save-and-exit/resume-password.html index 886c8c357..51deb98fe 100644 --- a/src/server/views/save-and-exit/resume-password.html +++ b/src/server/views/save-and-exit/resume-password.html @@ -17,7 +17,7 @@

{{ pageTitle }}

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

You have {{ attemptsLeft }} attempts to enter the correct answer. From eed7855c72e3349051a8449a55cd23282b793a79 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 1 Apr 2026 16:08:05 +0100 Subject: [PATCH 4/4] Removed custom property --- src/server/models/save-and-exit.js | 4 ++-- src/server/routes/save-and-exit.js | 2 +- src/server/routes/save-and-exit.test.js | 4 +--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 752d138e7..53b681b09 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -550,8 +550,8 @@ export function resumeSuccessViewModel(form, status) { /** * @typedef {object} BoomErrorCustomSaveAndExit - * @property {{ statusCode?: StatusCodes, payload?: CustomErrorPayload }} [output] - contains status code and payload - * @property {{ payload?: CustomErrorPayload }} [data] - custom payload for save-and-exit + * @property {{ statusCode?: StatusCodes }} [output] - contains status code + * @property {{ payload?: { latestId?: string }}} [data] - custom payload for save-and-exit */ /** diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index b45bc964b..859cf6e7d 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -272,7 +272,7 @@ export default [ } catch (err) { const error = /** @type {BoomErrorCustomSaveAndExit} */ (err) if (error.output?.statusCode === StatusCodes.GONE) { - const latestLinkId = error.data?.payload?.custom?.latestId + const latestLinkId = error.data?.payload?.latestId if (latestLinkId) { logger.info( `Old link ${magicLinkId} used but redirected to ${latestLinkId}` diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 2f1c87e80..c7b8fd97d 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -114,9 +114,7 @@ describe('Save-and-exit check routes', () => { const boomError = Boom.resourceGone('magic link consumed') boomError.data = { payload: { - custom: { - latestId: 'latest-link-id' - } + latestId: 'latest-link-id' } } throw boomError