diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index d1d2791de..53b681b09 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 }} [output] - contains status code + * @property {{ payload?: { latestId?: string }}} [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..859cf6e7d 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?.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 { + return h + .redirect(`${ERROR_BASE_URL}/${form.slug}`) + .code(StatusCodes.SEE_OTHER) + } + } 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..c7b8fd97d 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' @@ -33,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 @@ -59,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') }) @@ -82,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 @@ -104,7 +105,61 @@ 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 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.data = { + payload: { + 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('throws 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) // @ts-expect-error - allow partial objects for tests @@ -127,7 +182,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 @@ -149,7 +204,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 @@ -178,7 +233,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') }) @@ -201,7 +256,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 @@ -222,7 +277,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' @@ -244,7 +299,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' @@ -269,7 +324,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 @@ -298,7 +353,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 @@ -332,7 +387,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 @@ -369,7 +424,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 @@ -408,7 +463,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 @@ -449,7 +504,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 @@
- 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.