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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,5 @@
"tsconfig-paths": "^4.2.0",
"type-fest": "4.10.3"
},
"version": "1.29.8"
"version": "1.30.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ describe('make http request', () => {
)
})

it('invokes the webhook with custom headers', async () => {
$.step.parameters.method = 'POST'
$.step.parameters.data = 'meep meep'
$.step.parameters.url = 'http://test.local/endpoint?1234'
$.step.parameters.customHeaders = [
{ key: 'Key1', value: 'Value1' },
{ key: 'Key2', value: 'Value2' },
]
mocks.httpRequest.mockReturnValue('mock response')

await makeRequestAction.run($).catch(() => null)
expect(mocks.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: $.step.parameters.url,
method: $.step.parameters.method,
data: $.step.parameters.data,
headers: {
Key1: 'Value1',
Key2: 'Value2',
},
}),
)
})

it('should throw an error for error with http request', async () => {
$.step.parameters.method = 'POST'
$.step.parameters.data = 'meep meep'
Expand All @@ -84,6 +108,22 @@ describe('make http request', () => {
)
})

it.each([[{ value: 'test' }], [{ key: 'test' }]])(
'should throw error for invalid custom headers (no field)',
async () => {
$.step.parameters.customHeaders = [{ value: 'test' }]
await expect(makeRequestAction.run($)).rejects.toThrowError()
},
)

it('should throw error for invalid custom headers (duplicate keys)', async () => {
$.step.parameters.customHeaders = [
{ key: 'test', value: 'value1' },
{ key: 'test', value: 'value2' },
]
await expect(makeRequestAction.run($)).rejects.toThrowError()
})

it('should follow redirect once', async () => {
mocks.isUrlAllowed.mockResolvedValueOnce(false)
$.step.parameters.method = 'POST'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { IRawAction } from '@plumber/types'

import StepError from '@/errors/step'
import { ZodError } from 'zod'
import { fromZodError } from 'zod-validation-error'

import StepError, { GenericSolution } from '@/errors/step'

import {
DISALLOWED_IP_RESOLVED_ERROR,
RECURSIVE_WEBHOOK_ERROR_NAME,
} from '../../common/check-urls'

import { requestSchema } from './schema'

type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'

const REDIRECT_STATUS_CODES = [301, 302, 307, 308]
Expand Down Expand Up @@ -41,6 +46,31 @@ const action: IRawAction = {
'Any URL with a querystring will be re-encoded properly. Plumber URLs (e.g. https://plumber.gov.sg/webhooks/...) are prohibited.',
variables: true,
},
{
label: 'Custom Headers',
key: 'customHeaders',
type: 'multirow-multicol' as const,
required: false,
description: 'Add custom headers here.',
variables: true,
addRowButtonText: 'Add',
subFields: [
{
placeholder: 'Key',
key: 'key',
type: 'string' as const,
required: true,
variables: false,
},
{
placeholder: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
},
],
},
{
label: 'Data',
key: 'data',
Expand All @@ -57,11 +87,15 @@ const action: IRawAction = {
const url = $.step.parameters.url as string

try {
const parsedS = requestSchema.parse($.step.parameters)
const { customHeaders } = parsedS

let response = await $.http.request({
url,
method,
data,
maxRedirects: 0,
headers: customHeaders,
// overwriting this to allow redirects to resolve
validateStatus: (status) =>
(status >= 200 && status < 300) ||
Expand Down Expand Up @@ -98,6 +132,16 @@ const action: IRawAction = {

$.setActionItem({ raw: { data: responseData } })
} catch (err) {
if (err instanceof ZodError) {
const firstError = fromZodError(err).details[0]
throw new StepError(
`${firstError.message} under set up action`,
GenericSolution.ReconfigureInvalidField,
$.step.position,
$.app.name,
)
}

if (err.message === RECURSIVE_WEBHOOK_ERROR_NAME) {
throw new StepError(
RECURSIVE_WEBHOOK_ERROR_NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from 'zod'

import { sanitizeMarkdown } from '@/apps/telegram-bot/common/markdown-v1'

export const requestSchema = z.object({
customHeaders: z
.array(
z.object({
// key cannot be null or empty
key: z
.string({
required_error: 'Key empty',
invalid_type_error: 'Key empty',
})
.trim()
.min(1, 'Key empty'),
// value optional in the event the substituted variable is empty
value: z.string().trim().nullish().optional(),
}),
)
.transform((params, context) => {
const result = Object.create(null)
const seenFields = new Set<string>()
for (const { key, value } of params) {
// no null keys are allowed
if (!key) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: 'Key empty',
})
return z.NEVER
}
// catch duplicate fields
if (seenFields.has(key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `${key} key is repeated`,
fatal: true,
})
return z.NEVER
}
seenFields.add(key)

const cleanV = value?.replaceAll(/\r?\n|\r/g, ' ') || ''
result[key] = sanitizeMarkdown(cleanV)
}
return result
})
.nullish(),
})
21 changes: 2 additions & 19 deletions packages/backend/src/apps/delay/helpers/generate-timestamp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DateTime } from 'luxon'
import { generateTimestampFromFormats } from '@/helpers/generate-timestamp-from-formats'

const VALID_DATETIME_FORMATS = [
'yyyy-MM-dd HH:mm',
Expand All @@ -8,22 +8,5 @@ const VALID_DATETIME_FORMATS = [

export default function generateTimestamp(date: string, time: string): number {
const datetimeString = `${date} ${time}`
// check through our accepted formats
for (const datetimeFormat of VALID_DATETIME_FORMATS) {
// check both en-SG and en-US because Sept accepted for SG but Sep accepted for US
let datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
locale: 'en-SG',
})
if (datetime.isValid) {
return datetime.toMillis()
}

datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
locale: 'en-US',
})
if (datetime.isValid) {
return datetime.toMillis()
}
}
return NaN
return generateTimestampFromFormats(datetimeString, VALID_DATETIME_FORMATS)
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ describe('convert date time', () => {
{ toFormat: 'dd/LL/yyyy', expectedResult: '01/04/2024' },
{ toFormat: 'dd LLL yyyy', expectedResult: '01 Apr 2024' },
{ toFormat: 'dd LLLL yyyy', expectedResult: '01 April 2024' },
{ toFormat: 'yy/LL/dd', expectedResult: '24/04/01' },
{ toFormat: 'yyyy/LL/dd', expectedResult: '2024/04/01' },
{ toFormat: 'hh:mm a', expectedResult: '12:05 pm' },
{ toFormat: 'hh:mm:ss a', expectedResult: '12:05:10 pm' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const formatStringsEnum = z.enum([
'dd/LL/yyyy',
'dd LLL yyyy',
'dd LLLL yyyy',
'yy/LL/dd',
'yyyy/LL/dd',
'hh:mm a',
'hh:mm:ss a',
Expand Down Expand Up @@ -49,11 +48,6 @@ export const field = {
description: '02 January 2006',
value: ensureZodEnumValue(formatStringsEnum, 'dd LLLL yyyy'),
},
{
label: 'YY/MM/DD',
description: '24/01/22',
value: ensureZodEnumValue(formatStringsEnum, 'yy/LL/dd'),
},
{
label: 'YYYY/MM/DD',
description: '2024/01/22',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,20 @@ type FormField = {
othersRadioButton?: boolean
}

// Adapted from https://github.com/opengovsg/FormSG/blob/82c5ba6fff7e9628b6c32449148e89c0224e9ff5/shared/types/form/form.ts#L96
type PaymentProduct = {
_id: string
name: string
description: string
multi_qty: boolean
min_qty: number
max_qty: number
amount_cents: number
}

export const MOCK_ATTACHMENT_FILE_PATH = `${COMMON_S3_MOCK_FOLDER_PREFIX}plumber-logo.jpg`
const MOCK_NRIC = 'S1234568B'
const MOCK_UEN = '201612345A'

function generateVerifiedSubmitterInfoData(
authType: string,
Expand All @@ -41,10 +53,43 @@ function generateVerifiedSubmitterInfoData(
uinFin: filteredNric,
},
}
case 'CP':
return {
verifiedSubmitterInfo: {
cpUid: filteredNric,
cpUen: MOCK_UEN,
},
}
}
return {}
}

function generateMockPaymentData(products: Partial<PaymentProduct>[]) {
// if there are no payment products, default to a mocked one
const firstProduct: Partial<PaymentProduct> =
products.length > 0
? products[0]
: {
name: 'Test Product',
amount_cents: 123,
}

// Only the amount and product service is ideally obtainable based on their form data
return {
paymentContent: {
type: 'payment_charge',
status: 'succeeded',
payer: '[email protected]',
url: 'https://form.gov.sg/api/v3/payments/abcde/12345/invoice/download',
paymentIntent: 'pi_12345',
amount: (firstProduct.amount_cents / 100).toFixed(2),
productService: firstProduct.name,
dateTime: new Date().toISOString(),
transactionFee: '0.05',
},
}
}

async function getMockData($: IGlobalVariable) {
try {
const { formId } = getFormDetailsFromGlobalVariable($)
Expand Down Expand Up @@ -80,6 +125,10 @@ async function getMockData($: IGlobalVariable) {
)
}

if (data.responses[formFields[i]._id].fieldType === 'email') {
data.responses[formFields[i]._id].answer = $.user.email
}

data.responses[formFields[i]._id].order = i + 1
data.responses[formFields[i]._id].id = undefined
}
Expand All @@ -95,7 +144,10 @@ async function getMockData($: IGlobalVariable) {
submissionId: await generateIdAsync(),
submissionTime: DateTime.now().toISO(),
formId,
...generateVerifiedSubmitterInfoData(formDetails.form.authType, $),
...(formDetails.form.isSubmitterIdCollectionEnabled &&
generateVerifiedSubmitterInfoData(formDetails.form.authType, $)),
...(formDetails.form.payments_field.enabled &&
generateMockPaymentData(formDetails.form.payments_field.products)),
}
} catch (e) {
throw new Error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const action: IRawAction = {
label: 'Personalised fields',
key: 'letterParams',
type: 'multirow' as const,
required: false,
required: true,
description:
'Specify values for each personalised field in your template.',

Expand Down
Loading