Skip to content

Commit 1826dd7

Browse files
authored
Release v1.40.2 (#967)
* Add learning resources to sidebar * Improve missing pipe page * Prevent duplicate form submissions
2 parents 7eee784 + 88d9737 commit 1826dd7

File tree

17 files changed

+373
-70
lines changed

17 files changed

+373
-70
lines changed

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,5 @@
106106
"tsconfig-paths": "^4.2.0",
107107
"type-fest": "4.10.3"
108108
},
109-
"version": "1.40.1"
109+
"version": "1.40.2"
110110
}

packages/backend/src/apps/formsg/__tests__/auth/decrypt-form-response.test.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import { NricFilter } from '../../triggers/new-submission'
1212
LuxonSettings.defaultZone = 'Asia/Singapore'
1313
LuxonSettings.defaultLocale = 'en-SG'
1414

15+
const SUCCESS_DECRYPT_RESPONSE = {
16+
verified: true,
17+
internalId: 'submissionId',
18+
}
19+
20+
const FAILED_DECRYPT_RESPONSE = {
21+
verified: false,
22+
internalId: null as string | null,
23+
}
24+
1525
// mocks hoisted here so that they can be used in import mocks
1626
const mocks = vi.hoisted(() => {
1727
const webhooksAuthenticate = vi.fn()
@@ -128,7 +138,9 @@ describe('decrypt form response', () => {
128138

129139
it('should fail if no request in global variable', async () => {
130140
delete $.request
131-
await expect(decryptFormResponse($)).resolves.toEqual(false)
141+
await expect(decryptFormResponse($)).resolves.toEqual(
142+
FAILED_DECRYPT_RESPONSE,
143+
)
132144
expect(mocks.consoleError).toHaveBeenCalledWith(
133145
'No trigger item provided',
134146
)
@@ -138,7 +150,9 @@ describe('decrypt form response', () => {
138150
mocks.webhooksAuthenticate.mockImplementationOnce(() => {
139151
throw new Error('error')
140152
})
141-
await expect(decryptFormResponse($)).resolves.toEqual(false)
153+
await expect(decryptFormResponse($)).resolves.toEqual(
154+
FAILED_DECRYPT_RESPONSE,
155+
)
142156
expect(mocks.webhooksAuthenticate).toHaveBeenCalledTimes(1)
143157
expect(mocks.consoleError).toHaveBeenCalledWith(
144158
'Unable to verify formsg signature',
@@ -147,7 +161,9 @@ describe('decrypt form response', () => {
147161

148162
it('should fail and give warning if no connection exists', async () => {
149163
delete $.auth.data
150-
await expect(decryptFormResponse($)).resolves.toEqual(false)
164+
await expect(decryptFormResponse($)).resolves.toEqual(
165+
FAILED_DECRYPT_RESPONSE,
166+
)
151167
expect(mocks.consoleWarn).toHaveBeenCalledWith(
152168
'Form is not connected to any pipe after pipe is transferred',
153169
{
@@ -161,7 +177,9 @@ describe('decrypt form response', () => {
161177

162178
it('should fail if unable to decrypt form response', async () => {
163179
mockDecryptedSubmission(null)
164-
await expect(decryptFormResponse($)).resolves.toEqual(false)
180+
await expect(decryptFormResponse($)).resolves.toEqual(
181+
FAILED_DECRYPT_RESPONSE,
182+
)
165183
expect(decryptMock).toHaveBeenCalledTimes(1)
166184
expect(mocks.consoleError).toHaveBeenCalledWith(
167185
'Unable to decrypt formsg response',
@@ -170,7 +188,9 @@ describe('decrypt form response', () => {
170188

171189
it('should extract submission ID', async () => {
172190
mockDecryptedSubmission({ responses: [] })
173-
await expect(decryptFormResponse($)).resolves.toEqual(true)
191+
await expect(decryptFormResponse($)).resolves.toEqual(
192+
SUCCESS_DECRYPT_RESPONSE,
193+
)
174194
expect($.request.body).toEqual(
175195
expect.objectContaining({
176196
submissionId: 'submissionId',
@@ -180,7 +200,9 @@ describe('decrypt form response', () => {
180200

181201
it('should extract submission time as a ISO 8601 SGT formatted string', async () => {
182202
mockDecryptedSubmission({ responses: [] })
183-
await expect(decryptFormResponse($)).resolves.toEqual(true)
203+
await expect(decryptFormResponse($)).resolves.toEqual(
204+
SUCCESS_DECRYPT_RESPONSE,
205+
)
184206
expect($.request.body).toEqual(
185207
expect.objectContaining({
186208
submissionTime: '2023-07-06T18:26:27.505+08:00',
@@ -205,7 +227,9 @@ describe('decrypt form response', () => {
205227
},
206228
],
207229
})
208-
await expect(decryptFormResponse($)).resolves.toEqual(true)
230+
await expect(decryptFormResponse($)).resolves.toEqual(
231+
SUCCESS_DECRYPT_RESPONSE,
232+
)
209233
expect($.request.body).toEqual(
210234
expect.objectContaining({
211235
fields: {
@@ -260,7 +284,9 @@ describe('decrypt form response', () => {
260284
})
261285

262286
it('should handle nric filter - do nothing', async () => {
263-
await expect(decryptFormResponse($)).resolves.toEqual(true)
287+
await expect(decryptFormResponse($)).resolves.toEqual(
288+
SUCCESS_DECRYPT_RESPONSE,
289+
)
264290
expect($.request.body).toEqual(
265291
expect.objectContaining({
266292
fields: {
@@ -294,7 +320,9 @@ describe('decrypt form response', () => {
294320

295321
it('it should handle nric filter - remove', async () => {
296322
$.step.parameters.nricFilter = NricFilter.Remove
297-
await expect(decryptFormResponse($)).resolves.toEqual(true)
323+
await expect(decryptFormResponse($)).resolves.toEqual(
324+
SUCCESS_DECRYPT_RESPONSE,
325+
)
298326
expect($.request.body).toEqual(
299327
expect.objectContaining({
300328
fields: {
@@ -314,7 +342,9 @@ describe('decrypt form response', () => {
314342

315343
it('it should handle nric filter - hash', async () => {
316344
$.step.parameters.nricFilter = NricFilter.Hash
317-
await expect(decryptFormResponse($)).resolves.toEqual(true)
345+
await expect(decryptFormResponse($)).resolves.toEqual(
346+
SUCCESS_DECRYPT_RESPONSE,
347+
)
318348
expect($.request.body).toEqual(
319349
expect.objectContaining({
320350
fields: {
@@ -348,7 +378,9 @@ describe('decrypt form response', () => {
348378

349379
it('it should handle nric filter - mask', async () => {
350380
$.step.parameters.nricFilter = NricFilter.Mask
351-
await expect(decryptFormResponse($)).resolves.toEqual(true)
381+
await expect(decryptFormResponse($)).resolves.toEqual(
382+
SUCCESS_DECRYPT_RESPONSE,
383+
)
352384
expect($.request.body).toEqual(
353385
expect.objectContaining({
354386
fields: {
@@ -398,7 +430,9 @@ describe('decrypt form response', () => {
398430
cpUen: '987654321Z',
399431
},
400432
})
401-
await expect(decryptFormResponse($)).resolves.toEqual(true)
433+
await expect(decryptFormResponse($)).resolves.toEqual(
434+
SUCCESS_DECRYPT_RESPONSE,
435+
)
402436
expect($.request.body).toEqual(
403437
expect.objectContaining({
404438
verifiedSubmitterInfo: {
@@ -428,7 +462,9 @@ describe('decrypt form response', () => {
428462
},
429463
],
430464
})
431-
await expect(decryptFormResponse($)).resolves.toEqual(true)
465+
await expect(decryptFormResponse($)).resolves.toEqual(
466+
SUCCESS_DECRYPT_RESPONSE,
467+
)
432468
expect($.request.body).toEqual(
433469
expect.objectContaining({
434470
fields: {
@@ -474,7 +510,9 @@ describe('decrypt form response', () => {
474510
},
475511
],
476512
})
477-
await expect(decryptFormResponse($)).resolves.toEqual(true)
513+
await expect(decryptFormResponse($)).resolves.toEqual(
514+
SUCCESS_DECRYPT_RESPONSE,
515+
)
478516
expect($.request.body).toEqual(
479517
expect.objectContaining({
480518
fields: {
@@ -508,7 +546,9 @@ describe('decrypt form response', () => {
508546
it('attachment decryption function not called if pipe does not process files', async () => {
509547
$.flow.hasFileProcessingActions = false
510548
mocks.cryptoDecrypt.mockReturnValueOnce({ responses: [] })
511-
await expect(decryptFormResponse($)).resolves.toEqual(true)
549+
await expect(decryptFormResponse($)).resolves.toEqual(
550+
SUCCESS_DECRYPT_RESPONSE,
551+
)
512552
expect(mocks.cryptoDecryptWithAttachments).not.toBeCalled()
513553
})
514554

@@ -518,7 +558,9 @@ describe('decrypt form response', () => {
518558
attachments: {},
519559
content: { responses: [] },
520560
})
521-
await expect(decryptFormResponse($)).resolves.toEqual(true)
561+
await expect(decryptFormResponse($)).resolves.toEqual(
562+
SUCCESS_DECRYPT_RESPONSE,
563+
)
522564
expect(mocks.cryptoDecrypt).not.toBeCalled()
523565
})
524566
})
@@ -529,7 +571,9 @@ describe('decrypt form response', () => {
529571
mocks.cryptoDecrypt.mockReturnValueOnce({ responses: [] })
530572
mocks.parseFormEnv.mockReturnValue('staging')
531573

532-
await expect(decryptFormResponse($)).resolves.toEqual(true)
574+
await expect(decryptFormResponse($)).resolves.toEqual(
575+
SUCCESS_DECRYPT_RESPONSE,
576+
)
533577

534578
expect(mocks.parseFormEnv).toBeCalled()
535579
expect(mocks.getSdk).toBeCalledWith('staging')
@@ -563,7 +607,9 @@ describe('decrypt form response', () => {
563607
],
564608
})
565609

566-
await expect(decryptFormResponse($)).resolves.toEqual(true)
610+
await expect(decryptFormResponse($)).resolves.toEqual(
611+
SUCCESS_DECRYPT_RESPONSE,
612+
)
567613
expect($.request.body).toEqual(
568614
expect.objectContaining({
569615
fields: {

packages/backend/src/apps/formsg/auth/decrypt-form-response.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ export function filterNric($: IGlobalVariable, value: string): string | null {
5757

5858
export async function decryptFormResponse(
5959
$: IGlobalVariable,
60-
): Promise<boolean> {
60+
): Promise<{ verified: boolean; internalId: string | null }> {
6161
if (!$.request) {
6262
logger.error('No trigger item provided')
63-
return false
63+
return { verified: false, internalId: null }
6464
}
6565

6666
// Note: this could occur due to pipe transfer since connection becomes null
@@ -71,7 +71,7 @@ export async function decryptFormResponse(
7171
stepId: $.step.id,
7272
userId: $.user.id,
7373
})
74-
return false
74+
return { verified: false, internalId: null }
7575
}
7676

7777
const formSgSdk = getSdk(parseFormEnv($))
@@ -88,7 +88,7 @@ export async function decryptFormResponse(
8888
)
8989
} catch (e) {
9090
logger.error('Unable to verify formsg signature')
91-
return false
91+
return { verified: false, internalId: null }
9292
}
9393

9494
const formSecretKey = $.auth.data.privateKey as string
@@ -187,10 +187,10 @@ export async function decryptFormResponse(
187187
delete $.request.headers
188188
delete $.request.query
189189

190-
return true
190+
return { verified: true, internalId: data.submissionId }
191191
} else {
192192
// Could not decrypt the submission
193193
logger.error('Unable to decrypt formsg response')
194-
return false
194+
return { verified: false, internalId: null }
195195
}
196196
}

packages/backend/src/controllers/__tests__/webhooks/handler.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ const mocks = vi.hoisted(() => {
2929
})),
3030
})),
3131
},
32-
processTrigger: vi.fn(() => ({ executionId: 'execution-id' })),
32+
processTrigger: vi.fn(() => ({
33+
executionId: 'execution-id',
34+
shouldExecute: true,
35+
})),
3336
enqueueActionJob: vi.fn(),
3437
}
3538
})
@@ -200,5 +203,19 @@ describe('webhook handler', () => {
200203
expect(mocks.response.sendStatus).toHaveReturnedWith(200)
201204
},
202205
)
206+
207+
it('should not enqueue job when processTrigger returns shouldExecute: false', async () => {
208+
mocks.flow.active = true
209+
mocks.processTrigger.mockResolvedValueOnce({
210+
executionId: 'execution-id',
211+
shouldExecute: false,
212+
})
213+
214+
await webhookHandler(request, mocks.response)
215+
216+
expect(mocks.processTrigger).toHaveBeenCalledOnce()
217+
expect(mocks.enqueueActionJob).not.toHaveBeenCalled()
218+
expect(mocks.response.sendStatus).toHaveReturnedWith(200)
219+
})
203220
})
204221
})

packages/backend/src/controllers/webhooks/handler.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export default async (request: IRequest, response: Response) => {
7070
}
7171
}
7272

73+
let internalId: string = randomUUID()
7374
const testRun = !flow.active
7475
const triggerStep = await flow.getTriggerStep()
7576

@@ -80,8 +81,8 @@ export default async (request: IRequest, response: Response) => {
8081
return response.sendStatus(404)
8182
}
8283

83-
const isWebhookApp =
84-
triggerApp.key === 'webhook' || triggerApp.key === 'formsg'
84+
const isFormsgApp = triggerApp.key === 'formsg'
85+
const isWebhookApp = triggerApp.key === 'webhook' || isFormsgApp
8586

8687
if (!isWebhookApp) {
8788
logger.info(`Invalid trigger app for flow${flowId}`)
@@ -106,11 +107,13 @@ export default async (request: IRequest, response: Response) => {
106107
request,
107108
})
108109

109-
const verified = await triggerApp.auth.verifyWebhook($)
110+
const { verified, internalId: newInternalId } =
111+
await triggerApp.auth.verifyWebhook($)
110112

111113
if (!verified) {
112114
return response.sendStatus(401)
113115
}
116+
internalId = newInternalId
114117
}
115118

116119
// in case trigger type is 'webhook'
@@ -127,11 +130,16 @@ export default async (request: IRequest, response: Response) => {
127130
const triggerItem: ITriggerItem = {
128131
raw: payload,
129132
meta: {
130-
internalId: randomUUID(),
133+
internalId,
131134
},
132135
}
133136

134-
const { executionId } = await processTrigger({
137+
/**
138+
* NOTE: special case for when FormSG calls Plumber's webhook twice for payment forms.
139+
* Plumber will still return a 200 status code, so that FormSG does not auto-retry,
140+
* but Plumber not enqueue the job.
141+
*/
142+
const { executionId, shouldExecute } = await processTrigger({
135143
flowId,
136144
stepId: triggerStep.id,
137145
triggerItem,
@@ -146,7 +154,7 @@ export default async (request: IRequest, response: Response) => {
146154
testRun,
147155
})
148156

149-
if (testRun) {
157+
if (testRun || !shouldExecute) {
150158
return response.sendStatus(200)
151159
}
152160

0 commit comments

Comments
 (0)