Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/server/models/save-and-exit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/
18 changes: 17 additions & 1 deletion src/server/routes/save-and-exit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -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'
*/
87 changes: 71 additions & 16 deletions src/server/routes/save-and-exit.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Boom from '@hapi/boom'
import { StatusCodes } from 'http-status-codes'

import { createJoiError } from '~/src/server/helpers/error-helper.js'
Expand Down Expand Up @@ -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
Expand All @@ -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')
})
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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')
})
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/server/views/save-and-exit/resume-password.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<h1 class="govuk-heading-l">{{ pageTitle }}</h1>
<p class="govuk-body">
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.
</p>
<p class="govuk-body">
You have {{ attemptsLeft }} attempts to enter the correct answer.
Expand Down
Loading