Skip to content

Commit 597245f

Browse files
authored
Merge pull request #483 from DEFRA/fix/df-870-reuse
fix/df-870: Can use old save-and-exit link to extract most recent
2 parents e85adcd + 1d92e2e commit 597245f

7 files changed

Lines changed: 158 additions & 4 deletions

File tree

src/models/form.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export const getSavedLinkResponseSchema = Joi.object({
2222
invalidPasswordAttempts: Joi.number().min(0).required()
2323
}).label('getSavedLinkResponse')
2424

25+
export const getSavedLinkGoneSchema = Joi.object({
26+
output: {
27+
payload: {
28+
latestId: Joi.string().required()
29+
}
30+
}
31+
}).label('getSavedLinkGoneResponse')
32+
2533
export const validateSavedLinkResponseSchema = Joi.object({
2634
form: {
2735
id: Joi.string().required(),

src/repositories/save-and-exit-repository.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ export async function getSaveAndExitRecord(id) {
3535
try {
3636
const timer = createTimer()
3737
const result = await coll.findOne({
38-
magicLinkId: id,
39-
consumed: { $ne: true }
38+
magicLinkId: id
4039
})
4140

4241
logger.info(
@@ -54,6 +53,50 @@ export async function getSaveAndExitRecord(id) {
5453
}
5554
}
5655

56+
/**
57+
* Find the latest (active) link in a group of save-and-exit records
58+
* @param {string} groupId - group id of save-and-exit record
59+
* @returns { Promise<WithId<SaveAndExitDocument> | null> }
60+
*/
61+
export async function getLatestSaveAndExitByGroup(groupId) {
62+
const event = {
63+
category: saveAndExitLabel,
64+
action: 'read-latest-record-by-group',
65+
reference: groupId
66+
}
67+
logger.info({ event }, 'Reading latest save and exit record')
68+
69+
const coll = /** @type {Collection<SaveAndExitDocument>} */ (
70+
db.collection(SAVE_AND_EXIT_COLLECTION_NAME)
71+
)
72+
73+
try {
74+
const timer = createTimer()
75+
const result = await coll
76+
.find({
77+
magicLinkGroupId: groupId,
78+
consumed: { $ne: true }
79+
})
80+
.sort({
81+
createdAt: -1
82+
})
83+
.toArray()
84+
85+
logger.info(
86+
{ event: { ...event, duration: timer.elapsed } },
87+
`Read latest save and exit record (${timer.elapsed}ms)`
88+
)
89+
90+
return result.length ? result[0] : null
91+
} catch (err) {
92+
logger.error(
93+
{ err, event },
94+
`Failed to read latest save and exit record - ${getErrorMessage(err)}`
95+
)
96+
throw err
97+
}
98+
}
99+
57100
/**
58101
* Creates a save and exit record from SubmissionRecordInput
59102
* @param {Omit<SaveAndExitDocument, 'expireAt'>} recordInput

src/repositories/save-and-exit-repository.test.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createSaveAndExitRecord,
99
deleteSaveAndExitGroup,
1010
findExpiringRecords,
11+
getLatestSaveAndExitByGroup,
1112
getSaveAndExitRecord,
1213
incrementInvalidPasswordAttempts,
1314
lockRecordForExpiryEmail,
@@ -70,7 +71,7 @@ describe('save-and-exit-repository', () => {
7071
})
7172

7273
describe('getSaveAndExitRecord', () => {
73-
it('should get save and exit record', async () => {
74+
it('should get save and exit record if not comsumed', async () => {
7475
mockCollection.findOne.mockReturnValueOnce(submissionDocument)
7576
const submissionRecord = await getSaveAndExitRecord(
7677
STUB_SAVE_AND_EXIT_RECORD_ID
@@ -88,6 +89,38 @@ describe('save-and-exit-repository', () => {
8889
})
8990
})
9091

92+
describe('getLatestSaveAndExitByGroup', () => {
93+
it('should get latest save and exit record by group', async () => {
94+
const document1WithGroup = {
95+
...submissionDocument,
96+
magicLinkId: 'id1',
97+
magicLinkGroupId: 'magic-group-id'
98+
}
99+
const document2WithGroup = {
100+
...submissionDocument,
101+
magicLinkId: 'id2',
102+
magicLinkGroupId: 'magic-group-id'
103+
}
104+
mockCollection.find.mockReturnValueOnce({
105+
sort: jest.fn(() => {
106+
return { toArray: () => [document2WithGroup, document1WithGroup] }
107+
})
108+
})
109+
const submissionRecord =
110+
await getLatestSaveAndExitByGroup('magic-group-id')
111+
expect(submissionRecord).toEqual(document2WithGroup)
112+
})
113+
114+
it('should handle get latest save and exit record failures', async () => {
115+
mockCollection.find.mockImplementation(() => {
116+
throw new Error('an error')
117+
})
118+
await expect(
119+
getLatestSaveAndExitByGroup(STUB_SAVE_AND_EXIT_RECORD_ID)
120+
).rejects.toThrow(new Error('an error'))
121+
})
122+
})
123+
91124
describe('createSaveAndExitRecord', () => {
92125
it('should create a save and exit record when no previous relevant ones', async () => {
93126
jest.mocked(

src/routes/form.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Joi from 'joi'
33

44
import {
55
formSubmitResponseSchema,
6+
getSavedLinkGoneSchema,
67
getSavedLinkResponseSchema,
78
magicLinkSchema,
89
validateSavedLinkResponseSchema
@@ -67,7 +68,8 @@ export default [
6768
},
6869
response: {
6970
status: {
70-
200: getSavedLinkResponseSchema
71+
200: getSavedLinkResponseSchema,
72+
410: getSavedLinkGoneSchema
7173
}
7274
}
7375
}

src/routes/form.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FormStatus, SecurityQuestionsEnum } from '@defra/forms-model'
2+
import Boom from '@hapi/boom'
23
import { StatusCodes } from 'http-status-codes'
34

45
import { createServer } from '~/src/api/server.js'
@@ -177,6 +178,26 @@ describe('Forms route', () => {
177178
})
178179
})
179180

181+
test('Testing GET /save-and-exit route returns record with latest id when current one is consumed', async () => {
182+
jest.mocked(getSavedLinkDetails).mockImplementationOnce(() => {
183+
const boomError = Boom.resourceGone('consumed magic link')
184+
boomError.output.payload = {
185+
...boomError.output.payload,
186+
latestId: 'latest-link-id'
187+
}
188+
throw boomError
189+
})
190+
const response = await server.inject({
191+
method: 'GET',
192+
url: `/save-and-exit/${GUID_EMPTY}`
193+
})
194+
195+
expect(response.statusCode).toEqual(StatusCodes.GONE)
196+
expect(response.result).toMatchObject({
197+
latestId: 'latest-link-id'
198+
})
199+
})
200+
180201
test('Testing POST /save-and-exit route fails if with invalid payload', async () => {
181202
// @ts-expect-error - invalid type due to invalid payload
182203
jest.mocked(validateSavedLinkCredentials).mockResolvedValue({})

src/services/save-and-exit-service.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import argon2 from 'argon2'
55
import { createLogger } from '~/src/helpers/logging/logger.js'
66
import {
77
deleteSaveAndExitGroup,
8+
getLatestSaveAndExitByGroup,
89
getSaveAndExitRecord,
910
incrementInvalidPasswordAttempts,
1011
resetSaveAndExitRecord
@@ -13,6 +14,7 @@ import {
1314
const logger = createLogger()
1415

1516
const INVALID_MAGIC_LINK = 'Invalid magic link'
17+
const CONSUMED_MAGIC_LINK = 'Magic link has already been consumed'
1618

1719
/**
1820
* Get the save and exit link by magic link id
@@ -25,6 +27,20 @@ export async function getSavedLinkDetails(magicLinkId) {
2527
throw Boom.notFound(INVALID_MAGIC_LINK)
2628
}
2729

30+
if (record.consumed && record.magicLinkGroupId) {
31+
// Look for a non-comsumed link of the same group
32+
// This allows a user to use an old email link but still always point them at the latest save-and-exit record
33+
const latestRecord = await getLatestSaveAndExitByGroup(
34+
record.magicLinkGroupId
35+
)
36+
const boomError = Boom.resourceGone(CONSUMED_MAGIC_LINK)
37+
boomError.output.payload = {
38+
...boomError.output.payload,
39+
latestId: latestRecord?.magicLinkId
40+
}
41+
throw boomError
42+
}
43+
2844
return {
2945
form: record.form,
3046
question: record.security.question,

src/services/save-and-exit-service.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SecurityQuestionsEnum } from '@defra/forms-model'
33
import { buildDbDocument } from '~/src/repositories/__stubs__/save-and-exit.js'
44
import {
55
deleteSaveAndExitGroup,
6+
getLatestSaveAndExitByGroup,
67
getSaveAndExitRecord,
78
incrementInvalidPasswordAttempts,
89
markSaveAndExitRecordAsConsumed,
@@ -96,6 +97,36 @@ describe('save-and-exit service', () => {
9697
)
9798
})
9899

100+
test('should throw if link consumed)', async () => {
101+
jest.mocked(getSaveAndExitRecord).mockResolvedValue({
102+
// @ts-expect-error - partial record value
103+
form: {
104+
id: '1234'
105+
},
106+
security: {
107+
question: SecurityQuestionsEnum.MemorablePlace,
108+
answer: ''
109+
},
110+
consumed: true,
111+
magicLinkGroupId: 'group-id'
112+
})
113+
jest.mocked(getLatestSaveAndExitByGroup).mockResolvedValue({
114+
// @ts-expect-error - partial record value
115+
form: {
116+
id: '1234'
117+
},
118+
security: {
119+
question: SecurityQuestionsEnum.MemorablePlace,
120+
answer: ''
121+
},
122+
consumed: false,
123+
magicLinkGroupId: 'group-id'
124+
})
125+
await expect(getSavedLinkDetails('12345')).rejects.toThrow(
126+
'Magic link has already been consumed'
127+
)
128+
})
129+
99130
test('should return valid result)', async () => {
100131
jest.mocked(getSaveAndExitRecord).mockResolvedValue({
101132
// @ts-expect-error - partial record value

0 commit comments

Comments
 (0)