diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c256968e..57289854ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,51 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v5.13.0](https://github.com/opengovsg/FormSG/compare/v5.12.1...v5.13.0) + +- fix: use correct argument key when counting form submissions [`#2101`](https://github.com/opengovsg/FormSG/pull/2101) +- chore(adminsubmissionsservice): renamed form to submissions to reflect context [`#2098`](https://github.com/opengovsg/FormSG/pull/2098) +- feat: enable retries for webhooks [`#2093`](https://github.com/opengovsg/FormSG/pull/2093) +- feat: log form updates [`#2063`](https://github.com/opengovsg/FormSG/pull/2063) +- chore: remove endPage.buttons key from database [`#2087`](https://github.com/opengovsg/FormSG/pull/2087) +- feat: sort responses for csv download according to creation date [`#2028`](https://github.com/opengovsg/FormSG/pull/2028) +- fix(deps): downgrade typescript to 4.2.4 and pin [`#2097`](https://github.com/opengovsg/FormSG/pull/2097) +- chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#2095`](https://github.com/opengovsg/FormSG/pull/2095) +- chore(deps-dev): bump typescript from 4.2.4 to 4.3.2 [`#2096`](https://github.com/opengovsg/FormSG/pull/2096) +- chore(deps-dev): bump @typescript-eslint/parser from 4.26.0 to 4.26.1 [`#2094`](https://github.com/opengovsg/FormSG/pull/2094) +- fix(deps): downgrade typescript to 4.2.4 [`#2092`](https://github.com/opengovsg/FormSG/pull/2092) +- refactor(submissions.client.factory): extract admin form features [`#1983`](https://github.com/opengovsg/FormSG/pull/1983) +- chore(deps-dev): bump prettier from 2.2.1 to 2.3.1 [`#2075`](https://github.com/opengovsg/FormSG/pull/2075) +- fix: restore typings to some model static methods [`#2067`](https://github.com/opengovsg/FormSG/pull/2067) +- fix(deps): bump aws-sdk from 2.920.0 to 2.922.0 [`#2078`](https://github.com/opengovsg/FormSG/pull/2078) +- chore(deps-dev): bump eslint from 7.27.0 to 7.28.0 [`#2077`](https://github.com/opengovsg/FormSG/pull/2077) +- chore(deps-dev): bump core-js from 3.13.1 to 3.14.0 [`#2076`](https://github.com/opengovsg/FormSG/pull/2076) +- docs(script): add scripts to privatize all sp/rp student forms [`#2073`](https://github.com/opengovsg/FormSG/pull/2073) +- fix(deps): bump nocache from 2.1.0 to 3.0.0 [`#2068`](https://github.com/opengovsg/FormSG/pull/2068) +- fix(deps): bump @sentry/integrations from 6.5.0 to 6.5.1 [`#2071`](https://github.com/opengovsg/FormSG/pull/2071) +- fix(deps): bump twilio from 3.63.0 to 3.63.1 [`#2072`](https://github.com/opengovsg/FormSG/pull/2072) +- fix(deps): bump aws-sdk from 2.919.0 to 2.920.0 [`#2070`](https://github.com/opengovsg/FormSG/pull/2070) +- fix(deps): bump @sentry/browser from 6.5.0 to 6.5.1 [`#2069`](https://github.com/opengovsg/FormSG/pull/2069) +- refactor(formApiClientFactory): rearrange types [`#2061`](https://github.com/opengovsg/FormSG/pull/2061) +- docs(readme): remove active contributors [`#2064`](https://github.com/opengovsg/FormSG/pull/2064) +- refactor(beta): migrate to TypeScript [`#2058`](https://github.com/opengovsg/FormSG/pull/2058) +- refactor: replace set hook with validator hook in emailField model [`#1971`](https://github.com/opengovsg/FormSG/pull/1971) +- fix(deps): bump aws-sdk from 2.918.0 to 2.919.0 [`#2060`](https://github.com/opengovsg/FormSG/pull/2060) +- chore(deps-dev): bump @types/node from 14.17.1 to 14.17.2 [`#2059`](https://github.com/opengovsg/FormSG/pull/2059) +- fix(deps): update mongoose to 5.12.12, update model types [`#2046`](https://github.com/opengovsg/FormSG/pull/2046) +- chore(deps-dev): bump type-fest from 0.20.2 to 1.2.0 [`#2049`](https://github.com/opengovsg/FormSG/pull/2049) +- test(betas): provide coverage [`23f9a9f`](https://github.com/opengovsg/FormSG/commit/23f9a9fe9675eab1d25c1983a08a7c76e0139d52) + #### [v5.12.1](https://github.com/opengovsg/FormSG/compare/v5.12.0...v5.12.1) +> 8 June 2021 + +- chore: merge v5.12.1 into develop [`#2051`](https://github.com/opengovsg/FormSG/pull/2051) +- chore(deps-dev): bump @opengovsg/mockpass from 2.7.2 to 2.7.3 [`#2050`](https://github.com/opengovsg/FormSG/pull/2050) +- docs(scripts): add scripts to set student logos to selected forms [`#2048`](https://github.com/opengovsg/FormSG/pull/2048) +- chore: merge v5.12.0 into develop [`#2044`](https://github.com/opengovsg/FormSG/pull/2044) - fix: deny non-GET requests from RP and SP domains [`9a2c9dc`](https://github.com/opengovsg/FormSG/commit/9a2c9dc0f3287b35e03bf48674c7ef57411ffe87) +- chore: bump version to 5.12.1 [`6c415d2`](https://github.com/opengovsg/FormSG/commit/6c415d210186e9e797ca839a010032b469d6e9c4) #### [v5.12.0](https://github.com/opengovsg/FormSG/compare/v5.11.0...v5.12.0) @@ -46,7 +88,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(verification): loosen OTP waiting time by 2 seconds [`#1957`](https://github.com/opengovsg/FormSG/pull/1957) - chore: bump version to 5.12.0 [`85759bc`](https://github.com/opengovsg/FormSG/commit/85759bc9dc01f73da3cbd0ec73c636e58e983948) -#### [v5.11.0](https://github.com/opengovsg/FormSG/compare/v5.10.0...v5.11.0) +#### [v5.11.0](https://github.com/opengovsg/FormSG/compare/v5.10.1...v5.11.0) > 25 May 2021 @@ -114,6 +156,11 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#1868`](https://github.com/opengovsg/FormSG/pull/1868) - fix(deps): bump @sentry/integrations from 6.3.5 to 6.3.6 [`#1850`](https://github.com/opengovsg/FormSG/pull/1850) - chore: bump version to 5.11.0 [`54b1958`](https://github.com/opengovsg/FormSG/commit/54b1958d0968e670ef145461d9d7859384d573ef) + +#### [v5.10.1](https://github.com/opengovsg/FormSG/compare/v5.10.0...v5.10.1) + +> 17 May 2021 + - chore: bump version to v5.10.1 [`0442cd7`](https://github.com/opengovsg/FormSG/commit/0442cd72637019fb1e43bce5f8f5abe14ee79f8c) - fix: allow for unknown keys in updateEndPage validator [`617d86a`](https://github.com/opengovsg/FormSG/commit/617d86a28910eec6ebd3249a2de636086429d6a6) @@ -160,14 +207,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#1790`](https://github.com/opengovsg/FormSG/pull/1790) - feat(api-refactor): add specific update end page endpoint in server [`#1760`](https://github.com/opengovsg/FormSG/pull/1760) - feat: move server.ts into src/app [`#1785`](https://github.com/opengovsg/FormSG/pull/1785) -- fix: trigger digest cycle for delete logic [`#1787`](https://github.com/opengovsg/FormSG/pull/1787) -- chore: bump version to 5.9.0 [`6d6e475`](https://github.com/opengovsg/FormSG/commit/6d6e475c417cfb5efacb203888b0f296159d8ac1) - chore: bump version to v5.10.0 [`0615ce5`](https://github.com/opengovsg/FormSG/commit/0615ce5262fcdb65932ad6c9be9ee66503b0e949) #### [v5.9.0](https://github.com/opengovsg/FormSG/compare/v5.8.0...v5.9.0) > 4 May 2021 +- fix: trigger digest cycle for delete logic [`#1787`](https://github.com/opengovsg/FormSG/pull/1787) - fix: allow commas in email confirmation sender [`#1782`](https://github.com/opengovsg/FormSG/pull/1782) - chore(deps-dev): bump core-js from 3.11.1 to 3.11.2 [`#1780`](https://github.com/opengovsg/FormSG/pull/1780) - fix(deps): bump fp-ts from 2.10.4 to 2.10.5 [`#1781`](https://github.com/opengovsg/FormSG/pull/1781) @@ -192,7 +238,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump aws-sdk from 2.893.0 to 2.894.0 [`#1756`](https://github.com/opengovsg/FormSG/pull/1756) - fix(deps): bump @sentry/integrations from 6.3.1 to 6.3.3 [`#1755`](https://github.com/opengovsg/FormSG/pull/1755) - chore: merge v5.8.0 into develop [`#1751`](https://github.com/opengovsg/FormSG/pull/1751) -- chore: bump version to 5.9.0 [`902fd6a`](https://github.com/opengovsg/FormSG/commit/902fd6a764e94bd0882ca1f7bebb3e79f916c9f3) +- chore: bump version to 5.9.0 [`6d6e475`](https://github.com/opengovsg/FormSG/commit/6d6e475c417cfb5efacb203888b0f296159d8ac1) #### [v5.8.0](https://github.com/opengovsg/FormSG/compare/v5.7.1...v5.8.0) diff --git a/Dockerfile.development b/Dockerfile.development index 3dbdf6326d..452e751bc1 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -26,9 +26,12 @@ RUN apk update && apk upgrade && \ ttf-freefont \ tini \ # Localstack - these are necessary in order to initialise local S3 buckets + # jq is a package for easily parsing Localstack health endpoint's JSON output + jq \ py-pip && \ npm install --quiet node-gyp -g && \ - pip install awscli-local + # [ver1] ensures that the underlying AWS CLI version is also installed + pip install awscli-local[ver1] # Chinese fonts RUN echo @edge http://nl.alpinelinux.org/alpine/edge/testing >> /etc/apk/repositories && apk add wqy-zenhei@edge @@ -41,5 +44,5 @@ EXPOSE 5000 # tini is the init process that will adopt orphaned zombie processes # e.g. chromium when launched to create a new PDF ENTRYPOINT [ "tini", "--" ] -# Create local S3 buckets before building the app -CMD npm run docker-dev +# Create local AWS resources before building the app +CMD sh init-localstack.sh && npm run docker-dev \ No newline at end of file diff --git a/README.md b/README.md index c25bbf1d43..0c09001278 100755 --- a/README.md +++ b/README.md @@ -206,11 +206,9 @@ FormSG acknowledges the work done by [Arielle Baldwynn](https://github.com/white Contributions have also been made by: [@RyanAngJY](https://github.com/RyanAngJY) [@jeantanzy](https://github.com/jeantanzy) -[@yong-jie](https://github.com/yong-jie) [@pregnantboy](https://github.com/pregnantboy) [@namnguyen08](https://github.com/namnguyen08) [@zioul123](https://github.com/zioul123) [@JoelWee](https://github.com/JoelWee) [@limli](https://github.com/limli) [@tankevan](https://github.com/tankevan) -[@LoneRifle](https://github.com/LoneRifle) diff --git a/docker-compose.yml b/docker-compose.yml index 0ec2065816..63c317559d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: - MYINFO_CERT_PATH=./node_modules/@opengovsg/mockpass/static/certs/spcp.crt - MYINFO_CLIENT_ID=mockClientId - MYINFO_CLIENT_SECRET=mockClientSecret + - WEBHOOK_SQS_URL=http://localhost:4566/000000000000/local-webhooks-sqs-main - GA_TRACKING_ID - SENTRY_CONFIG_URL - TWILIO_ACCOUNT_SID @@ -105,17 +106,11 @@ services: depends_on: - formsg environment: - - SERVICES=s3 + - SERVICES=s3,sqs - DATA_DIR=/tmp/localstack/data - - ATTACHMENT_S3_BUCKET=local-attachment-bucket - - IMAGE_S3_BUCKET=local-image-bucket - - LOGO_S3_BUCKET=local-logo-bucket volumes: - './.localstack:/tmp/localstack' - '/var/run/docker.sock:/var/run/docker.sock' - # This is where we add scripts to initialise AWS resources. - # Docs: https://github.com/localstack/localstack#initializing-a-fresh-instance - - './docker-entrypoint-initaws.d:/docker-entrypoint-initaws.d' network_mode: 'service:formsg' # reuse formsg service's network stack so that it can resolve localhost:4566 to localstack:4566 maildev: diff --git a/docker-entrypoint-initaws.d/init-localstack.sh b/docker-entrypoint-initaws.d/init-localstack.sh deleted file mode 100644 index db04846f86..0000000000 --- a/docker-entrypoint-initaws.d/init-localstack.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -x -awslocal s3 mb s3://$IMAGE_S3_BUCKET -awslocal s3 mb s3://$LOGO_S3_BUCKET -awslocal s3 mb s3://$ATTACHMENT_S3_BUCKET -set +x \ No newline at end of file diff --git a/init-localstack.sh b/init-localstack.sh new file mode 100644 index 0000000000..bfb92305bc --- /dev/null +++ b/init-localstack.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Wait for all Localstack services to be ready +while [[ "$(curl -s -f http://localhost:4566/health | jq '[.services[] == "running"] | all')" != "true" ]]; do + sleep 5 +done + +# Create SQS queue for webhooks +# First create dead-letter queue and get its ARN so it can be specified as the DLQ +# for the main queue. Note that the DLQ name is not an environment variable +# in the application, as this is configured from the AWS console in production. +DLQ_NAME=local-webhooks-sqs-deadLetter +DLQ_URL=$(awslocal sqs create-queue --queue-name $DLQ_NAME | jq --raw-output '.QueueUrl') +DLQ_ARN=$(awslocal sqs get-queue-attributes --queue-url $DLQ_URL --attribute-names QueueArn | jq --raw-output '.Attributes.QueueArn') + +# Show output for all main resources created +set -x + +# For main queue, extract queue name, which is the part of the queue URL after the final "/" +awslocal sqs create-queue --queue-name ${WEBHOOK_SQS_URL##*/} --attributes '{ + "ReceiveMessageWaitTimeSeconds": "20", + "RedrivePolicy": "{\"deadLetterTargetArn\":\"'"$DLQ_ARN"'\",\"maxReceiveCount\":1}" +}' + +# Create S3 buckets +awslocal s3 mb s3://$IMAGE_S3_BUCKET +awslocal s3 mb s3://$LOGO_S3_BUCKET +awslocal s3 mb s3://$ATTACHMENT_S3_BUCKET + +set +x diff --git a/package-lock.json b/package-lock.json index 49d9bbd8d3..12dff19c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "FormSG", - "version": "5.12.1", + "version": "5.13.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4073,15 +4073,15 @@ } }, "@eslint/eslintrc": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.1.tgz", - "integrity": "sha512-5v7TDE9plVhvxQeWLXDTvFvJBdH6pEsdnl2g/dAptmuFEPedQ4Erq5rsDsX+mvAM610IhNaO2W5V1dOOnDKxkQ==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.2.tgz", + "integrity": "sha512-8nmGq/4ycLpIwzvhI4tNDmQztZ8sp+hI7cyG8i1nQDhkAbRzHpXPidRAHlNvCZQpJTKw5ItIpMw9RSToGF00mg==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.1.1", "espree": "^7.3.0", - "globals": "^12.1.0", + "globals": "^13.9.0", "ignore": "^4.0.6", "import-fresh": "^3.2.1", "js-yaml": "^3.13.1", @@ -4099,12 +4099,12 @@ } }, "globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.9.0.tgz", + "integrity": "sha512-74/FduwI/JaIrr1H8e71UbDE+5x7pIPs1C2rrwC52SszOo043CsWOZEMW7o2Y58xwm9b+0RBKDxY5n2sUpEFxA==", "dev": true, "requires": { - "type-fest": "^0.8.1" + "type-fest": "^0.20.2" } }, "ignore": { @@ -4120,9 +4120,9 @@ "dev": true }, "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true } } @@ -4861,9 +4861,9 @@ } }, "@opengovsg/mockpass": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@opengovsg/mockpass/-/mockpass-2.7.2.tgz", - "integrity": "sha512-m4484lKKfYCv4yiyKfCdphgLEHmNOtwVsbjSO9A2Dnfx7qhDLkPMTifziW85NecaUFnHlI1Q7Rg+S1mYaF9Xtg==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@opengovsg/mockpass/-/mockpass-2.7.3.tgz", + "integrity": "sha512-e/973c+IhMjCjx7TpiAwt6Sv8crr9utsDeirFnIdwvb6vRFD4enObdnQUE2lNnXSTxH3e9tLDJCuTV7mmATomA==", "dev": true, "requires": { "base-64": "^1.0.0", @@ -4927,125 +4927,125 @@ } }, "@sentry/browser": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.5.0.tgz", - "integrity": "sha512-n1e8hNKwuVP4bLqRK5J0DHFqnnnrbv6h6+Bc1eNRbf32/e6eZ3Cb36PTplqDCxwnMnnIEEowd5F4ZWeTLPPY3A==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.5.1.tgz", + "integrity": "sha512-iVLCdEFwsoWAzE/hNknexPQjjDpMQV7mmaq9Z1P63bD6MfhwVTx4hG4pHn8HEvC38VvCVf1wv0v/LxtoODAYXg==", "requires": { - "@sentry/core": "6.5.0", - "@sentry/types": "6.5.0", - "@sentry/utils": "6.5.0", + "@sentry/core": "6.5.1", + "@sentry/types": "6.5.1", + "@sentry/utils": "6.5.1", "tslib": "^1.9.3" }, "dependencies": { "@sentry/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.0.tgz", - "integrity": "sha512-yQpTCIYxBsYT0GenqHNNKeXV8CSkkYlAxB1IGV2eac4IKC5ph5GW6TfDGwvlzQSQ297RsRmOSA8o3I5gGPd2yA==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.1.tgz", + "integrity": "sha512-b/7a6CMoytaeFPx4IBjfxPw3nPvsQh7ui1C8Vw0LxNNDgBwVhPLzUOWeLWbo5YZCVbGEMIWwtCUQYWxneceZSA==" }, "@sentry/utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.0.tgz", - "integrity": "sha512-CcHuaQN6vRuAsIC+3sA23NmWLRmUN0x/HNQxk0DHJylvYQdEA0AUNoLXogykaXh6NrCx4DNq9yCQTNTSC3mFxg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.1.tgz", + "integrity": "sha512-Wv86JYGQH+ZJ5XGFQX7h6ijl32667ikenoL9EyXMn8UoOYX/MLwZoQZin1P60wmKkYR9ifTNVmpaI9OoTaH+UQ==", "requires": { - "@sentry/types": "6.5.0", + "@sentry/types": "6.5.1", "tslib": "^1.9.3" } } } }, "@sentry/core": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.5.0.tgz", - "integrity": "sha512-Hx/WvhM5bXcXqfIiz+505TjYYfPjQ8mrxby/EWl+L7dYUCyI/W6IZKTc/MoHlLuM+JPUW9c1bw/97TzbgTzaAA==", - "requires": { - "@sentry/hub": "6.5.0", - "@sentry/minimal": "6.5.0", - "@sentry/types": "6.5.0", - "@sentry/utils": "6.5.0", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.5.1.tgz", + "integrity": "sha512-Mh3sl/iUOT1myHmM6RlDy2ARzkUClx/g4DAt1rJ/IpQBOlDYQraplXSIW80i/hzRgQDfwhwgf4wUa5DicKBjKw==", + "requires": { + "@sentry/hub": "6.5.1", + "@sentry/minimal": "6.5.1", + "@sentry/types": "6.5.1", + "@sentry/utils": "6.5.1", "tslib": "^1.9.3" }, "dependencies": { "@sentry/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.0.tgz", - "integrity": "sha512-yQpTCIYxBsYT0GenqHNNKeXV8CSkkYlAxB1IGV2eac4IKC5ph5GW6TfDGwvlzQSQ297RsRmOSA8o3I5gGPd2yA==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.1.tgz", + "integrity": "sha512-b/7a6CMoytaeFPx4IBjfxPw3nPvsQh7ui1C8Vw0LxNNDgBwVhPLzUOWeLWbo5YZCVbGEMIWwtCUQYWxneceZSA==" }, "@sentry/utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.0.tgz", - "integrity": "sha512-CcHuaQN6vRuAsIC+3sA23NmWLRmUN0x/HNQxk0DHJylvYQdEA0AUNoLXogykaXh6NrCx4DNq9yCQTNTSC3mFxg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.1.tgz", + "integrity": "sha512-Wv86JYGQH+ZJ5XGFQX7h6ijl32667ikenoL9EyXMn8UoOYX/MLwZoQZin1P60wmKkYR9ifTNVmpaI9OoTaH+UQ==", "requires": { - "@sentry/types": "6.5.0", + "@sentry/types": "6.5.1", "tslib": "^1.9.3" } } } }, "@sentry/hub": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.5.0.tgz", - "integrity": "sha512-vEChnLoozOJzEJoTUvaAsK/n7IHoQFx8P1TzQmnR+8XGZJZmGHG6bBXUH0iS2a9hhR1WkoEBeiL+t96R9uyf0A==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.5.1.tgz", + "integrity": "sha512-lBRMBVMYP8B4PfRiM70murbtJAXiIAao/asDEMIRNGMP6pI2ArqXfJCBYDkStukhikYD0Kqb4trXq+JYF07Hbg==", "requires": { - "@sentry/types": "6.5.0", - "@sentry/utils": "6.5.0", + "@sentry/types": "6.5.1", + "@sentry/utils": "6.5.1", "tslib": "^1.9.3" }, "dependencies": { "@sentry/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.0.tgz", - "integrity": "sha512-yQpTCIYxBsYT0GenqHNNKeXV8CSkkYlAxB1IGV2eac4IKC5ph5GW6TfDGwvlzQSQ297RsRmOSA8o3I5gGPd2yA==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.1.tgz", + "integrity": "sha512-b/7a6CMoytaeFPx4IBjfxPw3nPvsQh7ui1C8Vw0LxNNDgBwVhPLzUOWeLWbo5YZCVbGEMIWwtCUQYWxneceZSA==" }, "@sentry/utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.0.tgz", - "integrity": "sha512-CcHuaQN6vRuAsIC+3sA23NmWLRmUN0x/HNQxk0DHJylvYQdEA0AUNoLXogykaXh6NrCx4DNq9yCQTNTSC3mFxg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.1.tgz", + "integrity": "sha512-Wv86JYGQH+ZJ5XGFQX7h6ijl32667ikenoL9EyXMn8UoOYX/MLwZoQZin1P60wmKkYR9ifTNVmpaI9OoTaH+UQ==", "requires": { - "@sentry/types": "6.5.0", + "@sentry/types": "6.5.1", "tslib": "^1.9.3" } } } }, "@sentry/integrations": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.5.0.tgz", - "integrity": "sha512-pI8DESNTbsj60CDtLzIdLHex59NGzjTBR9Wpt7SG8NPhgEAuS3tUU4Thjyib7sGb7mxRw1sSQt/FsjDd7vMjLg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.5.1.tgz", + "integrity": "sha512-NYiW0rH7fwv7aRtrRnfCSIiwulfV2NoLjhmghCONsyo10DNtYmOpogLotCytZFWLDnTJW1+pmTomq8UW/OSTcQ==", "requires": { - "@sentry/types": "6.5.0", - "@sentry/utils": "6.5.0", + "@sentry/types": "6.5.1", + "@sentry/utils": "6.5.1", "localforage": "^1.8.1", "tslib": "^1.9.3" } }, "@sentry/minimal": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.5.0.tgz", - "integrity": "sha512-MT83ONaBhTCFUlDIQFpsG/lq3ZjGK7jwQ10qxGadSg1KW6EvtQRg+OBwULeQ7C+nNEAhseNrC/qomZMT8brncg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.5.1.tgz", + "integrity": "sha512-q9Do/oreu1RP695CXCLowVDuQyk7ilE6FGdz2QLpTXAfx8247qOwk6+zy9Kea/Djk93+BoSDVQUSneNiVwl0nQ==", "requires": { - "@sentry/hub": "6.5.0", - "@sentry/types": "6.5.0", + "@sentry/hub": "6.5.1", + "@sentry/types": "6.5.1", "tslib": "^1.9.3" }, "dependencies": { "@sentry/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.0.tgz", - "integrity": "sha512-yQpTCIYxBsYT0GenqHNNKeXV8CSkkYlAxB1IGV2eac4IKC5ph5GW6TfDGwvlzQSQ297RsRmOSA8o3I5gGPd2yA==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.1.tgz", + "integrity": "sha512-b/7a6CMoytaeFPx4IBjfxPw3nPvsQh7ui1C8Vw0LxNNDgBwVhPLzUOWeLWbo5YZCVbGEMIWwtCUQYWxneceZSA==" } } }, "@sentry/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.0.tgz", - "integrity": "sha512-yQpTCIYxBsYT0GenqHNNKeXV8CSkkYlAxB1IGV2eac4IKC5ph5GW6TfDGwvlzQSQ297RsRmOSA8o3I5gGPd2yA==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.5.1.tgz", + "integrity": "sha512-b/7a6CMoytaeFPx4IBjfxPw3nPvsQh7ui1C8Vw0LxNNDgBwVhPLzUOWeLWbo5YZCVbGEMIWwtCUQYWxneceZSA==" }, "@sentry/utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.0.tgz", - "integrity": "sha512-CcHuaQN6vRuAsIC+3sA23NmWLRmUN0x/HNQxk0DHJylvYQdEA0AUNoLXogykaXh6NrCx4DNq9yCQTNTSC3mFxg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.5.1.tgz", + "integrity": "sha512-Wv86JYGQH+ZJ5XGFQX7h6ijl32667ikenoL9EyXMn8UoOYX/MLwZoQZin1P60wmKkYR9ifTNVmpaI9OoTaH+UQ==", "requires": { - "@sentry/types": "6.5.0", + "@sentry/types": "6.5.1", "tslib": "^1.9.3" } }, @@ -5534,9 +5534,9 @@ "dev": true }, "@types/node": { - "version": "14.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.1.tgz", - "integrity": "sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw==" + "version": "14.17.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.2.tgz", + "integrity": "sha512-sld7b/xmFum66AAKuz/rp/CUO8+98fMpyQ3SBfzzBNGMd/1iHBTAg9oyAvcYlAj46bpc74r91jSw2iFdnx29nw==" }, "@types/nodemailer": { "version": "6.4.2", @@ -5748,13 +5748,13 @@ } }, "@typescript-eslint/eslint-plugin": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.26.0.tgz", - "integrity": "sha512-yA7IWp+5Qqf+TLbd8b35ySFOFzUfL7i+4If50EqvjT6w35X8Lv0eBHb6rATeWmucks37w+zV+tWnOXI9JlG6Eg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.26.1.tgz", + "integrity": "sha512-aoIusj/8CR+xDWmZxARivZjbMBQTT9dImUtdZ8tVCVRXgBUuuZyM5Of5A9D9arQPxbi/0rlJLcuArclz/rCMJw==", "dev": true, "requires": { - "@typescript-eslint/experimental-utils": "4.26.0", - "@typescript-eslint/scope-manager": "4.26.0", + "@typescript-eslint/experimental-utils": "4.26.1", + "@typescript-eslint/scope-manager": "4.26.1", "debug": "^4.3.1", "functional-red-black-tree": "^1.0.1", "lodash": "^4.17.21", @@ -5770,43 +5770,43 @@ "dev": true }, "@typescript-eslint/experimental-utils": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.26.0.tgz", - "integrity": "sha512-TH2FO2rdDm7AWfAVRB5RSlbUhWxGVuxPNzGT7W65zVfl8H/WeXTk1e69IrcEVsBslrQSTDKQSaJD89hwKrhdkw==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.26.1.tgz", + "integrity": "sha512-sQHBugRhrXzRCs9PaGg6rowie4i8s/iD/DpTB+EXte8OMDfdCG5TvO73XlO9Wc/zi0uyN4qOmX9hIjQEyhnbmQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.26.0", - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/typescript-estree": "4.26.0", + "@typescript-eslint/scope-manager": "4.26.1", + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/typescript-estree": "4.26.1", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" } }, "@typescript-eslint/scope-manager": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.26.0.tgz", - "integrity": "sha512-G6xB6mMo4xVxwMt5lEsNTz3x4qGDt0NSGmTBNBPJxNsrTXJSm21c6raeYroS2OwQsOyIXqKZv266L/Gln1BWqg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.26.1.tgz", + "integrity": "sha512-TW1X2p62FQ8Rlne+WEShyd7ac2LA6o27S9i131W4NwDSfyeVlQWhw8ylldNNS8JG6oJB9Ha9Xyc+IUcqipvheQ==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/visitor-keys": "4.26.0" + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/visitor-keys": "4.26.1" } }, "@typescript-eslint/types": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.26.0.tgz", - "integrity": "sha512-rADNgXl1kS/EKnDr3G+m7fB9yeJNnR9kF7xMiXL6mSIWpr3Wg5MhxyfEXy/IlYthsqwBqHOr22boFbf/u6O88A==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.26.1.tgz", + "integrity": "sha512-STyMPxR3cS+LaNvS8yK15rb8Y0iL0tFXq0uyl6gY45glyI7w0CsyqyEXl/Fa0JlQy+pVANeK3sbwPneCbWE7yg==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.26.0.tgz", - "integrity": "sha512-GHUgahPcm9GfBuy3TzdsizCcPjKOAauG9xkz9TR8kOdssz2Iz9jRCSQm6+aVFa23d5NcSpo1GdHGSQKe0tlcbg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.26.1.tgz", + "integrity": "sha512-l3ZXob+h0NQzz80lBGaykdScYaiEbFqznEs99uwzm8fPHhDjwaBFfQkjUC/slw6Sm7npFL8qrGEAMxcfBsBJUg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/visitor-keys": "4.26.0", + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/visitor-keys": "4.26.1", "debug": "^4.3.1", "globby": "^11.0.3", "is-glob": "^4.0.1", @@ -5815,12 +5815,12 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.26.0.tgz", - "integrity": "sha512-cw4j8lH38V1ycGBbF+aFiLUls9Z0Bw8QschP3mkth50BbWzgFS33ISIgBzUMuQ2IdahoEv/rXstr8Zhlz4B1Zg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.26.1.tgz", + "integrity": "sha512-IGouNSSd+6x/fHtYRyLOM6/C+QxMDzWlDtN41ea+flWuSF9g02iqcIlX8wM53JkfljoIjP0U+yp7SiTS1onEkw==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", + "@typescript-eslint/types": "4.26.1", "eslint-visitor-keys": "^2.0.0" } }, @@ -5939,41 +5939,41 @@ } }, "@typescript-eslint/parser": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.26.0.tgz", - "integrity": "sha512-b4jekVJG9FfmjUfmM4VoOItQhPlnt6MPOBUL0AQbiTmm+SSpSdhHYlwayOm4IW9KLI/4/cRKtQCmDl1oE2OlPg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.26.1.tgz", + "integrity": "sha512-q7F3zSo/nU6YJpPJvQveVlIIzx9/wu75lr6oDbDzoeIRWxpoc/HQ43G4rmMoCc5my/3uSj2VEpg/D83LYZF5HQ==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.26.0", - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/typescript-estree": "4.26.0", + "@typescript-eslint/scope-manager": "4.26.1", + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/typescript-estree": "4.26.1", "debug": "^4.3.1" }, "dependencies": { "@typescript-eslint/scope-manager": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.26.0.tgz", - "integrity": "sha512-G6xB6mMo4xVxwMt5lEsNTz3x4qGDt0NSGmTBNBPJxNsrTXJSm21c6raeYroS2OwQsOyIXqKZv266L/Gln1BWqg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.26.1.tgz", + "integrity": "sha512-TW1X2p62FQ8Rlne+WEShyd7ac2LA6o27S9i131W4NwDSfyeVlQWhw8ylldNNS8JG6oJB9Ha9Xyc+IUcqipvheQ==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/visitor-keys": "4.26.0" + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/visitor-keys": "4.26.1" } }, "@typescript-eslint/types": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.26.0.tgz", - "integrity": "sha512-rADNgXl1kS/EKnDr3G+m7fB9yeJNnR9kF7xMiXL6mSIWpr3Wg5MhxyfEXy/IlYthsqwBqHOr22boFbf/u6O88A==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.26.1.tgz", + "integrity": "sha512-STyMPxR3cS+LaNvS8yK15rb8Y0iL0tFXq0uyl6gY45glyI7w0CsyqyEXl/Fa0JlQy+pVANeK3sbwPneCbWE7yg==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.26.0.tgz", - "integrity": "sha512-GHUgahPcm9GfBuy3TzdsizCcPjKOAauG9xkz9TR8kOdssz2Iz9jRCSQm6+aVFa23d5NcSpo1GdHGSQKe0tlcbg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.26.1.tgz", + "integrity": "sha512-l3ZXob+h0NQzz80lBGaykdScYaiEbFqznEs99uwzm8fPHhDjwaBFfQkjUC/slw6Sm7npFL8qrGEAMxcfBsBJUg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", - "@typescript-eslint/visitor-keys": "4.26.0", + "@typescript-eslint/types": "4.26.1", + "@typescript-eslint/visitor-keys": "4.26.1", "debug": "^4.3.1", "globby": "^11.0.3", "is-glob": "^4.0.1", @@ -5982,12 +5982,12 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.26.0.tgz", - "integrity": "sha512-cw4j8lH38V1ycGBbF+aFiLUls9Z0Bw8QschP3mkth50BbWzgFS33ISIgBzUMuQ2IdahoEv/rXstr8Zhlz4B1Zg==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.26.1.tgz", + "integrity": "sha512-IGouNSSd+6x/fHtYRyLOM6/C+QxMDzWlDtN41ea+flWuSF9g02iqcIlX8wM53JkfljoIjP0U+yp7SiTS1onEkw==", "dev": true, "requires": { - "@typescript-eslint/types": "4.26.0", + "@typescript-eslint/types": "4.26.1", "eslint-visitor-keys": "^2.0.0" } }, @@ -7098,9 +7098,9 @@ "integrity": "sha512-24q5Rh3bno7ldoyCq99d6hpnLI+PAMocdeVaaGt/5BTQMprvDwQToHfNnruqN11odCHZZIQbRBw+nZo1lTCH9g==" }, "aws-sdk": { - "version": "2.918.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.918.0.tgz", - "integrity": "sha512-ZjWanOA1Zo664EyWLCnbUlkwCjoRPmSIMx529W4gk1418qo3oCEcvUy1HeibGGIClYnZZ7J4FMQvVDm2+JtHLQ==", + "version": "2.922.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.922.0.tgz", + "integrity": "sha512-SufbR5TTCK94Zk/xIv4v/m0MM9z+KW999XnjXOyNWGFGHP9/FArjtHtq69+a3KpohYBR1dBj8wUhVjbClmQIBA==", "requires": { "buffer": "4.9.2", "events": "1.1.1", @@ -9450,9 +9450,9 @@ } }, "core-js": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.13.1.tgz", - "integrity": "sha512-JqveUc4igkqwStL2RTRn/EPFGBOfEZHxJl/8ej1mXJR75V3go2mFF4bmUYkEIT1rveHKnkUlcJX/c+f1TyIovQ==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.14.0.tgz", + "integrity": "sha512-3s+ed8er9ahK+zJpp9ZtuVcDoFzHNiZsPbNAAE4KXgrRHbjSqqNN6xGSXq6bq7TZIbKj4NLrLb6bJ5i+vSVjHA==", "dev": true }, "core-js-compat": { @@ -10213,9 +10213,9 @@ } }, "dayjs": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz", - "integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==" + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.5.tgz", + "integrity": "sha512-BUFis41ikLz+65iH6LHQCDm4YPMj5r1YFLdupPIyM4SGcXMmtiLQ7U37i+hGS8urIuqe7I/ou3IS1jVc4nbN4g==" }, "debug": { "version": "3.1.0", @@ -11214,13 +11214,13 @@ } }, "eslint": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.27.0.tgz", - "integrity": "sha512-JZuR6La2ZF0UD384lcbnd0Cgg6QJjiCwhMD6eU4h/VGPcVGwawNNzKU41tgokGXnfjOOyI6QIffthhJTPzzuRA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.28.0.tgz", + "integrity": "sha512-UMfH0VSjP0G4p3EWirscJEQ/cHqnT/iuH6oNZOB94nBjWbMnhGEPxsZm1eyIW0C/9jLI0Fow4W5DXLjEI7mn1g==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.1", + "@eslint/eslintrc": "^0.4.2", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -11237,7 +11237,7 @@ "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", + "glob-parent": "^5.1.2", "globals": "^13.6.0", "ignore": "^4.0.6", "import-fresh": "^3.0.0", @@ -11415,10 +11415,19 @@ } } }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, "globals": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.8.0.tgz", - "integrity": "sha512-rHtdA6+PDBIjeEvA91rpqzEvk/k3/i7EeNQiryiWuJH0Hw9cpyJMAt2jtbAwUaRdhD+573X4vWw6IcjKPasi9Q==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.9.0.tgz", + "integrity": "sha512-74/FduwI/JaIrr1H8e71UbDE+5x7pIPs1C2rrwC52SszOo043CsWOZEMW7o2Y58xwm9b+0RBKDxY5n2sUpEFxA==", "dev": true, "requires": { "type-fest": "^0.20.2" @@ -11529,6 +11538,12 @@ "prelude-ls": "^1.2.1" } }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18450,14 +18465,14 @@ "integrity": "sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE=" }, "mongoose": { - "version": "5.12.7", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.12.7.tgz", - "integrity": "sha512-BniNwACn7uflK2h+M3juvyLH5nn9JDFgnB5KE2EwWFwSrRyhSpPnCtanRKJW3OtMCJyPccMIjtGZxHNW7JfnIw==", + "version": "5.12.12", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.12.12.tgz", + "integrity": "sha512-n+ZmGApaL5x/r92w6S4pb+c075i+YKzg1F9YWkznSzQMtvetj/2dSjj2cqsITpd6z60k3K7ZaosIl6hzHwUA9g==", "requires": { "@types/mongodb": "^3.5.27", "bson": "^1.1.4", "kareem": "2.3.2", - "mongodb": "3.6.6", + "mongodb": "3.6.8", "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.8.3", "mquery": "3.2.5", @@ -18474,14 +18489,14 @@ "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==" }, "mongodb": { - "version": "3.6.6", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.6.tgz", - "integrity": "sha512-WlirMiuV1UPbej5JeCMqE93JRfZ/ZzqE7nJTwP85XzjAF4rRSeq2bGCb1cjfoHLOF06+HxADaPGqT0g3SbVT1w==", + "version": "3.6.8", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.8.tgz", + "integrity": "sha512-sDjJvI73WjON1vapcbyBD3Ao9/VN3TKYY8/QX9EPbs22KaCSrQ5rXo5ZZd44tWJ3wl3FlnrFZ+KyUtNH6+1ZPQ==", "requires": { "bl": "^2.2.1", "bson": "^1.1.4", "denque": "^1.4.1", - "optional-require": "^1.0.2", + "optional-require": "^1.0.3", "safe-buffer": "^5.1.2", "saslprep": "^1.0.0" } @@ -18756,9 +18771,9 @@ } }, "nocache": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz", - "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.0.tgz", + "integrity": "sha512-fd31hZ7uOvBrRSvzTsX51A8nW1SLKfcK7dESRDkEyltsGmpYfeINND8HoFGPEGkQZuhCsB5csAzcrLPXTtcRKg==" }, "node-abi": { "version": "2.19.1", @@ -20403,9 +20418,9 @@ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" }, "prettier": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", - "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", + "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", "dev": true }, "prettier-linter-helpers": { @@ -22487,6 +22502,32 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "sqs-consumer": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/sqs-consumer/-/sqs-consumer-5.5.0.tgz", + "integrity": "sha512-vzKzOZlZtZarOWbg/nbEoMyNu64XnQ4QB3e74nMBNaIuM/RhelUGNGrvrB83IW6a7/DxKDulM46h2TeQP3/1nA==", + "requires": { + "debug": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + } + } + }, + "sqs-producer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sqs-producer/-/sqs-producer-2.1.0.tgz", + "integrity": "sha512-UOlBaVIyCPJ/thAUSFjbB5MTgu3HG9FzFhjN5aiu/Y/QEeqoT4Twc+o7Yappwiz6easqJHz7+kqBq7Oy1GwQ8w==", + "requires": { + "aws-sdk": "^2.673.0" + } + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -23407,9 +23448,9 @@ }, "dependencies": { "ajv": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.5.0.tgz", - "integrity": "sha512-Y2l399Tt1AguU3BPRP9Fn4eN+Or+StUGWCUpbnFyXSo8NZ9S4uj+AG2pjs5apK+ZMOwYOz1+a+VKvKH7CudXgQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.0.tgz", + "integrity": "sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -25142,9 +25183,9 @@ "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, "twilio": { - "version": "3.63.0", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.63.0.tgz", - "integrity": "sha512-ftZckbTBjJ5dgzdII9j0sqYw9SYq3wqTC9r6NmV7CRU0EXXDil5/AbKb78xNPLtMPx3+mn2N+2oTkQlTtWs9TQ==", + "version": "3.63.1", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.63.1.tgz", + "integrity": "sha512-xwtOM78sO2jGxKg1AW+7XlJdrhTMW9dzr6665O+IB/VtNVQB7JQS48pLCZFnBaTvZOILVO0Q6t63wv24hIbr/A==", "requires": { "axios": "^0.21.1", "dayjs": "^1.8.29", @@ -25214,9 +25255,9 @@ "dev": true }, "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.2.0.tgz", + "integrity": "sha512-++0N6KyAj0t2webXst0PE0xuXb4Dv3z1Z+4SGzK+j/epeWBZCfkQbkW/ezscZwpinmBQ5wu/l4TqagKSVcAGCA==", "dev": true }, "type-is": { @@ -25244,9 +25285,9 @@ } }, "typescript": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz", - "integrity": "sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", + "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", "dev": true }, "uglify-js": { @@ -27082,6 +27123,11 @@ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true }, + "zod": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.0.0.tgz", + "integrity": "sha512-4DBG6siN02ooPB1yvEEqoe32maHzKEdGgtQ2HEz6FnFtgTjwZtzJ3ScuiDgtssWfDyLnQ3MvtSj6ff5ANL4STw==" + }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/package.json b/package.json index b9692103b8..3a0d8fb3dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "5.12.1", + "version": "5.13.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " @@ -64,8 +64,8 @@ "@opengovsg/myinfo-gov-client": "^4.0.0", "@opengovsg/ng-file-upload": "^12.2.15", "@opengovsg/spcp-auth-client": "^1.4.7", - "@sentry/browser": "^6.5.0", - "@sentry/integrations": "^6.5.0", + "@sentry/browser": "^6.5.1", + "@sentry/integrations": "^6.5.1", "@stablelib/base64": "^1.0.1", "JSONStream": "^1.3.5", "abortcontroller-polyfill": "^1.7.3", @@ -84,7 +84,7 @@ "angular-ui-bootstrap": "~2.5.6", "angular-ui-router": "~1.0.29", "aws-info": "^1.2.0", - "aws-sdk": "^2.918.0", + "aws-sdk": "^2.922.0", "axios": "^0.21.1", "bcrypt": "^5.0.1", "bluebird": "^3.5.2", @@ -125,13 +125,13 @@ "lodash": "^4.17.21", "moment-timezone": "0.5.33", "mongodb-uri": "^0.9.7", - "mongoose": "^5.12.7", + "mongoose": "^5.12.12", "multiparty": ">=4.2.2", "neverthrow": "^4.2.1", "ng-infinite-scroll": "^1.3.0", "ng-table": "^3.0.1", "ngclipboard": "^2.0.0", - "nocache": "^2.1.0", + "nocache": "^3.0.0", "node-cache": "^5.1.2", "nodemailer": "^6.6.1", "opossum": "^6.1.0", @@ -142,11 +142,13 @@ "slick-carousel": "1.8.1", "sortablejs": "~1.13.0", "spark-md5": "^3.0.1", + "sqs-consumer": "^5.5.0", + "sqs-producer": "^2.1.0", "text-encoding": "^0.7.0", "toastr": "^2.1.4", "triple-beam": "^1.3.0", "tweetnacl": "^1.0.1", - "twilio": "^3.63.0", + "twilio": "^3.63.1", "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "uuid": "^8.3.2", @@ -154,13 +156,14 @@ "web-streams-polyfill": "^3.0.3", "whatwg-fetch": "^3.6.2", "winston": "^3.3.3", - "winston-cloudwatch": "^2.5.2" + "winston-cloudwatch": "^2.5.2", + "zod": "^3.0.0" }, "devDependencies": { "@babel/core": "^7.14.3", "@babel/plugin-transform-runtime": "^7.14.3", "@babel/preset-env": "^7.14.4", - "@opengovsg/mockpass": "^2.7.2", + "@opengovsg/mockpass": "^2.7.3", "@types/bcrypt": "^5.0.0", "@types/bluebird": "^3.5.35", "@types/busboy": "^0.2.3", @@ -180,7 +183,7 @@ "@types/json-stringify-safe": "^5.0.0", "@types/mongodb": "^3.6.17", "@types/mongodb-uri": "^0.9.0", - "@types/node": "^14.17.1", + "@types/node": "^14.17.2", "@types/nodemailer": "^6.4.2", "@types/opossum": "^4.1.1", "@types/promise-retry": "^1.1.3", @@ -191,20 +194,20 @@ "@types/uid-generator": "^2.0.2", "@types/uuid": "^8.3.0", "@types/validator": "^13.1.3", - "@typescript-eslint/eslint-plugin": "^4.26.0", - "@typescript-eslint/parser": "^4.26.0", + "@typescript-eslint/eslint-plugin": "^4.26.1", + "@typescript-eslint/parser": "^4.26.1", "auto-changelog": "^2.3.0", "axios-mock-adapter": "^1.19.0", "babel-loader": "^8.2.2", "concurrently": "^6.2.0", "copy-webpack-plugin": "^6.0.2", - "core-js": "^3.13.1", + "core-js": "^3.14.0", "coveralls": "^3.1.0", "css-loader": "^2.1.1", "csv-parse": "^4.15.4", "date-fns": "^2.22.1", "env-cmd": "^10.1.0", - "eslint": "^7.27.0", + "eslint": "^7.28.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-import": "^2.23.4", @@ -228,7 +231,7 @@ "mongodb-memory-server-core": "^6.9.6", "ngrok": "^4.0.1", "optimize-css-assets-webpack-plugin": "^5.0.1", - "prettier": "^2.2.1", + "prettier": "^2.3.1", "proxyquire": "^2.1.3", "regenerator": "^0.14.4", "rimraf": "^3.0.2", @@ -245,8 +248,8 @@ "ts-loader": "^7.0.5", "ts-node": "^10.0.0", "ts-node-dev": "^1.1.6", - "type-fest": "^0.20.2", - "typescript": "^4.3.2", + "type-fest": "^1.2.0", + "typescript": "=4.2.4", "url-loader": "^1.1.2", "webpack": "^4.46.0", "webpack-cli": "^3.3.12", diff --git a/scripts/20210601_set-rp-sp-student-logos/set-rp-logos.js b/scripts/20210601_set-rp-sp-student-logos/set-rp-logos.js new file mode 100644 index 0000000000..432b04be14 --- /dev/null +++ b/scripts/20210601_set-rp-sp-student-logos/set-rp-logos.js @@ -0,0 +1,51 @@ +/* eslint-disable */ + +// BEFORE +// A: Count number of forms belonging to RP students. +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds } }).count() +} +// Should be 0 +{ + db.forms.count({ + 'startPage.logo.fileId': { $eq: '1622549887610-rp%20student%20logo.png' }, + }) +} + +// UPDATE +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + let rpStudentFormIds = db.forms.find({ admin: { $in: rpStudentUserIds } }).map(b => b._id) + + db.forms.updateMany( + { + _id: { $in: rpStudentFormIds }, + }, + { + $set: { + 'startPage.logo': { + state: 'CUSTOM', + fileId: '1622549887610-rp%20student%20logo.png', + fileName: 'RP Student Logo.png', + fileSizeInBytes: 15242, + }, + }, + } + ) +} + +// AFTER + +// Number of forms RP's logo fileId. Should be equal to A. +{ + db.forms.count({ + 'startPage.logo.fileId': { $eq: '1622549887610-rp%20student%20logo.png' }, + }) +} +// Count number of forms belonging to RP students. +// Should still remain the same as A. +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds } }).count() +} \ No newline at end of file diff --git a/scripts/20210601_set-rp-sp-student-logos/set-sp-logos.js b/scripts/20210601_set-rp-sp-student-logos/set-sp-logos.js new file mode 100644 index 0000000000..a1b60f53ab --- /dev/null +++ b/scripts/20210601_set-rp-sp-student-logos/set-sp-logos.js @@ -0,0 +1,42 @@ +/* eslint-disable */ + +// BEFORE +// A: Count number of forms belonging to SP students. +{ + let rpStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds } }).count() +} +// Should be 0 +{ + db.forms.count({ 'startPage.logo.fileId': { $eq: '1622549643061-sp%20student%20logo.png' } }) +} + +// UPDATE +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + let spStudentFormIds = db.forms.find({ admin: { $in: spStudentUserIds } }).map(b => b._id) + + db.forms.updateMany( + { + _id: { $in: spStudentFormIds } + }, + { $set: { 'startPage.logo': { + "state" : "CUSTOM", + "fileId" : "1622549643061-sp%20student%20logo.png", + "fileName" : "SP Student Logo.png", + "fileSizeInBytes" : 14189 + } } } + ) +} + +// AFTER + +// Number of forms SP's logo fileId. Should be equal to A. +{ + db.forms.count({ 'startPage.logo.fileId': { $eq: '1622549643061-sp%20student%20logo.png' } }) +} +// Should still remain the same as A. +{ + let rpStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: spStudentUserIds } }).count() +} diff --git a/scripts/20210604_private-rp-sp-student-forms/private-rp-stud-forms.js b/scripts/20210604_private-rp-sp-student-forms/private-rp-stud-forms.js new file mode 100644 index 0000000000..eb28bd9a90 --- /dev/null +++ b/scripts/20210604_private-rp-sp-student-forms/private-rp-stud-forms.js @@ -0,0 +1,45 @@ +/* eslint-disable */ + +// BEFORE +// A: Count number of forms belonging to RP students that are still public. +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds }, status: 'PUBLIC' }).count() +} +// B: Count number of forms belonging to RP students that are private. +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds }, status: 'PRIVATE' }).count() +} + +// UPDATE +// Number updated should be A +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + let rpStudentFormIds = db.forms.find({ admin: { $in: rpStudentUserIds }, status: 'PUBLIC' }).map(b => b._id) + + db.forms.updateMany( + { + _id: { $in: rpStudentFormIds }, + }, + { + $set: { + status: 'PRIVATE', + }, + } + ) +} + +// AFTER +// Count number of forms belonging to RP students that are still public +// Should be 0 +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds }, status: 'PUBLIC' }).count() +} +// Count number of forms belonging to RP students that are private. +// Should be A + B +{ + let rpStudentUserIds = db.users.find({ email: /.+myrp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: rpStudentUserIds }, status: 'PRIVATE' }).count() +} \ No newline at end of file diff --git a/scripts/20210604_private-rp-sp-student-forms/private-sp-stud-forms.js b/scripts/20210604_private-rp-sp-student-forms/private-sp-stud-forms.js new file mode 100644 index 0000000000..ac3b2dac26 --- /dev/null +++ b/scripts/20210604_private-rp-sp-student-forms/private-sp-stud-forms.js @@ -0,0 +1,45 @@ +/* eslint-disable */ + +// BEFORE +// A: Count number of forms belonging to SP students that are still public. +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: spStudentUserIds }, status: 'PUBLIC' }).count() +} +// B: Count number of forms belonging to SP students that are private. +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: spStudentUserIds }, status: 'PRIVATE' }).count() +} + +// UPDATE +// Number updated should be A +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + let spStudentFormIds = db.forms.find({ admin: { $in: spStudentUserIds }, status: 'PUBLIC' }).map(b => b._id) + + db.forms.updateMany( + { + _id: { $in: spStudentFormIds }, + }, + { + $set: { + status: 'PRIVATE', + }, + } + ) +} + +// AFTER +// Count number of forms belonging to SP students that are still public +// Should be 0 +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: spStudentUserIds }, status: 'PUBLIC' }).count() +} +// Count number of forms belonging to SP students that are private. +// Should be A + B +{ + let spStudentUserIds = db.users.find({ email: /.+ichat\.sp\.edu\.sg$/i }).map(b => b._id) + db.forms.find({ admin: { $in: spStudentUserIds }, status: 'PRIVATE' }).count() +} diff --git a/scripts/20210706_remove-endpage-buttons/delete-endpage-buttons.js b/scripts/20210706_remove-endpage-buttons/delete-endpage-buttons.js new file mode 100644 index 0000000000..948d7968a4 --- /dev/null +++ b/scripts/20210706_remove-endpage-buttons/delete-endpage-buttons.js @@ -0,0 +1,25 @@ +/* eslint-disable */ + +/* +Delete endPage.buttons key from old form documents in the database +*/ + +// == PRE-UPDATE CHECKS == + +// Count number of forms with endPage buttons key +db.getCollection('forms').count({ "endPage.buttons": { $exists: true } }) + +// == UPDATE == +// Delete endPage buttons key +// ~ Number of forms updated should match number which had endPage buttons key +db.getCollection('forms').updateMany({}, { + $unset: { + 'endPage.buttons': "", + } +}) + +// == POST-UPDATE CHECKS == + +// Check number of forms with endPage buttons key +// ~ Should be zero +db.getCollection('forms').count({ "endPage.buttons": { $exists: true } }) \ No newline at end of file diff --git a/src/app/config/feature-manager/aggregate-stats.config.ts b/src/app/config/feature-manager/aggregate-stats.config.ts index 21de03185f..c9793a833d 100644 --- a/src/app/config/feature-manager/aggregate-stats.config.ts +++ b/src/app/config/feature-manager/aggregate-stats.config.ts @@ -1,16 +1,16 @@ import { FeatureNames, RegisterableFeature } from './types' -const aggregateCollectionFeature: RegisterableFeature = { - name: FeatureNames.AggregateStats, - schema: { - aggregateCollection: { - doc: - 'Has to be defined (i.e. =true) if FormStats collection is to be used', - format: '*', - default: null, - env: 'AGGREGATE_COLLECTION', +const aggregateCollectionFeature: RegisterableFeature = + { + name: FeatureNames.AggregateStats, + schema: { + aggregateCollection: { + doc: 'Has to be defined (i.e. =true) if FormStats collection is to be used', + format: '*', + default: null, + env: 'AGGREGATE_COLLECTION', + }, }, - }, -} + } export default aggregateCollectionFeature diff --git a/src/app/config/feature-manager/google-analytics.config.ts b/src/app/config/feature-manager/google-analytics.config.ts index 166567bc81..35ef2e042c 100644 --- a/src/app/config/feature-manager/google-analytics.config.ts +++ b/src/app/config/feature-manager/google-analytics.config.ts @@ -1,15 +1,16 @@ import { FeatureNames, RegisterableFeature } from './types' -const googleAnalyticsFeature: RegisterableFeature = { - name: FeatureNames.GoogleAnalytics, - schema: { - GATrackingID: { - doc: 'Google Analytics tracking ID', - format: String, - default: null, - env: 'GA_TRACKING_ID', +const googleAnalyticsFeature: RegisterableFeature = + { + name: FeatureNames.GoogleAnalytics, + schema: { + GATrackingID: { + doc: 'Google Analytics tracking ID', + format: String, + default: null, + env: 'GA_TRACKING_ID', + }, }, - }, -} + } export default googleAnalyticsFeature diff --git a/src/app/config/feature-manager/intranet.config.ts b/src/app/config/feature-manager/intranet.config.ts index ff4bb94382..65a924df64 100644 --- a/src/app/config/feature-manager/intranet.config.ts +++ b/src/app/config/feature-manager/intranet.config.ts @@ -4,8 +4,7 @@ export const intranetFeature: RegisterableFeature = { name: FeatureNames.Intranet, schema: { intranetIpListPath: { - doc: - 'Path to file containing list of intranet IP addresses, separated by newlines', + doc: 'Path to file containing list of intranet IP addresses, separated by newlines', format: String, default: null, env: 'INTRANET_IP_LIST_PATH', diff --git a/src/app/config/feature-manager/spcp-myinfo.config.ts b/src/app/config/feature-manager/spcp-myinfo.config.ts index 21bfadf481..dcdf6157a6 100644 --- a/src/app/config/feature-manager/spcp-myinfo.config.ts +++ b/src/app/config/feature-manager/spcp-myinfo.config.ts @@ -9,8 +9,7 @@ const spcpMyInfoFeature: RegisterableFeature = { name: FeatureNames.SpcpMyInfo, schema: { isSPMaintenance: { - doc: - 'If set, displays a banner message on SingPass forms. Overrides IS_CP_MAINTENANCE', + doc: 'If set, displays a banner message on SingPass forms. Overrides IS_CP_MAINTENANCE', format: '*', default: null, env: 'IS_SP_MAINTENANCE', @@ -46,29 +45,25 @@ const spcpMyInfoFeature: RegisterableFeature = { env: 'CP_COOKIE_MAX_AGE', }, spIdpId: { - doc: - 'Partner ID of National Digital Identity Office for SingPass authentication', + doc: 'Partner ID of National Digital Identity Office for SingPass authentication', format: 'url', default: null, env: 'SINGPASS_IDP_ID', }, cpIdpId: { - doc: - 'Partner ID of National Digital Identity Office for CorpPass authentication', + doc: 'Partner ID of National Digital Identity Office for CorpPass authentication', format: 'url', default: null, env: 'CORPPASS_IDP_ID', }, spPartnerEntityId: { - doc: - 'Partner ID registered with National Digital Identity Office for SingPass authentication', + doc: 'Partner ID registered with National Digital Identity Office for SingPass authentication', format: 'url', default: null, env: 'SINGPASS_PARTNER_ENTITY_ID', }, cpPartnerEntityId: { - doc: - 'Partner ID registered with National Digital Identity Office for CorpPass authentication', + doc: 'Partner ID registered with National Digital Identity Office for CorpPass authentication', format: 'url', default: null, env: 'CORPPASS_PARTNER_ENTITY_ID', @@ -98,78 +93,67 @@ const spcpMyInfoFeature: RegisterableFeature = { env: 'CORPPASS_IDP_ENDPOINT', }, spEsrvcId: { - doc: - 'e-service ID registered with National Digital Identity office for SingPass authentication', + doc: 'e-service ID registered with National Digital Identity office for SingPass authentication', format: String, default: null, env: 'SINGPASS_ESRVC_ID', }, cpEsrvcId: { - doc: - 'e-service ID registered with National Digital Identity office for CorpPass authentication', + doc: 'e-service ID registered with National Digital Identity office for CorpPass authentication', format: String, default: null, env: 'CORPPASS_ESRVC_ID', }, spFormSgKeyPath: { - doc: - 'Path to X.509 key used for SingPass related communication with National Digital Identity office', + doc: 'Path to X.509 key used for SingPass related communication with National Digital Identity office', format: String, default: null, env: 'SP_FORMSG_KEY_PATH', }, cpFormSgKeyPath: { - doc: - 'Path to X.509 key used for CorpPass related communication with National Digital Identity office', + doc: 'Path to X.509 key used for CorpPass related communication with National Digital Identity office', format: String, default: null, env: 'CP_FORMSG_KEY_PATH', }, spFormSgCertPath: { - doc: - 'Path to X.509 cert used for SingPass related communication with National Digital Identity office', + doc: 'Path to X.509 cert used for SingPass related communication with National Digital Identity office', format: String, default: null, env: 'SP_FORMSG_CERT_PATH', }, cpFormSgCertPath: { - doc: - 'Path to X.509 cert used for CorpPass related communication with National Digital Identity office', + doc: 'Path to X.509 cert used for CorpPass related communication with National Digital Identity office', format: String, default: null, env: 'CP_FORMSG_CERT_PATH', }, spIdpCertPath: { - doc: - 'Path to National Digital Identity offices X.509 cert used for SingPass related communication', + doc: 'Path to National Digital Identity offices X.509 cert used for SingPass related communication', format: String, default: null, env: 'SP_IDP_CERT_PATH', }, cpIdpCertPath: { - doc: - 'Path to National Digital Identity offices X.509 cert used for CorpPass related communication', + doc: 'Path to National Digital Identity offices X.509 cert used for CorpPass related communication', format: String, default: null, env: 'CP_IDP_CERT_PATH', }, myInfoClientMode: { - doc: - 'Configures MyInfoGovClient. Set this to either `stg` or `prod` to fetch MyInfo data from the corresponding endpoints.', + doc: 'Configures MyInfoGovClient. Set this to either `stg` or `prod` to fetch MyInfo data from the corresponding endpoints.', format: Object.values(MyInfoMode), default: MyInfoMode.Production, env: 'MYINFO_CLIENT_CONFIG', }, myInfoKeyPath: { - doc: - 'Filepath to MyInfo private key, which is used to decrypt data and sign requests when communicating with MyInfo.', + doc: 'Filepath to MyInfo private key, which is used to decrypt data and sign requests when communicating with MyInfo.', format: String, default: null, env: 'MYINFO_FORMSG_KEY_PATH', }, myInfoCertPath: { - doc: - "Path to MyInfo's public certificate, which is used to verify their signature.", + doc: "Path to MyInfo's public certificate, which is used to verify their signature.", format: String, default: null, env: 'MYINFO_CERT_PATH', diff --git a/src/app/config/feature-manager/types.ts b/src/app/config/feature-manager/types.ts index 075095c27e..6e2b3ac337 100644 --- a/src/app/config/feature-manager/types.ts +++ b/src/app/config/feature-manager/types.ts @@ -79,6 +79,7 @@ export interface IVerifiedFields { export interface IWebhookVerifiedContent { signingSecretKey: string + webhookQueueUrl: string } export interface IIntranet { diff --git a/src/app/config/feature-manager/verified-fields.config.ts b/src/app/config/feature-manager/verified-fields.config.ts index 44bce07431..181b9bbf72 100644 --- a/src/app/config/feature-manager/verified-fields.config.ts +++ b/src/app/config/feature-manager/verified-fields.config.ts @@ -1,15 +1,16 @@ import { FeatureNames, RegisterableFeature } from './types' -const verifiedFieldsFeature: RegisterableFeature = { - name: FeatureNames.VerifiedFields, - schema: { - verificationSecretKey: { - doc: 'The secret key for signing verified responses (email, mobile)', - format: String, - default: null, - env: 'VERIFICATION_SECRET_KEY', +const verifiedFieldsFeature: RegisterableFeature = + { + name: FeatureNames.VerifiedFields, + schema: { + verificationSecretKey: { + doc: 'The secret key for signing verified responses (email, mobile)', + format: String, + default: null, + env: 'VERIFICATION_SECRET_KEY', + }, }, - }, -} + } export default verifiedFieldsFeature diff --git a/src/app/config/feature-manager/webhook-verified-content.config.ts b/src/app/config/feature-manager/webhook-verified-content.config.ts index 77999d3fcc..f5511ef7f4 100644 --- a/src/app/config/feature-manager/webhook-verified-content.config.ts +++ b/src/app/config/feature-manager/webhook-verified-content.config.ts @@ -1,16 +1,22 @@ import { FeatureNames, RegisterableFeature } from './types' -const webhookVerifiedContentFeature: RegisterableFeature = { - name: FeatureNames.WebhookVerifiedContent, - schema: { - signingSecretKey: { - doc: - 'The secret key for signing verified content passed into the database and for signing webhooks', - format: String, - default: null, - env: 'SIGNING_SECRET_KEY', +const webhookVerifiedContentFeature: RegisterableFeature = + { + name: FeatureNames.WebhookVerifiedContent, + schema: { + signingSecretKey: { + doc: 'The secret key for signing verified content passed into the database and for signing webhooks', + format: String, + default: null, + env: 'SIGNING_SECRET_KEY', + }, + webhookQueueUrl: { + doc: 'URL of AWS SQS queue for webhook retries', + format: String, + default: '', + env: 'WEBHOOK_SQS_URL', + }, }, - }, -} + } export default webhookVerifiedContentFeature diff --git a/src/app/config/logger.ts b/src/app/config/logger.ts index fe7455aa66..6f007071a7 100644 --- a/src/app/config/logger.ts +++ b/src/app/config/logger.ts @@ -106,7 +106,7 @@ export const customFormat = format.printf((info) => { // e.g. logger.info('param1', 'param2') // The second parameter onwards will be passed into the `splat` key and // require formatting (because that is just how the library is written). - const splatSymbol = (Symbol.for('splat') as unknown) as string + const splatSymbol = Symbol.for('splat') as unknown as string const splatArgs = info[splatSymbol] || [] const rest = splatArgs.map((data: any) => formatWithInspect(data)).join(' ') const msg = formatWithInspect(info.message) diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index 68a57683f6..249d0dc5af 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -156,8 +156,7 @@ export const optionalVarsSchema: Schema = { env: 'IS_LOGIN_BANNER', }, siteBannerContent: { - doc: - 'The banner message to show on all pages. Allows for HTML. Will supersede all other banner content if it exists.', + doc: 'The banner message to show on all pages. Allows for HTML. Will supersede all other banner content if it exists.', format: String, default: '', env: 'SITE_BANNER_CONTENT', @@ -170,8 +169,7 @@ export const optionalVarsSchema: Schema = { }, }, formsgSdkMode: { - doc: - 'Inform SDK which public keys are to be used to sign, encrypt, or decrypt data that is passed to it', + doc: 'Inform SDK which public keys are to be used to sign, encrypt, or decrypt data that is passed to it', format: ['staging', 'production', 'development', 'test'], default: 'production' as PackageMode, env: 'FORMSG_SDK_MODE', @@ -190,8 +188,7 @@ export const optionalVarsSchema: Schema = { env: 'MAIL_LOGGER', }, debug: { - doc: - 'If set to true, then logs SMTP traffic, otherwise logs only transaction events.', + doc: 'If set to true, then logs SMTP traffic, otherwise logs only transaction events.', format: 'Boolean', default: false, env: 'MAIL_DEBUG', @@ -209,8 +206,7 @@ export const optionalVarsSchema: Schema = { env: 'CHROMIUM_BIN', }, maxMessages: { - doc: - 'Nodemailer config to help to keep the connection up-to-date for long-running messaging', + doc: 'Nodemailer config to help to keep the connection up-to-date for long-running messaging', format: 'int', default: 100, env: 'SES_MAX_MESSAGES', @@ -236,8 +232,7 @@ export const optionalVarsSchema: Schema = { env: 'AWS_REGION', }, customCloudWatchGroup: { - doc: - 'Name of CloudWatch log group to store short-term logs. Log streams are separated by date.', + doc: 'Name of CloudWatch log group to store short-term logs. Log streams are separated by date.', format: String, default: '', env: 'CUSTOM_CLOUDWATCH_LOG_GROUP', @@ -251,8 +246,7 @@ export const optionalVarsSchema: Schema = { env: 'PORT', }, otpLifeSpan: { - doc: - 'OTP Life Span for Login. (Should be in miliseconds, e.g. 1000 * 60 * 15 = 15 mins)', + doc: 'OTP Life Span for Login. (Should be in miliseconds, e.g. 1000 * 60 * 15 = 15 mins)', format: 'int', default: 900000, env: 'OTP_LIFE_SPAN', @@ -272,15 +266,13 @@ export const optionalVarsSchema: Schema = { }, rateLimit: { submissions: { - doc: - 'Per-minute, per-IP, per-instance request limit for submissions endpoints', + doc: 'Per-minute, per-IP, per-instance request limit for submissions endpoints', format: 'int', default: 80, env: 'SUBMISSIONS_RATE_LIMIT', }, sendAuthOtp: { - doc: - 'Per-minute, per-IP request limit for OTPs to log in to the admin console', + doc: 'Per-minute, per-IP request limit for OTPs to log in to the admin console', format: 'int', default: 60, env: 'SEND_AUTH_OTP_RATE_LIMIT', @@ -361,8 +353,7 @@ export const loadS3BucketUrlSchema = ({ env: 'AWS_ENDPOINT', }, attachmentBucketUrl: { - doc: - 'Url of attachment S3 bucket derived from S3 endpoint and bucket name', + doc: 'Url of attachment S3 bucket derived from S3 endpoint and bucket name', format: (val) => validateS3BucketUrl(val, { isDev, hasTrailingSlash: true, region }), default: null, diff --git a/src/app/constants/timezone.ts b/src/app/constants/timezone.ts new file mode 100644 index 0000000000..02365fd100 --- /dev/null +++ b/src/app/constants/timezone.ts @@ -0,0 +1 @@ +export const TIMEZONE = 'Asia/Singapore' diff --git a/src/app/models/__tests__/admin_verification.server.model.spec.ts b/src/app/models/__tests__/admin_verification.server.model.spec.ts index 250cc50e50..930f9cdd3d 100644 --- a/src/app/models/__tests__/admin_verification.server.model.spec.ts +++ b/src/app/models/__tests__/admin_verification.server.model.spec.ts @@ -234,9 +234,8 @@ describe('AdminVerification Model', () => { await expect(AdminVerification.countDocuments()).resolves.toEqual(1) // Act - const actualPromise = AdminVerification.incrementAttemptsByAdminId( - adminId, - ) + const actualPromise = + AdminVerification.incrementAttemptsByAdminId(adminId) // Assert // Exactly the same as initial params, but with numOtpAttempts @@ -256,9 +255,8 @@ describe('AdminVerification Model', () => { const freshAdminId = new ObjectID() // Act - const actualPromise = AdminVerification.incrementAttemptsByAdminId( - freshAdminId, - ) + const actualPromise = + AdminVerification.incrementAttemptsByAdminId(freshAdminId) // Assert await expect(actualPromise).resolves.toBeNull() diff --git a/src/app/models/__tests__/encrypt-submission.server.model.spec.ts b/src/app/models/__tests__/encrypt-submission.server.model.spec.ts index e182bcfe1b..f48656cb2f 100644 --- a/src/app/models/__tests__/encrypt-submission.server.model.spec.ts +++ b/src/app/models/__tests__/encrypt-submission.server.model.spec.ts @@ -33,16 +33,15 @@ describe('Encrypt Submission Model', () => { const validFormId = new ObjectId().toHexString() const createdDate = new Date() // Add valid encrypt submission. - const validSubmission = await Submission.create( - { + const validSubmission = + await Submission.create({ form: validFormId, myInfoFields: [], submissionType: SubmissionType.Encrypt, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 1, created: createdDate, - }, - ) + }) // Act const result = await EncryptSubmission.findSingleMetadata( @@ -391,15 +390,14 @@ describe('Encrypt Submission Model', () => { it('should return null when type of submission with given id is not SubmissionType.Encrypt', async () => { // Arrange const validFormId = new ObjectId().toHexString() - const validEmailSubmission = await Submission.create( - { + const validEmailSubmission = + await Submission.create({ submissionType: SubmissionType.Email, form: validFormId, recipientEmails: ['any@example.com'], responseHash: 'any hash', responseSalt: 'any salt', - }, - ) + }) // Act const actual = await EncryptSubmission.findEncryptedSubmissionById( diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index a38e6d32ce..5eb7a8bae1 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -72,6 +72,7 @@ const FORM_DEFAULTS = { permissionList: [], webhook: { url: '', + isRetryEnabled: false, }, status: 'PRIVATE', submissionLimit: null, @@ -392,9 +393,9 @@ describe('Form Model', () => { expect(actualSavedObject).toEqual(expectedObject) // Remove indeterministic id from actual permission list - const actualPermissionList = ((saved.toObject() as unknown) as IEncryptedForm).permissionList?.map( - (permission) => omit(permission, '_id'), - ) + const actualPermissionList = ( + saved.toObject() as unknown as IEncryptedForm + ).permissionList?.map((permission) => omit(permission, '_id')) expect(actualPermissionList).toEqual(permissionList) }) @@ -1074,9 +1075,9 @@ describe('Form Model', () => { ], } - const mockNewFormLogic = ({ + const mockNewFormLogic = { logicType: LogicType.PreventSubmit, - } as unknown) as ILogicSchema + } as unknown as ILogicSchema it('should return form upon successful create logic if form_logic is currently empty', async () => { // arrange @@ -1957,7 +1958,9 @@ describe('Form Model', () => { it('should return updated form when successfully updating form field', async () => { // Arrange - const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + const originalFormFields = ( + form.form_fields as Types.DocumentArray + ).toObject() const newField = { ...originalFormFields[1], @@ -1994,7 +1997,9 @@ describe('Form Model', () => { it('should return validation error if field type of new field does not match the field to update', async () => { // Arrange - const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + const originalFormFields = ( + form.form_fields as Types.DocumentArray + ).toObject() const newField: FormFieldWithId = { ...originalFormFields[1], @@ -2017,7 +2022,9 @@ describe('Form Model', () => { it('should return validation error if model validation fails whilst updating field', async () => { // Arrange - const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + const originalFormFields = ( + form.form_fields as Types.DocumentArray + ).toObject() const newField: FormFieldWithId = { ...originalFormFields[2], @@ -2149,7 +2156,9 @@ describe('Form Model', () => { // Assert expect(updatedForm).not.toBeNull() expect( - (updatedForm?.form_fields as Types.DocumentArray).toObject(), + ( + updatedForm?.form_fields as Types.DocumentArray + ).toObject(), ).toEqual([ // Should be rearranged to the 0th index position, and the previously // 0th index field should be pushed to 1st index. @@ -2174,7 +2183,9 @@ describe('Form Model', () => { // Assert expect(updatedForm).not.toBeNull() expect( - (updatedForm?.form_fields as Types.DocumentArray).toObject(), + ( + updatedForm?.form_fields as Types.DocumentArray + ).toObject(), ).toEqual([ originalFields[0], originalFields[2], diff --git a/src/app/models/__tests__/form_statistics_total.server.model.spec.ts b/src/app/models/__tests__/form_statistics_total.server.model.spec.ts index 7163dacca7..cc20af1f8d 100644 --- a/src/app/models/__tests__/form_statistics_total.server.model.spec.ts +++ b/src/app/models/__tests__/form_statistics_total.server.model.spec.ts @@ -22,11 +22,11 @@ describe('FormStatisticsTotal Model', () => { formCounts.forEach((count) => { submissionPromises.push( // Using mongodb native function to bypass collection presave hook. - (FormStatsModel.collection.insertOne({ + FormStatsModel.collection.insertOne({ formId: new ObjectId(), totalCount: count, lastSubmission: new Date(), - }) as unknown) as Promise, + }) as unknown as Promise, ) }) await Promise.all(submissionPromises) @@ -53,11 +53,11 @@ describe('FormStatisticsTotal Model', () => { formCounts.forEach((count) => { submissionPromises.push( // Using mongodb native function to bypass collection presave hook. - (FormStatsModel.collection.insertOne({ + FormStatsModel.collection.insertOne({ formId: new ObjectId(), totalCount: count, lastSubmission: new Date(), - }) as unknown) as Promise, + }) as unknown as Promise, ) }) await Promise.all(submissionPromises) diff --git a/src/app/models/__tests__/login.server.model.spec.ts b/src/app/models/__tests__/login.server.model.spec.ts index 64ce5e53cd..1c625b1d2f 100644 --- a/src/app/models/__tests__/login.server.model.spec.ts +++ b/src/app/models/__tests__/login.server.model.spec.ts @@ -126,7 +126,7 @@ describe('login.server.model', () => { const agencyId = new ObjectId() const mockEsrvcId = 'esrvcid' const mockAuthType = 'SP' - const fullForm = ({ + const fullForm = { _id: formId, admin: { _id: adminId, @@ -136,7 +136,7 @@ describe('login.server.model', () => { }, authType: mockAuthType, esrvcId: mockEsrvcId, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm it('should save the correct form data', async () => { const saved = await LoginModel.addLoginFromForm(fullForm) diff --git a/src/app/models/__tests__/submission.server.model.spec.ts b/src/app/models/__tests__/submission.server.model.spec.ts index d99438fecf..f726745928 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -1,6 +1,8 @@ -import { ObjectID } from 'bson' +import { ObjectId } from 'bson' +import { promises as dns } from 'dns' import { times } from 'lodash' import mongoose from 'mongoose' +import { mocked } from 'ts-jest/utils' import getSubmissionModel, { getEmailSubmissionModel, @@ -16,19 +18,155 @@ import { SubmissionType, } from '../../../../src/types' +jest.mock('dns', () => ({ + promises: { + resolve: jest.fn(), + }, +})) +const MockDns = mocked(dns, true) + const Submission = getSubmissionModel(mongoose) const EncryptedSubmission = getEncryptSubmissionModel(mongoose) const EmailSubmission = getEmailSubmissionModel(mongoose) // TODO: Add more tests for the rest of the submission schema. describe('Submission Model', () => { - beforeAll(async () => await dbHandler.connect()) + beforeAll(async () => { + await dbHandler.connect() + MockDns.resolve.mockResolvedValue(['1.1.1.1']) + }) afterEach(async () => await dbHandler.clearDatabase()) afterAll(async () => await dbHandler.closeDatabase()) const MOCK_ENCRYPTED_CONTENT = 'abcdefg encryptedContent' + const MOCK_VERIFIED_CONTENT = 'hijklmnop verifiedContent' + const MOCK_WEBHOOK_URL = 'https://test.web.site' describe('Statics', () => { + describe('retrieveWebhookInfoById', () => { + it('should return the populated submission when the submission and webhook URL exist', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + }, + }, + }) + const submission = await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(submission._id), + ) + + expect(result).toEqual({ + webhookUrl: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + webhookView: { + data: { + formId: String(form._id), + submissionId: String(submission._id), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 0, + created: submission.created, + }, + }, + }) + }) + + it('should return null when the submission ID does not exist', async () => { + // Create submission + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: true, + }, + }, + }) + await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + // Attempt to find submission with a different ID + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(new ObjectId().toHexString()), + ) + + expect(result).toBeNull() + }) + + it('should return empty string for the webhook URL when the form does not have a webhook URL', async () => { + const { form } = await dbHandler.insertEncryptForm() + const submission = await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(submission._id), + ) + + expect(result).toEqual({ + webhookUrl: '', + isRetryEnabled: false, + webhookView: { + data: { + formId: String(form._id), + submissionId: String(submission._id), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 0, + created: submission.created, + }, + }, + }) + }) + + it('should return false for isRetryEnabled when the form does not have retries enabled', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: false, + }, + }, + }) + const submission = await EncryptedSubmission.create({ + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 0, + }) + + const result = await EncryptedSubmission.retrieveWebhookInfoById( + String(submission._id), + ) + + expect(result).toEqual({ + webhookUrl: MOCK_WEBHOOK_URL, + isRetryEnabled: false, + webhookView: { + data: { + formId: String(form._id), + submissionId: String(submission._id), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 0, + created: submission.created, + }, + }, + }) + }) + }) + describe('findFormsWithSubsAbove', () => { it('should return ids and counts of forms with more than given minimum submissions', async () => { // Arrange @@ -105,9 +243,9 @@ describe('Submission Model', () => { describe('Methods', () => { describe('getWebhookView', () => { - it('should return non-null view with encryptedSubmission type (without verified content)', async () => { + it('should return non-null view with encryptedSubmission type when submission has no verified content', async () => { // Arrange - const formId = new ObjectID() + const formId = new ObjectId() const submission = await EncryptedSubmission.create({ submissionType: SubmissionType.Encrypt, @@ -135,9 +273,82 @@ describe('Submission Model', () => { }) }) + it('should return non-null view with encryptedSubmission type when submission has verified content', async () => { + // Arrange + const formId = new ObjectId() + + const submission = await EncryptedSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: MOCK_VERIFIED_CONTENT, + version: 1, + authType: AuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + // Act + const actualWebhookView = submission.getWebhookView() + + // Assert + expect(actualWebhookView).toEqual({ + data: { + formId: expect.any(String), + submissionId: expect.any(String), + created: expect.any(Date), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: MOCK_VERIFIED_CONTENT, + version: 1, + }, + }) + }) + + it('should return non-null view with encryptedSubmission type when submission is populated with webhook info', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + webhook: { + url: MOCK_WEBHOOK_URL, + isRetryEnabled: false, + }, + }, + }) + + const submission = await EncryptedSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: form._id, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: MOCK_VERIFIED_CONTENT, + version: 1, + authType: AuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + const populatedSubmission = await EncryptedSubmission.findById( + submission._id, + ).populate('form', 'webhook') + + // Act + const actualWebhookView = populatedSubmission!.getWebhookView() + + // Assert + expect(actualWebhookView).toEqual({ + data: { + formId: expect.any(String), + submissionId: expect.any(String), + created: expect.any(Date), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: MOCK_VERIFIED_CONTENT, + version: 1, + }, + }) + }) + it('should return null view with non-encryptSubmission type', async () => { // Arrange - const formId = new ObjectID() + const formId = new ObjectId() const submission = await EmailSubmission.create({ submissionType: SubmissionType.Email, form: formId, @@ -162,7 +373,7 @@ describe('Submission Model', () => { describe('addWebhookResponse', () => { it('should return updated submission with webhook response when submission ID is valid', async () => { // Arrange - const formId = new ObjectID() + const formId = new ObjectId() const submission = await EncryptedSubmission.create({ submissionType: SubmissionType.Encrypt, form: formId, @@ -205,7 +416,7 @@ describe('Submission Model', () => { it('should return null when submission id is invalid', async () => { // Arrange - const formId = new ObjectID() + const formId = new ObjectId() const submission = await EncryptedSubmission.create({ submissionType: SubmissionType.Encrypt, form: formId, @@ -231,7 +442,7 @@ describe('Submission Model', () => { }, } as IWebhookResponse - const invalidSubmissionId = new ObjectID().toHexString() + const invalidSubmissionId = new ObjectId().toHexString() // Act const actualSubmission = await EncryptedSubmission.addWebhookResponse( diff --git a/src/app/models/admin_verification.server.model.ts b/src/app/models/admin_verification.server.model.ts index 3f5d675999..e6ff3e895c 100644 --- a/src/app/models/admin_verification.server.model.ts +++ b/src/app/models/admin_verification.server.model.ts @@ -54,7 +54,6 @@ AdminVerificationSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) * Upserts given OTP into AdminVerification collection. */ AdminVerificationSchema.statics.upsertOtp = async function ( - this: IAdminVerificationModel, upsertParams: UpsertOtpParams, ) { return this.findOneAndUpdate( @@ -73,7 +72,6 @@ AdminVerificationSchema.statics.upsertOtp = async function ( * @returns the incremented document */ AdminVerificationSchema.statics.incrementAttemptsByAdminId = async function ( - this: IAdminVerificationModel, adminId: IUserSchema['_id'], ) { return this.findOneAndUpdate( diff --git a/src/app/models/agency.server.model.ts b/src/app/models/agency.server.model.ts index 31614def79..0930252b24 100644 --- a/src/app/models/agency.server.model.ts +++ b/src/app/models/agency.server.model.ts @@ -50,9 +50,7 @@ const AgencySchema = new Schema( ) // Methods -AgencySchema.methods.getPublicView = function ( - this: IAgencySchema, -): PublicAgency { +AgencySchema.methods.getPublicView = function (): PublicAgency { return pick(this, AGENCY_PUBLIC_FIELDS) as PublicAgency } diff --git a/src/app/models/field/emailField.ts b/src/app/models/field/emailField.ts index f2793282fd..3913ca6f33 100644 --- a/src/app/models/field/emailField.ts +++ b/src/app/models/field/emailField.ts @@ -29,9 +29,19 @@ const createEmailFieldSchema = (): Schema => { includeFormSummary: { type: Boolean, default: false, - set: function (this: IEmailFieldSchema, v: boolean) { - // PDF response not allowed for encrypt forms - return this.parent().responseMode === ResponseMode.Encrypt ? false : v + validate: { + validator: function (this: IEmailFieldSchema, v: boolean) { + // always ok to set to false + return ( + !v || + // either true or false is okay if not in storage mode + this.parent().responseMode !== ResponseMode.Encrypt || + // in storage mode, we can ignore this field if email confirmation is not enabled anyway + !this.autoReplyOptions.hasAutoReply + ) + }, + message: + 'PDF response summaries are not allowed for email confirmations in storage mode forms', }, }, }, @@ -62,21 +72,6 @@ const createEmailFieldSchema = (): Schema => { }, }) - // PDF response not allowed if autoreply is set in encrypted forms. If - // autoreply is not set, then we don't care whether the pdf is set. - EmailFieldSchema.pre('validate', function (next) { - if (this.parent().responseMode === ResponseMode.Encrypt) { - const { hasAutoReply, includeFormSummary } = this.autoReplyOptions || {} - if (hasAutoReply && includeFormSummary) { - return next( - Error('Autoreply PDF is not allowed for storage mode forms'), - ) - } - } - - return next() - }) - return EmailFieldSchema } diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 0f0e8e9f95..516aa8b4f9 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -356,6 +356,10 @@ const compileFormModel = (db: Mongoose): IFormModel => { 'Webhook must be a valid URL over HTTPS and point to a public IP.', }, }, + isRetryEnabled: { + type: Boolean, + default: false, + }, }, msgSrvcName: { @@ -435,10 +439,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { FormLogicPath.discriminator(LogicType.PreventSubmit, PreventSubmitLogicSchema) // Methods - FormSchema.methods.getDashboardView = function ( - this: IFormSchema, - admin: IPopulatedUser, - ) { + FormSchema.methods.getDashboardView = function (admin: IPopulatedUser) { return { _id: this._id, title: this.title, @@ -450,7 +451,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { } // Method to return myInfo attributes - FormSchema.methods.getUniqueMyInfoAttrs = function (this: IFormSchema) { + FormSchema.methods.getUniqueMyInfoAttrs = function () { if (this.authType !== AuthType.MyInfo) { return [] } @@ -461,7 +462,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Return essential form creation parameters with the given properties FormSchema.methods.getDuplicateParams = function ( - this: IFormSchema, overrideProps: OverrideProps, ) { const newForm = pick(this, [ @@ -477,7 +477,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { return { ...newForm, ...overrideProps } } - FormSchema.methods.getPublicView = function (this: IFormSchema): PublicForm { + FormSchema.methods.getPublicView = function (): PublicForm { const basePublicView = pick(this, FORM_PUBLIC_FIELDS) as PublicFormValues // Return non-populated public fields of form if not populated. @@ -493,7 +493,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { } // Archives form. - FormSchema.methods.archive = function (this: IFormSchema) { + FormSchema.methods.archive = function () { // Return instantly when form is already archived. if (this.status === Status.Archived) { return Promise.resolve(this) @@ -503,17 +503,14 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } - const FormDocumentSchema = (FormSchema as unknown) as Schema + const FormDocumentSchema = FormSchema as unknown as Schema - FormDocumentSchema.methods.getSettings = function ( - this: IFormDocument, - ): FormSettings { + FormDocumentSchema.methods.getSettings = function (): FormSettings { return pick(this, FORM_SETTING_FIELDS) } // Transfer ownership of the form to another user FormDocumentSchema.methods.transferOwner = async function ( - this: IFormDocument, currentOwner: IUserSchema, newOwner: IUserSchema, ) { @@ -530,7 +527,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormDocumentSchema.methods.updateFormCollaborators = async function ( - this: IFormDocument, updatedPermissions: Permission[], ) { this.permissionList = updatedPermissions @@ -538,7 +534,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormDocumentSchema.methods.updateFormFieldById = function ( - this: IFormDocument, fieldId: string, newField: FormFieldWithId, ) { @@ -554,17 +549,13 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } - FormDocumentSchema.methods.insertFormField = function ( - this: IFormDocument, - newField: FormField, - ) { + FormDocumentSchema.methods.insertFormField = function (newField: FormField) { // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(this.form_fields as Types.DocumentArray).push(newField) return this.save() } FormDocumentSchema.methods.duplicateFormFieldById = function ( - this: IFormDocument, fieldId: string, ) { const fieldToDuplicate = getFormFieldById(this.form_fields, fieldId) @@ -579,7 +570,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormDocumentSchema.methods.reorderFormFieldById = function ( - this: IFormDocument, fieldId: string, newPosition: number, ): Promise { @@ -601,10 +591,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Statics // Method to retrieve data for OTP verification - FormSchema.statics.getOtpData = async function ( - this: IFormModel, - formId: string, - ) { + FormSchema.statics.getOtpData = async function (formId: string) { try { const data = await this.findById(formId, 'msgSrvcName admin').populate({ path: 'admin', @@ -627,7 +614,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Returns the form with populated admin details FormSchema.statics.getFullFormById = async function ( - this: IFormModel, formId: string, fields?: (keyof IPopulatedForm)[], ): Promise { @@ -641,7 +627,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Deactivate form by ID FormSchema.statics.deactivateById = async function ( - this: IFormModel, formId: string, ): Promise { const form = await this.findById(formId) @@ -653,7 +638,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormSchema.statics.getMetaByUserIdOrEmail = async function ( - this: IFormModel, userId: IUserSchema['_id'], userEmail: IUserSchema['email'], ): Promise { @@ -684,7 +668,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Deletes specified form logic. FormSchema.statics.deleteFormLogic = async function ( - this: IFormModel, formId: string, logicId: string, ): Promise { @@ -702,7 +685,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Creates specified form logic. FormSchema.statics.createFormLogic = async function ( - this: IFormModel, formId: string, createLogicBody: LogicDto, ): Promise { @@ -718,7 +700,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Deletes specified form field by id. FormSchema.statics.deleteFormFieldById = async function ( - this: IFormModel, formId: string, fieldId: string, ): Promise { @@ -731,7 +712,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Updates specified form logic. FormSchema.statics.updateFormLogic = async function ( - this: IFormModel, formId: string, logicId: string, updatedLogic: LogicDto, @@ -750,7 +730,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormSchema.statics.updateEndPageById = async function ( - this: IFormModel, formId: string, newEndPage: EndPage, ) { @@ -762,7 +741,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { } FormSchema.statics.updateStartPageById = async function ( - this: IFormModel, formId: string, newStartPage: StartPage, ) { diff --git a/src/app/models/form_feedback.server.model.ts b/src/app/models/form_feedback.server.model.ts index 3f42609a83..5315c81de8 100644 --- a/src/app/models/form_feedback.server.model.ts +++ b/src/app/models/form_feedback.server.model.ts @@ -1,4 +1,4 @@ -import { Mongoose, Schema } from 'mongoose' +import { Mongoose, QueryCursor, Schema } from 'mongoose' import { IFormFeedbackModel, IFormFeedbackSchema } from '../../types' @@ -7,7 +7,7 @@ import { FORM_SCHEMA_ID } from './form.server.model' export const FORM_FEEDBACK_SCHEMA_ID = 'FormFeedback' export const FORM_FEEDBACK_COLLECTION_NAME = 'formfeedback' -const FormFeedbackSchema = new Schema( +const FormFeedbackSchema = new Schema( { formId: { type: Schema.Types.ObjectId, @@ -39,15 +39,12 @@ const FormFeedbackSchema = new Schema( * @param formId the form id to return the submissions cursor for * @returns a cursor to the feedback retrieved */ -const getFeedbackCursorByFormId: IFormFeedbackModel['getFeedbackCursorByFormId'] = function ( - this: IFormFeedbackModel, - formId, -) { +FormFeedbackSchema.statics.getFeedbackCursorByFormId = function ( + formId: string, +): QueryCursor { return this.find({ formId }).batchSize(2000).read('secondary').lean().cursor() } -FormFeedbackSchema.statics.getFeedbackCursorByFormId = getFeedbackCursorByFormId - /** * Form Feedback Schema * @param db Active DB Connection @@ -57,7 +54,7 @@ const getFormFeedbackModel = (db: Mongoose): IFormFeedbackModel => { try { return db.model(FORM_FEEDBACK_SCHEMA_ID) as IFormFeedbackModel } catch { - return db.model( + return db.model( FORM_FEEDBACK_SCHEMA_ID, FormFeedbackSchema, FORM_FEEDBACK_COLLECTION_NAME, diff --git a/src/app/models/form_statistics_total.server.model.ts b/src/app/models/form_statistics_total.server.model.ts index 212eaec6b7..76aa6962c7 100644 --- a/src/app/models/form_statistics_total.server.model.ts +++ b/src/app/models/form_statistics_total.server.model.ts @@ -51,7 +51,6 @@ const compileFormStatisticsTotalModel = (db: Mongoose) => { // Static functions FormStatisticsTotalSchema.statics.aggregateFormCount = function ( - this: IFormStatisticsTotalModel, minSubCount: number, ): Promise { return this.aggregate([ diff --git a/src/app/models/login.server.model.ts b/src/app/models/login.server.model.ts index b39a4b812b..d9276d302d 100644 --- a/src/app/models/login.server.model.ts +++ b/src/app/models/login.server.model.ts @@ -54,7 +54,6 @@ const LoginSchema = new Schema( ) LoginSchema.statics.addLoginFromForm = function ( - this: ILoginModel, form: IPopulatedForm, ): Promise { if (!form.authType || !form.esrvcId) { @@ -72,7 +71,6 @@ LoginSchema.statics.addLoginFromForm = function ( } LoginSchema.statics.aggregateLoginStats = function ( - this: ILoginModel, esrvcId: string, gte: Date, lte: Date, diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index 4d0e434d4f..2e37daae76 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -9,14 +9,17 @@ import { IEmailSubmissionSchema, IEncryptedSubmissionSchema, IEncryptSubmissionModel, + IPopulatedWebhookSubmission, ISubmissionModel, ISubmissionSchema, IWebhookResponse, IWebhookResponseSchema, MyInfoAttribute, SubmissionCursorData, + SubmissionData, SubmissionMetadata, SubmissionType, + SubmissionWebhookInfo, WebhookData, WebhookView, } from '../../types' @@ -71,7 +74,6 @@ SubmissionSchema.index({ // Base schema static methods SubmissionSchema.statics.findFormsWithSubsAbove = function ( - this: ISubmissionModel, minSubCount: number, ): Promise { return this.aggregate([ @@ -180,10 +182,13 @@ const EncryptSubmissionSchema = new Schema< * which will be posted to the webhook URL. */ EncryptSubmissionSchema.methods.getWebhookView = function ( - this: IEncryptedSubmissionSchema, + this: IEncryptedSubmissionSchema | IPopulatedWebhookSubmission, ): WebhookView { + const formId = this.populated('form') + ? String(this.form._id) + : String(this.form) const webhookData: WebhookData = { - formId: String(this.form), + formId, submissionId: String(this._id), encryptedContent: this.encryptedContent, verifiedContent: this.verifiedContent, @@ -197,7 +202,6 @@ EncryptSubmissionSchema.methods.getWebhookView = function ( } EncryptSubmissionSchema.statics.addWebhookResponse = function ( - this: IEncryptSubmissionModel, submissionId: string, webhookResponse: IWebhookResponse, ): Promise { @@ -208,8 +212,23 @@ EncryptSubmissionSchema.statics.addWebhookResponse = function ( ).exec() } -EncryptSubmissionSchema.statics.findSingleMetadata = function ( +EncryptSubmissionSchema.statics.retrieveWebhookInfoById = function ( this: IEncryptSubmissionModel, + submissionId: string, +): Promise { + return this.findById(submissionId) + .populate('form', 'webhook') + .then((populatedSubmission: IPopulatedWebhookSubmission | null) => { + if (!populatedSubmission) return null + return { + webhookUrl: populatedSubmission.form.webhook?.url ?? '', + isRetryEnabled: !!populatedSubmission.form.webhook?.isRetryEnabled, + webhookView: populatedSubmission.getWebhookView(), + } + }) +} + +EncryptSubmissionSchema.statics.findSingleMetadata = function ( formId: string, submissionId: string, ): Promise { @@ -254,7 +273,6 @@ type MetadataAggregateResult = { } EncryptSubmissionSchema.statics.findAllMetadataByFormId = function ( - this: IEncryptSubmissionModel, formId: string, { page = 1, @@ -319,11 +337,13 @@ EncryptSubmissionSchema.statics.findAllMetadataByFormId = function ( ) } -const getSubmissionCursorByFormId: IEncryptSubmissionModel['getSubmissionCursorByFormId'] = function ( - this: IEncryptSubmissionModel, - formId, - dateRange = {}, -) { +EncryptSubmissionSchema.statics.getSubmissionCursorByFormId = function ( + formId: string, + dateRange: { + startDate?: string + endDate?: string + } = {}, +): QueryCursor { const streamQuery = { form: formId, ...createQueryWithDateParam(dateRange?.startDate, dateRange?.endDate), @@ -345,13 +365,10 @@ const getSubmissionCursorByFormId: IEncryptSubmissionModel['getSubmissionCursorB ) } -EncryptSubmissionSchema.statics.getSubmissionCursorByFormId = getSubmissionCursorByFormId - EncryptSubmissionSchema.statics.findEncryptedSubmissionById = function ( - this: IEncryptSubmissionModel, formId: string, submissionId: string, -) { +): Promise { return this.findOne({ _id: submissionId, form: formId, diff --git a/src/app/models/token.server.model.ts b/src/app/models/token.server.model.ts index c07699a2b5..ba954686a5 100644 --- a/src/app/models/token.server.model.ts +++ b/src/app/models/token.server.model.ts @@ -33,7 +33,6 @@ TokenSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) * Upserts given OTP into Token collection. */ TokenSchema.statics.upsertOtp = async function ( - this: ITokenModel, upsertParams: Omit, ) { return this.findOneAndUpdate( @@ -51,10 +50,7 @@ TokenSchema.statics.upsertOtp = async function ( * given email. * @param email the email to retrieve the related Token document */ -TokenSchema.statics.incrementAttemptsByEmail = async function ( - this: ITokenModel, - email: string, -) { +TokenSchema.statics.incrementAttemptsByEmail = async function (email: string) { return this.findOneAndUpdate( { email: email }, { $inc: { numOtpAttempts: 1 } }, @@ -67,7 +63,7 @@ TokenSchema.statics.incrementAttemptsByEmail = async function ( * @param db - Active DB Connection * @returns Token model */ -const getTokenModel = (db: Mongoose) => { +const getTokenModel = (db: Mongoose): ITokenModel => { try { return db.model(TOKEN_SCHEMA_ID) as ITokenModel } catch { diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index 84c334b4e7..07977a2980 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -104,7 +104,7 @@ const compileUserModel = (db: Mongoose) => { ) // Methods - UserSchema.methods.getPublicView = function (this: IUserSchema): PublicUser { + UserSchema.methods.getPublicView = function (): PublicUser { // Return public view of nested agency document if populated. return { agency: this.populated('agency') @@ -118,7 +118,6 @@ const compileUserModel = (db: Mongoose) => { * Upserts given user details into User collection. */ UserSchema.statics.upsertUser = async function ( - this: IUserModel, upsertParams: Pick, ) { return this.findOneAndUpdate( @@ -141,7 +140,6 @@ const compileUserModel = (db: Mongoose) => { * User collection. */ UserSchema.statics.findContactNumbersByEmails = async function ( - this: IUserModel, emails: string[], ) { return this.find() diff --git a/src/app/modules/analytics/__tests__/analytics.service.spec.ts b/src/app/modules/analytics/__tests__/analytics.service.spec.ts index 1b25fdf1ca..930d645489 100644 --- a/src/app/modules/analytics/__tests__/analytics.service.spec.ts +++ b/src/app/modules/analytics/__tests__/analytics.service.spec.ts @@ -85,9 +85,9 @@ describe('analytics.service', () => { it('should return DatabaseError when error occurs whilst retrieving form count', async () => { // Arrange const execSpy = jest.fn().mockRejectedValueOnce(new Error('boom')) - jest.spyOn(FormModel, 'estimatedDocumentCount').mockReturnValueOnce(({ + jest.spyOn(FormModel, 'estimatedDocumentCount').mockReturnValueOnce({ exec: execSpy, - } as unknown) as Query) + } as unknown as Query) // Act const actualTE = await getFormCount() @@ -146,9 +146,9 @@ describe('analytics.service', () => { it('should return DatabaseError when error occurs whilst retrieving user count', async () => { // Arrange const execSpy = jest.fn().mockRejectedValueOnce(new Error('boom')) - jest.spyOn(UserModel, 'estimatedDocumentCount').mockReturnValueOnce(({ + jest.spyOn(UserModel, 'estimatedDocumentCount').mockReturnValueOnce({ exec: execSpy, - } as unknown) as Query) + } as unknown as Query) // Act const actualTE = await getUserCount() @@ -205,9 +205,9 @@ describe('analytics.service', () => { const execSpy = jest.fn().mockRejectedValueOnce(new Error('boom')) jest .spyOn(SubmissionModel, 'estimatedDocumentCount') - .mockReturnValueOnce(({ + .mockReturnValueOnce({ exec: execSpy, - } as unknown) as Query) + } as unknown as Query) // Act const actualTE = await getSubmissionCount() diff --git a/src/app/modules/auth/auth.middlewares.ts b/src/app/modules/auth/auth.middlewares.ts index 89070f4523..3c7b8a0010 100644 --- a/src/app/modules/auth/auth.middlewares.ts +++ b/src/app/modules/auth/auth.middlewares.ts @@ -1,10 +1,14 @@ import { StatusCodes } from 'http-status-codes' +import { createLoggerWithLabel } from '../../config/logger' +import { createReqMeta } from '../../utils/request' import { ControllerHandler } from '../core/core.types' import * as UserService from '../user/user.service' import { isUserInSession } from './auth.utils' +const logger = createLoggerWithLabel(module) + /** * Middleware that only allows authenticated users to pass through to the next * handler. @@ -30,11 +34,11 @@ const DENIED_DOMAINS = ['myrp.edu.sg', 'ichat.sp.edu.sg'] * @returns 400 if user in session is from a disallowed domain and * HTTP method changes database state; next otherwise */ -export const denyRpSpStudentEmails: ControllerHandler< - unknown, - unknown, - unknown -> = async (req, res, next) => { +export const denyRpSpStudentEmails: ControllerHandler = async ( + req, + res, + next, +) => { const userId = (req.session as Express.AuthedSession).user._id return UserService.findUserById(userId) .map((user) => { @@ -53,3 +57,36 @@ export const denyRpSpStudentEmails: ControllerHandler< .json({ message: 'User not found' }), ) } + +/** + * Logs all admin actions which change database state (i.e. non-GET requests) + * @returns next + */ +export const logAdminAction: ControllerHandler<{ formId: string }> = async ( + req, + res, + next, +) => { + const sessionUserId = (req.session as Express.AuthedSession).user._id + const body = req.body + const method = req.method + const query = req.query + const { formId } = req.params + + if (req.method.toLowerCase() !== 'get') { + logger.info({ + message: 'Admin attempting to make changes', + meta: { + action: 'logAdminAction', + method, + ...createReqMeta(req), + sessionUserId, + formId, + query, + body, + }, + }) + } + + return next() +} diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index ccd8d1e38b..885d2b410f 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -300,18 +300,20 @@ export const getFormAfterPermissionChecks = ({ * @returns err(ForbiddenFormError if user does not have permission * @returns err(DatabaseError) if any database error occurs */ -export const checkFormForPermissions = (level: PermissionLevel) => ({ - user, - form, -}: { - user: IUserSchema - form: IPopulatedForm -}): Result => - // Step 1: Check whether form is available to be retrieved. - assertFormAvailable(form) - // Step 2: Check required permission levels. - .andThen(() => getAssertPermissionFn(level)(user, form)) - .map(() => form) +export const checkFormForPermissions = + (level: PermissionLevel) => + ({ + user, + form, + }: { + user: IUserSchema + form: IPopulatedForm + }): Result => + // Step 1: Check whether form is available to be retrieved. + assertFormAvailable(form) + // Step 2: Check required permission levels. + .andThen(() => getAssertPermissionFn(level)(user, form)) + .map(() => form) /** * Retrieves the form of given formId provided that the form is public. diff --git a/src/app/modules/billing/__tests__/billing.factory.spec.ts b/src/app/modules/billing/__tests__/billing.factory.spec.ts index 247ace448c..4657c62e57 100644 --- a/src/app/modules/billing/__tests__/billing.factory.spec.ts +++ b/src/app/modules/billing/__tests__/billing.factory.spec.ts @@ -50,7 +50,7 @@ describe('billing.factory', () => { it('should return MissingFeatureError when spcp-myinfo feature is disabled', async () => { // Argument here does not matter, as the function should always return a MissingFeatureError const result = await BillingFactory.recordLoginByForm( - ({} as unknown) as IPopulatedForm, + {} as unknown as IPopulatedForm, ) expect(MockBillingService.recordLoginByForm).not.toHaveBeenCalled() expect(result._unsafeUnwrapErr()).toEqual( @@ -80,9 +80,10 @@ describe('billing.factory', () => { total: 100, }, ] - const serviceGetStatsSpy = MockBillingService.getSpLoginStats.mockReturnValue( - okAsync(mockLoginStats), - ) + const serviceGetStatsSpy = + MockBillingService.getSpLoginStats.mockReturnValue( + okAsync(mockLoginStats), + ) // Act const actualResults = await BillingFactory.getSpLoginStats( @@ -100,12 +101,12 @@ describe('billing.factory', () => { describe('recordLoginByForm', () => { it('should call BillingService.recordLoginByForm', async () => { - const mockLoginDoc = ({ + const mockLoginDoc = { mockKey: 'mockValue', - } as unknown) as ILoginSchema - const mockForm = ({ + } as unknown as ILoginSchema + const mockForm = { mockFormKey: 'mockFormvalue', - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm MockBillingService.recordLoginByForm.mockResolvedValueOnce( okAsync(mockLoginDoc), ) diff --git a/src/app/modules/billing/__tests__/billing.routes.spec.ts b/src/app/modules/billing/__tests__/billing.routes.spec.ts index 5bc76cde85..0a076eea6a 100644 --- a/src/app/modules/billing/__tests__/billing.routes.spec.ts +++ b/src/app/modules/billing/__tests__/billing.routes.spec.ts @@ -51,14 +51,12 @@ describe('billing.routes', () => { // Log in user. const session = await createAuthedSession(defaultUser.email, request) // Generate login statistics. - const { - generatedLoginTimes, - generatedForms, - } = await generateLoginStatistics({ - user: defaultUser, - esrvcIdToCheck: VALID_ESRVCID_1, - altEsrvcId: VALID_ESRVCID_2, - }) + const { generatedLoginTimes, generatedForms } = + await generateLoginStatistics({ + user: defaultUser, + esrvcIdToCheck: VALID_ESRVCID_1, + altEsrvcId: VALID_ESRVCID_2, + }) // Act const response = await session.get('/billing').query({ diff --git a/src/app/modules/billing/__tests__/billing.service.spec.ts b/src/app/modules/billing/__tests__/billing.service.spec.ts index bf42ca1841..9f02cf29fb 100644 --- a/src/app/modules/billing/__tests__/billing.service.spec.ts +++ b/src/app/modules/billing/__tests__/billing.service.spec.ts @@ -19,8 +19,8 @@ describe('billing.service', () => { describe('recordLoginByForm', () => { beforeEach(() => jest.restoreAllMocks()) it('should call LoginModel.addLoginFromForm with the given form', async () => { - const mockForm = ({ authType: AuthType.SP } as unknown) as IPopulatedForm - const mockLogin = ({ esrvcId: 'esrvcId' } as unknown) as ILoginSchema + const mockForm = { authType: AuthType.SP } as unknown as IPopulatedForm + const mockLogin = { esrvcId: 'esrvcId' } as unknown as ILoginSchema const addLoginSpy = jest .spyOn(LoginModel, 'addLoginFromForm') .mockResolvedValueOnce(mockLogin) @@ -30,8 +30,8 @@ describe('billing.service', () => { }) it('should return FormHasNoAuthError when form has authType NIL', async () => { - const mockForm = ({ authType: AuthType.NIL } as unknown) as IPopulatedForm - const mockLogin = ({ esrvcId: 'esrvcId' } as unknown) as ILoginSchema + const mockForm = { authType: AuthType.NIL } as unknown as IPopulatedForm + const mockLogin = { esrvcId: 'esrvcId' } as unknown as ILoginSchema const addLoginSpy = jest .spyOn(LoginModel, 'addLoginFromForm') .mockResolvedValueOnce(mockLogin) @@ -41,7 +41,7 @@ describe('billing.service', () => { }) it('should return DatabaseError when adding login fails', async () => { - const mockForm = ({ authType: AuthType.SP } as unknown) as IPopulatedForm + const mockForm = { authType: AuthType.SP } as unknown as IPopulatedForm const addLoginSpy = jest .spyOn(LoginModel, 'addLoginFromForm') .mockRejectedValueOnce('') diff --git a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts index 85e91c8e29..e835515e2c 100644 --- a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts @@ -44,9 +44,9 @@ jest.doMock('mongoose', () => ({ const MOCK_NOTIFICATION = { notificationType: 'Bounce' } as IEmailNotification const MOCK_REQ = expressHandler.mockRequest({ - body: ({ + body: { Message: JSON.stringify(MOCK_NOTIFICATION), - } as unknown) as ISnsNotification, + } as unknown as ISnsNotification, }) const MOCK_RES = expressHandler.mockResponse() const MOCK_EMAIL_RECIPIENTS = ['a@email.com', 'b@email.com'] diff --git a/src/app/modules/bounce/__tests__/bounce.service.spec.ts b/src/app/modules/bounce/__tests__/bounce.service.spec.ts index b04642b9dc..d98175ebf6 100644 --- a/src/app/modules/bounce/__tests__/bounce.service.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.service.spec.ts @@ -298,10 +298,8 @@ describe('BounceService', () => { ], }) - const notifiedRecipients = await BounceService.sendEmailBounceNotification( - bounceDoc, - form, - ) + const notifiedRecipients = + await BounceService.sendEmailBounceNotification(bounceDoc, form) expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({ emailRecipients: [testUser.email], @@ -329,10 +327,8 @@ describe('BounceService', () => { ], }) - const notifiedRecipients = await BounceService.sendEmailBounceNotification( - bounceDoc, - form, - ) + const notifiedRecipients = + await BounceService.sendEmailBounceNotification(bounceDoc, form) expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({ emailRecipients: [collabEmail], @@ -358,10 +354,8 @@ describe('BounceService', () => { ], }) - const notifiedRecipients = await BounceService.sendEmailBounceNotification( - bounceDoc, - form, - ) + const notifiedRecipients = + await BounceService.sendEmailBounceNotification(bounceDoc, form) expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled() expect(notifiedRecipients._unsafeUnwrap()).toEqual([]) @@ -384,10 +378,8 @@ describe('BounceService', () => { ], }) - const notifiedRecipients = await BounceService.sendEmailBounceNotification( - bounceDoc, - form, - ) + const notifiedRecipients = + await BounceService.sendEmailBounceNotification(bounceDoc, form) expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled() expect(notifiedRecipients._unsafeUnwrap()).toEqual([]) diff --git a/src/app/modules/bounce/bounce.controller.ts b/src/app/modules/bounce/bounce.controller.ts index ece53f08f3..7b0e065500 100644 --- a/src/app/modules/bounce/bounce.controller.ts +++ b/src/app/modules/bounce/bounce.controller.ts @@ -18,106 +18,104 @@ const logger = createLoggerWithLabel(module) * @param req Express request object * @param res - Express response object */ -export const handleSns: ControllerHandler< - unknown, - never, - ISnsNotification -> = async (req, res) => { - const notificationResult = await BounceService.validateSnsRequest( - req.body, - ).andThen(() => BounceService.safeParseNotification(req.body.Message)) - if (notificationResult.isErr()) { - logger.warn({ - message: 'Unable to parse email notification request', - meta: { - action: 'handleSns', - }, - error: notificationResult.error, - }) - return res.sendStatus(StatusCodes.UNAUTHORIZED) - } - const notification = notificationResult.value - - BounceService.logEmailNotification(notification) - // If not admin response, no more action to be taken - if ( - BounceService.extractEmailType(notification) !== EmailType.AdminResponse - ) { - return res.sendStatus(StatusCodes.OK) - } +export const handleSns: ControllerHandler = + async (req, res) => { + const notificationResult = await BounceService.validateSnsRequest( + req.body, + ).andThen(() => BounceService.safeParseNotification(req.body.Message)) + if (notificationResult.isErr()) { + logger.warn({ + message: 'Unable to parse email notification request', + meta: { + action: 'handleSns', + }, + error: notificationResult.error, + }) + return res.sendStatus(StatusCodes.UNAUTHORIZED) + } + const notification = notificationResult.value - const bounceDocResult = await BounceService.getUpdatedBounceDoc(notification) - if (bounceDocResult.isErr()) { - logger.warn({ - message: 'Error while retrieving or creating new bounce doc', - meta: { - action: 'handleSns', - }, - error: bounceDocResult.error, - }) - return res.sendStatus(StatusCodes.OK) - } - const bounceDoc = bounceDocResult.value + BounceService.logEmailNotification(notification) + // If not admin response, no more action to be taken + if ( + BounceService.extractEmailType(notification) !== EmailType.AdminResponse + ) { + return res.sendStatus(StatusCodes.OK) + } - const formResult = await FormService.retrieveFullFormById(bounceDoc.formId) - if (formResult.isErr()) { - // Either database error occurred or the formId saved in the bounce collection - // doesn't exist, so something went wrong. - logger.error({ - message: 'Failed to retrieve form corresponding to bounced formId', - meta: { - action: 'handleSns', - formId: bounceDoc.formId, - }, - }) - return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) - } - const form = formResult.value + const bounceDocResult = await BounceService.getUpdatedBounceDoc( + notification, + ) + if (bounceDocResult.isErr()) { + logger.warn({ + message: 'Error while retrieving or creating new bounce doc', + meta: { + action: 'handleSns', + }, + error: bounceDocResult.error, + }) + return res.sendStatus(StatusCodes.OK) + } + const bounceDoc = bounceDocResult.value - if (bounceDoc.isCriticalBounce()) { - // Send notifications and deactivate form on best-effort basis, ignore errors - const possibleSmsRecipients = await BounceService.getEditorsWithContactNumbers( - form, - ).unwrapOr([]) - const emailRecipients = await BounceService.sendEmailBounceNotification( - bounceDoc, - form, - ).unwrapOr([]) - const smsRecipients = await BounceService.sendSmsBounceNotification( - bounceDoc, - form, - possibleSmsRecipients, - ).unwrapOr([]) - bounceDoc.setNotificationState(emailRecipients, smsRecipients) + const formResult = await FormService.retrieveFullFormById(bounceDoc.formId) + if (formResult.isErr()) { + // Either database error occurred or the formId saved in the bounce collection + // doesn't exist, so something went wrong. + logger.error({ + message: 'Failed to retrieve form corresponding to bounced formId', + meta: { + action: 'handleSns', + formId: bounceDoc.formId, + }, + }) + return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) + } + const form = formResult.value - const shouldDeactivate = bounceDoc.areAllPermanentBounces() - if (shouldDeactivate) { - await FormService.deactivateForm(bounceDoc.formId) - await BounceService.notifyAdminsOfDeactivation( + if (bounceDoc.isCriticalBounce()) { + // Send notifications and deactivate form on best-effort basis, ignore errors + const possibleSmsRecipients = + await BounceService.getEditorsWithContactNumbers(form).unwrapOr([]) + const emailRecipients = await BounceService.sendEmailBounceNotification( + bounceDoc, + form, + ).unwrapOr([]) + const smsRecipients = await BounceService.sendSmsBounceNotification( + bounceDoc, form, possibleSmsRecipients, - ) + ).unwrapOr([]) + bounceDoc.setNotificationState(emailRecipients, smsRecipients) + + const shouldDeactivate = bounceDoc.areAllPermanentBounces() + if (shouldDeactivate) { + await FormService.deactivateForm(bounceDoc.formId) + await BounceService.notifyAdminsOfDeactivation( + form, + possibleSmsRecipients, + ) + } + + // Important log message for user follow-ups + BounceService.logCriticalBounce({ + bounceDoc, + notification, + autoEmailRecipients: emailRecipients, + autoSmsRecipients: smsRecipients, + hasDeactivated: shouldDeactivate, + }) } - // Important log message for user follow-ups - BounceService.logCriticalBounce({ - bounceDoc, - notification, - autoEmailRecipients: emailRecipients, - autoSmsRecipients: smsRecipients, - hasDeactivated: shouldDeactivate, - }) + return BounceService.saveBounceDoc(bounceDoc) + .map(() => res.sendStatus(StatusCodes.OK)) + .mapErr((error) => { + // Accept the risk that there might be concurrency problems + // when multiple server instances try to access the same + // document, due to notifications arriving asynchronously. + if (error instanceof DatabaseConflictError) + return res.sendStatus(StatusCodes.OK) + // Otherwise internal database error + return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) + }) } - - return BounceService.saveBounceDoc(bounceDoc) - .map(() => res.sendStatus(StatusCodes.OK)) - .mapErr((error) => { - // Accept the risk that there might be concurrency problems - // when multiple server instances try to access the same - // document, due to notifications arriving asynchronously. - if (error instanceof DatabaseConflictError) - return res.sendStatus(StatusCodes.OK) - // Otherwise internal database error - return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) - }) -} diff --git a/src/app/modules/bounce/bounce.model.ts b/src/app/modules/bounce/bounce.model.ts index da20085f46..10df96d501 100644 --- a/src/app/modules/bounce/bounce.model.ts +++ b/src/app/modules/bounce/bounce.model.ts @@ -77,7 +77,6 @@ BounceSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) * @returns the created Bounce document */ BounceSchema.statics.fromSnsNotification = function ( - this: IBounceModel, snsInfo: IEmailNotification, formId: string, ): IBounceSchema { @@ -103,7 +102,6 @@ BounceSchema.statics.fromSnsNotification = function ( * @returns the updated document */ BounceSchema.methods.updateBounceInfo = function ( - this: IBounceSchema, snsInfo: IEmailNotification, ): IBounceSchema { // First, get rid of outdated emails @@ -141,9 +139,7 @@ BounceSchema.methods.updateBounceInfo = function ( * bounced), false otherwise. * @returns true if all recipients bounced */ -BounceSchema.methods.isCriticalBounce = function ( - this: IBounceSchema, -): boolean { +BounceSchema.methods.isCriticalBounce = function (): boolean { return this.bounces.every((emailInfo) => emailInfo.hasBounced) } @@ -152,9 +148,7 @@ BounceSchema.methods.isCriticalBounce = function ( * all bounces were permanent, false otherwise. * @returns true if all bounecs were permanent */ -BounceSchema.methods.areAllPermanentBounces = function ( - this: IBounceSchema, -): boolean { +BounceSchema.methods.areAllPermanentBounces = function (): boolean { return this.bounces.every( (emailInfo) => emailInfo.hasBounced && emailInfo.bounceType === BounceType.Permanent, @@ -165,7 +159,7 @@ BounceSchema.methods.areAllPermanentBounces = function ( * Returns the list of email recipients for this form * @returns Array of email addresses */ -BounceSchema.methods.getEmails = function (this: IBounceSchema): string[] { +BounceSchema.methods.getEmails = function (): string[] { // Return a regular array to prevent unexpected bugs with mongoose // CoreDocumentArray return Array.from(this.bounces.map((emailInfo) => emailInfo.email)) @@ -177,7 +171,6 @@ BounceSchema.methods.getEmails = function (this: IBounceSchema): string[] { * @returns void. Modifies document in place. */ BounceSchema.methods.setNotificationState = function ( - this: IBounceSchema, emailRecipients: string[], smsRecipients: UserContactView[], ): void { @@ -194,7 +187,7 @@ BounceSchema.methods.setNotificationState = function ( * false otherwise. * @returns true if at least one admin or collaborator has been notified */ -BounceSchema.methods.hasNotified = function (this: IBounceSchema): boolean { +BounceSchema.methods.hasNotified = function (): boolean { return this.hasAutoEmailed || this.hasAutoSmsed } diff --git a/src/app/modules/core/core.types.ts b/src/app/modules/core/core.types.ts index 896aa7618a..a8168d8572 100644 --- a/src/app/modules/core/core.types.ts +++ b/src/app/modules/core/core.types.ts @@ -11,5 +11,5 @@ export type ControllerHandler< ResBody = unknown, ReqBody = unknown, ReqQuery = unknown, - Locals = Record + Locals = Record, > = RequestHandler diff --git a/src/app/modules/examples/__tests__/examples.factory.spec.ts b/src/app/modules/examples/__tests__/examples.factory.spec.ts index 21089076a3..4df4721198 100644 --- a/src/app/modules/examples/__tests__/examples.factory.spec.ts +++ b/src/app/modules/examples/__tests__/examples.factory.spec.ts @@ -20,10 +20,11 @@ const MockExamplesService = mocked(ExamplesService) describe('examples.factory', () => { describe('aggregate-stats feature disabled', () => { - const MOCK_DISABLED_FEATURE: RegisteredFeature = { - isEnabled: false, - props: {} as IAggregateStats, - } + const MOCK_DISABLED_FEATURE: RegisteredFeature = + { + isEnabled: false, + props: {} as IAggregateStats, + } const ExamplesFactory = createExamplesFactory(MOCK_DISABLED_FEATURE) describe('getExampleForms', () => { @@ -52,10 +53,11 @@ describe('examples.factory', () => { }) describe('aggregate-stats feature enabled', () => { - const MOCK_ENABLED_FEATURE: RegisteredFeature = { - isEnabled: true, - props: {} as IAggregateStats, - } + const MOCK_ENABLED_FEATURE: RegisteredFeature = + { + isEnabled: true, + props: {} as IAggregateStats, + } const ExamplesFactory = createExamplesFactory(MOCK_ENABLED_FEATURE) describe('getExampleForms', () => { diff --git a/src/app/modules/examples/examples.service.ts b/src/app/modules/examples/examples.service.ts index 52fbc4c3a0..d6c7c907c2 100644 --- a/src/app/modules/examples/examples.service.ts +++ b/src/app/modules/examples/examples.service.ts @@ -239,29 +239,28 @@ const getFormInfo = ( * @returns ok(list of retrieved example forms) if `shouldGetTotalNumResults` is not of string `"true"` * @returns err(DatabaseError) if any errors occurs whilst running the pipeline on the database */ -export const getExampleForms = (type: RetrievalType) => ( - query: ExamplesQueryParams, -): ResultAsync => { - const { - lookUpMiddleware, - groupByMiddleware, - generalQueryModel, - } = RETRIEVAL_TO_QUERY_DATA_MAP[type] +export const getExampleForms = + (type: RetrievalType) => + ( + query: ExamplesQueryParams, + ): ResultAsync => { + const { lookUpMiddleware, groupByMiddleware, generalQueryModel } = + RETRIEVAL_TO_QUERY_DATA_MAP[type] - const queryBuilder = getExamplesQueryBuilder({ - query, - lookUpMiddleware, - groupByMiddleware, - generalQueryModel, - }) + const queryBuilder = getExamplesQueryBuilder({ + query, + lookUpMiddleware, + groupByMiddleware, + generalQueryModel, + }) - const { pageNo, shouldGetTotalNumResults } = query - const offset = pageNo * PAGE_SIZE || 0 + const { pageNo, shouldGetTotalNumResults } = query + const offset = pageNo * PAGE_SIZE || 0 - return shouldGetTotalNumResults - ? execExamplesQueryWithTotal(queryBuilder, offset) - : execExamplesQuery(queryBuilder, offset) -} + return shouldGetTotalNumResults + ? execExamplesQueryWithTotal(queryBuilder, offset) + : execExamplesQuery(queryBuilder, offset) + } /** * Retrieves a single form for examples from either the FormStatisticsTotal @@ -273,63 +272,63 @@ export const getExampleForms = (type: RetrievalType) => ( * @returns err(DatabaseError) if any errors occurs whilst running the pipeline on the database * @returns err(ResultsNotFoundError) if form info cannot be retrieved with the given form id */ -export const getSingleExampleForm = (type: RetrievalType) => ( - formId: string, -): ResultAsync => { - const { - singleSearchPipeline, - generalQueryModel, - } = RETRIEVAL_TO_QUERY_DATA_MAP[type] +export const getSingleExampleForm = + (type: RetrievalType) => + ( + formId: string, + ): ResultAsync => { + const { singleSearchPipeline, generalQueryModel } = + RETRIEVAL_TO_QUERY_DATA_MAP[type] - return ( - // Step 1: Retrieve base form info to augment. - getFormInfo(formId) - // Step 2a: Execute aggregate query with relevant single search pipeline. - .andThen((formInfo) => - ResultAsync.fromPromise( - generalQueryModel - .aggregate(singleSearchPipeline(formId)) - .read('secondary') - .exec() as Promise, - (error) => { - logger.error({ - message: 'Failed to retrieve a single example form', - meta: { - action: 'getSingleExampleForm', - }, - error, - }) + return ( + // Step 1: Retrieve base form info to augment. + getFormInfo(formId) + // Step 2a: Execute aggregate query with relevant single search pipeline. + .andThen((formInfo) => + ResultAsync.fromPromise( + generalQueryModel + .aggregate(singleSearchPipeline(formId)) + .read('secondary') + .exec() as Promise, + (error) => { + logger.error({ + message: 'Failed to retrieve a single example form', + meta: { + action: 'getSingleExampleForm', + }, + error, + }) - return new DatabaseError() - }, - // Step 2b: Augment the initial base form info with the retrieved - // statistics from the aggregate pipeline. - ).map((queryResult) => { - // Process result depending on whether search pipeline returned - // results. - // If the statistics cannot be found, add default "null" fields. - if (!queryResult || queryResult.length === 0) { - const emptyStatsExampleInfo: FormInfo = { - ...formInfo, - count: 0, - lastSubmission: null, - timeText: '-', - avgFeedback: null, + return new DatabaseError() + }, + // Step 2b: Augment the initial base form info with the retrieved + // statistics from the aggregate pipeline. + ).map((queryResult) => { + // Process result depending on whether search pipeline returned + // results. + // If the statistics cannot be found, add default "null" fields. + if (!queryResult || queryResult.length === 0) { + const emptyStatsExampleInfo: FormInfo = { + ...formInfo, + count: 0, + lastSubmission: null, + timeText: '-', + avgFeedback: null, + } + return { form: emptyStatsExampleInfo } } - return { form: emptyStatsExampleInfo } - } - // Statistics can be found. - const [statistics] = queryResult - const processedExampleInfo: FormInfo = { - ...formInfo, - count: statistics.count, - lastSubmission: statistics.lastSubmission, - avgFeedback: statistics.avgFeedback, - timeText: formatToRelativeString(statistics.lastSubmission), - } - return { form: processedExampleInfo } - }), - ) - ) -} + // Statistics can be found. + const [statistics] = queryResult + const processedExampleInfo: FormInfo = { + ...formInfo, + count: statistics.count, + lastSubmission: statistics.lastSubmission, + avgFeedback: statistics.avgFeedback, + timeText: formatToRelativeString(statistics.lastSubmission), + } + return { form: processedExampleInfo } + }), + ) + ) + } diff --git a/src/app/modules/feedback/__tests__/feedback.service.spec.ts b/src/app/modules/feedback/__tests__/feedback.service.spec.ts index 378f775030..68b5a54271 100644 --- a/src/app/modules/feedback/__tests__/feedback.service.spec.ts +++ b/src/app/modules/feedback/__tests__/feedback.service.spec.ts @@ -70,9 +70,9 @@ describe('feedback.service', () => { const validFormId = new ObjectId().toHexString() countSpy.mockImplementationOnce( () => - (({ + ({ exec: () => Promise.reject(new Error('boom')), - } as unknown) as mongoose.Query), + } as unknown as mongoose.Query), ) // Act @@ -93,7 +93,7 @@ describe('feedback.service', () => { it('should return stream successfully', async () => { // Arrange const mockFormId = 'some form id' - const mockCursor = ('some cursor' as unknown) as mongoose.QueryCursor + const mockCursor = 'some cursor' as unknown as mongoose.QueryCursor const streamSpy = jest .spyOn(FormFeedback, 'getFeedbackCursorByFormId') .mockReturnValue(mockCursor) @@ -211,10 +211,10 @@ describe('feedback.service', () => { const sortSpy = jest.fn().mockReturnThis() const findSpy = jest.spyOn(FormFeedback, 'find').mockImplementationOnce( () => - (({ + ({ sort: sortSpy, exec: () => Promise.reject(new Error('boom')), - } as unknown) as mongoose.Query), + } as unknown as mongoose.Query), ) // Act diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 5bcd535d64..9ba77c679a 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -65,14 +65,14 @@ describe('FormService', () => { it('should return full populated form successfully', async () => { // Arrange const formId = new ObjectId().toHexString() - const expectedForm = ({ + const expectedForm = { _id: formId, title: 'mock title', admin: { _id: new ObjectId(), email: 'mockEmail@example.com', }, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const retrieveFormSpy = jest .spyOn(Form, 'getFullFormById') .mockResolvedValueOnce(expectedForm) @@ -106,11 +106,11 @@ describe('FormService', () => { it('should return FormNotFoundError when retrieved form does not contain admin', async () => { // Arrange const formId = new ObjectId().toHexString() - const expectedForm = ({ + const expectedForm = { _id: formId, title: 'mock title', // Note no admin key-value. - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const retrieveFormSpy = jest .spyOn(Form, 'getFullFormById') .mockResolvedValueOnce(expectedForm) @@ -146,14 +146,14 @@ describe('FormService', () => { it('should return form successfully', async () => { // Arrange const formId = new ObjectId().toHexString() - const expectedForm = ({ + const expectedForm = { _id: formId, title: 'mock title', admin: { _id: new ObjectId(), email: 'mockEmail@example.com', }, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const retrieveFormSpy = jest .spyOn(Form, 'getFullFormById') .mockResolvedValueOnce(expectedForm) @@ -193,11 +193,11 @@ describe('FormService', () => { it('should still return retrieved form even when it does not contain admin', async () => { // Arrange const formId = new ObjectId().toHexString() - const expectedForm = ({ + const expectedForm = { _id: formId, title: 'mock title', // Note no admin key-value. - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const retrieveFormSpy = jest .spyOn(Form, 'getFullFormById') .mockResolvedValueOnce(expectedForm) @@ -243,11 +243,9 @@ describe('FormService', () => { title: 'mock title', admin: new ObjectId(), } as IFormSchema - const retrieveFormSpy = jest - .spyOn(Form, 'findById') - .mockReturnValueOnce(({ - exec: jest.fn().mockResolvedValue(expectedForm), - } as unknown) as mongoose.Query) + const retrieveFormSpy = jest.spyOn(Form, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(expectedForm), + } as unknown as mongoose.Query) // Act const actualResult = await FormService.retrieveFormById(formId) @@ -262,11 +260,9 @@ describe('FormService', () => { // Arrange const formId = new ObjectId().toHexString() // Resolve query to null. - const retrieveFormSpy = jest - .spyOn(Form, 'findById') - .mockReturnValueOnce(({ - exec: jest.fn().mockResolvedValue(null), - } as unknown) as mongoose.Query) + const retrieveFormSpy = jest.spyOn(Form, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(null), + } as unknown as mongoose.Query) // Act const actualResult = await FormService.retrieveFormById(formId) @@ -285,11 +281,9 @@ describe('FormService', () => { title: 'mock title', // Note no admin key-value. } as IFormSchema - const retrieveFormSpy = jest - .spyOn(Form, 'findById') - .mockReturnValueOnce(({ - exec: jest.fn().mockResolvedValue(expectedForm), - } as unknown) as mongoose.Query) + const retrieveFormSpy = jest.spyOn(Form, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(expectedForm), + } as unknown as mongoose.Query) // Act const actualResult = await FormService.retrieveFormById(formId) @@ -304,11 +298,9 @@ describe('FormService', () => { // Arrange const formId = new ObjectId().toHexString() // Mock rejection. - const retrieveFormSpy = jest - .spyOn(Form, 'findById') - .mockReturnValueOnce(({ - exec: jest.fn().mockRejectedValue(new Error('some error')), - } as unknown) as mongoose.Query) + const retrieveFormSpy = jest.spyOn(Form, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockRejectedValue(new Error('some error')), + } as unknown as mongoose.Query) // Act const actualResult = await FormService.retrieveFormById(formId) @@ -329,9 +321,8 @@ describe('FormService', () => { } as IPopulatedForm // Act - const actual = await FormService.checkFormSubmissionLimitAndDeactivateForm( - form, - ) + const actual = + await FormService.checkFormSubmissionLimitAndDeactivateForm(form) // Assert expect(actual._unsafeUnwrap()).toEqual(form) @@ -359,9 +350,10 @@ describe('FormService', () => { await Promise.all(submissionPromises) // Act - const actual = await FormService.checkFormSubmissionLimitAndDeactivateForm( - form as IPopulatedForm, - ) + const actual = + await FormService.checkFormSubmissionLimitAndDeactivateForm( + form as IPopulatedForm, + ) // Assert expect(actual._unsafeUnwrap()).toEqual(validForm) @@ -389,9 +381,8 @@ describe('FormService', () => { await Promise.all(submissionPromises) // Act - const actual = await FormService.checkFormSubmissionLimitAndDeactivateForm( - form, - ) + const actual = + await FormService.checkFormSubmissionLimitAndDeactivateForm(form) // Assert expect(actual._unsafeUnwrapErr()).toEqual( diff --git a/src/app/modules/form/__tests__/form.utils.spec.ts b/src/app/modules/form/__tests__/form.utils.spec.ts index 0f63b8504c..7012d93ee5 100644 --- a/src/app/modules/form/__tests__/form.utils.spec.ts +++ b/src/app/modules/form/__tests__/form.utils.spec.ts @@ -65,12 +65,12 @@ describe('form.utils', () => { // Arrange const fieldToFind = generateDefaultField(BasicField.Number) // Should not turn this unit test into an integration test, so mocking return and leaving responsibility to mongoose. - const mockDocArray = ({ + const mockDocArray = { 0: generateDefaultField(BasicField.LongText), 1: fieldToFind, isMongooseDocumentArray: true, id: jest.fn().mockReturnValue(fieldToFind), - } as unknown) as Types.DocumentArray + } as unknown as Types.DocumentArray // Act const result = getFormFieldById(mockDocArray, fieldToFind._id) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index d81ecf70a6..e719669a1e 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -61,6 +61,8 @@ import { Status, } from 'src/types' import { + DuplicateFormBody, + EditFormFieldParams, EncryptSubmissionDto, FieldCreateDto, FieldUpdateDto, @@ -92,11 +94,7 @@ import { InvalidFileTypeError, } from '../admin-form.errors' import * as AdminFormService from '../admin-form.service' -import { - DuplicateFormBody, - EditFormFieldParams, - PermissionLevel, -} from '../admin-form.types' +import { PermissionLevel } from '../admin-form.types' jest.mock('src/app/modules/auth/auth.service') const MockAuthService = mocked(AuthService) @@ -625,18 +623,18 @@ describe('admin-form.controller', () => { email: 'randomrandomtest@example.com', } as IPopulatedUser - const MOCK_SCRUBBED_FORM = ({ + const MOCK_SCRUBBED_FORM = { _id: MOCK_FORM_ID, title: 'mock preview title', admin: { _id: MOCK_USER_ID }, - } as unknown) as PublicForm + } as unknown as PublicForm - const MOCK_FORM = (mocked({ + const MOCK_FORM = mocked({ admin: MOCK_USER, _id: MOCK_FORM_ID, title: MOCK_SCRUBBED_FORM.title, getPublicView: jest.fn().mockResolvedValue(MOCK_SCRUBBED_FORM), - }) as unknown) as MockedObject + }) as unknown as MockedObject const MOCK_REQ = expressHandler.mockRequest({ params: { @@ -3268,18 +3266,18 @@ describe('admin-form.controller', () => { email: 'alwaystesting@example.com', } as IPopulatedUser - const MOCK_SCRUBBED_FORM = ({ + const MOCK_SCRUBBED_FORM = { _id: MOCK_FORM_ID, title: "guess what it's another mock title", admin: { _id: MOCK_USER_ID }, - } as unknown) as PublicForm + } as unknown as PublicForm - const MOCK_FORM = (mocked({ + const MOCK_FORM = mocked({ admin: MOCK_USER, _id: MOCK_FORM_ID, title: MOCK_SCRUBBED_FORM.title, getPublicView: jest.fn().mockResolvedValue(MOCK_SCRUBBED_FORM), - }) as unknown) as MockedObject + }) as unknown as MockedObject const MOCK_REQ = expressHandler.mockRequest({ params: { @@ -5135,11 +5133,11 @@ describe('admin-form.controller', () => { MockSubmissionService.sendEmailConfirmations.mockReturnValue( okAsync(true), ) - jest.spyOn(EmailSubmissionUtil, 'SubmissionEmailObj').mockReturnValue(({ + jest.spyOn(EmailSubmissionUtil, 'SubmissionEmailObj').mockReturnValue({ dataCollationData: MOCK_DATA_COLLATION_DATA, formData: MOCK_FORM_DATA, autoReplyData: MOCK_AUTOREPLY_DATA, - } as unknown) as EmailSubmissionUtil.SubmissionEmailObj) + } as unknown as EmailSubmissionUtil.SubmissionEmailObj) }) it('should call all services correctly when submission is valid', async () => { @@ -8800,14 +8798,14 @@ describe('admin-form.controller', () => { email: 'somerandom@example.com', } as IPopulatedUser - const MOCK_FORM = ({ + const MOCK_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, startPage: { paragraph: 'old end page', }, title: 'mock start page title', - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const MOCK_UPDATED_FORM = { ...MOCK_FORM, diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index b6d40fed84..5c0ac4f00a 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -44,6 +44,8 @@ import { Status, } from 'src/types' import { + DuplicateFormBody, + EditFormFieldParams, FieldCreateDto, FieldUpdateDto, SettingsUpdateDto, @@ -86,11 +88,7 @@ import { updateFormSettings, updateStartPage, } from '../admin-form.service' -import { - DuplicateFormBody, - EditFormFieldParams, - OverrideProps, -} from '../admin-form.types' +import { OverrideProps } from '../admin-form.types' import * as AdminFormUtils from '../admin-form.utils' const FormModel = getFormModel(mongoose) @@ -370,9 +368,9 @@ describe('admin-form.service', () => { status: Status.Archived, } as IEmailFormSchema const mockArchiveFn = jest.fn().mockResolvedValue(mockArchivedForm) - const mockInitialForm = ({ + const mockInitialForm = { archive: mockArchiveFn, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await archiveForm(mockInitialForm) @@ -388,9 +386,9 @@ describe('admin-form.service', () => { const mockArchiveFn = jest .fn() .mockRejectedValue(new Error(mockErrorString)) - const mockInitialForm = ({ + const mockInitialForm = { archive: mockArchiveFn, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await archiveForm(mockInitialForm) @@ -405,7 +403,7 @@ describe('admin-form.service', () => { describe('duplicateForm', () => { const MOCK_NEW_ADMIN_ID = new ObjectId().toHexString() - const MOCK_VALID_FORM = ({ + const MOCK_VALID_FORM = { _id: new ObjectId(), admin: new ObjectId(), endPage: { @@ -419,7 +417,7 @@ describe('admin-form.service', () => { fileSizeInBytes: 10000, } as ICustomFormLogo, }, - } as unknown) as IFormDocument + } as unknown as IFormDocument const MOCK_EMAIL_OVERRIDE_PARAMS: DuplicateFormBody = { responseMode: ResponseMode.Email, title: 'mock new title', @@ -587,7 +585,7 @@ describe('admin-form.service', () => { title: 'mock populated form', } as IPopulatedForm - const mockUpdatedForm = ({ + const mockUpdatedForm = { _id: new ObjectId(), admin: MOCK_CURRENT_OWNER, emails: [MOCK_NEW_OWNER_EMAIL], @@ -596,13 +594,13 @@ describe('admin-form.service', () => { populate: jest.fn().mockReturnValue({ execPopulate: jest.fn().mockResolvedValue(expectedPopulateResult), }), - } as unknown) as IFormSchema + } as unknown as IFormSchema - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm MockUserService.findUserById.mockReturnValueOnce( okAsync(MOCK_CURRENT_OWNER), @@ -636,11 +634,11 @@ describe('admin-form.service', () => { MockUserService.findUserByEmail.mockReturnValueOnce( errAsync(new MissingUserError()), ) - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actualResult = await transferFormOwnership( @@ -661,11 +659,11 @@ describe('admin-form.service', () => { it('should return MissingUserError when current form owner cannot be found in the database', async () => { // Arrange - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm MockUserService.findUserById.mockReturnValueOnce( errAsync(new MissingUserError()), ) @@ -685,11 +683,11 @@ describe('admin-form.service', () => { it('should return DatabaseError when database error occurs whilst retrieving current form owner', async () => { // Arrange - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm MockUserService.findUserById.mockReturnValueOnce( errAsync(new DatabaseError()), ) @@ -715,11 +713,11 @@ describe('admin-form.service', () => { MockUserService.findUserByEmail.mockReturnValueOnce( errAsync(new DatabaseError()), ) - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actualResult = await transferFormOwnership( @@ -739,11 +737,11 @@ describe('admin-form.service', () => { MockUserService.findUserById.mockReturnValueOnce( okAsync(MOCK_CURRENT_OWNER), ) - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actualResult = await transferFormOwnership( @@ -764,7 +762,7 @@ describe('admin-form.service', () => { it('should return DatabaseError when database error occurs during populating the updated form', async () => { // Arrange const mockPopulateErrorStr = 'population failed!' - const mockUpdatedForm = ({ + const mockUpdatedForm = { _id: new ObjectId(), admin: MOCK_CURRENT_OWNER, emails: [MOCK_NEW_OWNER_EMAIL], @@ -776,13 +774,13 @@ describe('admin-form.service', () => { .fn() .mockRejectedValue(new Error(mockPopulateErrorStr)), }), - } as unknown) as IFormSchema + } as unknown as IFormSchema - const mockValidForm = ({ + const mockValidForm = { title: 'some mock form', admin: MOCK_CURRENT_OWNER, transferOwner: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm MockUserService.findUserById.mockReturnValueOnce( okAsync(MOCK_CURRENT_OWNER), @@ -935,7 +933,7 @@ describe('admin-form.service', () => { }) describe('editFormFields', () => { - const MOCK_UPDATED_FORM = ({ + const MOCK_UPDATED_FORM = { _id: new ObjectId(), admin: new ObjectId(), form_fields: [ @@ -943,9 +941,9 @@ describe('admin-form.service', () => { generateDefaultField(BasicField.Mobile), generateDefaultField(BasicField.Dropdown), ], - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm - const MOCK_INTIAL_FORM = mocked(({ + const MOCK_INTIAL_FORM = mocked({ _id: MOCK_UPDATED_FORM._id, admin: MOCK_UPDATED_FORM.admin, form_fields: [ @@ -953,7 +951,7 @@ describe('admin-form.service', () => { generateDefaultField(BasicField.Mobile), ], save: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), - } as unknown) as IPopulatedForm) + } as unknown as IPopulatedForm) it('should return updated form', async () => { // Arrange @@ -1029,7 +1027,7 @@ describe('admin-form.service', () => { }) describe('updateForm', () => { - const MOCK_UPDATED_FORM = ({ + const MOCK_UPDATED_FORM = { _id: new ObjectId(), admin: new ObjectId(), status: Status.Private, @@ -1037,15 +1035,15 @@ describe('admin-form.service', () => { generateDefaultField(BasicField.Mobile), generateDefaultField(BasicField.Dropdown), ], - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm - const MOCK_INITIAL_FORM = mocked(({ + const MOCK_INITIAL_FORM = mocked({ _id: MOCK_UPDATED_FORM._id, admin: MOCK_UPDATED_FORM.admin, status: Status.Public, form_fields: MOCK_UPDATED_FORM.form_fields, save: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), - } as unknown) as IPopulatedForm) + } as unknown as IPopulatedForm) it('should successfully update given form keys', async () => { // Arrange @@ -1101,23 +1099,23 @@ describe('admin-form.service', () => { }, } - const MOCK_UPDATED_FORM = ({ + const MOCK_UPDATED_FORM = { ...MOCK_UPDATED_SETTINGS, responseMode: ResponseMode.Encrypt, publicKey: 'some public key', getSettings: jest.fn().mockReturnValue(MOCK_UPDATED_SETTINGS), - } as unknown) as IFormDocument + } as unknown as IFormDocument - const MOCK_EMAIL_FORM = mocked(({ + const MOCK_EMAIL_FORM = mocked({ _id: new ObjectId(), status: Status.Public, responseMode: ResponseMode.Email, - } as unknown) as IPopulatedForm) - const MOCK_ENCRYPT_FORM = mocked(({ + } as unknown as IPopulatedForm) + const MOCK_ENCRYPT_FORM = mocked({ _id: new ObjectId(), status: Status.Public, responseMode: ResponseMode.Encrypt, - } as unknown) as IPopulatedForm) + } as unknown as IPopulatedForm) const EMAIL_UPDATE_SPY = jest .spyOn(EmailFormModel, 'findByIdAndUpdate') @@ -1231,11 +1229,11 @@ describe('admin-form.service', () => { title: 'some mock form', form_fields: [mockNewField], } - const mockForm = ({ + const mockForm = { ...mockUpdatedForm, form_fields: [fieldToUpdate], updateFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await updateFormField( @@ -1254,11 +1252,11 @@ describe('admin-form.service', () => { it('should return FieldNotFoundError when field update returns null', async () => { // Arrange - const mockForm = ({ + const mockForm = { title: 'another mock form', form_fields: [], updateFormFieldById: jest.fn().mockResolvedValue(null), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const invalidFieldId = new ObjectId().toHexString() const mockNewField = generateDefaultField( @@ -1278,14 +1276,14 @@ describe('admin-form.service', () => { it('should return DatabaseValidationError when field model update throws a validation error', async () => { // Arrange - const mockForm = ({ + const mockForm = { title: 'another another mock form', form_fields: [], updateFormFieldById: jest.fn().mockRejectedValue( // @ts-ignore new mongoose.Error.ValidationError(), ), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const invalidFieldId = new ObjectId().toHexString() const mockNewField = generateDefaultField( @@ -1319,11 +1317,11 @@ describe('admin-form.service', () => { // Append created field to end of form_fields. form_fields: [...initialFields, expectedCreatedField], } - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: initialFields, insertFormField: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const formCreateParams = pick(expectedCreatedField, [ 'title', 'fieldType', @@ -1343,14 +1341,14 @@ describe('admin-form.service', () => { generateDefaultField(BasicField.Mobile), generateDefaultField(BasicField.Image), ] - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: initialFields, insertFormField: jest.fn().mockRejectedValue( // @ts-ignore new mongoose.Error.ValidationError({ errors: 'does not matter' }), ), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const formCreateParams = { fieldType: BasicField.ShortText, title: 'some title', @@ -1380,18 +1378,18 @@ describe('admin-form.service', () => { let mockEmailForm: IPopulatedForm, mockEncryptForm: IPopulatedForm beforeEach(() => { - mockEmailForm = ({ + mockEmailForm = { _id: new ObjectId(), status: Status.Public, responseMode: ResponseMode.Email, ...mockFormLogic, - } as unknown) as IPopulatedForm - mockEncryptForm = ({ + } as unknown as IPopulatedForm + mockEncryptForm = { _id: new ObjectId(), status: Status.Public, responseMode: ResponseMode.Encrypt, ...mockFormLogic, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm }) it('should return ok(form) on successful form logic delete for email mode form', async () => { @@ -1484,12 +1482,12 @@ describe('admin-form.service', () => { // Append duplicated field to end of form_fields. form_fields: [fieldToDuplicate, duplicatedField], } as IFormSchema - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: [fieldToDuplicate], _id: new ObjectId(), duplicateFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await duplicateFormField( @@ -1514,12 +1512,12 @@ describe('admin-form.service', () => { it('should return FormNotFoundError when field duplication returns null', async () => { // Arrange const fieldToDuplicate = generateDefaultField(BasicField.Mobile) - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: [fieldToDuplicate], _id: new ObjectId(), duplicateFormFieldById: jest.fn().mockResolvedValue(null), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await duplicateFormField(mockForm, fieldToDuplicate._id) @@ -1531,14 +1529,14 @@ describe('admin-form.service', () => { it('should return DatabaseValidationError when field model update throws a validation error', async () => { // Arrange const initialFields = [generateDefaultField(BasicField.Mobile)] - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: initialFields, duplicateFormFieldById: jest.fn().mockRejectedValue( // @ts-ignore new mongoose.Error.ValidationError({ errors: 'does not matter' }), ), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await duplicateFormField(mockForm, initialFields[0]._id) @@ -1558,10 +1556,10 @@ describe('admin-form.service', () => { const mockUpdatedForm = { form_fields: [mockFormFields[1], mockFormFields[0]], } - const mockForm = ({ + const mockForm = { form_fields: mockFormFields, reorderFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const fieldToReorder = String(mockFormFields[0]._id) const newPosition = 1 @@ -1582,10 +1580,10 @@ describe('admin-form.service', () => { it('should return FieldNotFoundError when null is returned from the model instance method', async () => { // Arrange - const mockForm = ({ + const mockForm = { form_fields: [generateDefaultField(BasicField.YesNo)], reorderFormFieldById: jest.fn().mockResolvedValue(null), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const fieldToReorder = new ObjectId().toHexString() const newPosition = 2 @@ -1606,13 +1604,13 @@ describe('admin-form.service', () => { it('should return database error when error occurs whilst reordering fields', async () => { // Arrange - const mockForm = ({ + const mockForm = { form_fields: [generateDefaultField(BasicField.YesNo)], // Rejection reorderFormFieldById: jest .fn() .mockRejectedValue(new Error('some error')), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const fieldToReorder = new ObjectId().toHexString() const newPosition = 2 @@ -1643,12 +1641,12 @@ describe('admin-form.service', () => { write: false, }, ] - const mockForm = ({ + const mockForm = { title: 'some mock form', updateFormCollaborators: jest .fn() .mockResolvedValue({ permissionList: newCollaborators }), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await updateFormCollaborators(mockForm, newCollaborators) @@ -1668,12 +1666,12 @@ describe('admin-form.service', () => { write: false, }, ] - const mockForm = ({ + const mockForm = { title: 'some mock form', updateFormCollaborators: jest .fn() .mockRejectedValue(new DatabaseError()), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await updateFormCollaborators(mockForm, newCollaborators) @@ -1735,26 +1733,26 @@ describe('admin-form.service', () => { mockEncryptFormUpdated: IPopulatedForm beforeEach(() => { - mockEmailForm = ({ + mockEmailForm = { _id: mockEmailFormId, status: Status.Public, responseMode: ResponseMode.Email, ...mockFormLogicOld, - } as unknown) as IPopulatedForm - mockEncryptForm = ({ + } as unknown as IPopulatedForm + mockEncryptForm = { _id: mockEncryptFormId, status: Status.Public, responseMode: ResponseMode.Encrypt, ...mockFormLogicOld, - } as unknown) as IPopulatedForm - mockEmailFormUpdated = ({ + } as unknown as IPopulatedForm + mockEmailFormUpdated = { ...mockEmailForm, ...mockFormLogicUpdated, - } as unknown) as IPopulatedForm - mockEncryptFormUpdated = ({ + } as unknown as IPopulatedForm + mockEncryptFormUpdated = { ...mockEncryptForm, ...mockFormLogicUpdated, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm }) it('should return ok(created logic) on successful form logic create for email mode form', async () => { @@ -1800,7 +1798,7 @@ describe('admin-form.service', () => { it('should return err(FormNotFoundError) if db does not return form object', async () => { // Arrange - CREATE_SPY.mockResolvedValue((undefined as unknown) as IFormSchema) + CREATE_SPY.mockResolvedValue(undefined as unknown as IFormSchema) // Act const actualResult = await createFormLogic( @@ -1885,11 +1883,11 @@ describe('admin-form.service', () => { // Append created field to end of form_fields. form_fields: [initialFields[1]], } as IFormSchema - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: initialFields, _id: new ObjectId(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm deleteSpy.mockResolvedValueOnce(mockUpdatedForm) // Act @@ -1905,11 +1903,11 @@ describe('admin-form.service', () => { it("should return FieldNotFoundError when the fieldId does not exist in the form's fields", async () => { // Arrange - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: [generateDefaultField(BasicField.Nric)], _id: new ObjectId(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Act const actual = await deleteFormField( @@ -1925,11 +1923,11 @@ describe('admin-form.service', () => { it('should return FormNotFoundError when field deletion returns null', async () => { // Arrange const fieldToDelete = generateDefaultField(BasicField.Mobile) - const mockForm = ({ + const mockForm = { title: 'some mock form', form_fields: [fieldToDelete], _id: new ObjectId(), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm deleteSpy.mockResolvedValueOnce(null) // Act @@ -2093,26 +2091,26 @@ describe('admin-form.service', () => { mockEncryptFormUpdated: IPopulatedForm beforeEach(() => { - mockEmailForm = ({ + mockEmailForm = { _id: mockEmailFormId, status: Status.Public, responseMode: ResponseMode.Email, ...mockFormLogicOld, - } as unknown) as IPopulatedForm - mockEncryptForm = ({ + } as unknown as IPopulatedForm + mockEncryptForm = { _id: mockEncryptFormId, status: Status.Public, responseMode: ResponseMode.Encrypt, ...mockFormLogicOld, - } as unknown) as IPopulatedForm - mockEmailFormUpdated = ({ + } as unknown as IPopulatedForm + mockEmailFormUpdated = { ...mockEmailForm, ...mockFormLogicUpdated, - } as unknown) as IPopulatedForm - mockEncryptFormUpdated = ({ + } as unknown as IPopulatedForm + mockEncryptFormUpdated = { ...mockEncryptForm, ...mockFormLogicUpdated, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm }) it('should return ok(updated logic) on successful form logic update for email mode form', async () => { @@ -2196,12 +2194,12 @@ describe('admin-form.service', () => { it("should return FieldNotFoundError when the fieldId does not exist in the form's fields", async () => { // Arrange const MOCK_ID = new ObjectId().toHexString() - const MOCK_FORM = ({ + const MOCK_FORM = { title: 'some mock form', // Append created field to end of form_fields. form_fields: [], _id: new ObjectId(), - } as unknown) as IFormSchema + } as unknown as IFormSchema const expectedError = new FieldNotFoundError( `Attempted to retrieve field ${MOCK_ID} from ${MOCK_FORM._id} but field was not present`, ) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts index 6c8f30068c..b4a01b62e5 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts @@ -12,16 +12,13 @@ import { ResponseMode, Status, } from 'src/types' +import { DuplicateFormBody, EditFormFieldParams } from 'src/types/api' import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' import { ForbiddenFormError } from '../../form.errors' import { EditFieldError } from '../admin-form.errors' -import { - DuplicateFormBody, - EditFormFieldParams, - OverrideProps, -} from '../admin-form.types' +import { OverrideProps } from '../admin-form.types' import { assertHasDeletePermissions, assertHasReadPermissions, @@ -449,13 +446,13 @@ describe('admin-form.utils', () => { const initialField = generateDefaultField(BasicField.Email, { title: 'some old title', }) - const desyncedEmailField = ({ + const desyncedEmailField = { ...initialField, title: 'new title', hasAllowedEmailDomains: true, // true but empty array allowedEmailDomains: [], - } as unknown) as IEmailFieldSchema + } as unknown as IEmailFieldSchema const updateFieldParams: EditFormFieldParams = { action: { @@ -484,12 +481,12 @@ describe('admin-form.utils', () => { it('should return synced email field when creating with desynced email field', async () => { // Arrange - const desyncedEmailField = ({ + const desyncedEmailField = { ...generateDefaultField(BasicField.Email), hasAllowedEmailDomains: false, // False but contains domains. allowedEmailDomains: ['@example.com'], - } as unknown) as IEmailFieldSchema + } as unknown as IEmailFieldSchema const createFieldParams: EditFormFieldParams = { action: { diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 424dbbb43d..418f540553 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -26,12 +26,14 @@ import { ResponseMode, } from '../../../../types' import { + DuplicateFormBody, EncryptSubmissionDto, EndPageUpdateDto, ErrorDto, FieldCreateDto, FieldUpdateDto, FormFieldDto, + FormUpdateParams, PermissionsUpdateDto, SettingsUpdateDto, StartPageUpdateDto, @@ -75,11 +77,7 @@ import { } from './admin-form.constants' import { EditFieldError } from './admin-form.errors' import * as AdminFormService from './admin-form.service' -import { - DuplicateFormBody, - FormUpdateParams, - PermissionLevel, -} from './admin-form.types' +import { PermissionLevel } from './admin-form.types' import { mapRouteError } from './admin-form.utils' // NOTE: Refer to this for documentation: https://github.com/sideway/joi-date/blob/master/API.md @@ -497,7 +495,7 @@ const validateDateRange = celebrate({ */ export const countFormSubmissions: ControllerHandler< { formId: string }, - unknown, + ErrorDto | number, unknown, { startDate?: string; endDate?: string } > = async (req, res) => { @@ -1424,15 +1422,14 @@ export const submitEncryptPreview: ControllerHandler< }), ) .map(({ parsedResponses, form }) => { - const submission = EncryptSubmissionService.createEncryptSubmissionWithoutSave( - { + const submission = + EncryptSubmissionService.createEncryptSubmissionWithoutSave({ form, encryptedContent, // Don't bother encrypting and signing mock variables for previews verifiedContent: '', version, - }, - ) + }) void SubmissionService.sendEmailConfirmations({ form, @@ -1507,9 +1504,10 @@ export const submitEmailPreview: ControllerHandler< } const form = formResult.value - const parsedResponsesResult = await EmailSubmissionService.validateAttachments( - responses, - ).andThen(() => SubmissionService.getProcessedResponses(form, responses)) + const parsedResponsesResult = + await EmailSubmissionService.validateAttachments(responses).andThen(() => + SubmissionService.getProcessedResponses(form, responses), + ) if (parsedResponsesResult.isErr()) { logger.error({ message: 'Error while parsing responses for preview submission', diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts index e9594cb1cb..415fb45f35 100644 --- a/src/app/modules/form/admin-form/admin-form.routes.ts +++ b/src/app/modules/form/admin-form/admin-form.routes.ts @@ -7,11 +7,11 @@ import { celebrate, Joi as BaseJoi, Segments } from 'celebrate' import { Router } from 'express' import { ResponseMode } from '../../../../types' +import { DuplicateFormBody } from '../../../../types/api' import { withUserAuthentication } from '../../auth/auth.middlewares' import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller' import * as AdminFormController from './admin-form.controller' -import { DuplicateFormBody } from './admin-form.types' export const AdminFormsRouter = Router() diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 5f5ea9e6a0..eb5c64a9bf 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -24,9 +24,12 @@ import { Permission, } from '../../../../types' import { + DuplicateFormBody, + EditFormFieldParams, EndPageUpdateDto, FieldCreateDto, FieldUpdateDto, + FormUpdateParams, SettingsUpdateDto, StartPageUpdateDto, } from '../../../../types/api' @@ -62,11 +65,6 @@ import { FieldNotFoundError, InvalidFileTypeError, } from './admin-form.errors' -import { - DuplicateFormBody, - EditFormFieldParams, - FormUpdateParams, -} from './admin-form.types' import { getUpdatedFormFields, processDuplicateOverrideProps, diff --git a/src/app/modules/form/admin-form/admin-form.types.ts b/src/app/modules/form/admin-form/admin-form.types.ts index 5378a59bf5..bde8301348 100644 --- a/src/app/modules/form/admin-form/admin-form.types.ts +++ b/src/app/modules/form/admin-form/admin-form.types.ts @@ -1,6 +1,5 @@ import { Result } from 'neverthrow' -import { EditFieldActions } from '../../../../shared/constants' import { IFieldSchema, IForm, @@ -23,19 +22,6 @@ export type AssertFormFn = ( form: IPopulatedForm, ) => Result -export type DuplicateFormBody = { - title: string -} & ( - | { - responseMode: ResponseMode.Email - emails: string | string[] - } - | { - responseMode: ResponseMode.Encrypt - publicKey: string - } -) - export type OverrideProps = { endPage?: IForm['endPage'] startPage?: IForm['startPage'] @@ -46,34 +32,4 @@ export type OverrideProps = { publicKey?: string } -export type EditFormFieldParams = { - field: IFieldSchema -} & ( - | { - action: { - name: Exclude - } - } - | { - action: { - name: EditFieldActions.Reorder - position: number - } - } -) - -export type FormUpdateParams = { - editFormField?: EditFormFieldParams - authType?: IForm['authType'] - emails?: IForm['emails'] - esrvcId?: IForm['esrvcId'] - form_logics?: IForm['form_logics'] - hasCaptcha?: IForm['hasCaptcha'] - inactiveMessage?: IForm['inactiveMessage'] - permissionList?: IForm['permissionList'] - status?: IForm['status'] - title?: IForm['title'] - webhook?: IForm['webhook'] -} - export type EditFormFieldResult = Result diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 57c6788265..b51d6d2dc5 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -9,6 +9,7 @@ import { ResponseMode, Status, } from '../../../../types' +import { DuplicateFormBody, EditFormFieldParams } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' import { @@ -38,8 +39,6 @@ import { } from './admin-form.errors' import { AssertFormFn, - DuplicateFormBody, - EditFormFieldParams, EditFormFieldResult, OverrideProps, PermissionLevel, diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 8ac521272d..8f5eab18ed 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -439,11 +439,11 @@ describe('public-form.controller', () => { email: 'randomrandomtest@example.com', } as IPopulatedUser - const MOCK_SCRUBBED_FORM = ({ + const MOCK_SCRUBBED_FORM = { _id: MOCK_FORM_ID, title: 'mock title', admin: { _id: MOCK_USER_ID }, - } as unknown) as PublicForm + } as unknown as PublicForm const BASE_FORM = { admin: MOCK_USER, @@ -489,10 +489,10 @@ describe('public-form.controller', () => { it('should return 200 when there is no AuthType on the request', async () => { // Arrange - const MOCK_NIL_AUTH_FORM = ({ + const MOCK_NIL_AUTH_FORM = { ...BASE_FORM, authType: AuthType.NIL, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -519,10 +519,10 @@ describe('public-form.controller', () => { it('should return 200 when client authenticates using SP', async () => { // Arrange const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName } - const MOCK_SP_AUTH_FORM = ({ + const MOCK_SP_AUTH_FORM = { ...BASE_FORM, authType: AuthType.SP, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -553,10 +553,10 @@ describe('public-form.controller', () => { it('should return 200 when client authenticates using CP', async () => { // Arrange const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName } - const MOCK_CP_AUTH_FORM = ({ + const MOCK_CP_AUTH_FORM = { ...BASE_FORM, authType: AuthType.CP, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -585,12 +585,12 @@ describe('public-form.controller', () => { it('should return 200 when client authenticates using MyInfo', async () => { // Arrange - const MOCK_MYINFO_AUTH_FORM = ({ + const MOCK_MYINFO_AUTH_FORM = { ...BASE_FORM, esrvcId: 'thing', authType: AuthType.MyInfo, toJSON: jest.fn().mockReturnValue(BASE_FORM), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const MOCK_MYINFO_DATA = new MyInfoData({ uinFin: 'i am a fish', } as IPersonResponse) @@ -632,11 +632,11 @@ describe('public-form.controller', () => { // Errors describe('errors in myInfo', () => { - const MOCK_MYINFO_FORM = ({ + const MOCK_MYINFO_FORM = { ...BASE_FORM, toJSON: jest.fn().mockReturnThis(), authType: AuthType.MyInfo, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm // Setup because this gets invoked at the start of the controller to decide which branch to take beforeAll(() => { @@ -852,10 +852,10 @@ describe('public-form.controller', () => { }) describe('errors in spcp', () => { - const MOCK_SPCP_FORM = ({ + const MOCK_SPCP_FORM = { ...BASE_FORM, authType: AuthType.SP, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm it('should return 200 with the form but without a spcpSession when the JWT token could not be found', async () => { // Arrange // 1. Mock the response and calls @@ -1000,10 +1000,10 @@ describe('public-form.controller', () => { it('should return 200 with isIntranetUser set to false when a user accesses a form from outside intranet', async () => { // Arrange - const MOCK_NIL_AUTH_FORM = ({ + const MOCK_NIL_AUTH_FORM = { ...BASE_FORM, authType: AuthType.NIL, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -1030,10 +1030,10 @@ describe('public-form.controller', () => { it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.SP form', async () => { // Arrange - const MOCK_SP_AUTH_FORM = ({ + const MOCK_SP_AUTH_FORM = { ...BASE_FORM, authType: AuthType.SP, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() @@ -1065,10 +1065,10 @@ describe('public-form.controller', () => { it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.CP form', async () => { // Arrange - const MOCK_CP_AUTH_FORM = ({ + const MOCK_CP_AUTH_FORM = { ...BASE_FORM, authType: AuthType.CP, - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse() @@ -1100,12 +1100,12 @@ describe('public-form.controller', () => { it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.MyInfo form', async () => { // Arrange - const MOCK_MYINFO_AUTH_FORM = ({ + const MOCK_MYINFO_AUTH_FORM = { ...BASE_FORM, esrvcId: 'thing', authType: AuthType.MyInfo, toJSON: jest.fn().mockReturnValue(BASE_FORM), - } as unknown) as IPopulatedForm + } as unknown as IPopulatedForm const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), @@ -1279,11 +1279,11 @@ describe('public-form.controller', () => { it('should return 200 with the redirect url when the request is valid and the form has authType MyInfo', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { authType: AuthType.MyInfo, esrvcId: '12345', getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), - } as unknown) as MyInfoForm + } as unknown as MyInfoForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( @@ -1334,9 +1334,9 @@ describe('public-form.controller', () => { it('should return 400 when the form has authType MyInfo and is missing esrvcId', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { authType: AuthType.MyInfo, - } as unknown) as MyInfoForm + } as unknown as MyInfoForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( @@ -1360,9 +1360,9 @@ describe('public-form.controller', () => { it('should return 400 when the form has authType SP and is missing esrvcId', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { authType: AuthType.SP, - } as unknown) as SpcpForm + } as unknown as SpcpForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( @@ -1386,9 +1386,9 @@ describe('public-form.controller', () => { it('should return 400 when the form has authType CP and is missing esrvcId', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { authType: AuthType.CP, - } as unknown) as SpcpForm + } as unknown as SpcpForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( @@ -1434,10 +1434,10 @@ describe('public-form.controller', () => { it('should return 500 when the redirectURL could not be created', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { esrvcId: '234', authType: AuthType.CP, - } as unknown) as SpcpForm + } as unknown as SpcpForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( @@ -1463,11 +1463,11 @@ describe('public-form.controller', () => { it('should return 500 when the redirectURL feature is not implemented', async () => { // Arrange - const MOCK_FORM = ({ + const MOCK_FORM = { esrvcId: '234', authType: AuthType.MyInfo, getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), - } as unknown) as SpcpForm + } as unknown as SpcpForm const mockRes = expressHandler.mockResponse() MockFormService.retrieveFullFormById.mockReturnValueOnce( okAsync(MOCK_FORM), diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 586014eac8..722d1b06f5 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -10,6 +10,7 @@ import { PrivateFormErrorDto, PublicFormAuthRedirectDto, PublicFormAuthValidateEsrvcIdDto, + PublicFormViewDto, } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' import { isMongoError } from '../../../utils/handle-mongo-error' @@ -36,7 +37,7 @@ import { AuthTypeMismatchError, PrivateFormError } from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' -import { PublicFormViewDto, RedirectParams } from './public-form.types' +import { RedirectParams } from './public-form.types' import { mapFormAuthError, mapRouteError } from './public-form.utils' const logger = createLoggerWithLabel(module) diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index 8b138e85e0..14c2f66a93 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -1,8 +1,3 @@ -import { IFieldSchema, PublicForm } from 'src/types' - -import { SpcpSession } from '../../../../types/spcp' -import { IPossiblyPrefilledField } from '../../myinfo/myinfo.types' - export type Metatags = { title: string description?: string @@ -16,16 +11,3 @@ export type RedirectParams = { // TODO(#144): Rename Id to formId after all routes have been updated. Id: string } - -// NOTE: This is needed because PublicForm inherits from IFormDocument (where form_fields has type of IFieldSchema). -// However, the form returned back to the client has form_field of two possible types -interface PossiblyPrefilledPublicForm extends Omit { - form_fields: IPossiblyPrefilledField[] | IFieldSchema[] -} - -export type PublicFormViewDto = { - form: PossiblyPrefilledPublicForm - spcpSession?: SpcpSession - isIntranetUser?: boolean - myInfoError?: true -} diff --git a/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts b/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts index a25e3d1ec1..37c17b3122 100644 --- a/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts +++ b/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts @@ -22,9 +22,10 @@ describe('google-analytics.factory', () => { const mockRes = expressHandler.mockResponse() it('should call res correctly if google-analytics feature is disabled', () => { - const MOCK_DISABLED_GA_FEATURE: RegisteredFeature = { - isEnabled: false, - } + const MOCK_DISABLED_GA_FEATURE: RegisteredFeature = + { + isEnabled: false, + } const GoogleAnalyticsFactory = createGoogleAnalyticsFactory( MOCK_DISABLED_GA_FEATURE, @@ -38,9 +39,10 @@ describe('google-analytics.factory', () => { }) it('should call res correctly if google-analytics feature is enabled', () => { - const MOCK_ENABLED_GA_FEATURE: RegisteredFeature = { - isEnabled: true, - } + const MOCK_ENABLED_GA_FEATURE: RegisteredFeature = + { + isEnabled: true, + } const GoogleAnalyticsFactory = createGoogleAnalyticsFactory( MOCK_ENABLED_GA_FEATURE, diff --git a/src/app/modules/myinfo/__tests__/myinfo.adapter.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.adapter.spec.ts index fcba314c2e..d0d5d21f02 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.adapter.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.adapter.spec.ts @@ -206,10 +206,12 @@ describe('myinfo.adapter', () => { it('should correctly return single vehicle numbers', () => { // Grab first vehicle number - const expected = (MYINFO_VEHNO_AVAILABLE.vehicles![0] as SetRequired< - MyInfoVehicleFull, - 'vehicleno' - >).vehicleno.value + const expected = ( + MYINFO_VEHNO_AVAILABLE.vehicles![0] as SetRequired< + MyInfoVehicleFull, + 'vehicleno' + > + ).vehicleno.value const response: IPersonResponse = { uinFin: MOCK_UINFIN, data: { diff --git a/src/app/modules/myinfo/__tests__/myinfo.controller.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.controller.spec.ts index 3b27cb5137..231c643d3c 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.controller.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.controller.spec.ts @@ -294,7 +294,7 @@ describe('MyInfoController', () => { ) // Return value is ignored MockBillingFactory.recordLoginByForm.mockReturnValue( - okAsync(({} as unknown) as ILoginSchema), + okAsync({} as unknown as ILoginSchema), ) }) diff --git a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts index 0c87e6b5c9..7f63539a37 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts @@ -46,14 +46,11 @@ describe('myinfo.factory', () => { const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm( - ({} as unknown) as IPopulatedForm, + {} as unknown as IPopulatedForm, {}, ) - const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields( - '', - {} as MyInfoData, - [], - ) + const prefillAndSaveMyInfoFieldsResult = + await MyInfoFactory.prefillAndSaveMyInfoFields('', {} as MyInfoData, []) const saveMyInfoHashesResult = await MyInfoFactory.saveMyInfoHashes( '', '', @@ -95,14 +92,11 @@ describe('myinfo.factory', () => { const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm( - ({} as unknown) as IPopulatedForm, + {} as unknown as IPopulatedForm, {}, ) - const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields( - '', - {} as MyInfoData, - [], - ) + const prefillAndSaveMyInfoFieldsResult = + await MyInfoFactory.prefillAndSaveMyInfoFields('', {} as MyInfoData, []) const saveMyInfoHashesResult = await MyInfoFactory.saveMyInfoHashes( '', '', @@ -128,12 +122,12 @@ describe('myinfo.factory', () => { }) it('should call the MyInfoService constructor when isEnabled is true and props is truthy', () => { - const mockProps = ({ + const mockProps = { myInfoClientMode: 'mock1', myInfoKeyPath: 'mock2', spCookieMaxAge: 200, spEsrvcId: 'mock3', - } as unknown) as ISpcpMyInfo + } as unknown as ISpcpMyInfo createMyInfoFactory({ isEnabled: true, props: mockProps, diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index 418ec67bac..59d08aab77 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -12,6 +12,7 @@ import { IHashes, IMyInfoHashSchema, IPopulatedForm, + IPossiblyPrefilledField, MyInfoAttribute, } from 'src/types' @@ -27,7 +28,7 @@ import { MyInfoMissingAccessTokenError, MyInfoParseRelayStateError, } from '../myinfo.errors' -import { IPossiblyPrefilledField, MyInfoRelayState } from '../myinfo.types' +import { MyInfoRelayState } from '../myinfo.types' import { MOCK_ACCESS_TOKEN, @@ -209,9 +210,7 @@ describe('MyInfoService', () => { const mockReturnValue = { mock: 'value' } const mockUpdateHashes = jest .spyOn(MyInfoHash, 'updateHashes') - .mockResolvedValueOnce( - (mockReturnValue as unknown) as IMyInfoHashSchema, - ) + .mockResolvedValueOnce(mockReturnValue as unknown as IMyInfoHashSchema) MockBcrypt.hash.mockImplementation((v) => Promise.resolve(v)) const expectedHashes = {} as Record MOCK_POPULATED_FORM_FIELDS.forEach((field) => { @@ -317,7 +316,7 @@ describe('MyInfoService', () => { MockBcrypt.compare.mockResolvedValue(true) const result = await myInfoService.checkMyInfoHashes( - (MOCK_RESPONSES as unknown) as ProcessedFieldResponse[], + MOCK_RESPONSES as unknown as ProcessedFieldResponse[], MOCK_HASHES as IHashes, ) @@ -328,7 +327,7 @@ describe('MyInfoService', () => { MockBcrypt.compare.mockRejectedValue('') const result = await myInfoService.checkMyInfoHashes( - (MOCK_RESPONSES as unknown) as ProcessedFieldResponse[], + MOCK_RESPONSES as unknown as ProcessedFieldResponse[], MOCK_HASHES as IHashes, ) @@ -347,7 +346,7 @@ describe('MyInfoService', () => { }) const result = await myInfoService.checkMyInfoHashes( - (MOCK_RESPONSES as unknown) as ProcessedFieldResponse[], + MOCK_RESPONSES as unknown as ProcessedFieldResponse[], MOCK_HASHES as IHashes, ) diff --git a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts index 2056029180..7f0e680335 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts @@ -145,7 +145,7 @@ export const MOCK_SERVICE_PARAMS: IMyInfoServiceConfig = { } as ISpcpMyInfo, } -export const MOCK_MYINFO_FORM = ({ +export const MOCK_MYINFO_FORM = { _id: MOCK_FORM_ID, esrvcId: MOCK_ESRVC_ID, authType: AuthType.MyInfo, @@ -161,7 +161,7 @@ export const MOCK_MYINFO_FORM = ({ return this }, form_fields: [], -} as unknown) as IFormSchema +} as unknown as IFormSchema export const MOCK_SUCCESSFUL_COOKIE: MyInfoSuccessfulCookiePayload = { accessToken: MOCK_ACCESS_TOKEN, diff --git a/src/app/modules/myinfo/myinfo.adapter.ts b/src/app/modules/myinfo/myinfo.adapter.ts index 89408a1dce..d202462eb3 100644 --- a/src/app/modules/myinfo/myinfo.adapter.ts +++ b/src/app/modules/myinfo/myinfo.adapter.ts @@ -291,9 +291,10 @@ export class MyInfoData { * Retrieves the fieldValue for the given internal MyInfo attribute. * @param attr Internal FormSG MyInfo attribute */ - getFieldValueForAttr( - attr: InternalAttr, - ): { fieldValue: string | undefined; isReadOnly: boolean } { + getFieldValueForAttr(attr: InternalAttr): { + fieldValue: string | undefined + isReadOnly: boolean + } { const externalAttr = internalAttrToExternal(attr) const fieldValue = this._formatFieldValue(externalAttr) return { diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index d4e66be084..a62fe99902 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -6,6 +6,7 @@ import { IHashes, IMyInfoHashSchema, IPopulatedForm, + IPossiblyPrefilledField, } from '../../../types' import config from '../../config/config' import FeatureManager, { @@ -33,11 +34,7 @@ import { MyInfoParseRelayStateError, } from './myinfo.errors' import { MyInfoService } from './myinfo.service' -import { - IMyInfoRedirectURLArgs, - IPossiblyPrefilledField, - MyInfoParsedRelayState, -} from './myinfo.types' +import { IMyInfoRedirectURLArgs, MyInfoParsedRelayState } from './myinfo.types' interface IMyInfoFactory { createRedirectURL: ( diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index dae865966a..06d753aa95 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -16,6 +16,7 @@ import { IHashes, IMyInfoHashSchema, IPopulatedForm, + IPossiblyPrefilledField, MyInfoAttribute, } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' @@ -47,7 +48,6 @@ import { import { IMyInfoRedirectURLArgs, IMyInfoServiceConfig, - IPossiblyPrefilledField, MyInfoParsedRelayState, } from './myinfo.types' import { @@ -302,9 +302,8 @@ export class MyInfoService { if (!field.myInfo?.attr) return field const myInfoAttr = field.myInfo.attr - const { fieldValue, isReadOnly } = myInfoData.getFieldValueForAttr( - myInfoAttr, - ) + const { fieldValue, isReadOnly } = + myInfoData.getFieldValueForAttr(myInfoAttr) const prefilledField = cloneDeep(field) as IPossiblyPrefilledField prefilledField.fieldValue = fieldValue diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index d28b697f7c..bea4ab40db 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -1,9 +1,6 @@ -import { LeanDocument } from 'mongoose' - import { AuthType, Environment, - IFieldSchema, IFormSchema, IMyInfo, MyInfoAttribute, @@ -23,10 +20,6 @@ export interface IMyInfoRedirectURLArgs { requestedAttributes: MyInfoAttribute[] } -export interface IPossiblyPrefilledField extends LeanDocument { - fieldValue?: string -} - export type MyInfoHashPromises = Partial< Record> > diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index ecb97642a0..6f3febbaee 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -12,6 +12,7 @@ import { IFormSchema, IHashes, IMyInfo, + IPossiblyPrefilledField, MapRouteError, } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' @@ -39,7 +40,6 @@ import { MyInfoMissingHashError, } from './myinfo.errors' import { - IPossiblyPrefilledField, MyInfoComparePromises, MyInfoCookiePayload, MyInfoCookieState, diff --git a/src/app/modules/myinfo/myinfo_hash.model.ts b/src/app/modules/myinfo/myinfo_hash.model.ts index bf4c168a8c..28bbdb4d5d 100644 --- a/src/app/modules/myinfo/myinfo_hash.model.ts +++ b/src/app/modules/myinfo/myinfo_hash.model.ts @@ -45,7 +45,6 @@ MyInfoHashSchema.index({ MyInfoHashSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) MyInfoHashSchema.statics.updateHashes = async function ( - this: IMyInfoHashModel, uinFin: string, formId: string, readOnlyHashes: IHashes, @@ -71,7 +70,6 @@ MyInfoHashSchema.statics.updateHashes = async function ( } MyInfoHashSchema.statics.findHashes = async function ( - this: IMyInfoHashModel, uinFin: string, formId: string, ): Promise { diff --git a/src/app/modules/spcp/__tests__/spcp.factory.spec.ts b/src/app/modules/spcp/__tests__/spcp.factory.spec.ts index b7fb6db682..0999570c5e 100644 --- a/src/app/modules/spcp/__tests__/spcp.factory.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.factory.spec.ts @@ -27,12 +27,10 @@ describe('spcp.factory', () => { ) const fetchLoginPageResult = await SpcpFactory.fetchLoginPage('') const validateLoginPageResult = SpcpFactory.validateLoginPage('') - const extractSingpassJwtPayloadResult = await SpcpFactory.extractSingpassJwtPayload( - '', - ) - const extractCorppassJwtPayloadResult = await SpcpFactory.extractCorppassJwtPayload( - '', - ) + const extractSingpassJwtPayloadResult = + await SpcpFactory.extractSingpassJwtPayload('') + const extractCorppassJwtPayloadResult = + await SpcpFactory.extractCorppassJwtPayload('') const parseOOBParamsResult = SpcpFactory.parseOOBParams('', '', AuthType.SP) const getSpcpAttributesResult = await SpcpFactory.getSpcpAttributes( '', @@ -40,7 +38,7 @@ describe('spcp.factory', () => { AuthType.SP, ) const createJWTResult = SpcpFactory.createJWT( - ({} as unknown) as JwtPayload, + {} as unknown as JwtPayload, 0, AuthType.SP, ) @@ -77,12 +75,10 @@ describe('spcp.factory', () => { ) const fetchLoginPageResult = await SpcpFactory.fetchLoginPage('') const validateLoginPageResult = SpcpFactory.validateLoginPage('') - const extractSingpassJwtPayloadResult = await SpcpFactory.extractSingpassJwtPayload( - '', - ) - const extractCorppassJwtPayloadResult = await SpcpFactory.extractCorppassJwtPayload( - '', - ) + const extractSingpassJwtPayloadResult = + await SpcpFactory.extractSingpassJwtPayload('') + const extractCorppassJwtPayloadResult = + await SpcpFactory.extractCorppassJwtPayload('') const parseOOBParamsResult = SpcpFactory.parseOOBParams('', '', AuthType.SP) const getSpcpAttributesResult = await SpcpFactory.getSpcpAttributes( '', @@ -90,7 +86,7 @@ describe('spcp.factory', () => { AuthType.SP, ) const createJWTResult = SpcpFactory.createJWT( - ({} as unknown) as JwtPayload, + {} as unknown as JwtPayload, 0, AuthType.SP, ) diff --git a/src/app/modules/spcp/__tests__/spcp.test.constants.ts b/src/app/modules/spcp/__tests__/spcp.test.constants.ts index bb9234c51f..fce05097de 100644 --- a/src/app/modules/spcp/__tests__/spcp.test.constants.ts +++ b/src/app/modules/spcp/__tests__/spcp.test.constants.ts @@ -107,7 +107,7 @@ export const MOCK_JWT_PAYLOAD = { rememberMe: true, } -export const MOCK_SP_FORM = ({ +export const MOCK_SP_FORM = { authType: 'SP', title: 'Mock SP form', _id: new ObjectId().toHexString(), @@ -116,9 +116,9 @@ export const MOCK_SP_FORM = ({ agency: new ObjectId().toHexString(), }, getPublicView: () => _.omit(this, 'admin'), -} as unknown) as IPopulatedForm +} as unknown as IPopulatedForm -export const MOCK_CP_FORM = ({ +export const MOCK_CP_FORM = { authType: 'CP', title: 'Mock CP form', _id: new ObjectId().toHexString(), @@ -127,9 +127,9 @@ export const MOCK_CP_FORM = ({ agency: new ObjectId().toHexString(), }, getPublicView: () => _.omit(this, 'admin'), -} as unknown) as IPopulatedForm +} as unknown as IPopulatedForm -export const MOCK_MYINFO_FORM = ({ +export const MOCK_MYINFO_FORM = { authType: 'MyInfo', title: 'Mock MyInfo form', _id: new ObjectId().toHexString(), @@ -137,7 +137,7 @@ export const MOCK_MYINFO_FORM = ({ _id: new ObjectId().toHexString(), agency: new ObjectId().toHexString(), }, -} as unknown) as IPopulatedForm +} as unknown as IPopulatedForm export const MOCK_LOGIN_DOC = { _id: new ObjectId().toHexString(), diff --git a/src/app/modules/submission/__tests__/submission/submission.service.spec.ts b/src/app/modules/submission/__tests__/submission/submission.service.spec.ts index 357f777847..4a04c1180b 100644 --- a/src/app/modules/submission/__tests__/submission/submission.service.spec.ts +++ b/src/app/modules/submission/__tests__/submission/submission.service.spec.ts @@ -136,10 +136,10 @@ describe('submission.service', () => { // Act const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Encrypt, form_fields: [mobileField, emailField], - } as unknown) as IFormSchema, + } as unknown as IFormSchema, [mobileResponse, emailResponse], ) @@ -180,10 +180,10 @@ describe('submission.service', () => { // Act const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Email, form_fields: [shortTextField, decimalField], - } as unknown) as IFormSchema, + } as unknown as IFormSchema, [shortTextResponse, decimalResponse], ) @@ -204,10 +204,10 @@ describe('submission.service', () => { // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Email, form_fields: [extraField], - } as unknown) as IEmailFormSchema, + } as unknown as IEmailFormSchema, [], ) @@ -224,10 +224,10 @@ describe('submission.service', () => { // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Encrypt, form_fields: [extraField], - } as unknown) as IEncryptedFormSchema, + } as unknown as IEncryptedFormSchema, [], ) @@ -267,10 +267,10 @@ describe('submission.service', () => { // Act const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Encrypt, form_fields: [mobileField, emailField], - } as unknown) as IFormSchema, + } as unknown as IFormSchema, [mobileResponse, emailResponse], ) @@ -296,10 +296,10 @@ describe('submission.service', () => { // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Encrypt, form_fields: [mobileField], - } as unknown) as IEncryptedFormSchema, + } as unknown as IEncryptedFormSchema, [mobileResponse], ) @@ -317,10 +317,10 @@ describe('submission.service', () => { // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Email, form_fields: [nricField], - } as unknown) as IEmailFormSchema, + } as unknown as IEmailFormSchema, [nricResponse], ) @@ -335,19 +335,19 @@ describe('submission.service', () => { // Mock logic util to return non-empty to check if error is thrown jest .spyOn(LogicUtil, 'getLogicUnitPreventingSubmit') - .mockReturnValueOnce(({ + .mockReturnValueOnce({ preventSubmitMessage: 'mock prevent submit', conditions: [], logicType: LogicType.PreventSubmit, _id: 'some id', - } as unknown) as IPreventSubmitLogicSchema) + } as unknown as IPreventSubmitLogicSchema) // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Encrypt, form_fields: [], - } as unknown) as IEncryptedFormSchema, + } as unknown as IEncryptedFormSchema, [], ) @@ -360,12 +360,12 @@ describe('submission.service', () => { it('should return error when email form submission is prevented by logic', async () => { // Arrange // Mock logic util to return non-empty to check if error is thrown. - const mockReturnLogicUnit = ({ + const mockReturnLogicUnit = { preventSubmitMessage: 'mock prevent submit', conditions: [], logicType: LogicType.PreventSubmit, _id: 'some id', - } as unknown) as IPreventSubmitLogicSchema + } as unknown as IPreventSubmitLogicSchema jest .spyOn(LogicUtil, 'getLogicUnitPreventingSubmit') @@ -373,10 +373,10 @@ describe('submission.service', () => { // Act + Assert const actualResult = SubmissionService.getProcessedResponses( - ({ + { responseMode: ResponseMode.Email, form_fields: [], - } as unknown) as IEmailFormSchema, + } as unknown as IEmailFormSchema, [], ) @@ -529,9 +529,9 @@ describe('submission.service', () => { // Arrange countSpy.mockImplementationOnce( () => - (({ + ({ exec: () => Promise.reject(new Error('boom')), - } as unknown) as mongoose.Query), + } as unknown as mongoose.Query), ) // Act @@ -550,7 +550,7 @@ describe('submission.service', () => { describe('sendEmailConfirmations', () => { it('should call mail service and return true when email confirmations are sent successfully', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -562,7 +562,7 @@ describe('submission.service', () => { autoReplyOptions: AUTOREPLY_OPTIONS_2, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ { status: 'fulfilled', @@ -612,10 +612,10 @@ describe('submission.service', () => { }) it('should not call mail service when there are no email fields', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [generateDefaultField(BasicField.Number)], - } as unknown) as IFormSchema + } as unknown as IFormSchema const responses = [ { @@ -638,7 +638,7 @@ describe('submission.service', () => { }) it('should not call mail service when there are email fields but all without email confirmation', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -650,7 +650,7 @@ describe('submission.service', () => { autoReplyOptions: { hasAutoReply: false }, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema const responses = [ { @@ -679,7 +679,7 @@ describe('submission.service', () => { }) it('should call mail service when there is a mix of email fields with and without confirmation', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -691,7 +691,7 @@ describe('submission.service', () => { autoReplyOptions: { hasAutoReply: false }, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ { status: 'fulfilled', @@ -734,7 +734,7 @@ describe('submission.service', () => { }) it('should call mail service with responsesData empty when autoReplyData is undefined', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -746,7 +746,7 @@ describe('submission.service', () => { autoReplyOptions: AUTOREPLY_OPTIONS_2, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ { status: 'fulfilled', @@ -796,7 +796,7 @@ describe('submission.service', () => { }) it('should call mail service with attachments undefined when there are no attachments', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -808,7 +808,7 @@ describe('submission.service', () => { autoReplyOptions: AUTOREPLY_OPTIONS_2, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ { status: 'fulfilled', @@ -858,7 +858,7 @@ describe('submission.service', () => { }) it('should return SendEmailConfirmationError when mail service errors', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -870,7 +870,7 @@ describe('submission.service', () => { autoReplyOptions: AUTOREPLY_OPTIONS_2, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema MockMailService.sendAutoReplyEmails.mockImplementationOnce(() => Promise.reject('rejected'), ) @@ -915,7 +915,7 @@ describe('submission.service', () => { }) it('should return SendEmailConfirmationError when any email confirmations fail', async () => { - const mockForm = ({ + const mockForm = { _id: MOCK_FORM_ID, form_fields: [ { @@ -927,7 +927,7 @@ describe('submission.service', () => { autoReplyOptions: AUTOREPLY_OPTIONS_2, }, ], - } as unknown) as IFormSchema + } as unknown as IFormSchema const mockReason = 'reason' MockMailService.sendAutoReplyEmails.mockResolvedValueOnce([ { diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.receiver.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.receiver.spec.ts index 6a795dc426..06895b026e 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.receiver.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.receiver.spec.ts @@ -26,9 +26,9 @@ const RealBusboy = jest.requireActual('busboy') as typeof Busboy const MOCK_HEADERS = { key: 'value' } const MOCK_BUSBOY_ON = jest.fn().mockReturnThis() -const MOCK_BUSBOY = ({ +const MOCK_BUSBOY = { on: MOCK_BUSBOY_ON, -} as unknown) as busboy.Busboy +} as unknown as busboy.Busboy const VALID_FILE_PATH = 'tests/unit/backend/resources/' const VALID_FILENAME_1 = 'valid.txt' @@ -102,9 +102,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) fileStream.emit('data', VALID_FILE_CONTENT_1) @@ -150,9 +149,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) fileStream.emit('data', VALID_FILE_CONTENT_1) @@ -212,9 +210,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) fileStream1.emit('data', VALID_FILE_CONTENT_1) @@ -278,9 +275,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) fileStream1.emit('data', VALID_FILE_CONTENT_1) @@ -336,9 +332,8 @@ describe('email-submission.receiver', () => { fileSize: 7 * MB, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) fileStream.emit('data', Buffer.alloc(7 * MB + 1)) @@ -357,9 +352,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) realBusboy.emit('error', new Error()) @@ -376,9 +370,8 @@ describe('email-submission.receiver', () => { 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }, }) - const resultPromise = EmailSubmissionReceiver.configureMultipartReceiver( - realBusboy, - ) + const resultPromise = + EmailSubmissionReceiver.configureMultipartReceiver(realBusboy) form.pipe(realBusboy) form.emit('end') diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts index ab8042a986..32e576b257 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts @@ -51,14 +51,14 @@ describe('email-submission.service', () => { const MOCK_EMAIL = 'a@abc.com' const MOCK_RESPONSE_HASH = 'mockHash' const MOCK_RESPONSE_SALT = 'mockSalt' - const MOCK_FORM = ({ + const MOCK_FORM = { admin: new ObjectId(), _id: new ObjectId(), title: 'mock title', getUniqueMyInfoAttrs: () => [], authType: 'NIL', emails: [MOCK_EMAIL], - } as unknown) as IPopulatedEmailForm + } as unknown as IPopulatedEmailForm it('should create a new submission without saving it to the database', async () => { const result = EmailSubmissionService.createEmailSubmissionWithoutSave( @@ -227,7 +227,7 @@ describe('email-submission.service', () => { ) const response = generateNewAttachmentResponse() const responseAsEmailField = generateSingleAnswerFormData( - (response as unknown) as ProcessedSingleAnswerResponse, + response as unknown as ProcessedSingleAnswerResponse, ) const expectedBaseString = `${response.question} ${response.answer}; ${response.content}` @@ -273,7 +273,7 @@ describe('email-submission.service', () => { content: Buffer.from('content1'), }) const responseAsEmailField1 = generateSingleAnswerFormData( - (response1 as unknown) as ProcessedSingleAnswerResponse, + response1 as unknown as ProcessedSingleAnswerResponse, ) const response2 = generateNewAttachmentResponse({ @@ -283,7 +283,7 @@ describe('email-submission.service', () => { }) const expectedBaseString = `${response1.question} ${response1.answer}; ${response2.question} ${response2.answer}; ${response1.content}${response2.content}` const responseAsEmailField2 = generateSingleAnswerFormData( - (response2 as unknown) as ProcessedSingleAnswerResponse, + response2 as unknown as ProcessedSingleAnswerResponse, ) const result = await EmailSubmissionService.hashSubmission( @@ -333,7 +333,7 @@ describe('email-submission.service', () => { const createEmailSubmissionSpy = jest .spyOn(EmailSubmissionModel, 'create') .mockResolvedValueOnce( - (mockSubmission as unknown) as ResolvedValue, + mockSubmission as unknown as ResolvedValue, ) const result = await EmailSubmissionService.saveSubmissionMetadata( MOCK_EMAIL_FORM as IPopulatedEmailForm, diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.test.constants.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.test.constants.ts index fabc049246..3e7c402147 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.test.constants.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.test.constants.ts @@ -19,9 +19,8 @@ export const MOCK_NO_RESPONSES_BODY = { } export const MOCK_TEXT_FIELD = generateDefaultField(BasicField.ShortText) -export const MOCK_TEXTFIELD_RESPONSE = generateSingleAnswerResponse( - MOCK_TEXT_FIELD, -) +export const MOCK_TEXTFIELD_RESPONSE = + generateSingleAnswerResponse(MOCK_TEXT_FIELD) export const MOCK_ATTACHMENT_FIELD = generateDefaultField(BasicField.Attachment) export const MOCK_ATTACHMENT_RESPONSE = generateAttachmentResponse( @@ -31,9 +30,8 @@ export const MOCK_ATTACHMENT_RESPONSE = generateAttachmentResponse( ) export const MOCK_SECTION_FIELD = generateDefaultField(BasicField.Section) -export const MOCK_SECTION_RESPONSE = generateSingleAnswerResponse( - MOCK_SECTION_FIELD, -) +export const MOCK_SECTION_RESPONSE = + generateSingleAnswerResponse(MOCK_SECTION_FIELD) export const MOCK_CHECKBOX_FIELD = generateDefaultField(BasicField.Checkbox) export const MOCK_CHECKBOX_RESPONSE = generateCheckboxResponse( diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts index 309db8751c..25533986b7 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts @@ -96,12 +96,12 @@ const getResponse = ( _id: string, answer: string, ): WithQuestion => - (({ + ({ _id, fieldType: BasicField.Attachment, question: 'mockQuestion', answer, - } as unknown) as WithQuestion) + } as unknown as WithQuestion) const ALL_SINGLE_SUBMITTED_RESPONSES = basicTypes // Attachments are special cases, requiring filename and content @@ -216,18 +216,18 @@ describe('email-submission.util', () => { [firstAttachment, secondAttachment], ) expect(firstResponse.answer).toBe(firstAttachment.filename) - expect(((firstResponse as unknown) as IAttachmentResponse).filename).toBe( + expect((firstResponse as unknown as IAttachmentResponse).filename).toBe( firstAttachment.filename, ) - expect( - ((firstResponse as unknown) as IAttachmentResponse).content, - ).toEqual(firstAttachment.content) + expect((firstResponse as unknown as IAttachmentResponse).content).toEqual( + firstAttachment.content, + ) expect(secondResponse.answer).toBe(secondAttachment.filename) + expect((secondResponse as unknown as IAttachmentResponse).filename).toBe( + secondAttachment.filename, + ) expect( - ((secondResponse as unknown) as IAttachmentResponse).filename, - ).toBe(secondAttachment.filename) - expect( - ((secondResponse as unknown) as IAttachmentResponse).content, + (secondResponse as unknown as IAttachmentResponse).content, ).toEqual(secondAttachment.content) }) @@ -236,10 +236,10 @@ describe('email-submission.util', () => { const response = getResponse(attachment.fieldId, MOCK_ANSWER) addAttachmentToResponses([response], [attachment]) expect(response.answer).toBe(attachment.filename) - expect(((response as unknown) as IAttachmentResponse).filename).toBe( + expect((response as unknown as IAttachmentResponse).filename).toBe( attachment.filename, ) - expect(((response as unknown) as IAttachmentResponse).content).toEqual( + expect((response as unknown as IAttachmentResponse).content).toEqual( attachment.content, ) }) diff --git a/src/app/modules/submission/email-submission/email-submission.controller.ts b/src/app/modules/submission/email-submission/email-submission.controller.ts index d55f3523eb..c6ebd63aa7 100644 --- a/src/app/modules/submission/email-submission/email-submission.controller.ts +++ b/src/app/modules/submission/email-submission/email-submission.controller.ts @@ -272,9 +272,8 @@ const submitEmailModeForm: ControllerHandler< // NOTE: This should short circuit in the event of an error. // This is why sendSubmissionToAdmin is separated from sendEmailConfirmations in 2 blocks return MailService.sendSubmissionToAdmin({ - replyToEmails: EmailSubmissionService.extractEmailAnswers( - parsedResponses, - ), + replyToEmails: + EmailSubmissionService.extractEmailAnswers(parsedResponses), form, submission, attachments, diff --git a/src/app/modules/submission/email-submission/email-submission.middleware.ts b/src/app/modules/submission/email-submission/email-submission.middleware.ts index 1bf2709c6f..755b55b55f 100644 --- a/src/app/modules/submission/email-submission/email-submission.middleware.ts +++ b/src/app/modules/submission/email-submission/email-submission.middleware.ts @@ -30,9 +30,8 @@ export const receiveEmailSubmission: ControllerHandler< } return EmailSubmissionReceiver.createMultipartReceiver(req.headers) .asyncAndThen((receiver) => { - const result = EmailSubmissionReceiver.configureMultipartReceiver( - receiver, - ) + const result = + EmailSubmissionReceiver.configureMultipartReceiver(receiver) req.pipe(receiver) return result }) diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts index 5f3c838500..93fc5f8fc5 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -267,9 +267,8 @@ export const getInvalidFileExtensions = ( ): Promise => { // Turn it into an array of promises that each resolve // to an array of file extensions that are invalid (if any) - const getInvalidFileExtensionsInZip = FileValidation.getInvalidFileExtensionsInZip( - FilePlatforms.Server, - ) + const getInvalidFileExtensionsInZip = + FileValidation.getInvalidFileExtensionsInZip(FilePlatforms.Server) const promises = attachments.map((attachment) => { const extension = FileValidation.getFileExtension(attachment.filename) if (FileValidation.isInvalidFileExtension(extension)) { @@ -492,13 +491,11 @@ export const addAttachmentToResponses = ( attachments: IAttachmentInfo[], ): void => { // Create a map of the attachments with fieldId as keys - const attachmentMap: Record< - IAttachmentInfo['fieldId'], - IAttachmentInfo - > = attachments.reduce>((acc, attachment) => { - acc[attachment.fieldId] = attachment - return acc - }, {}) + const attachmentMap: Record = + attachments.reduce>((acc, attachment) => { + acc[attachment.fieldId] = attachment + return acc + }, {}) if (responses) { // matches responses to attachments using id, adding filename and content to response diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts index 17dd3b4bf7..b5b668c076 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts @@ -43,13 +43,13 @@ describe('encrypt-submission.service', () => { afterAll(async () => await dbHandler.closeDatabase()) describe('createEncryptSubmissionWithoutSave', () => { - const MOCK_FORM = ({ + const MOCK_FORM = { admin: new ObjectId(), _id: new ObjectId(), title: 'mock title', getUniqueMyInfoAttrs: () => [], authType: 'NIL', - } as unknown) as IPopulatedEncryptedForm + } as unknown as IPopulatedEncryptedForm const MOCK_ENCRYPTED_CONTENT = 'mockEncryptedContent' const MOCK_VERIFIED_CONTENT = 'mockVerifiedContent' const MOCK_VERSION = 1 @@ -81,7 +81,7 @@ describe('encrypt-submission.service', () => { describe('getSubmissionCursor', () => { it('should return cursor successfully when date range is not provided', async () => { // Arrange - const mockCursor = (jest.fn() as unknown) as mongoose.QueryCursor + const mockCursor = jest.fn() as unknown as mongoose.QueryCursor const getSubmissionSpy = jest .spyOn(EncryptSubmission, 'getSubmissionCursorByFormId') .mockReturnValueOnce(mockCursor) @@ -99,7 +99,7 @@ describe('encrypt-submission.service', () => { it('should return cursor successfully when date range is provided', async () => { // Arrange - const mockCursor = (jest.fn() as unknown) as mongoose.QueryCursor + const mockCursor = jest.fn() as unknown as mongoose.QueryCursor const getSubmissionSpy = jest .spyOn(EncryptSubmission, 'getSubmissionCursorByFormId') .mockReturnValueOnce(mockCursor) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index a8538da65e..1691f417c7 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -149,9 +149,8 @@ const submitEncryptModeForm: ControllerHandler< } // Check that the form has not reached submission limits - const formSubmissionLimitResult = await FormService.checkFormSubmissionLimitAndDeactivateForm( - form, - ) + const formSubmissionLimitResult = + await FormService.checkFormSubmissionLimitAndDeactivateForm(form) if (formSubmissionLimitResult.isErr()) { logger.warn({ message: @@ -270,14 +269,16 @@ const submitEncryptModeForm: ControllerHandler< // Encrypt Verified SPCP Fields let verified if (form.authType === AuthType.SP || form.authType === AuthType.CP) { - const encryptVerifiedContentResult = VerifiedContentFactory.getVerifiedContent( - { type: form.authType, data: { uinFin, userInfo } }, - ).andThen((verifiedContent) => - VerifiedContentFactory.encryptVerifiedContent({ - verifiedContent, - formPublicKey: form.publicKey, - }), - ) + const encryptVerifiedContentResult = + VerifiedContentFactory.getVerifiedContent({ + type: form.authType, + data: { uinFin, userInfo }, + }).andThen((verifiedContent) => + VerifiedContentFactory.encryptVerifiedContent({ + verifiedContent, + formPublicKey: form.publicKey, + }), + ) if (encryptVerifiedContentResult.isErr()) { const { error } = encryptVerifiedContentResult @@ -359,15 +360,14 @@ const submitEncryptModeForm: ControllerHandler< }) // Fire webhooks if available - // Note that we push data to webhook endpoints on a best effort basis - // As such, we should not await on these post requests + // To avoid being coupled to latency of receiving system, + // do not await on webhook const webhookUrl = form.webhook?.url if (webhookUrl) { - void WebhookFactory.sendWebhook( + void WebhookFactory.sendInitialWebhook( submission, webhookUrl, - ).andThen((response) => - WebhookFactory.saveWebhookRecord(submission._id, response), + !!form.webhook?.isRetryEnabled, ) } diff --git a/src/app/modules/submission/submission.errors.ts b/src/app/modules/submission/submission.errors.ts index 5bf1a82e4b..105c42e99c 100644 --- a/src/app/modules/submission/submission.errors.ts +++ b/src/app/modules/submission/submission.errors.ts @@ -12,7 +12,7 @@ export class ConflictError extends ApplicationError { } export class SubmissionNotFoundError extends ApplicationError { - constructor(message: string) { + constructor(message = 'Submission not found for given ID') { super(message) } } diff --git a/src/app/modules/user/__tests__/user.service.spec.ts b/src/app/modules/user/__tests__/user.service.spec.ts index 91b86e802c..eb5eb8f5bc 100644 --- a/src/app/modules/user/__tests__/user.service.spec.ts +++ b/src/app/modules/user/__tests__/user.service.spec.ts @@ -413,9 +413,9 @@ describe('user.service', () => { it('should return admin successfully', async () => { // Arrange const mockUserId = new ObjectID().toHexString() - const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(defaultUser), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserById(mockUserId) @@ -429,9 +429,9 @@ describe('user.service', () => { it('should return DatabaseError when query throws an error', async () => { // Arrange const mockUserId = new ObjectID().toHexString() - const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce({ exec: jest.fn().mockRejectedValue(new Error('database bad')), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserById(mockUserId) @@ -445,9 +445,9 @@ describe('user.service', () => { it('should return MissingUserError when query returns null', async () => { // Arrange const mockUserId = new ObjectID().toHexString() - const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findById').mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserById(mockUserId) @@ -463,9 +463,9 @@ describe('user.service', () => { it('should return admin successfully', async () => { // Arrange const mockEmail = 'someemail@example.com' - const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(defaultUser), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserByEmail(mockEmail) @@ -479,9 +479,9 @@ describe('user.service', () => { it('should return DatabaseError when query throws an error', async () => { // Arrange const mockEmail = 'another@example.com' - const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce({ exec: jest.fn().mockRejectedValue(new Error('database bad!')), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserByEmail(mockEmail) @@ -495,9 +495,9 @@ describe('user.service', () => { it('should return MissingUserError when query returns null', async () => { // Arrange const mockEmail = 'mockEmail@example.com' - const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce(({ + const findSpy = jest.spyOn(UserModel, 'findOne').mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null), - } as unknown) as Query) + } as unknown as Query) // Act const actualResult = await UserService.findUserByEmail(mockEmail) diff --git a/src/app/modules/verification/__tests__/verification.factory.spec.ts b/src/app/modules/verification/__tests__/verification.factory.spec.ts index 40b39c6fb6..da87e62650 100644 --- a/src/app/modules/verification/__tests__/verification.factory.spec.ts +++ b/src/app/modules/verification/__tests__/verification.factory.spec.ts @@ -23,13 +23,10 @@ describe('verification.factory', () => { const createTransactionResult = await verificationFactory.createTransaction( '', ) - const getTransactionMetadataResult = await verificationFactory.getTransactionMetadata( - '', - ) - const resetFieldForTransactionResult = await verificationFactory.resetFieldForTransaction( - '', - '', - ) + const getTransactionMetadataResult = + await verificationFactory.getTransactionMetadata('') + const resetFieldForTransactionResult = + await verificationFactory.resetFieldForTransaction('', '') const sendNewOtpResult = await verificationFactory.sendNewOtp({ transactionId: '', fieldId: '', @@ -57,13 +54,10 @@ describe('verification.factory', () => { const createTransactionResult = await verificationFactory.createTransaction( '', ) - const getTransactionMetadataResult = await verificationFactory.getTransactionMetadata( - '', - ) - const resetFieldForTransactionResult = await verificationFactory.resetFieldForTransaction( - '', - '', - ) + const getTransactionMetadataResult = + await verificationFactory.getTransactionMetadata('') + const resetFieldForTransactionResult = + await verificationFactory.resetFieldForTransaction('', '') const sendNewOtpResult = await verificationFactory.sendNewOtp({ transactionId: '', fieldId: '', diff --git a/src/app/modules/verification/__tests__/verification.service.spec.ts b/src/app/modules/verification/__tests__/verification.service.spec.ts index d511c1f83b..2d8adaa53e 100644 --- a/src/app/modules/verification/__tests__/verification.service.spec.ts +++ b/src/app/modules/verification/__tests__/verification.service.spec.ts @@ -83,11 +83,11 @@ describe('Verification service', () => { afterAll(async () => await dbHandler.closeDatabase()) describe('createTransaction', () => { - const mockForm = ({ + const mockForm = { _id: new ObjectId(), title: 'mockForm', form_fields: [], - } as unknown) as IFormSchema + } as unknown as IFormSchema let createTransactionFromFormSpy: jest.SpyInstance< Promise, [form: IFormSchema] diff --git a/src/app/modules/verification/verification.model.ts b/src/app/modules/verification/verification.model.ts index 53d96c3d82..c090a5864f 100644 --- a/src/app/modules/verification/verification.model.ts +++ b/src/app/modules/verification/verification.model.ts @@ -69,14 +69,11 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { }) // Instance methods - VerificationSchema.methods.getPublicView = function ( - this: IVerificationSchema, - ): PublicTransaction { + VerificationSchema.methods.getPublicView = function (): PublicTransaction { return pick(this, VERIFICATION_PUBLIC_FIELDS) as PublicTransaction } VerificationSchema.methods.getField = function ( - this: IVerificationSchema, fieldId: string, ): IVerificationFieldSchema | undefined { return this.fields.find((field) => field._id === fieldId) @@ -85,7 +82,6 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { // Static methods // Method to return non-sensitive fields VerificationSchema.statics.getPublicViewById = async function ( - this: IVerificationModel, id: IVerificationSchema['_id'], ): Promise { const document = await this.findById(id) @@ -94,7 +90,6 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { } VerificationSchema.statics.createTransactionFromForm = async function ( - this: IVerificationModel, form: IFormSchema, ): Promise { const { form_fields } = form @@ -108,7 +103,6 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { } VerificationSchema.statics.incrementFieldRetries = async function ( - this: IVerificationModel, transactionId: string, fieldId: string, ): Promise { @@ -131,7 +125,6 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { } VerificationSchema.statics.resetField = async function ( - this: IVerificationModel, transactionId: string, fieldId: string, ): Promise { @@ -157,7 +150,6 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => { } VerificationSchema.statics.updateHashForField = async function ( - this: IVerificationModel, updateData: UpdateFieldData, ): Promise { return this.findOneAndUpdate( diff --git a/src/app/modules/verified-content/__tests__/verified-content.factory.spec.ts b/src/app/modules/verified-content/__tests__/verified-content.factory.spec.ts index 188bd48b2a..cbf8f0f834 100644 --- a/src/app/modules/verified-content/__tests__/verified-content.factory.spec.ts +++ b/src/app/modules/verified-content/__tests__/verified-content.factory.spec.ts @@ -17,13 +17,13 @@ const MockVerifiedContentService = mocked(VerifiedContentService) describe('verified-content.factory', () => { describe('verified content feature disabled', () => { - const MOCK_DISABLED_FEAT: RegisteredFeature = { - isEnabled: false, - } + const MOCK_DISABLED_FEAT: RegisteredFeature = + { + isEnabled: false, + } - const VerifiedContentFactory = createVerifiedContentFactory( - MOCK_DISABLED_FEAT, - ) + const VerifiedContentFactory = + createVerifiedContentFactory(MOCK_DISABLED_FEAT) it('should return MissingFeatureError when invoking getVerifiedContent', async () => { // Act @@ -51,9 +51,10 @@ describe('verified-content.factory', () => { }) describe('verified content feature enabled but missing signing secret key', () => { - const MOCK_ENABLED_FEAT_WO_KEY: RegisteredFeature = { - isEnabled: true, - } + const MOCK_ENABLED_FEAT_WO_KEY: RegisteredFeature = + { + isEnabled: true, + } const VerifiedContentFactory = createVerifiedContentFactory( MOCK_ENABLED_FEAT_WO_KEY, @@ -85,16 +86,16 @@ describe('verified-content.factory', () => { }) describe('verified content feature enabled with signing secret key', () => { - const MOCK_ENABLED_FEAT: RegisteredFeature = { - isEnabled: true, - props: { - signingSecretKey: 'some secret key', - }, - } + const MOCK_ENABLED_FEAT: RegisteredFeature = + { + isEnabled: true, + props: { + signingSecretKey: 'some secret key', + }, + } - const VerifiedContentFactory = createVerifiedContentFactory( - MOCK_ENABLED_FEAT, - ) + const VerifiedContentFactory = + createVerifiedContentFactory(MOCK_ENABLED_FEAT) it('should invoke VerifiedContentService.getVerifiedContent', async () => { // Arrange diff --git a/src/app/modules/webhook/__tests__/webhook.consumer.spec.ts b/src/app/modules/webhook/__tests__/webhook.consumer.spec.ts new file mode 100644 index 0000000000..9a75934623 --- /dev/null +++ b/src/app/modules/webhook/__tests__/webhook.consumer.spec.ts @@ -0,0 +1,285 @@ +import aws from 'aws-sdk' +import { ObjectId } from 'bson' +import { addHours } from 'date-fns' +import mongoose from 'mongoose' +import { errAsync, okAsync } from 'neverthrow' +import { mocked } from 'ts-jest/utils' + +import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import { IWebhookResponse, SubmissionWebhookInfo } from 'src/types' + +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { createWebhookQueueHandler } from '../webhook.consumer' +import { WebhookPushToQueueError } from '../webhook.errors' +import { WebhookProducer } from '../webhook.producer' +import * as WebhookService from '../webhook.service' +import { WebhookQueueMessageObject } from '../webhook.types' + +jest.mock('../webhook.service') +const MockWebhookService = mocked(WebhookService, true) + +const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) + +const MOCK_WEBHOOK_SUCCESS_RESPONSE: IWebhookResponse = { + signature: 'mockSignature', + webhookUrl: 'mockWebhookUrl', + response: { + data: 'mockData', + headers: 'mockHeaders', + status: 200, + }, +} +const MOCK_WEBHOOK_FAILURE_RESPONSE: IWebhookResponse = { + signature: 'mockSignature', + webhookUrl: 'mockWebhookUrl', + response: { + data: 'mockData', + headers: 'mockHeaders', + status: 400, + }, +} + +const SUCCESS_PRODUCER = { + sendMessage: jest.fn().mockReturnValue(okAsync(true)), +} as unknown as WebhookProducer + +const FAILURE_PRODUCER = { + sendMessage: jest + .fn() + .mockReturnValue(errAsync(new WebhookPushToQueueError())), +} as unknown as WebhookProducer + +const VALID_MESSAGE_BODY: WebhookQueueMessageObject = { + submissionId: new ObjectId().toHexString(), + previousAttempts: [Date.now()], + nextAttempt: Date.now(), + _v: 0, +} + +const VALID_SQS_MESSAGE: aws.SQS.Message = { + Body: JSON.stringify(VALID_MESSAGE_BODY), +} + +const MOCK_WEBHOOK_INFO = { + isRetryEnabled: true, + webhookUrl: 'some url', + webhookView: { + data: { + submissionId: VALID_MESSAGE_BODY.submissionId, + }, + }, +} as SubmissionWebhookInfo + +describe('webhook.consumer', () => { + beforeAll(async () => await dbHandler.connect()) + beforeEach(() => { + jest.clearAllMocks() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('createWebhookQueueHandler', () => { + it('should reject when message body is undefined', async () => { + const result = createWebhookQueueHandler(SUCCESS_PRODUCER)({}) + + await expect(result).toReject() + }) + + it('should reject when message body cannot be parsed', async () => { + const result = createWebhookQueueHandler(SUCCESS_PRODUCER)({ + Body: 'yoooooooooooo', + }) + + await expect(result).toReject() + }) + + it('should requeue webhook when it is not due', async () => { + const message = { + Body: JSON.stringify({ + ...VALID_MESSAGE_BODY, + // next attempt in the future + nextAttempt: addHours(Date.now(), 1).getTime(), + }), + } + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(message), + ).toResolve() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(SUCCESS_PRODUCER.sendMessage).toHaveBeenCalled() + }) + + it('should reject when it fails to requeue webhook which is not due', async () => { + const message = { + Body: JSON.stringify({ + ...VALID_MESSAGE_BODY, + // next attempt in the future + nextAttempt: addHours(Date.now(), 1).getTime(), + }), + } + + await expect( + createWebhookQueueHandler(FAILURE_PRODUCER)(message), + ).toReject() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(FAILURE_PRODUCER.sendMessage).toHaveBeenCalled() + }) + + it('should reject when submission ID cannot be found', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce(null) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toReject() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should reject when database error occurs', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockRejectedValueOnce(new Error('')) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toReject() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should resolve when form has no webhook URL', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce({ + ...MOCK_WEBHOOK_INFO, + webhookUrl: '', + }) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toResolve() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should resolve when form does not have retries enabled', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce({ + ...MOCK_WEBHOOK_INFO, + isRetryEnabled: false, + }) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toResolve() + expect(MockWebhookService.sendWebhook).not.toHaveBeenCalled() + expect(MockWebhookService.saveWebhookRecord).not.toHaveBeenCalled() + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should resolve without requeuing when webhook succeeds', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce(MOCK_WEBHOOK_INFO) + MockWebhookService.sendWebhook.mockReturnValueOnce( + okAsync(MOCK_WEBHOOK_SUCCESS_RESPONSE), + ) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toResolve() + expect(MockWebhookService.sendWebhook).toHaveBeenCalledWith( + MOCK_WEBHOOK_INFO.webhookView, + MOCK_WEBHOOK_INFO.webhookUrl, + ) + expect(MockWebhookService.saveWebhookRecord).toHaveBeenCalledWith( + VALID_MESSAGE_BODY.submissionId, + MOCK_WEBHOOK_SUCCESS_RESPONSE, + ) + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should requeue webhook when retry fails and there are retries remaining', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce(MOCK_WEBHOOK_INFO) + MockWebhookService.sendWebhook.mockReturnValueOnce( + // note failure response instead of success + okAsync(MOCK_WEBHOOK_FAILURE_RESPONSE), + ) + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(VALID_SQS_MESSAGE), + ).toResolve() + expect(MockWebhookService.sendWebhook).toHaveBeenCalledWith( + MOCK_WEBHOOK_INFO.webhookView, + MOCK_WEBHOOK_INFO.webhookUrl, + ) + expect(MockWebhookService.saveWebhookRecord).toHaveBeenCalledWith( + VALID_MESSAGE_BODY.submissionId, + MOCK_WEBHOOK_FAILURE_RESPONSE, + ) + expect(SUCCESS_PRODUCER.sendMessage).toHaveBeenCalled() + }) + + it('should resolve without requeuing when retry fails and there are no retries remaining', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce(MOCK_WEBHOOK_INFO) + MockWebhookService.sendWebhook.mockReturnValueOnce( + okAsync(MOCK_WEBHOOK_SUCCESS_RESPONSE), + ) + const message = { + Body: JSON.stringify({ + ...VALID_MESSAGE_BODY, + // length greater than max possible number of retries + previousAttempts: Array(10).fill(0), + }), + } + + await expect( + createWebhookQueueHandler(SUCCESS_PRODUCER)(message), + ).toResolve() + expect(MockWebhookService.sendWebhook).toHaveBeenCalledWith( + MOCK_WEBHOOK_INFO.webhookView, + MOCK_WEBHOOK_INFO.webhookUrl, + ) + expect(MockWebhookService.saveWebhookRecord).toHaveBeenCalledWith( + VALID_MESSAGE_BODY.submissionId, + MOCK_WEBHOOK_SUCCESS_RESPONSE, + ) + expect(SUCCESS_PRODUCER.sendMessage).not.toHaveBeenCalled() + }) + + it('should reject when retry fails and subsequently fails to be requeued', async () => { + jest + .spyOn(EncryptSubmissionModel, 'retrieveWebhookInfoById') + .mockResolvedValueOnce(MOCK_WEBHOOK_INFO) + MockWebhookService.sendWebhook.mockReturnValueOnce( + okAsync(MOCK_WEBHOOK_FAILURE_RESPONSE), + ) + + await expect( + createWebhookQueueHandler(FAILURE_PRODUCER)(VALID_SQS_MESSAGE), + ).toReject() + expect(MockWebhookService.sendWebhook).toHaveBeenCalledWith( + MOCK_WEBHOOK_INFO.webhookView, + MOCK_WEBHOOK_INFO.webhookUrl, + ) + expect(MockWebhookService.saveWebhookRecord).toHaveBeenCalledWith( + VALID_MESSAGE_BODY.submissionId, + MOCK_WEBHOOK_FAILURE_RESPONSE, + ) + expect(FAILURE_PRODUCER.sendMessage).toHaveBeenCalled() + }) + }) +}) diff --git a/src/app/modules/webhook/__tests__/webhook.message.spec.ts b/src/app/modules/webhook/__tests__/webhook.message.spec.ts new file mode 100644 index 0000000000..579a4ec0f6 --- /dev/null +++ b/src/app/modules/webhook/__tests__/webhook.message.spec.ts @@ -0,0 +1,193 @@ +import { ObjectId } from 'bson' + +import { + DUE_TIME_TOLERANCE_SECONDS, + QUEUE_MESSAGE_VERSION, + RETRY_INTERVALS, +} from '../webhook.constants' +import { + WebhookNoMoreRetriesError, + WebhookQueueMessageParsingError, +} from '../webhook.errors' +import { WebhookQueueMessage } from '../webhook.message' +import { WebhookQueueMessageObject } from '../webhook.types' +import { prettifyEpoch } from '../webhook.utils' + +describe('WebhookQueueMessage', () => { + const VALID_MESSAGE: WebhookQueueMessageObject = { + submissionId: new ObjectId().toHexString(), + previousAttempts: [Date.now()], + nextAttempt: Date.now(), + _v: 0, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('deserialise', () => { + it('should return WebhookQueueMessageParsingError when string is invalid JSON', () => { + const result = WebhookQueueMessage.deserialise('tis') + + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + WebhookQueueMessageParsingError, + ) + }) + + it('should return WebhookQueueMessageParsingError when JSON has invalid shape', () => { + const result = WebhookQueueMessage.deserialise( + JSON.stringify({ but: 'a' }), + ) + + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + WebhookQueueMessageParsingError, + ) + }) + + it('should return WebhookQueueMessageParsingError when submissionId is not an ObjectId', () => { + const result = WebhookQueueMessage.deserialise( + JSON.stringify({ + ...VALID_MESSAGE, + submissionId: 'flesh wound', + }), + ) + + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + WebhookQueueMessageParsingError, + ) + }) + + it('should return instance of WebhookQueueMessage when input is valid', () => { + const result = WebhookQueueMessage.deserialise( + JSON.stringify(VALID_MESSAGE), + ) + + expect(result._unsafeUnwrap().message).toEqual(VALID_MESSAGE) + }) + }) + + describe('fromSubmissionId', () => { + const MOCK_NOW = Date.now() + + beforeAll(() => { + jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW) + }) + + afterAll(() => jest.restoreAllMocks()) + + it('should correctly create a WebhookQueueMessage without any retry history', () => { + const submissionId = new ObjectId().toHexString() + const result = WebhookQueueMessage.fromSubmissionId(submissionId) + + expect(result._unsafeUnwrap().message).toEqual({ + submissionId, + previousAttempts: [MOCK_NOW], + nextAttempt: expect.any(Number), + _v: QUEUE_MESSAGE_VERSION, + }) + }) + }) + + describe('serialise', () => { + it('should return stringified message', () => { + const msg = new WebhookQueueMessage(VALID_MESSAGE) + + expect(msg.serialise()).toEqual(JSON.stringify(VALID_MESSAGE)) + }) + }) + + describe('isDue', () => { + const MOCK_NOW = Date.now() + + beforeAll(() => { + jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW) + }) + + afterAll(() => jest.restoreAllMocks()) + + it('should return true if nextAttempt is in the past', () => { + const msg = new WebhookQueueMessage({ + ...VALID_MESSAGE, + nextAttempt: MOCK_NOW - 1, + }) + + expect(msg.isDue()).toBe(true) + }) + + it('should return true if nextAttempt is in the future but within tolerance', () => { + const msg = new WebhookQueueMessage({ + ...VALID_MESSAGE, + nextAttempt: MOCK_NOW + DUE_TIME_TOLERANCE_SECONDS * 1000 - 1, + }) + + expect(msg.isDue()).toBe(true) + }) + + it('should return false if nextAttempt is in the future and outside tolerance', () => { + const msg = new WebhookQueueMessage({ + ...VALID_MESSAGE, + nextAttempt: MOCK_NOW + DUE_TIME_TOLERANCE_SECONDS * 1000 + 1, + }) + + expect(msg.isDue()).toBe(true) + }) + }) + + describe('incrementAttempts', () => { + it('should return incremented attempts when retries have not been exhausted', () => { + const msg = new WebhookQueueMessage(VALID_MESSAGE) + + const result = msg.incrementAttempts()._unsafeUnwrap() + + expect(result.message.previousAttempts).toEqual([ + ...VALID_MESSAGE.previousAttempts, + VALID_MESSAGE.nextAttempt, + ]) + expect(result.message.submissionId).toBe(VALID_MESSAGE.submissionId) + // nextAttempt should have been incremented + expect(result.message.nextAttempt).toBeGreaterThan( + VALID_MESSAGE.nextAttempt, + ) + }) + + it('should return WebhookNoMoreRetriesError when retries have been exhausted', () => { + const msg = new WebhookQueueMessage({ + ...VALID_MESSAGE, + // length greater than allowed number of retries + previousAttempts: Array(RETRY_INTERVALS.length).fill(0), + }) + + const result = msg.incrementAttempts()._unsafeUnwrapErr() + + expect(result).toBeInstanceOf(WebhookNoMoreRetriesError) + }) + }) + + describe('getRetriesFailedState', () => { + it('should correctly convert message to failed state', () => { + const msg = new WebhookQueueMessage(VALID_MESSAGE) + + expect(msg.getRetriesFailedState()).toEqual({ + submissionId: VALID_MESSAGE.submissionId, + previousAttempts: [ + ...VALID_MESSAGE.previousAttempts, + VALID_MESSAGE.nextAttempt, + ].map(prettifyEpoch), + _v: VALID_MESSAGE._v, + }) + }) + }) + + describe('prettify', () => { + it('should return human-readable form of message', () => { + const msg = new WebhookQueueMessage(VALID_MESSAGE) + + expect(msg.prettify()).toEqual({ + submissionId: VALID_MESSAGE.submissionId, + previousAttempts: VALID_MESSAGE.previousAttempts.map(prettifyEpoch), + nextAttempt: prettifyEpoch(VALID_MESSAGE.nextAttempt), + _v: VALID_MESSAGE._v, + }) + }) + }) +}) diff --git a/src/app/modules/webhook/__tests__/webhook.producer.spec.ts b/src/app/modules/webhook/__tests__/webhook.producer.spec.ts new file mode 100644 index 0000000000..eeb54b225d --- /dev/null +++ b/src/app/modules/webhook/__tests__/webhook.producer.spec.ts @@ -0,0 +1,145 @@ +import { ObjectId } from 'bson' +import { addHours, addMinutes, subMinutes } from 'date-fns' +import { Producer } from 'sqs-producer' +import { mocked } from 'ts-jest/utils' + +import { MAX_DELAY_SECONDS } from '../webhook.constants' +import { WebhookPushToQueueError } from '../webhook.errors' +import { WebhookQueueMessage } from '../webhook.message' +import { WebhookProducer } from '../webhook.producer' + +jest.mock('sqs-producer') +const MockSqsProducer = mocked(Producer, true) + +describe('WebhookProducer', () => { + let webhookProducer: WebhookProducer + const mockSendMessage = jest.fn() + + const MOCK_NOW = Date.now() + + const MESSAGE_BODY = { + submissionId: new ObjectId().toHexString(), + previousAttempts: [MOCK_NOW], + nextAttempt: MOCK_NOW, + _v: 0, + } + + beforeAll(() => { + MockSqsProducer.create.mockReturnValue({ + send: mockSendMessage, + } as unknown as Producer) + webhookProducer = new WebhookProducer('') + }) + + beforeEach(() => { + jest.resetAllMocks() + jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW) + }) + + describe('sendMessage', () => { + it('should return true when message is sent on first try', async () => { + mockSendMessage.mockResolvedValueOnce([]) + const webhookMessage = new WebhookQueueMessage(MESSAGE_BODY) + + const result = await webhookProducer.sendMessage(webhookMessage) + + expect(result._unsafeUnwrap()).toBe(true) + expect(mockSendMessage).toHaveBeenCalledTimes(1) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: expect.any(Number), + }) + }) + + it('should return true when message fails on first try, but subsequently succeeds', async () => { + mockSendMessage + .mockRejectedValueOnce(new Error('')) + .mockResolvedValueOnce([]) + const webhookMessage = new WebhookQueueMessage(MESSAGE_BODY) + + const result = await webhookProducer.sendMessage(webhookMessage) + + expect(result._unsafeUnwrap()).toBe(true) + expect(mockSendMessage).toHaveBeenCalledTimes(2) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: expect.any(Number), + }) + }) + + it('should return WebhookPushToQueueError when message fails all attempts to be sent', async () => { + mockSendMessage.mockRejectedValue(new Error('')) + const webhookMessage = new WebhookQueueMessage(MESSAGE_BODY) + + const result = await webhookProducer.sendMessage(webhookMessage, { + minTimeout: 0, + }) + + expect(result._unsafeUnwrapErr()).toEqual(new WebhookPushToQueueError()) + // promise-retry retries 10 times by default, so total is 1 try + 10 retries = 11 + expect(mockSendMessage).toHaveBeenCalledTimes(11) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: expect.any(Number), + }) + }) + + it('should queue message with 0 delay when nextAttempt is in the past', async () => { + mockSendMessage.mockResolvedValueOnce([]) + const webhookMessage = new WebhookQueueMessage({ + ...MESSAGE_BODY, + nextAttempt: subMinutes(MOCK_NOW, 10).getTime(), + }) + + const result = await webhookProducer.sendMessage(webhookMessage) + + expect(result._unsafeUnwrap()).toBe(true) + expect(mockSendMessage).toHaveBeenCalledTimes(1) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: 0, + }) + }) + + it('should queue message with a maximum of 15min delay when nextAttempt is in the future', async () => { + mockSendMessage.mockResolvedValueOnce([]) + const webhookMessage = new WebhookQueueMessage({ + ...MESSAGE_BODY, + nextAttempt: addHours(MOCK_NOW, 10).getTime(), + }) + + const result = await webhookProducer.sendMessage(webhookMessage) + + expect(result._unsafeUnwrap()).toBe(true) + expect(mockSendMessage).toHaveBeenCalledTimes(1) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: MAX_DELAY_SECONDS, + }) + }) + + it('should queue message with exactly the required delay of nextAttempt is less than 15min in the future', async () => { + const minutesInFuture = 10 + mockSendMessage.mockResolvedValueOnce([]) + const webhookMessage = new WebhookQueueMessage({ + ...MESSAGE_BODY, + nextAttempt: addMinutes(MOCK_NOW, minutesInFuture).getTime(), + }) + + const result = await webhookProducer.sendMessage(webhookMessage) + + expect(result._unsafeUnwrap()).toBe(true) + expect(mockSendMessage).toHaveBeenCalledTimes(1) + expect(mockSendMessage).toHaveBeenCalledWith({ + body: JSON.stringify(webhookMessage.message), + id: webhookMessage.submissionId, + delaySeconds: minutesInFuture * 60, + }) + }) + }) +}) diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts index e0e5485dda..c763f183aa 100644 --- a/src/app/modules/webhook/__tests__/webhook.service.spec.ts +++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts @@ -1,10 +1,10 @@ import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' -import { ObjectID } from 'bson' +import { ObjectId } from 'bson' import mongoose from 'mongoose' +import { ok, okAsync } from 'neverthrow' import { mocked } from 'ts-jest/utils' import formsgSdk from 'src/app/config/formsg-sdk' -import getFormModel from 'src/app/models/form.server.model' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors' import * as WebhookValidationModule from 'src/app/modules/webhook/webhook.validation' @@ -12,14 +12,15 @@ import { transformMongoError } from 'src/app/utils/handle-mongo-error' import { IEncryptedSubmissionSchema, IWebhookResponse, - ResponseMode, WebhookView, } from 'src/types' import dbHandler from 'tests/unit/backend/helpers/jest-db' import { SubmissionNotFoundError } from '../../submission/submission.errors' -import { saveWebhookRecord, sendWebhook } from '../webhook.service' +import { WebhookQueueMessage } from '../webhook.message' +import { WebhookProducer } from '../webhook.producer' +import * as WebhookService from '../webhook.service' // define suite-wide mocks jest.mock('axios') @@ -28,10 +29,16 @@ const MockAxios = mocked(axios, true) jest.mock('src/app/modules/webhook/webhook.validation') const MockWebhookValidationModule = mocked(WebhookValidationModule, true) -// define test constants -const FormModel = getFormModel(mongoose) +jest.mock('src/app/config/formsg-sdk') +const MockFormSgSdk = mocked(formsgSdk, true) + +jest.mock('../webhook.message.ts') +const MockWebhookQueueMessage = mocked(WebhookQueueMessage, true) + const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) +// define test constants + const MOCK_WEBHOOK_URL = 'https://form.gov.sg/endpoint' const DEFAULT_ERROR_MSG = 'a generic error has occurred' const AXIOS_ERROR_MSG = 'an axios error has occurred' @@ -68,18 +75,30 @@ const MOCK_WEBHOOK_FAILURE_RESPONSE: Pick = { headers: '{}', }, } -const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick< - IWebhookResponse, - 'response' -> = { - response: { - data: '', - status: 0, - headers: '', - }, -} +const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick = + { + response: { + data: '', + status: 0, + headers: '', + }, + } describe('webhook.service', () => { + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_SUBMISSION_ID = new ObjectId().toHexString() + const MOCK_WEBHOOK_VIEW: WebhookView = { + data: { + created: new Date(), + encryptedContent: 'mockEncryptedContent', + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + verifiedContent: 'mockVerifiedContent', + version: 1, + }, + } + const MOCK_SIGNATURE = 'mockSignature' + beforeAll(async () => await dbHandler.connect()) afterEach(async () => { await dbHandler.clearDatabase() @@ -87,57 +106,26 @@ describe('webhook.service', () => { afterAll(async () => await dbHandler.closeDatabase()) // test variables - let testEncryptedSubmission: IEncryptedSubmissionSchema let testConfig: AxiosRequestConfig - let testSubmissionWebhookView: WebhookView | null - let testSignature: string beforeEach(async () => { jest.restoreAllMocks() - // prepare for form creation workflow - const MOCK_ADMIN_OBJ_ID = new ObjectID() const MOCK_EPOCH = 1487076708000 - const preloaded = await dbHandler.insertFormCollectionReqs({ - userId: MOCK_ADMIN_OBJ_ID, - }) - jest.spyOn(Date, 'now').mockImplementation(() => MOCK_EPOCH) - // instantiate new form and save - const testEncryptedForm = await FormModel.create({ - title: 'Test Form', - admin: preloaded.user._id, - responseMode: ResponseMode.Encrypt, - publicKey: 'fake-public-key', - }) - - // initialise encrypted submussion - testEncryptedSubmission = await EncryptSubmissionModel.create({ - form: testEncryptedForm._id, - authType: testEncryptedForm.authType, - myInfoFields: [], - encryptedContent: 'encrypted-content', - verifiedContent: 'verified-content', - version: 1, - webhookResponses: [], - }) - - // initialise webhook related variables - testSubmissionWebhookView = testEncryptedSubmission.getWebhookView() - - testSignature = formsgSdk.webhooks.generateSignature({ - uri: MOCK_WEBHOOK_URL, - submissionId: testEncryptedSubmission._id, - formId: testEncryptedForm._id, - epoch: MOCK_EPOCH, - }) + MockFormSgSdk.webhooks.generateSignature.mockReturnValueOnce(MOCK_SIGNATURE) + const mockWebhookHeader = `t=${MOCK_EPOCH},s=${MOCK_SUBMISSION_ID},f=${MOCK_FORM_ID},v1=${MOCK_SIGNATURE}` + MockFormSgSdk.webhooks.constructHeader.mockReturnValueOnce( + mockWebhookHeader, + ) testConfig = { headers: { - 'X-FormSG-Signature': `t=${MOCK_EPOCH},s=${testEncryptedSubmission._id},f=${testEncryptedForm._id},v1=${testSignature}`, + 'X-FormSG-Signature': mockWebhookHeader, }, maxRedirects: 0, + timeout: 10000, } }) @@ -146,7 +134,7 @@ describe('webhook.service', () => { // Arrange const mockWebhookResponse = { ...MOCK_WEBHOOK_SUCCESS_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } as IWebhookResponse @@ -157,8 +145,8 @@ describe('webhook.service', () => { .mockRejectedValueOnce(mockDBError) // Act - const actual = await saveWebhookRecord( - testEncryptedSubmission._id, + const actual = await WebhookService.saveWebhookRecord( + MOCK_SUBMISSION_ID, mockWebhookResponse, ) @@ -166,7 +154,7 @@ describe('webhook.service', () => { const expectedError = transformMongoError(mockDBError) expect(addWebhookResponseSpy).toHaveBeenCalledWith( - testEncryptedSubmission._id, + MOCK_SUBMISSION_ID, mockWebhookResponse, ) expect(actual._unsafeUnwrapErr()).toEqual(expectedError) @@ -176,13 +164,13 @@ describe('webhook.service', () => { // Arrange const mockWebhookResponse = { ...MOCK_WEBHOOK_SUCCESS_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } as IWebhookResponse // Act - const actual = await saveWebhookRecord( - new ObjectID(), + const actual = await WebhookService.saveWebhookRecord( + new ObjectId(), mockWebhookResponse, ) @@ -197,15 +185,15 @@ describe('webhook.service', () => { it('should return updated submission with new webhook response if the record is successfully saved', async () => { // Arrange const mockWebhookResponse = { - _id: testEncryptedSubmission._id, - created: testEncryptedSubmission.created, + _id: MOCK_SUBMISSION_ID, + created: new Date(), ...MOCK_WEBHOOK_SUCCESS_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } as IWebhookResponse const expectedSubmission = new EncryptSubmissionModel({ - ...testEncryptedSubmission, + _id: MOCK_SUBMISSION_ID, }) expectedSubmission.webhookResponses = [mockWebhookResponse] @@ -214,14 +202,14 @@ describe('webhook.service', () => { .mockResolvedValue(expectedSubmission) // Act - const actual = await saveWebhookRecord( - testEncryptedSubmission._id, + const actual = await WebhookService.saveWebhookRecord( + MOCK_SUBMISSION_ID, mockWebhookResponse, ) // Assert expect(addWebhookResponseSpy).toHaveBeenCalledWith( - testEncryptedSubmission._id, + MOCK_SUBMISSION_ID, mockWebhookResponse, ) expect(actual._unsafeUnwrap()).toEqual(expectedSubmission) @@ -236,8 +224,8 @@ describe('webhook.service', () => { ) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) @@ -257,8 +245,8 @@ describe('webhook.service', () => { ) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) @@ -287,28 +275,28 @@ describe('webhook.service', () => { toJSON: () => jest.fn(), } - expect( - MockWebhookValidationModule.validateWebhookUrl, - ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) MockAxios.post.mockRejectedValue(MOCK_AXIOS_ERROR) MockAxios.isAxiosError.mockReturnValue(true) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) // Assert const expectedResult = { ...MOCK_WEBHOOK_FAILURE_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } + expect( + MockWebhookValidationModule.validateWebhookUrl, + ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, - testSubmissionWebhookView, + MOCK_WEBHOOK_VIEW, testConfig, ) expect(actual._unsafeUnwrap()).toEqual(expectedResult) @@ -322,15 +310,15 @@ describe('webhook.service', () => { MockAxios.isAxiosError.mockReturnValue(false) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) // Assert const expectedResult = { ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } @@ -339,7 +327,7 @@ describe('webhook.service', () => { ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, - testSubmissionWebhookView, + MOCK_WEBHOOK_VIEW, testConfig, ) expect(actual._unsafeUnwrap()).toEqual(expectedResult) @@ -355,15 +343,15 @@ describe('webhook.service', () => { MockAxios.isAxiosError.mockReturnValue(false) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) // Assert const expectedResult = { ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } @@ -372,7 +360,7 @@ describe('webhook.service', () => { ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, - testSubmissionWebhookView, + MOCK_WEBHOOK_VIEW, testConfig, ) expect(actual._unsafeUnwrap()).toEqual(expectedResult) @@ -385,15 +373,15 @@ describe('webhook.service', () => { MockAxios.post.mockResolvedValue(MOCK_AXIOS_SUCCESS_RESPONSE) // Act - const actual = await sendWebhook( - testEncryptedSubmission, + const actual = await WebhookService.sendWebhook( + MOCK_WEBHOOK_VIEW, MOCK_WEBHOOK_URL, ) // Assert const expectedResult = { ...MOCK_WEBHOOK_SUCCESS_RESPONSE, - signature: testSignature, + signature: MOCK_SIGNATURE, webhookUrl: MOCK_WEBHOOK_URL, } @@ -402,10 +390,85 @@ describe('webhook.service', () => { ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, - testSubmissionWebhookView, + MOCK_WEBHOOK_VIEW, testConfig, ) expect(actual._unsafeUnwrap()).toEqual(expectedResult) }) }) + + describe('createInitialWebhookSender', () => { + // This suite only checks for correct behaviour for webhook retries, + // since there are separate tests for sending webhooks and saving + // responses to the database. + let testSubmission: IEncryptedSubmissionSchema + const MOCK_PRODUCER = { + sendMessage: jest.fn().mockReturnValue(okAsync(true)), + } as unknown as WebhookProducer + beforeEach(() => { + jest.clearAllMocks() + + testSubmission = new EncryptSubmissionModel({ + _id: MOCK_SUBMISSION_ID, + }) + jest + .spyOn(EncryptSubmissionModel, 'addWebhookResponse') + .mockResolvedValue(testSubmission) + MockWebhookValidationModule.validateWebhookUrl.mockResolvedValue() + }) + + it('should return true without retrying when webhook is successful and retries are enabled', async () => { + MockAxios.post.mockResolvedValue(MOCK_AXIOS_SUCCESS_RESPONSE) + + const result = await WebhookService.createInitialWebhookSender( + MOCK_PRODUCER, + )(testSubmission, MOCK_WEBHOOK_URL, /* isRetryEnabled= */ true) + + expect(result._unsafeUnwrap()).toBe(true) + expect(MockWebhookQueueMessage.fromSubmissionId).not.toHaveBeenCalled() + }) + + it('should return true without retrying when webhook fails but retries are not enabled globally', async () => { + MockAxios.post.mockResolvedValue(MOCK_AXIOS_SUCCESS_RESPONSE) + + const result = await WebhookService + .createInitialWebhookSender + // no producer passed to createInitialWebhookSender, so retries not enabled globally + ()(testSubmission, MOCK_WEBHOOK_URL, true) + + expect(result._unsafeUnwrap()).toBe(true) + expect(MockWebhookQueueMessage.fromSubmissionId).not.toHaveBeenCalled() + }) + + it('should return true without retrying when webhook fails and retries are not enabled for form', async () => { + MockAxios.post.mockResolvedValue(MOCK_AXIOS_FAILURE_RESPONSE) + + const result = await WebhookService + .createInitialWebhookSender + // no producer passed to createInitialWebhookSender, so retries not enabled globally + ()(testSubmission, MOCK_WEBHOOK_URL, /* isRetryEnabled= */ false) + + expect(result._unsafeUnwrap()).toBe(true) + expect(MockWebhookQueueMessage.fromSubmissionId).not.toHaveBeenCalled() + }) + + it('should return true and retry when webhook fails and retries are enabled', async () => { + const mockQueueMessage = + 'mockQueueMessage' as unknown as WebhookQueueMessage + MockWebhookQueueMessage.fromSubmissionId.mockReturnValueOnce( + ok(mockQueueMessage), + ) + MockAxios.post.mockResolvedValue(MOCK_AXIOS_FAILURE_RESPONSE) + + const result = await WebhookService.createInitialWebhookSender( + MOCK_PRODUCER, + )(testSubmission, MOCK_WEBHOOK_URL, /* isRetryEnabled= */ true) + + expect(result._unsafeUnwrap()).toBe(true) + expect(MockWebhookQueueMessage.fromSubmissionId).toHaveBeenCalledWith( + String(testSubmission._id), + ) + expect(MOCK_PRODUCER.sendMessage).toHaveBeenCalledWith(mockQueueMessage) + }) + }) }) diff --git a/src/app/modules/webhook/__tests__/webhook.utils.spec.ts b/src/app/modules/webhook/__tests__/webhook.utils.spec.ts new file mode 100644 index 0000000000..7dc36f52f1 --- /dev/null +++ b/src/app/modules/webhook/__tests__/webhook.utils.spec.ts @@ -0,0 +1,71 @@ +import { addHours, addMinutes } from 'date-fns' +import { last } from 'lodash' +import { mocked } from 'ts-jest/utils' + +import { randomUniformInt } from 'src/app/utils/random-uniform' + +import { MAX_DELAY_SECONDS, RETRY_INTERVALS } from '../webhook.constants' +import { WebhookNoMoreRetriesError } from '../webhook.errors' +import { calculateDelaySeconds, getNextAttempt } from '../webhook.utils' + +jest.mock('src/app/utils/random-uniform') +const MockRandomUniformInt = mocked(randomUniformInt, true) + +describe('webhook.utils', () => { + const MOCK_NOW = Date.now() + const MOCK_RANDOM_INT = 37 + + beforeAll(() => { + jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW) + MockRandomUniformInt.mockReturnValue(MOCK_RANDOM_INT) + }) + describe('getNextAttempt', () => { + it('should return WebhookNoMoreRetriesError when retry limit is exceeded', () => { + // array of previous attempts is equal to RETRY_INTERVALS + 1, meaning + // all retries are used up (in addition to 1 initial webhook attempt) + const result = getNextAttempt(Array(RETRY_INTERVALS.length + 1).fill(0)) + + expect(result._unsafeUnwrapErr()).toEqual(new WebhookNoMoreRetriesError()) + }) + + it('should return time of next attempt correctly when there are retries remaining', () => { + // total number of allowed attempts is RETRY_INTERVALS.length + 1, with the +1 + // accounting for the initial attempt + const result = getNextAttempt( + Array(RETRY_INTERVALS.length).fill(MOCK_NOW), + ) + + const finalRetryInterval = last(RETRY_INTERVALS)! + expect(MockRandomUniformInt).toHaveBeenCalledWith( + finalRetryInterval.base - finalRetryInterval.jitter, + finalRetryInterval.base + finalRetryInterval.jitter, + ) + // previousAttempts array was filled with MOCK_NOW, so next attempt is calculated + // from MOCK_NOW + expect(result._unsafeUnwrap()).toBe(MOCK_NOW + MOCK_RANDOM_INT * 1000) + }) + }) + + describe('calculateDelaySeconds', () => { + it('should return 0 when nextAttempt is in the past', () => { + const result = calculateDelaySeconds(MOCK_NOW - 1000) + + expect(result).toBe(0) + }) + + it('should return a maximum of 15min regardless of how far nextAttempt is in the future', () => { + const result = calculateDelaySeconds(addHours(MOCK_NOW, 12).getTime()) + + expect(result).toBe(MAX_DELAY_SECONDS) + }) + + it('should return exactly the time to nextAttempt if it is less than 15min in the future', () => { + const minutesInFuture = 10 + const result = calculateDelaySeconds( + addMinutes(MOCK_NOW, minutesInFuture).getTime(), + ) + + expect(result).toBe(minutesInFuture * 60) + }) + }) +}) diff --git a/src/app/modules/webhook/webhook.constants.ts b/src/app/modules/webhook/webhook.constants.ts new file mode 100644 index 0000000000..1001941bc9 --- /dev/null +++ b/src/app/modules/webhook/webhook.constants.ts @@ -0,0 +1,52 @@ +import config from '../../config/config' + +import { RetryInterval } from './webhook.types' + +/** + * Current version of queue message format. + */ +export const QUEUE_MESSAGE_VERSION = 0 + +// Conversion to seconds +const hours = (h: number) => h * 60 * 60 +const minutes = (m: number) => m * 60 + +/** + * Encodes retry policy. + * Element 0 is time to wait + jitter before + * retrying the first time, element 1 is time to wait + * to wait + jitter before 2nd time, etc. + * All units are in seconds. + * + * @example [{ base: 10, jitter: 5}, { base: 20, jitter: 5 }] means + * the first retry is attempted between 10 - 5 = 5 seconds and + * 10 + 5 = 15 seconds after the submission. If the first retry fails, + * then the second retry is attempted between 15 and 25 seconds after + * the submission. + */ +export const RETRY_INTERVALS: RetryInterval[] = config.isDev + ? [ + { base: 10, jitter: 5 }, + { base: 20, jitter: 5 }, + { base: 30, jitter: 5 }, + ] + : [ + { base: minutes(5), jitter: minutes(1) }, + { base: hours(1), jitter: minutes(15) }, + { base: hours(2), jitter: minutes(30) }, + { base: hours(4), jitter: hours(1) }, + { base: hours(8), jitter: hours(2) }, + { base: hours(20), jitter: hours(4) }, + ] + +/** + * Max possible delay for a message, as specified by AWS. + */ +export const MAX_DELAY_SECONDS = minutes(15) + +/** + * Tolerance allowed for determining if a message is due to be sent. + * If a message's next attempt is scheduled either in the past or this + * number of seconds in the future, it will be sent. + */ +export const DUE_TIME_TOLERANCE_SECONDS = minutes(1) diff --git a/src/app/modules/webhook/webhook.consumer.ts b/src/app/modules/webhook/webhook.consumer.ts new file mode 100644 index 0000000000..1af9de0126 --- /dev/null +++ b/src/app/modules/webhook/webhook.consumer.ts @@ -0,0 +1,258 @@ +import aws from 'aws-sdk' +import https from 'https' +import mongoose from 'mongoose' +import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import { Consumer } from 'sqs-consumer' + +import { SubmissionWebhookInfo } from '../../../types' +import config from '../../config/config' +import { createLoggerWithLabel } from '../../config/logger' +import { getEncryptSubmissionModel } from '../../models/submission.server.model' +import { transformMongoError } from '../../utils/handle-mongo-error' +import { PossibleDatabaseError } from '../core/core.errors' +import { SubmissionNotFoundError } from '../submission/submission.errors' + +import { + WebhookNoMoreRetriesError, + WebhookPushToQueueError, + WebhookRetriesNotEnabledError, + WebhookValidationError, +} from './webhook.errors' +import { WebhookQueueMessage } from './webhook.message' +import { WebhookProducer } from './webhook.producer' +import * as WebhookService from './webhook.service' +import { isSuccessfulResponse } from './webhook.utils' + +const logger = createLoggerWithLabel(module) +const EncryptSubmission = getEncryptSubmissionModel(mongoose) + +/** + * Starts polling a queue for webhook messages. + * @param queueUrl URL of queue from which to consume messages + * @param producer Producer which can be used to enqueue messages + */ +export const startWebhookConsumer = ( + queueUrl: string, + producer: WebhookProducer, +): void => { + const app = Consumer.create({ + queueUrl, + region: config.aws.region, + handleMessage: createWebhookQueueHandler(producer), + // By default, the default Node.js HTTP/HTTPS SQS agent + // creates a new TCP connection for every new request. + // In production, pass an SQS instance to avoid the cost + // of establishing new connections. + sqs: config.isDev + ? undefined + : new aws.SQS({ + region: config.aws.region, + httpOptions: { + agent: new https.Agent({ + keepAlive: true, + }), + }, + }), + }) + + app.on('error', (error, message) => { + logger.error({ + message: + 'Webhook consumer encountered error while interacting with queue', + meta: { + action: 'startWebhookConsumer', + message, + }, + error, + }) + }) + + app.start() + + logger.info({ + message: 'Webhook consumer started', + meta: { + action: 'startWebhookConsumer', + }, + }) +} + +/** + * Creates a handler to consume messages from webhook queue. + * This handler does the following: + * 1) Parses the message + * 2) If the webhook is not due, requeues the message + * 3) If the webhook is due, attempts the webhook + * 4) Records the webhook attempt in the database + * 5) If the webhook failed again, requeues the message + * + * Exported for testing. + * @param producer Producer which can write messages to queue + * @returns Handler for consumption of queue messages + */ +export const createWebhookQueueHandler = + (producer: WebhookProducer) => + async (sqsMessage: aws.SQS.Message): Promise => { + const { Body, MessageId } = sqsMessage + const logMeta = { + action: 'createWebhookQueueHandler', + MessageId, + } + logger.info({ + message: 'Consumed message from webhook queue', + meta: logMeta, + }) + if (!Body) { + logger.error({ + message: 'Webhook queue message contained undefined body', + meta: logMeta, + }) + // Malformed message will be retried until redrive policy is exceeded, + // upon which it will be moved to dead-letter queue + return Promise.reject() + } + + // Parse message + const webhookMessageResult = WebhookQueueMessage.deserialise(Body) + if (webhookMessageResult.isErr()) { + logger.error({ + message: 'Webhook queue message could not be parsed', + meta: logMeta, + error: webhookMessageResult.error, + }) + return Promise.reject() + } + const webhookMessage = webhookMessageResult.value + + // If not due, requeue + if (!webhookMessage.isDue()) { + logger.info({ + message: 'Webhook not due yet, requeueing', + meta: logMeta, + }) + const requeueResult = await producer.sendMessage(webhookMessage) + if (requeueResult.isErr()) { + logger.error({ + message: 'Webhook queue message could not be requeued', + meta: { + ...logMeta, + webhookMessage: webhookMessage.prettify(), + }, + error: requeueResult.error, + }) + // Reject so message is moved to DLQ + return Promise.reject() + } + // Delete existing message from queue + return Promise.resolve() + } + + // If due, send webhook + // First, retrieve webhook view and URL from database + const retryResult = await retrieveWebhookInfo( + webhookMessage.submissionId, + ).andThen< + true, + | WebhookRetriesNotEnabledError + | WebhookValidationError + | WebhookNoMoreRetriesError + | WebhookPushToQueueError + >((webhookInfo) => { + const { webhookUrl, isRetryEnabled } = webhookInfo + // Webhook URL was deleted or retries disabled + if (!webhookUrl || !isRetryEnabled) + return errAsync( + new WebhookRetriesNotEnabledError(webhookUrl, isRetryEnabled), + ) + + // Attempt webhook + return WebhookService.sendWebhook( + webhookInfo.webhookView, + webhookUrl, + ).andThen((webhookResponse) => { + // Save webhook response to database, but carry on even if it fails + void WebhookService.saveWebhookRecord( + webhookMessage.submissionId, + webhookResponse, + ) + + // Webhook was successful, no further action required + if (isSuccessfulResponse(webhookResponse)) return okAsync(true) + + // Requeue webhook for subsequent retry + return webhookMessage + .incrementAttempts() + .asyncAndThen((newMessage) => producer.sendMessage(newMessage)) + }) + }) + + if (retryResult.isOk()) return Promise.resolve() + // Error cases + // Special handling for max retries exceeded - log a separate message + // and resolve Promise so that message is removed from queue + if (retryResult.error instanceof WebhookNoMoreRetriesError) { + logger.warn({ + message: 'Maximum retries exceeded for webhook', + meta: { + action: 'createWebhookQueueHandler', + webhookMessage: webhookMessage.getRetriesFailedState(), + }, + }) + return Promise.resolve() + } + // Special handling for retries not enabled - this should not be moved + // to DLQ as admin has disabled webhooks and/or webhook retries on purpose + if (retryResult.error instanceof WebhookRetriesNotEnabledError) { + logger.warn({ + message: 'Webhook retries no longer enabled on form', + meta: { + action: 'createWebhookQueueHandler', + webhookMessage: webhookMessage.prettify(), + }, + }) + return Promise.resolve() + } + // Remaining cases are unexpected errors, move to DLQ + logger.error({ + message: 'Error while attempting to retry webhook', + meta: { + action: 'createWebhookQueueHandler', + webhookMessage: webhookMessage.prettify(), + }, + error: retryResult.error, + }) + // Reject so retry can be moved to dead-letter queue + // if redrive policy is exceeded + return Promise.reject() + } + +/** + * Retrieves all relevant information to send webhook for a given submission. + * @param submissionId + * @returns ok(webhook information) if database retrieval succeeds + * @returns err if submission ID does not exist or database retrieval errors + */ +const retrieveWebhookInfo = ( + submissionId: string, +): ResultAsync< + SubmissionWebhookInfo, + SubmissionNotFoundError | PossibleDatabaseError +> => { + return ResultAsync.fromPromise( + EncryptSubmission.retrieveWebhookInfoById(submissionId), + (error) => { + logger.error({ + message: 'Error while retrieving webhook info for submission', + meta: { + action: 'retrieveWebhookInfo', + submissionId, + }, + error, + }) + return transformMongoError(error) + }, + ).andThen((submissionInfo) => { + if (!submissionInfo) return errAsync(new SubmissionNotFoundError()) + return okAsync(submissionInfo) + }) +} diff --git a/src/app/modules/webhook/webhook.errors.ts b/src/app/modules/webhook/webhook.errors.ts index 210f4ff2ce..426d32f170 100644 --- a/src/app/modules/webhook/webhook.errors.ts +++ b/src/app/modules/webhook/webhook.errors.ts @@ -39,3 +39,61 @@ export class WebhookFailedWithAxiosError extends ApplicationError { this.meta = { originalError: error } } } + +/** + * Webhook queue message incorrectly formatted and hence could not be parsed + */ +export class WebhookQueueMessageParsingError extends ApplicationError { + meta: { + originalError: unknown + } + + constructor( + error: unknown, + message = 'Unable to parse body of webhook queue message', + ) { + super(message) + this.meta = { originalError: error } + } +} + +/** + * Maximum retries exceeded for webhook. + */ +export class WebhookNoMoreRetriesError extends ApplicationError { + constructor(message = 'Maximum retries exceeded for webhook') { + super(message) + } +} + +/** + * Failed to push message to SQS. + */ +export class WebhookPushToQueueError extends ApplicationError { + constructor(message = 'Failed to push webhook to message queue') { + super(message) + } +} + +/** + * Cannot send webhook retry because form has no webhook URL or does not have + * retries enabled. + */ +export class WebhookRetriesNotEnabledError extends ApplicationError { + meta: { + webhookUrl: string + isRetryEnabled: boolean + } + + constructor( + webhookUrl: string, + isRetryEnabled: boolean, + message = 'Unable to send webhook as form has no webhook URL or does not have retries enabled', + ) { + super(message) + this.meta = { + webhookUrl, + isRetryEnabled, + } + } +} diff --git a/src/app/modules/webhook/webhook.factory.ts b/src/app/modules/webhook/webhook.factory.ts index 9e2db715d3..f65b3aed22 100644 --- a/src/app/modules/webhook/webhook.factory.ts +++ b/src/app/modules/webhook/webhook.factory.ts @@ -6,22 +6,34 @@ import FeatureManager, { } from '../../config/feature-manager' import { MissingFeatureError } from '../core/core.errors' +import { startWebhookConsumer } from './webhook.consumer' +import { WebhookProducer } from './webhook.producer' import * as WebhookService from './webhook.service' interface IWebhookFactory { - sendWebhook: typeof WebhookService.sendWebhook - saveWebhookRecord: typeof WebhookService.saveWebhookRecord + sendInitialWebhook: ReturnType< + typeof WebhookService.createInitialWebhookSender + > } export const createWebhookFactory = ({ isEnabled, props, }: RegisteredFeature): IWebhookFactory => { - if (isEnabled && props) return WebhookService + if (isEnabled && props) { + const { webhookQueueUrl } = props + let producer: WebhookProducer | undefined + if (webhookQueueUrl) { + producer = new WebhookProducer(webhookQueueUrl) + startWebhookConsumer(webhookQueueUrl, producer) + } + return { + sendInitialWebhook: WebhookService.createInitialWebhookSender(producer), + } + } const error = new MissingFeatureError(FeatureNames.SpcpMyInfo) return { - sendWebhook: () => errAsync(error), - saveWebhookRecord: () => errAsync(error), + sendInitialWebhook: () => errAsync(error), } } diff --git a/src/app/modules/webhook/webhook.message.ts b/src/app/modules/webhook/webhook.message.ts new file mode 100644 index 0000000000..c26089749c --- /dev/null +++ b/src/app/modules/webhook/webhook.message.ts @@ -0,0 +1,181 @@ +import { differenceInSeconds } from 'date-fns' +import { Result } from 'neverthrow' + +import { createLoggerWithLabel } from '../../config/logger' + +import { + DUE_TIME_TOLERANCE_SECONDS, + QUEUE_MESSAGE_VERSION, +} from './webhook.constants' +import { + WebhookNoMoreRetriesError, + WebhookQueueMessageParsingError, +} from './webhook.errors' +import { + WebhookFailedQueueMessage, + webhookMessageSchema, + WebhookQueueMessageObject, + WebhookQueueMessagePrettified, +} from './webhook.types' +import { getNextAttempt, prettifyEpoch } from './webhook.utils' + +const logger = createLoggerWithLabel(module) + +/** + * Encapsulates a queue message for webhook retries. + */ +export class WebhookQueueMessage { + message: WebhookQueueMessageObject + + constructor(message: WebhookQueueMessageObject) { + this.message = message + } + + /** + * Converts a webhook queue message body into an encapsulated + * class instance. + * @param body Raw body of webhook queue message + * @returns ok(encapsulated message) if message can be parsed successfully + * @returns err if message fails to be parsed + */ + static deserialise( + body: string, + ): Result { + return Result.fromThrowable( + () => JSON.parse(body) as unknown, + (error) => { + logger.error({ + message: 'Unable to parse webhook queue message body', + meta: { + action: 'deserialise', + body, + }, + error, + }) + return new WebhookQueueMessageParsingError(error) + }, + )() + .andThen((parsed) => + Result.fromThrowable( + () => webhookMessageSchema.parse(parsed), + (error) => { + logger.error({ + message: 'Webhook queue message body has wrong shape', + meta: { + action: 'deserialise', + body, + }, + error, + }) + return new WebhookQueueMessageParsingError(error) + }, + )(), + ) + .map((validated) => new WebhookQueueMessage(validated)) + } + + /** + * Initialises a webhook queue message which has not been + * retried as yet. This function succeeds as long as + * the retry policy allows for at least one retry. + * + * Assumes that initial webhook has just been attempted, + * hence uses the current date as the time of the first + * webhook attempt. + * @param submissionId + * @returns ok(encapsulated message) if retry policy exists + * @returns err if the retry policy does not allow any retries + */ + static fromSubmissionId( + submissionId: string, + ): Result { + const initialAttempt = Date.now() + return getNextAttempt(/* previousAttempts =*/ [initialAttempt]).map( + (nextAttempt) => + new WebhookQueueMessage({ + submissionId, + previousAttempts: [initialAttempt], + nextAttempt, + _v: QUEUE_MESSAGE_VERSION, + }), + ) + } + + /** + * Serialises for enqueueing. + * @returns Serialised message + */ + serialise(): string { + return JSON.stringify(this.message) + } + + /** + * Determines whether the message is currently due to be sent. + * @returns true if webhook is currently due to be sent, false otherwise + */ + isDue(): boolean { + // Allow tolerance for clock drift + return ( + // Argument order is important. If nextAttempt is in the past, + // differenceInSeconds will return a negative number. + differenceInSeconds(this.message.nextAttempt, Date.now()) <= + DUE_TIME_TOLERANCE_SECONDS + ) + } + + /** + * Updates the message as having just been retried, and adds a new time for the + * next attempt. + * This function should only be called on a message for which the webhook has just + * been attempted and failed. + * @returns ok(WebhookQueueMessage) if message can still be retried + * @returns err(WebhookNoMoreRetriesError) if max retries have been exceeded + */ + incrementAttempts(): Result { + const updatedPreviousAttempts = [ + ...this.message.previousAttempts, + this.message.nextAttempt, + ] + return getNextAttempt(updatedPreviousAttempts).map( + (nextAttempt) => + new WebhookQueueMessage({ + submissionId: this.message.submissionId, + previousAttempts: updatedPreviousAttempts, + nextAttempt, + _v: QUEUE_MESSAGE_VERSION, + }), + ) + } + + /** + * Converts a message to reflect that all retries have failed. + * @returns Message converted into a failure shape + */ + getRetriesFailedState(): WebhookFailedQueueMessage { + return { + submissionId: this.submissionId, + previousAttempts: [ + ...this.message.previousAttempts, + this.nextAttempt, + ].map(prettifyEpoch), + _v: this.message._v, + } + } + + prettify(): WebhookQueueMessagePrettified { + return { + submissionId: this.submissionId, + previousAttempts: this.message.previousAttempts.map(prettifyEpoch), + nextAttempt: prettifyEpoch(this.nextAttempt), + _v: this.message._v, + } + } + + get submissionId(): string { + return this.message.submissionId + } + + get nextAttempt(): number { + return this.message.nextAttempt + } +} diff --git a/src/app/modules/webhook/webhook.producer.ts b/src/app/modules/webhook/webhook.producer.ts new file mode 100644 index 0000000000..f90aec62c7 --- /dev/null +++ b/src/app/modules/webhook/webhook.producer.ts @@ -0,0 +1,79 @@ +import { ResultAsync } from 'neverthrow' +import promiseRetry from 'promise-retry' +import { OperationOptions } from 'retry' +import { Producer } from 'sqs-producer' + +import config from '../../config/config' +import { createLoggerWithLabel } from '../../config/logger' + +import { WebhookPushToQueueError } from './webhook.errors' +import { WebhookQueueMessage } from './webhook.message' +import { calculateDelaySeconds } from './webhook.utils' + +const logger = createLoggerWithLabel(module) + +/** + * Encapsulates a producer which can write webhook retry messages + * to a message queue. + */ +export class WebhookProducer { + producer: Producer + + constructor(queueUrl: string) { + this.producer = Producer.create({ + queueUrl, + region: config.aws.region, + }) + } + + /** + * Enqueues a message. + * @param queueMessage Message to send + * @param retryOptions optional customisation of retry parameters + * @returns ok(true) if sending message suceeds + * @returns err if sending message fails + */ + sendMessage( + queueMessage: WebhookQueueMessage, + retryOptions?: OperationOptions, + ): ResultAsync { + const sendMessageRetry = promiseRetry(async (retry, attemptNum) => { + try { + await this.producer.send({ + body: queueMessage.serialise(), + id: queueMessage.submissionId, // only needs to be unique within request + delaySeconds: calculateDelaySeconds(queueMessage.nextAttempt), + }) + logger.info({ + message: `Pushed webhook to queue`, + meta: { + action: 'sendMessage', + webhookMessage: queueMessage.prettify(), + attemptNum, + }, + }) + return true + } catch (error) { + logger.error({ + message: `Failed to push webhook to queue`, + meta: { + action: 'sendMessage', + attemptNum, + }, + error, + }) + return retry(error) + } + }, retryOptions) + return ResultAsync.fromPromise(sendMessageRetry, (error) => { + logger.error({ + message: 'All attempts to push webhook to queue failed', + meta: { + action: 'sendMessage', + }, + error, + }) + return new WebhookPushToQueueError() + }) + } +} diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts index ce4e6bf55c..be3f7feb19 100644 --- a/src/app/modules/webhook/webhook.service.ts +++ b/src/app/modules/webhook/webhook.service.ts @@ -7,6 +7,7 @@ import { IEncryptedSubmissionSchema, ISubmissionSchema, IWebhookResponse, + WebhookView, } from '../../../types' import formsgSdk from '../../config/formsg-sdk' import { createLoggerWithLabel } from '../../config/logger' @@ -18,9 +19,12 @@ import { SubmissionNotFoundError } from '../submission/submission.errors' import { WebhookFailedWithAxiosError, WebhookFailedWithUnknownError, + WebhookPushToQueueError, WebhookValidationError, } from './webhook.errors' -import { formatWebhookResponse } from './webhook.utils' +import { WebhookQueueMessage } from './webhook.message' +import { WebhookProducer } from './webhook.producer' +import { formatWebhookResponse, isSuccessfulResponse } from './webhook.utils' import { validateWebhookUrl } from './webhook.validation' const logger = createLoggerWithLabel(module) @@ -69,17 +73,11 @@ export const saveWebhookRecord = ( } export const sendWebhook = ( - submission: IEncryptedSubmissionSchema, + webhookView: WebhookView, webhookUrl: string, -): ResultAsync< - IWebhookResponse, - | WebhookValidationError - | WebhookFailedWithAxiosError - | WebhookFailedWithUnknownError -> => { +): ResultAsync => { const now = Date.now() - const submissionWebhookView = submission.getWebhookView() - const { submissionId, formId } = submissionWebhookView.data + const { submissionId, formId } = webhookView.data const signature = formsgSdk.webhooks.generateSignature({ uri: webhookUrl, @@ -109,7 +107,7 @@ export const sendWebhook = ( }) .andThen(() => ResultAsync.fromPromise( - axios.post(webhookUrl, submissionWebhookView, { + axios.post(webhookUrl, webhookView, { headers: { 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({ epoch: now, @@ -119,6 +117,9 @@ export const sendWebhook = ( }), }, maxRedirects: 0, + // Timeout after 10 seconds to allow for cold starts in receiver, + // e.g. Lambdas + timeout: 10 * 1000, }), (error) => { logger.error({ @@ -175,3 +176,43 @@ export const sendWebhook = ( }) }) } + +/** + * Creates a function which sends a webhook and saves the necessary records. + * This function sends the INITIAL webhook, which occurs immediately after + * a submission. If the initial webhook fails and retries are enabled, the + * webhook is queued for retries. + * @returns function which sends webhook and saves a record of it + */ +export const createInitialWebhookSender = + (producer?: WebhookProducer) => + ( + submission: IEncryptedSubmissionSchema, + webhookUrl: string, + isRetryEnabled: boolean, + ): ResultAsync< + true, + | WebhookValidationError + | PossibleDatabaseError + | SubmissionNotFoundError + | WebhookPushToQueueError + > => { + // Attempt to send webhook + return sendWebhook(submission.getWebhookView(), webhookUrl).andThen( + (webhookResponse) => + // Save record of sending to database + saveWebhookRecord(submission._id, webhookResponse).andThen(() => { + // If webhook successful or retries not enabled, no further action + if ( + isSuccessfulResponse(webhookResponse) || + !producer || + !isRetryEnabled + ) + return okAsync(true) + // Webhook failed and retries enabled, so create initial message and enqueue + return WebhookQueueMessage.fromSubmissionId( + String(submission._id), + ).asyncAndThen((queueMessage) => producer.sendMessage(queueMessage)) + }), + ) + } diff --git a/src/app/modules/webhook/webhook.types.ts b/src/app/modules/webhook/webhook.types.ts index 0a77e16e4d..f6256b02e2 100644 --- a/src/app/modules/webhook/webhook.types.ts +++ b/src/app/modules/webhook/webhook.types.ts @@ -1,9 +1,6 @@ -import { - IEncryptedSubmissionSchema, - IFormSchema, - ISubmissionSchema, - WebhookView, -} from '../../../types' +import * as z from 'zod' + +import { IFormSchema, ISubmissionSchema, WebhookView } from '../../../types' export interface WebhookParams { webhookUrl: string @@ -14,7 +11,45 @@ export interface WebhookParams { signature: string } -export interface WebhookRequestLocals { - form: IFormSchema - submission: IEncryptedSubmissionSchema +/** + * Schema for webhook queue message, which allows an object to be validated. + */ +export const webhookMessageSchema = z.object({ + submissionId: z.string().regex(/^[a-f\d]{24}$/i), + previousAttempts: z.array(z.number()), + nextAttempt: z.number(), + _v: z.number(), +}) + +/** + * Shape of webhook queue message object. + */ +export type WebhookQueueMessageObject = z.infer + +/** + * Webhook queue message object formatted for readable logs. + */ +export type WebhookQueueMessagePrettified = Omit< + WebhookQueueMessageObject, + 'previousAttempts' | 'nextAttempt' +> & { + previousAttempts: string[] + nextAttempt: string +} + +/** + * Failed webhook queue message formatted for readable logs. + * Same as a regular queue message except no next attempt. + */ +export type WebhookFailedQueueMessage = Omit< + WebhookQueueMessagePrettified, + 'nextAttempt' +> + +/** + * Specification of when a webhook should be retried. + */ +export type RetryInterval = { + base: number + jitter: number } diff --git a/src/app/modules/webhook/webhook.utils.ts b/src/app/modules/webhook/webhook.utils.ts index e3331465b4..8fb69408ab 100644 --- a/src/app/modules/webhook/webhook.utils.ts +++ b/src/app/modules/webhook/webhook.utils.ts @@ -1,7 +1,15 @@ import { AxiosResponse } from 'axios' +import { inRange } from 'lodash' +import moment from 'moment-timezone' +import { err, ok, Result } from 'neverthrow' import { stringifySafe } from '../../../shared/util/stringify-safe' import { IWebhookResponse } from '../../../types' +import { TIMEZONE } from '../../constants/timezone' +import { randomUniformInt } from '../../utils/random-uniform' + +import { MAX_DELAY_SECONDS, RETRY_INTERVALS } from './webhook.constants' +import { WebhookNoMoreRetriesError } from './webhook.errors' /** * Formats a response object for update in the Submissions collection @@ -14,3 +22,61 @@ export const formatWebhookResponse = ( headers: stringifySafe(response?.headers) ?? '', data: stringifySafe(response?.data) ?? '', }) + +/** + * Computes epoch of next webhook attempt based on previous attempts. + * @param previousAttempts Array of epochs of previous attempts + * @returns ok(epoch of next attempt) if there are valid retries remaining + * @returns err(WebhookNoMoreRetriesError) if there are no more retries remaining + */ +export const getNextAttempt = ( + previousAttempts: number[], +): Result => { + // Total allowed number of attempts is RETRY_INTERVALS + 1. + // The +1 accounts for the initial webhook attempt immediately + // after form submission. + if (previousAttempts.length >= RETRY_INTERVALS.length + 1) { + return err(new WebhookNoMoreRetriesError()) + } + // The -1 accounts for the initial webhook attempt, e.g. if + // the length of previousAttempts is 1, then we should get + // the interval for the first retry at RETRY_INTERVALS[0] + const interval = RETRY_INTERVALS[previousAttempts.length - 1] + const nextAttemptWaitTimeSeconds = randomUniformInt( + interval.base - interval.jitter, + interval.base + interval.jitter, + ) + // Calculate next attempt based on time from initial attempt, or + // current date if initial attempt does not exist + const initialAttempt = previousAttempts[0] ?? Date.now() + return ok(initialAttempt + nextAttemptWaitTimeSeconds * 1000) +} + +/** + * Encodes success condition of webhook. Webhooks are considered + * successful if the status code >= 200 and < 300. + * @param webhookResponse Response from receiving server + * @returns true if webhook was successful + */ +export const isSuccessfulResponse = ( + webhookResponse: IWebhookResponse, +): boolean => inRange(webhookResponse.response.status, 200, 300) + +/** + * Calculates the number of seconds to delay a message sent to + * the webhook queue. This is the minimum of (time to next attempt, + * max possible delay timeout). + * @param nextAttempt Epoch of next attempt + */ +export const calculateDelaySeconds = (nextAttempt: number): number => { + const secondsToNextAttempt = Math.max(0, (nextAttempt - Date.now()) / 1000) + return Math.min(secondsToNextAttempt, MAX_DELAY_SECONDS) +} + +/** + * Converts an epoch to a readable format. + * @param epoch + * @returns the epoch represented as a readable string + */ +export const prettifyEpoch = (epoch: number): string => + moment(epoch).tz(TIMEZONE).format('D MMM YYYY, h:mm:ssa z') diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts index f0797fdd6a..c3d1858f34 100644 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts @@ -212,12 +212,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: formLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const session = await createAuthedSession(user.email, request) @@ -244,12 +244,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: formLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const session = await createAuthedSession(user.email, request) @@ -275,12 +275,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: formLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const diffUser = await dbHandler.insertUser({ mailName: 'newUser', @@ -317,12 +317,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: wrongLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const session = await createAuthedSession(user.email, request) @@ -352,12 +352,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: formLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const session = await createAuthedSession(user.email, request) @@ -388,12 +388,12 @@ describe('admin-form.logic.routes', () => { }, }) - const updatedLogic = ({ + const updatedLogic = { _id: formLogicId, logicType: LogicType.PreventSubmit, conditions: [], preventSubmitMessage: 'Some message', - } as unknown) as ILogicSchema + } as unknown as ILogicSchema const session = await createAuthedSession(user.email, request) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.settings.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.settings.routes.spec.ts index 45ae661a3e..b5b7f3546a 100644 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.settings.routes.spec.ts +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.settings.routes.spec.ts @@ -1,4 +1,5 @@ import { ObjectId } from 'bson-ext' +import { merge } from 'lodash' import mongoose from 'mongoose' import { errAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' @@ -85,11 +86,8 @@ describe('admin-form.settings.routes', () => { // Assert const expectedResponse = JSON.parse( - JSON.stringify({ - ...formToUpdate.getSettings(), - // Should get updated with new settings - ...settingsToUpdate, - }), + // Should get updated with new settings + JSON.stringify(merge(formToUpdate.getSettings(), settingsToUpdate)), ) expect(response.status).toEqual(200) expect(response.body).toEqual(expectedResponse) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index 4fa0f2b9ba..2dc1afb484 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { denyRpSpStudentEmails, + logAdminAction, withUserAuthentication, } from '../../../../../modules/auth/auth.middlewares' @@ -19,6 +20,9 @@ export const AdminFormsRouter = Router() AdminFormsRouter.use(withUserAuthentication) AdminFormsRouter.use(denyRpSpStudentEmails) +// Log all non-get admin form actions +AdminFormsRouter.use('/:formId([a-fA-F0-9]{24})', logAdminAction) + AdminFormsRouter.use(AdminFormsSettingsRouter) AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts index 5768e55e89..799e784260 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts @@ -24,8 +24,9 @@ const updateSettingsValidator = celebrate({ submissionLimit: Joi.number().allow(null), title: Joi.string(), webhook: Joi.object({ - url: Joi.string().uri().required().allow(''), - }), + url: Joi.string().uri().allow(''), + isRetryEnabled: Joi.boolean(), + }).min(1), }).min(1), }) diff --git a/src/app/routes/api/v3/billings/__tests__/billings.routes.spec.ts b/src/app/routes/api/v3/billings/__tests__/billings.routes.spec.ts index 0fd696d66f..7628bc38a4 100644 --- a/src/app/routes/api/v3/billings/__tests__/billings.routes.spec.ts +++ b/src/app/routes/api/v3/billings/__tests__/billings.routes.spec.ts @@ -51,14 +51,12 @@ describe('billings.routes', () => { // Log in user. const session = await createAuthedSession(defaultUser.email, request) // Generate login statistics. - const { - generatedLoginTimes, - generatedForms, - } = await generateLoginStatistics({ - user: defaultUser, - esrvcIdToCheck: VALID_ESRVCID_1, - altEsrvcId: VALID_ESRVCID_2, - }) + const { generatedLoginTimes, generatedForms } = + await generateLoginStatistics({ + user: defaultUser, + esrvcIdToCheck: VALID_ESRVCID_1, + altEsrvcId: VALID_ESRVCID_2, + }) // Act const response = await session.get('/billings').query({ diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts index 89164a5423..62de12cb67 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts @@ -19,9 +19,8 @@ export const MOCK_NO_RESPONSES_BODY = { } export const MOCK_TEXT_FIELD = generateDefaultField(BasicField.ShortText) -export const MOCK_TEXTFIELD_RESPONSE = generateSingleAnswerResponse( - MOCK_TEXT_FIELD, -) +export const MOCK_TEXTFIELD_RESPONSE = + generateSingleAnswerResponse(MOCK_TEXT_FIELD) export const MOCK_ATTACHMENT_FIELD = generateDefaultField(BasicField.Attachment) export const MOCK_ATTACHMENT_RESPONSE = generateAttachmentResponse( @@ -31,9 +30,8 @@ export const MOCK_ATTACHMENT_RESPONSE = generateAttachmentResponse( ) export const MOCK_SECTION_FIELD = generateDefaultField(BasicField.Section) -export const MOCK_SECTION_RESPONSE = generateSingleAnswerResponse( - MOCK_SECTION_FIELD, -) +export const MOCK_SECTION_RESPONSE = + generateSingleAnswerResponse(MOCK_SECTION_FIELD) export const MOCK_CHECKBOX_FIELD = generateDefaultField(BasicField.Checkbox) export const MOCK_CHECKBOX_RESPONSE = generateCheckboxResponse( diff --git a/src/app/services/captcha/__tests__/captcha.factory.spec.ts b/src/app/services/captcha/__tests__/captcha.factory.spec.ts index 6f49b5f257..ad13cbaa88 100644 --- a/src/app/services/captcha/__tests__/captcha.factory.spec.ts +++ b/src/app/services/captcha/__tests__/captcha.factory.spec.ts @@ -28,8 +28,8 @@ describe('captcha.factory', () => { undefined, ) captchaFactory.validateCaptchaParams( - ({} as unknown) as Request, - ({} as unknown) as Response, + {} as unknown as Request, + {} as unknown as Response, nextSpy, ) expect(verifyResult._unsafeUnwrap()).toBe(true) @@ -48,8 +48,8 @@ describe('captcha.factory', () => { undefined, ) captchaFactory.validateCaptchaParams( - ({} as unknown) as Request, - ({} as unknown) as Response, + {} as unknown as Request, + {} as unknown as Response, nextSpy, ) expect(verifyResult._unsafeUnwrap()).toBe(true) diff --git a/src/app/services/captcha/__tests__/captcha.service.spec.ts b/src/app/services/captcha/__tests__/captcha.service.spec.ts index 8492e5d4b9..32eebd6b85 100644 --- a/src/app/services/captcha/__tests__/captcha.service.spec.ts +++ b/src/app/services/captcha/__tests__/captcha.service.spec.ts @@ -20,9 +20,8 @@ describe('captcha.service', () => { beforeEach(() => jest.clearAllMocks()) it('should return MissingCaptchaError when response is falsy', async () => { - const verifyCaptchaResponse = makeCaptchaResponseVerifier( - MOCK_PRIVATE_KEY, - ) + const verifyCaptchaResponse = + makeCaptchaResponseVerifier(MOCK_PRIVATE_KEY) const result = await verifyCaptchaResponse(null, undefined) expect(result._unsafeUnwrapErr()).toEqual(new MissingCaptchaError()) @@ -31,9 +30,8 @@ describe('captcha.service', () => { it('should return VerifyCaptchaError when captcha response is incorrect', async () => { MockAxios.get.mockResolvedValueOnce({ data: { success: false } }) - const verifyCaptchaResponse = makeCaptchaResponseVerifier( - MOCK_PRIVATE_KEY, - ) + const verifyCaptchaResponse = + makeCaptchaResponseVerifier(MOCK_PRIVATE_KEY) const result = await verifyCaptchaResponse(MOCK_RESPONSE, MOCK_REMOTE_IP) expect(MockAxios.get).toHaveBeenCalledWith(GOOGLE_RECAPTCHA_URL, { @@ -49,9 +47,8 @@ describe('captcha.service', () => { it('should return true when captcha response is correct', async () => { MockAxios.get.mockResolvedValueOnce({ data: { success: true } }) - const verifyCaptchaResponse = makeCaptchaResponseVerifier( - MOCK_PRIVATE_KEY, - ) + const verifyCaptchaResponse = + makeCaptchaResponseVerifier(MOCK_PRIVATE_KEY) const result = await verifyCaptchaResponse(MOCK_RESPONSE, MOCK_REMOTE_IP) expect(MockAxios.get).toHaveBeenCalledWith(GOOGLE_RECAPTCHA_URL, { @@ -67,9 +64,8 @@ describe('captcha.service', () => { it('should return CaptchaConnectionError when connection with captcha server fails', async () => { MockAxios.get.mockRejectedValueOnce(false) - const verifyCaptchaResponse = makeCaptchaResponseVerifier( - MOCK_PRIVATE_KEY, - ) + const verifyCaptchaResponse = + makeCaptchaResponseVerifier(MOCK_PRIVATE_KEY) const result = await verifyCaptchaResponse(MOCK_RESPONSE, MOCK_REMOTE_IP) expect(MockAxios.get).toHaveBeenCalledWith(GOOGLE_RECAPTCHA_URL, { diff --git a/src/app/services/captcha/captcha.service.ts b/src/app/services/captcha/captcha.service.ts index a29c4865d9..dbc1e6a846 100644 --- a/src/app/services/captcha/captcha.service.ts +++ b/src/app/services/captcha/captcha.service.ts @@ -12,45 +12,47 @@ import { const logger = createLoggerWithLabel(module) -export const makeCaptchaResponseVerifier = (captchaPrivateKey: string) => ( - response?: unknown, - remoteip?: string, -): ResultAsync< - true, - CaptchaConnectionError | VerifyCaptchaError | MissingCaptchaError -> => { - if (!response || typeof response !== 'string') { - return errAsync(new MissingCaptchaError()) - } - const verifyCaptchaPromise = axios.get<{ success: boolean }>( - GOOGLE_RECAPTCHA_URL, - { - params: { - secret: captchaPrivateKey, - response, - remoteip, - }, - }, - ) - return ResultAsync.fromPromise(verifyCaptchaPromise, (error) => { - logger.error({ - message: 'Error verifying captcha', - meta: { - action: 'verifyCaptchaResponse', +export const makeCaptchaResponseVerifier = + (captchaPrivateKey: string) => + ( + response?: unknown, + remoteip?: string, + ): ResultAsync< + true, + CaptchaConnectionError | VerifyCaptchaError | MissingCaptchaError + > => { + if (!response || typeof response !== 'string') { + return errAsync(new MissingCaptchaError()) + } + const verifyCaptchaPromise = axios.get<{ success: boolean }>( + GOOGLE_RECAPTCHA_URL, + { + params: { + secret: captchaPrivateKey, + response, + remoteip, + }, }, - error, - }) - return new CaptchaConnectionError() - }).andThen(({ data }) => { - if (!data.success) { - logger.warn({ - message: 'Incorrect captcha response', + ) + return ResultAsync.fromPromise(verifyCaptchaPromise, (error) => { + logger.error({ + message: 'Error verifying captcha', meta: { action: 'verifyCaptchaResponse', }, + error, }) - return errAsync(new VerifyCaptchaError()) - } - return okAsync(true) - }) -} + return new CaptchaConnectionError() + }).andThen(({ data }) => { + if (!data.success) { + logger.warn({ + message: 'Incorrect captcha response', + meta: { + action: 'verifyCaptchaResponse', + }, + }) + return errAsync(new VerifyCaptchaError()) + } + return okAsync(true) + }) + } diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 37611565f2..dc2c8be9bf 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -28,9 +28,9 @@ const MOCK_RETRY_COUNT = 10 describe('mail.service', () => { const sendMailSpy = jest.fn() - const mockTransporter = ({ + const mockTransporter = { sendMail: sendMailSpy, - } as unknown) as Mail + } as unknown as Mail // Set up mocks for MailUtils beforeAll(() => { @@ -725,9 +725,10 @@ describe('mail.service', () => { }, ], } - const DEFAULT_AUTO_REPLY_BODY = `Dear Sir or Madam,\n\nThank you for submitting this form.\n\nRegards,\n${MOCK_AUTOREPLY_PARAMS.form.admin.agency.fullName}`.split( - '\n', - ) + const DEFAULT_AUTO_REPLY_BODY = + `Dear Sir or Madam,\n\nThank you for submitting this form.\n\nRegards,\n${MOCK_AUTOREPLY_PARAMS.form.admin.agency.fullName}`.split( + '\n', + ) beforeAll(async () => { defaultHtml = ( diff --git a/src/app/services/sms/__tests__/sms.factory.spec.ts b/src/app/services/sms/__tests__/sms.factory.spec.ts index ff9945ef15..7927f985b2 100644 --- a/src/app/services/sms/__tests__/sms.factory.spec.ts +++ b/src/app/services/sms/__tests__/sms.factory.spec.ts @@ -24,9 +24,9 @@ jest.mock('twilio', () => jest.mock('../sms.service') const MockSmsService = mocked(SmsService, true) -const MOCKED_TWILIO = ({ +const MOCKED_TWILIO = { mocked: 'this is mocked', -} as unknown) as Twilio.Twilio +} as unknown as Twilio.Twilio const MOCK_BOUNCE_SMS_PARAMS: BounceNotificationSmsParams = { adminEmail: 'admin@email.com', diff --git a/src/app/services/sms/__tests__/sms.service.spec.ts b/src/app/services/sms/__tests__/sms.service.spec.ts index 8452e600d0..4579a0344c 100644 --- a/src/app/services/sms/__tests__/sms.service.spec.ts +++ b/src/app/services/sms/__tests__/sms.service.spec.ts @@ -35,14 +35,14 @@ const twilioSuccessSpy = jest.fn().mockResolvedValue({ sid: 'testSid', }) -const MOCK_VALID_CONFIG = ({ +const MOCK_VALID_CONFIG = { msgSrvcSid: MOCK_MSG_SRVC_SID, client: { messages: { create: twilioSuccessSpy, }, }, -} as unknown) as TwilioConfig +} as unknown as TwilioConfig const twilioFailureSpy = jest.fn().mockResolvedValue({ status: 'testStatus', @@ -50,14 +50,14 @@ const twilioFailureSpy = jest.fn().mockResolvedValue({ errorCode: 21211, }) -const MOCK_INVALID_CONFIG = ({ +const MOCK_INVALID_CONFIG = { msgSrvcSid: MOCK_MSG_SRVC_SID, client: { messages: { create: twilioFailureSpy, }, }, -} as unknown) as TwilioConfig +} as unknown as TwilioConfig const smsCountSpy = jest.spyOn(SmsCountModel, 'logSms') diff --git a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts b/src/app/services/sms/__tests__/sms_count.server.model.spec.ts index 7a022c6f86..ad0d66b67f 100644 --- a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts +++ b/src/app/services/sms/__tests__/sms_count.server.model.spec.ts @@ -561,9 +561,8 @@ const createVerificationSmsCountParams = ({ logType?: LogType smsType?: SmsType } = {}) => { - const smsCountParams: Partial = cloneDeep( - MOCK_SMSCOUNT_PARAMS, - ) + const smsCountParams: Partial = + cloneDeep(MOCK_SMSCOUNT_PARAMS) smsCountParams.logType = logType smsCountParams.smsType = smsType smsCountParams.msgSrvcSid = MOCK_MSG_SRVC_SID diff --git a/src/app/services/sms/sms.factory.ts b/src/app/services/sms/sms.factory.ts index 737f430ada..0af5748e39 100644 --- a/src/app/services/sms/sms.factory.ts +++ b/src/app/services/sms/sms.factory.ts @@ -73,12 +73,8 @@ export const createSmsFactory = ( } } - const { - twilioAccountSid, - twilioApiKey, - twilioApiSecret, - twilioMsgSrvcSid, - } = smsFeature.props + const { twilioAccountSid, twilioApiKey, twilioApiSecret, twilioMsgSrvcSid } = + smsFeature.props const twilioClient = Twilio(twilioApiKey, twilioApiSecret, { accountSid: twilioAccountSid, diff --git a/src/app/services/sms/sms.service.ts b/src/app/services/sms/sms.service.ts index 95fd568f8b..489ad34d99 100644 --- a/src/app/services/sms/sms.service.ts +++ b/src/app/services/sms/sms.service.ts @@ -102,12 +102,8 @@ const getTwilio = async ( try { const credentials = await getCredentials(msgSrvcName) if (credentials !== null) { - const { - accountSid, - apiKey, - apiSecret, - messagingServiceSid, - } = credentials + const { accountSid, apiKey, apiSecret, messagingServiceSid } = + credentials // Create twilioClient const result: TwilioConfig = { client: Twilio(apiKey, apiSecret, { accountSid }), diff --git a/src/app/services/sms/sms_count.server.model.ts b/src/app/services/sms/sms_count.server.model.ts index e7638e2ac8..66d8176f15 100644 --- a/src/app/services/sms/sms_count.server.model.ts +++ b/src/app/services/sms/sms_count.server.model.ts @@ -74,13 +74,11 @@ const bounceSmsCountSchema = { }, } -const FormDeactivatedSmsCountSchema = new Schema( - bounceSmsCountSchema, -) +const FormDeactivatedSmsCountSchema = + new Schema(bounceSmsCountSchema) -const BouncedSubmissionSmsCountSchema = new Schema( - bounceSmsCountSchema, -) +const BouncedSubmissionSmsCountSchema = + new Schema(bounceSmsCountSchema) const compileSmsCountModel = (db: Mongoose) => { const SmsCountSchema = new Schema( @@ -109,10 +107,12 @@ const compileSmsCountModel = (db: Mongoose) => { }, ) - SmsCountSchema.statics.logSms = async function ( - this: ISmsCountModel, - { smsData, msgSrvcSid, smsType, logType }: LogSmsParams, - ) { + SmsCountSchema.statics.logSms = async function ({ + smsData, + msgSrvcSid, + smsType, + logType, + }: LogSmsParams) { const schemaData: Omit = { ...smsData, msgSrvcSid, diff --git a/src/app/utils/field-validation/validators/__tests__/dropdown-validation.spec.ts b/src/app/utils/field-validation/validators/__tests__/dropdown-validation.spec.ts index 5c0ff431f1..d9049d034d 100644 --- a/src/app/utils/field-validation/validators/__tests__/dropdown-validation.spec.ts +++ b/src/app/utils/field-validation/validators/__tests__/dropdown-validation.spec.ts @@ -94,7 +94,7 @@ describe('Dropdown validation', () => { fieldOptions: ['KISS', 'DRY', 'YAGNI'], }) const response = generateNewSingleAnswerResponse(BasicField.Dropdown, { - answer: (['KISS', 'DRY'] as unknown) as string, + answer: ['KISS', 'DRY'] as unknown as string, }) const validateResult = validateField('formId', formField, response) expect(validateResult.isErr()).toBe(true) diff --git a/src/app/utils/field-validation/validators/__tests__/email-validation.spec.ts b/src/app/utils/field-validation/validators/__tests__/email-validation.spec.ts index 1a29fb2a9d..ec83b72acf 100644 --- a/src/app/utils/field-validation/validators/__tests__/email-validation.spec.ts +++ b/src/app/utils/field-validation/validators/__tests__/email-validation.spec.ts @@ -14,7 +14,7 @@ describe('Email field validation', () => { beforeEach(() => { jest .spyOn( - (formsgSdk.verification as unknown) as VerificationMock, + formsgSdk.verification as unknown as VerificationMock, 'authenticate', ) .mockImplementation(() => true) @@ -175,7 +175,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isOk()).toBe(true) @@ -205,7 +205,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isErr()).toBe(true) @@ -237,7 +237,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isOk()).toBe(true) @@ -267,7 +267,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isOk()).toBe(true) @@ -296,7 +296,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isOk()).toBe(true) @@ -325,7 +325,7 @@ describe('Email field validation', () => { } as ISingleAnswerResponse const validateResult = validateField( 'formId', - (formField as unknown) as IFieldSchema, + formField as unknown as IFieldSchema, response as ProcessedFieldResponse, ) expect(validateResult.isErr()).toBe(true) @@ -366,7 +366,7 @@ describe('Email field validation', () => { it('should reject email addresses if isVerifiable is true but signature is invalid', () => { jest .spyOn( - (formsgSdk.verification as unknown) as VerificationMock, + formsgSdk.verification as unknown as VerificationMock, 'authenticate', ) .mockImplementation(() => false) diff --git a/src/app/utils/field-validation/validators/__tests__/mobile-num-validation.spec.ts b/src/app/utils/field-validation/validators/__tests__/mobile-num-validation.spec.ts index ccb9fdd978..caff55f504 100644 --- a/src/app/utils/field-validation/validators/__tests__/mobile-num-validation.spec.ts +++ b/src/app/utils/field-validation/validators/__tests__/mobile-num-validation.spec.ts @@ -17,7 +17,7 @@ describe('Mobile number validation tests', () => { beforeEach(() => { jest .spyOn( - (formsgSdk.verification as unknown) as VerificationMock, + formsgSdk.verification as unknown as VerificationMock, 'authenticate', ) .mockImplementation(() => true) @@ -178,7 +178,7 @@ describe('Mobile number validation tests', () => { it('should reject mobile numbers if isVerifiable is true and signature is present but invalid', () => { jest .spyOn( - (formsgSdk.verification as unknown) as VerificationMock, + formsgSdk.verification as unknown as VerificationMock, 'authenticate', ) .mockImplementation(() => false) diff --git a/src/app/utils/field-validation/validators/__tests__/table-validation.spec.ts b/src/app/utils/field-validation/validators/__tests__/table-validation.spec.ts index 576a20600b..ec14515f03 100644 --- a/src/app/utils/field-validation/validators/__tests__/table-validation.spec.ts +++ b/src/app/utils/field-validation/validators/__tests__/table-validation.spec.ts @@ -268,7 +268,7 @@ describe('Table validation', () => { columns: [generateTableShortTextColumn()], }) const response = generateNewTableResponse({ - answerArray: [[(null as unknown) as string]], + answerArray: [[null as unknown as string]], }) const validateResult = validateField(formId, formField, response) expect(validateResult.isErr()).toBe(true) diff --git a/src/app/utils/field-validation/validators/attachmentValidator.ts b/src/app/utils/field-validation/validators/attachmentValidator.ts index f786e22373..6aed2bd41c 100644 --- a/src/app/utils/field-validation/validators/attachmentValidator.ts +++ b/src/app/utils/field-validation/validators/attachmentValidator.ts @@ -38,15 +38,14 @@ const attachmentContentValidator: AttachmentValidator = (response) => { * Returns a validation function to check if * attachment size is within the specified limit. */ -const makeAttachmentSizeValidator: AttachmentValidatorConstructor = ( - attachmentField, -) => (response) => { - const { attachmentSize } = attachmentField - const byteSizeLimit = parseInt(attachmentSize) * MILLION - return response.content.byteLength < byteSizeLimit - ? right(response) - : left(`AttachmentValidator:\t File size more than limit`) -} +const makeAttachmentSizeValidator: AttachmentValidatorConstructor = + (attachmentField) => (response) => { + const { attachmentSize } = attachmentField + const byteSizeLimit = parseInt(attachmentSize) * MILLION + return response.content.byteLength < byteSizeLimit + ? right(response) + : left(`AttachmentValidator:\t File size more than limit`) + } /** * Returns a validation function for an attachment field when called. diff --git a/src/app/utils/field-validation/validators/checkboxValidator.ts b/src/app/utils/field-validation/validators/checkboxValidator.ts index 689eef03be..d69e0b8e6c 100644 --- a/src/app/utils/field-validation/validators/checkboxValidator.ts +++ b/src/app/utils/field-validation/validators/checkboxValidator.ts @@ -27,41 +27,39 @@ const checkboxAnswerValidator: CheckboxValidator = (response) => { * Returns a validation function to check if number of * selected checkbox options is less than the minimum number specified. */ -const minOptionsValidator: CheckboxValidatorConstructor = (checkboxField) => ( - response, -) => { - const { validateByValue } = checkboxField - const { customMin } = checkboxField.ValidationOptions - const { answerArray } = response - - if (!validateByValue || !customMin) return right(response) - - return answerArray.length >= customMin - ? right(response) - : left( - `CheckboxValidator:\t answer has less options selected than minimum specified`, - ) -} +const minOptionsValidator: CheckboxValidatorConstructor = + (checkboxField) => (response) => { + const { validateByValue } = checkboxField + const { customMin } = checkboxField.ValidationOptions + const { answerArray } = response + + if (!validateByValue || !customMin) return right(response) + + return answerArray.length >= customMin + ? right(response) + : left( + `CheckboxValidator:\t answer has less options selected than minimum specified`, + ) + } /** * Returns a validation function to check if number of * selected checkbox options is more than the maximum number specified. */ -const maxOptionsValidator: CheckboxValidatorConstructor = (checkboxField) => ( - response, -) => { - const { validateByValue } = checkboxField - const { customMax } = checkboxField.ValidationOptions - const { answerArray } = response - - if (!validateByValue || !customMax) return right(response) - - return answerArray.length <= customMax - ? right(response) - : left( - `CheckboxValidator:\t answer has more options selected than maximum specified`, - ) -} +const maxOptionsValidator: CheckboxValidatorConstructor = + (checkboxField) => (response) => { + const { validateByValue } = checkboxField + const { customMax } = checkboxField.ValidationOptions + const { answerArray } = response + + if (!validateByValue || !customMax) return right(response) + + return answerArray.length <= customMax + ? right(response) + : left( + `CheckboxValidator:\t answer has more options selected than maximum specified`, + ) + } // The overall logic for the following three validators is as follows: // We split the answers into: @@ -79,19 +77,19 @@ const maxOptionsValidator: CheckboxValidatorConstructor = (checkboxField) => ( * For those which do not start with "Others: ", they must be one of the fieldOptions since they cannot possibly be an "Others" option. * For those which start with "Others: ", they must also be one of the fieldOptions unless othersRadioButton is enabled. */ -const validOptionsValidator: CheckboxValidatorConstructor = (checkboxField) => ( - response, -) => { - const { fieldOptions, othersRadioButton } = checkboxField - const { answerArray } = response - - return answerArray.every( - (answer) => - fieldOptions.includes(answer) || isOtherOption(othersRadioButton, answer), - ) - ? right(response) - : left(`CheckboxValidator:\t answer is not valid`) -} +const validOptionsValidator: CheckboxValidatorConstructor = + (checkboxField) => (response) => { + const { fieldOptions, othersRadioButton } = checkboxField + const { answerArray } = response + + return answerArray.every( + (answer) => + fieldOptions.includes(answer) || + isOtherOption(othersRadioButton, answer), + ) + ? right(response) + : left(`CheckboxValidator:\t answer is not valid`) + } /** * Returns a validation function to check if there are any @@ -101,20 +99,19 @@ const validOptionsValidator: CheckboxValidatorConstructor = (checkboxField) => ( * We had already checked if all of them are one of the fieldOptions. Since fieldOptions are distinct, * there should be no duplicates amongst the non-others answers. */ -const duplicateNonOtherOptionsValidator: CheckboxValidatorConstructor = ( - checkboxField, -) => (response) => { - const { othersRadioButton } = checkboxField - const { answerArray } = response - - const nonOtherAnswers = answerArray.filter( - (answer) => !isOtherOption(othersRadioButton, answer), - ) - - return nonOtherAnswers.length === new Set(nonOtherAnswers).size - ? right(response) - : left(`CheckboxValidator:\t duplicate non-other answers in response`) -} +const duplicateNonOtherOptionsValidator: CheckboxValidatorConstructor = + (checkboxField) => (response) => { + const { othersRadioButton } = checkboxField + const { answerArray } = response + + const nonOtherAnswers = answerArray.filter( + (answer) => !isOtherOption(othersRadioButton, answer), + ) + + return nonOtherAnswers.length === new Set(nonOtherAnswers).size + ? right(response) + : left(`CheckboxValidator:\t duplicate non-other answers in response`) + } /** * Returns a validation function to check if there are any @@ -124,48 +121,48 @@ const duplicateNonOtherOptionsValidator: CheckboxValidatorConstructor = ( * Note that it is possible for Admins to create fieldOptions that * look like ['Option 1', 'Others: please elaborate']. */ -const duplicateOtherOptionsValidator: CheckboxValidatorConstructor = ( - checkboxField, -) => (response) => { - const { fieldOptions, othersRadioButton } = checkboxField - const { answerArray } = response - - const otherAnswers = answerArray.filter((answer) => - isOtherOption(othersRadioButton, answer), - ) - - // First check the answers which do not appear in fieldOptions. - // There should be at most one. - - const otherAnswersNotInFieldOptions = otherAnswers.filter( - (answer) => !fieldOptions.includes(answer), - ) - - if (otherAnswersNotInFieldOptions.length > 1) { - return left(`CheckboxValidator:\t duplicate other answers in response`) +const duplicateOtherOptionsValidator: CheckboxValidatorConstructor = + (checkboxField) => (response) => { + const { fieldOptions, othersRadioButton } = checkboxField + const { answerArray } = response + + const otherAnswers = answerArray.filter((answer) => + isOtherOption(othersRadioButton, answer), + ) + + // First check the answers which do not appear in fieldOptions. + // There should be at most one. + + const otherAnswersNotInFieldOptions = otherAnswers.filter( + (answer) => !fieldOptions.includes(answer), + ) + + if (otherAnswersNotInFieldOptions.length > 1) { + return left(`CheckboxValidator:\t duplicate other answers in response`) + } + + // Next check that for the remaining answers which do appear in fieldOptions, + // Either there should no duplicates, OR + // There should be at most 1 duplicate and otherAnswersNotInFieldOptions.length === 0 + // i.e. the 'Others' field is used for the duplicate response. + + const otherAnswersInFieldOptions = otherAnswers.filter((answer) => + fieldOptions.includes(answer), + ) + + const numDuplicates = + otherAnswersInFieldOptions.length - + new Set(otherAnswersInFieldOptions).size + + if (numDuplicates > 1) { + return left(`CheckboxValidator:\t duplicate other answers in response`) + } else if (numDuplicates === 1 && otherAnswersInFieldOptions.length !== 0) { + return left(`CheckboxValidator:\t duplicate other answers in response`) + } else { + return right(response) + } } - // Next check that for the remaining answers which do appear in fieldOptions, - // Either there should no duplicates, OR - // There should be at most 1 duplicate and otherAnswersNotInFieldOptions.length === 0 - // i.e. the 'Others' field is used for the duplicate response. - - const otherAnswersInFieldOptions = otherAnswers.filter((answer) => - fieldOptions.includes(answer), - ) - - const numDuplicates = - otherAnswersInFieldOptions.length - new Set(otherAnswersInFieldOptions).size - - if (numDuplicates > 1) { - return left(`CheckboxValidator:\t duplicate other answers in response`) - } else if (numDuplicates === 1 && otherAnswersInFieldOptions.length !== 0) { - return left(`CheckboxValidator:\t duplicate other answers in response`) - } else { - return right(response) - } -} - /** * Returns a validation function for a checkbox field when called. */ diff --git a/src/app/utils/field-validation/validators/common.ts b/src/app/utils/field-validation/validators/common.ts index 9b130b512f..5c50a9ebba 100644 --- a/src/app/utils/field-validation/validators/common.ts +++ b/src/app/utils/field-validation/validators/common.ts @@ -9,15 +9,14 @@ import formsgSdk from '../../../config/formsg-sdk' /** * A function which returns a validator to check if single answer has a non-empty response */ -export const notEmptySingleAnswerResponse: ResponseValidator = ( - response, -) => { - if (response.answer.trim().length === 0) - return left( - 'CommonValidator.notEmptySingleAnswerResponse:\tanswer is an empty string', - ) - return right(response) -} +export const notEmptySingleAnswerResponse: ResponseValidator = + (response) => { + if (response.answer.trim().length === 0) + return left( + 'CommonValidator.notEmptySingleAnswerResponse:\tanswer is an empty string', + ) + return right(response) + } /** * A function which returns a signature validator constructor for mobile and email verified field. @@ -25,31 +24,30 @@ export const notEmptySingleAnswerResponse: ResponseValidator ResponseValidator = (formField) => ( - response, -) => { - const { isVerifiable, _id } = formField - if (!isVerifiable) { - return right(response) // no validation occurred - } - const { signature, answer } = response - if (!signature) { - return left( - `CommonValidator.makeSignatureValidator:\t answer does not have valid signature`, - ) - } - const isSigned = - formsgSdk.verification.authenticate && - formsgSdk.verification.authenticate({ - signatureString: signature, - submissionCreatedAt: Date.now(), - fieldId: _id, - answer, - }) - - return isSigned - ? right(response) - : left( +) => ResponseValidator = + (formField) => (response) => { + const { isVerifiable, _id } = formField + if (!isVerifiable) { + return right(response) // no validation occurred + } + const { signature, answer } = response + if (!signature) { + return left( `CommonValidator.makeSignatureValidator:\t answer does not have valid signature`, ) -} + } + const isSigned = + formsgSdk.verification.authenticate && + formsgSdk.verification.authenticate({ + signatureString: signature, + submissionCreatedAt: Date.now(), + fieldId: _id, + answer, + }) + + return isSigned + ? right(response) + : left( + `CommonValidator.makeSignatureValidator:\t answer does not have valid signature`, + ) + } diff --git a/src/app/utils/field-validation/validators/dateValidator.ts b/src/app/utils/field-validation/validators/dateValidator.ts index 073bc48629..32e4288654 100644 --- a/src/app/utils/field-validation/validators/dateValidator.ts +++ b/src/app/utils/field-validation/validators/dateValidator.ts @@ -71,19 +71,18 @@ const futureOnlyValidator: DateValidator = (response) => { * Returns a validator to check if date is within the * specified custom date range. */ -const makeCustomDateValidator: DateValidatorConstructor = (dateField) => ( - response, -) => { - const { answer } = response - const answerDate = createMomentFromDateString(answer) +const makeCustomDateValidator: DateValidatorConstructor = + (dateField) => (response) => { + const { answer } = response + const answerDate = createMomentFromDateString(answer) - const { customMinDate, customMaxDate } = dateField.dateValidation || {} + const { customMinDate, customMaxDate } = dateField.dateValidation || {} - return (customMinDate && answerDate.isBefore(customMinDate)) || - (customMaxDate && answerDate.isAfter(customMaxDate)) - ? left(`DateValidator:\t answer does not pass date logic validation`) - : right(response) -} + return (customMinDate && answerDate.isBefore(customMinDate)) || + (customMaxDate && answerDate.isAfter(customMaxDate)) + ? left(`DateValidator:\t answer does not pass date logic validation`) + : right(response) + } /** * Returns the appropriate validator diff --git a/src/app/utils/field-validation/validators/decimalValidator.ts b/src/app/utils/field-validation/validators/decimalValidator.ts index 1ebb427c33..7de5cee0a0 100644 --- a/src/app/utils/field-validation/validators/decimalValidator.ts +++ b/src/app/utils/field-validation/validators/decimalValidator.ts @@ -22,31 +22,30 @@ interface IIsFloatOptions { * Returns a validation function * to check if decimal is within the specified custom range. */ -const makeDecimalFloatRangeValidator: DecimalValidatorConstructor = ( - decimalField, -) => (response) => { - const { customMin, customMax } = decimalField.ValidationOptions // defaults to customMin: null, customMax: null - const { answer } = response +const makeDecimalFloatRangeValidator: DecimalValidatorConstructor = + (decimalField) => (response) => { + const { customMin, customMax } = decimalField.ValidationOptions // defaults to customMin: null, customMax: null + const { answer } = response - const isFloatOptions: IIsFloatOptions = {} - // Necessary to add 'min' and 'max' property manually as - // isFloatOptions tests for presence of property - // See https://github.com/validatorjs/validator.js/blob/302d2957c924b515cb22f7e87b5e84fee8636d6e/src/lib/isFloat.js#L13 + const isFloatOptions: IIsFloatOptions = {} + // Necessary to add 'min' and 'max' property manually as + // isFloatOptions tests for presence of property + // See https://github.com/validatorjs/validator.js/blob/302d2957c924b515cb22f7e87b5e84fee8636d6e/src/lib/isFloat.js#L13 - if (customMin || customMin === 0) { - isFloatOptions['min'] = customMin - } - if (customMax || customMax === 0) { - isFloatOptions['max'] = customMax - } + if (customMin || customMin === 0) { + isFloatOptions['min'] = customMin + } + if (customMax || customMax === 0) { + isFloatOptions['max'] = customMax + } - // isFloat validates range correctly for floats up to 15 decimal places - // (1.999999999999999 >= 2) is False - // (1.9999999999999999 >= 2) is True - return isFloat(answer, isFloatOptions) - ? right(response) - : left(`DecimalValidator:\t answer is not a valid float`) -} + // isFloat validates range correctly for floats up to 15 decimal places + // (1.999999999999999 >= 2) is False + // (1.9999999999999999 >= 2) is True + return isFloat(answer, isFloatOptions) + ? right(response) + : left(`DecimalValidator:\t answer is not a valid float`) + } /** * Returns a validator to check if diff --git a/src/app/utils/field-validation/validators/dropdownValidator.ts b/src/app/utils/field-validation/validators/dropdownValidator.ts index 1e434f47af..065c9063f4 100644 --- a/src/app/utils/field-validation/validators/dropdownValidator.ts +++ b/src/app/utils/field-validation/validators/dropdownValidator.ts @@ -19,20 +19,19 @@ type DropdownValidatorConstructor = ( * Returns a validation function * to check if dropdown selection is one of the options. */ -const makeDropdownValidator: DropdownValidatorConstructor = (dropdownField) => ( - response, -) => { - const { myInfo, fieldOptions } = dropdownField - // Inject fieldOptions for MyInfo. This is necessary because the - // client strips out MyInfo data to keep each form submission lightweight - const validOptions = myInfo?.attr - ? getMyInfoFieldOptions(myInfo.attr) - : fieldOptions - const { answer } = response - return isOneOfOptions(validOptions, answer) - ? right(response) - : left(`DropdownValidator:\t answer is not a valid dropdown option`) -} +const makeDropdownValidator: DropdownValidatorConstructor = + (dropdownField) => (response) => { + const { myInfo, fieldOptions } = dropdownField + // Inject fieldOptions for MyInfo. This is necessary because the + // client strips out MyInfo data to keep each form submission lightweight + const validOptions = myInfo?.attr + ? getMyInfoFieldOptions(myInfo.attr) + : fieldOptions + const { answer } = response + return isOneOfOptions(validOptions, answer) + ? right(response) + : left(`DropdownValidator:\t answer is not a valid dropdown option`) + } /** * Returns a validation function for a dropdown field when called. diff --git a/src/app/utils/field-validation/validators/emailValidator.ts b/src/app/utils/field-validation/validators/emailValidator.ts index 1a5f72dd8c..e7394552bc 100644 --- a/src/app/utils/field-validation/validators/emailValidator.ts +++ b/src/app/utils/field-validation/validators/emailValidator.ts @@ -27,24 +27,20 @@ const emailFormatValidator: EmailValidator = (response) => { * Returns a validation function * to check if email domain is valid. */ -const makeEmailDomainValidator: EmailValidatorConstructor = (emailField) => ( - response, -) => { - const { - isVerifiable, - hasAllowedEmailDomains, - allowedEmailDomains, - } = emailField - const { answer } = response - const emailAddress = String(answer) - if (!(isVerifiable && hasAllowedEmailDomains && allowedEmailDomains.length)) - return right(response) - const emailDomain = '@' + emailAddress.split('@').pop() +const makeEmailDomainValidator: EmailValidatorConstructor = + (emailField) => (response) => { + const { isVerifiable, hasAllowedEmailDomains, allowedEmailDomains } = + emailField + const { answer } = response + const emailAddress = String(answer) + if (!(isVerifiable && hasAllowedEmailDomains && allowedEmailDomains.length)) + return right(response) + const emailDomain = '@' + emailAddress.split('@').pop() - return allowedEmailDomains.includes(emailDomain) - ? right(response) - : left(`EmailValidator:\t answer is not a valid email domain`) -} + return allowedEmailDomains.includes(emailDomain) + ? right(response) + : left(`EmailValidator:\t answer is not a valid email domain`) + } /** * Returns a validation function for a email field when called. diff --git a/src/app/utils/field-validation/validators/numberValidator.ts b/src/app/utils/field-validation/validators/numberValidator.ts index a504219aa8..23307d9fa1 100644 --- a/src/app/utils/field-validation/validators/numberValidator.ts +++ b/src/app/utils/field-validation/validators/numberValidator.ts @@ -26,43 +26,40 @@ const numberFormatValidator: NumberValidator = (response) => { * Returns a validation function to check if number length is * less than the minimum length specified. */ -const minLengthValidator: NumberValidatorConstructor = (numberField) => ( - response, -) => { - const { answer } = response - const { customMin } = numberField.ValidationOptions - return !customMin || answer.length >= customMin - ? right(response) - : left(`NumberValidator:\t answer is shorter than custom minimum length`) -} +const minLengthValidator: NumberValidatorConstructor = + (numberField) => (response) => { + const { answer } = response + const { customMin } = numberField.ValidationOptions + return !customMin || answer.length >= customMin + ? right(response) + : left(`NumberValidator:\t answer is shorter than custom minimum length`) + } /** * Returns a validation function to check if number length is * more than the maximum length specified. */ -const maxLengthValidator: NumberValidatorConstructor = (numberField) => ( - response, -) => { - const { answer } = response - const { customMax } = numberField.ValidationOptions - return !customMax || answer.length <= customMax - ? right(response) - : left(`NumberValidator:\t answer is longer than custom maximum length`) -} +const maxLengthValidator: NumberValidatorConstructor = + (numberField) => (response) => { + const { answer } = response + const { customMax } = numberField.ValidationOptions + return !customMax || answer.length <= customMax + ? right(response) + : left(`NumberValidator:\t answer is longer than custom maximum length`) + } /** * Returns a validation function to check if number length is * equal to the exact length specified. */ -const exactLengthValidator: NumberValidatorConstructor = (numberField) => ( - response, -) => { - const { answer } = response - const { customVal } = numberField.ValidationOptions - return !customVal || answer.length === customVal - ? right(response) - : left(`NumberValidator:\t answer does not match custom exact length`) -} +const exactLengthValidator: NumberValidatorConstructor = + (numberField) => (response) => { + const { answer } = response + const { customVal } = numberField.ValidationOptions + return !customVal || answer.length === customVal + ? right(response) + : left(`NumberValidator:\t answer does not match custom exact length`) + } /** * Returns the appropriate validation function diff --git a/src/app/utils/field-validation/validators/radioButtonValidator.ts b/src/app/utils/field-validation/validators/radioButtonValidator.ts index 2e71cf8cda..87020a4211 100644 --- a/src/app/utils/field-validation/validators/radioButtonValidator.ts +++ b/src/app/utils/field-validation/validators/radioButtonValidator.ts @@ -17,19 +17,18 @@ type RadioButtonValidatorConstructor = ( * Returns a validation function to check if the * selected radio option is one of the specified options. */ -const makeRadioOptionsValidator: RadioButtonValidatorConstructor = ( - radioButtonField, -) => (response) => { - const { answer } = response - const { fieldOptions, othersRadioButton } = radioButtonField - const isValid = - isOneOfOptions(fieldOptions, answer) || - isOtherOption(othersRadioButton, answer) +const makeRadioOptionsValidator: RadioButtonValidatorConstructor = + (radioButtonField) => (response) => { + const { answer } = response + const { fieldOptions, othersRadioButton } = radioButtonField + const isValid = + isOneOfOptions(fieldOptions, answer) || + isOtherOption(othersRadioButton, answer) - return isValid - ? right(response) - : left(`RadioButtonValidator:\tanswer is not a valid radio button option`) -} + return isValid + ? right(response) + : left(`RadioButtonValidator:\tanswer is not a valid radio button option`) + } /** * Returns a validation function for a radio button field when called. diff --git a/src/app/utils/field-validation/validators/ratingValidator.ts b/src/app/utils/field-validation/validators/ratingValidator.ts index 6c06b5d61e..14c95e179c 100644 --- a/src/app/utils/field-validation/validators/ratingValidator.ts +++ b/src/app/utils/field-validation/validators/ratingValidator.ts @@ -15,22 +15,21 @@ type RatingValidatorConstructor = (ratingField: IRatingField) => RatingValidator * Returns a validation function to check if the * selected rating option is a valid option. */ -const makeRatingLimitsValidator: RatingValidatorConstructor = (ratingField) => ( - response, -) => { - const { answer } = response - const { steps } = ratingField.ratingOptions +const makeRatingLimitsValidator: RatingValidatorConstructor = + (ratingField) => (response) => { + const { answer } = response + const { steps } = ratingField.ratingOptions - const isValid = isInt(answer, { - min: 1, - max: steps, - allow_leading_zeroes: false, - }) + const isValid = isInt(answer, { + min: 1, + max: steps, + allow_leading_zeroes: false, + }) - return isValid - ? right(response) - : left(`RatingValidator:\t answer is not a valid rating`) -} + return isValid + ? right(response) + : left(`RatingValidator:\t answer is not a valid rating`) + } /** * Returns a validation function for a rating field when called. diff --git a/src/app/utils/field-validation/validators/sectionValidator.ts b/src/app/utils/field-validation/validators/sectionValidator.ts index f28bce5bca..c60d83bfcd 100644 --- a/src/app/utils/field-validation/validators/sectionValidator.ts +++ b/src/app/utils/field-validation/validators/sectionValidator.ts @@ -3,15 +3,15 @@ import { left, right } from 'fp-ts/lib/Either' import { ProcessedSingleAnswerResponse } from 'src/app/modules/submission/submission.types' import { ResponseValidator } from 'src/types/field/utils/validation' -type SectionValidatorConstructor = () => ResponseValidator +type SectionValidatorConstructor = + () => ResponseValidator /** * Returns a validation function for a section field when called. */ -export const constructSectionValidator: SectionValidatorConstructor = () => ( - response, -) => { - return response.answer === '' - ? right(response) - : left(`SectionValidator.emptyAnswer:\tanswer is not an empty string`) -} +export const constructSectionValidator: SectionValidatorConstructor = + () => (response) => { + return response.answer === '' + ? right(response) + : left(`SectionValidator.emptyAnswer:\tanswer is not an empty string`) + } diff --git a/src/app/utils/field-validation/validators/tableValidator.ts b/src/app/utils/field-validation/validators/tableValidator.ts index c0659d164f..d0bdf04fe4 100644 --- a/src/app/utils/field-validation/validators/tableValidator.ts +++ b/src/app/utils/field-validation/validators/tableValidator.ts @@ -21,117 +21,113 @@ type TableValidatorConstructor = ( * Returns a validation function to check if the * response has less than the minimum number of rows specified. */ -const makeMinimumRowsValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { answerArray } = response - const { minimumRows } = tableField +const makeMinimumRowsValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { answerArray } = response + const { minimumRows } = tableField - return answerArray.length >= minimumRows - ? right(response) - : left(`TableValidator:\tanswer has less than the minimum number of rows`) -} + return answerArray.length >= minimumRows + ? right(response) + : left(`TableValidator:\tanswer has less than the minimum number of rows`) + } /** * Returns a validation function to check if addMoreRows is not set * and if so, whether the response has more than minimum number of rows. */ -const makeAddMoreRowsValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { answerArray } = response - const { minimumRows, addMoreRows } = tableField - - if (addMoreRows) return right(response) - return answerArray.length === minimumRows - ? right(response) - : left( - `TableValidator:\tanswer has extra rows even though addMoreRows is false`, - ) -} +const makeAddMoreRowsValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { answerArray } = response + const { minimumRows, addMoreRows } = tableField + + if (addMoreRows) return right(response) + return answerArray.length === minimumRows + ? right(response) + : left( + `TableValidator:\tanswer has extra rows even though addMoreRows is false`, + ) + } /** * Returns a validation function to check if the * response has more than the maximum number of rows specified. */ -const makeMaximumRowsValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { answerArray } = response - const { maximumRows } = tableField +const makeMaximumRowsValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { answerArray } = response + const { maximumRows } = tableField - if (!maximumRows) return right(response) + if (!maximumRows) return right(response) - return answerArray.length <= maximumRows - ? right(response) - : left(`TableValidator:\tanswer has more than the maximum number of rows`) -} + return answerArray.length <= maximumRows + ? right(response) + : left(`TableValidator:\tanswer has more than the maximum number of rows`) + } /** * Returns a validation function to check if the * response has the correct number of answers for each row. */ -const makeRowLengthValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { answerArray } = response - const { columns } = tableField - - return answerArray.every((row) => row.length === columns.length) - ? right(response) - : left(`TableValidator:\tanswer has rows with incorrect number of answers`) -} +const makeRowLengthValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { answerArray } = response + const { columns } = tableField + + return answerArray.every((row) => row.length === columns.length) + ? right(response) + : left( + `TableValidator:\tanswer has rows with incorrect number of answers`, + ) + } /** * Returns a validation function that checks that the columns only have allowed types */ -const makeColumnTypeValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { columns } = tableField - return columns.every((column) => - ALLOWED_COLUMN_TYPES.includes(column.columnType), - ) - ? right(response) - : left(`TableValidator:\tanswer has columns with non-allowed types`) -} +const makeColumnTypeValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { columns } = tableField + return columns.every((column) => + ALLOWED_COLUMN_TYPES.includes(column.columnType), + ) + ? right(response) + : left(`TableValidator:\tanswer has columns with non-allowed types`) + } /** * Returns a validation function that applies * the correct validator for each table cell. */ -const makeTableCellValidator: TableValidatorConstructor = (tableField) => ( - response, -) => { - const { answerArray, isVisible, _id } = response - const { columns } = tableField - const answerFieldColumns = columns.map((column) => - createAnswerFieldFromColumn(column), - ) - - return answerArray.every((row) => { - return row.every((answer, i) => { - const answerField = answerFieldColumns[i] - const answerResponse: ProcessedSingleAnswerResponse = { - answer, - isVisible, - fieldType: answerField.fieldType, - _id, - question: answerField.title, - } - - return validateField( - answerField._id || 'Table field validation', - answerField, - answerResponse, - ).isOk() +const makeTableCellValidator: TableValidatorConstructor = + (tableField) => (response) => { + const { answerArray, isVisible, _id } = response + const { columns } = tableField + const answerFieldColumns = columns.map((column) => + createAnswerFieldFromColumn(column), + ) + + return answerArray.every((row) => { + return row.every((answer, i) => { + const answerField = answerFieldColumns[i] + const answerResponse: ProcessedSingleAnswerResponse = { + answer, + isVisible, + fieldType: answerField.fieldType, + _id, + question: answerField.title, + } + + return validateField( + answerField._id || 'Table field validation', + answerField, + answerResponse, + ).isOk() + }) }) - }) - ? right(response) - : left(`TableValidator:\tanswer failed field validation`) -} + ? right(response) + : left(`TableValidator:\tanswer failed field validation`) + } /** * Returns a validation function for a table field when called. diff --git a/src/app/utils/field-validation/validators/textValidator.ts b/src/app/utils/field-validation/validators/textValidator.ts index 65be85fadd..b58aea807c 100644 --- a/src/app/utils/field-validation/validators/textValidator.ts +++ b/src/app/utils/field-validation/validators/textValidator.ts @@ -17,53 +17,52 @@ type TextFieldValidatorConstructor = ( * Returns a validator to check if * text length is less than the min length specified. */ -const minLengthValidator: TextFieldValidatorConstructor = (textField) => ( - response, -) => { - const { customMin } = textField.ValidationOptions - const min = customMin !== null ? Number(customMin) : null - if (min === null) return right(response) - return response.answer.length >= min - ? right(response) - : left(`TextValidator.minLength:\tanswer is less than minimum of ${min}`) -} +const minLengthValidator: TextFieldValidatorConstructor = + (textField) => (response) => { + const { customMin } = textField.ValidationOptions + const min = customMin !== null ? Number(customMin) : null + if (min === null) return right(response) + return response.answer.length >= min + ? right(response) + : left(`TextValidator.minLength:\tanswer is less than minimum of ${min}`) + } /** * Returns a validator to check if * text length is more than the max length specified. */ -const maxLengthValidator: TextFieldValidatorConstructor = (textField) => ( - response, -) => { - const { customMax } = textField.ValidationOptions - const max = customMax !== null ? Number(customMax) : null - if (max === null) return right(response) - return response.answer.length <= max - ? right(response) - : left(`TextValidator.maxLength:\tanswer is greater than maximum of ${max}`) -} +const maxLengthValidator: TextFieldValidatorConstructor = + (textField) => (response) => { + const { customMax } = textField.ValidationOptions + const max = customMax !== null ? Number(customMax) : null + if (max === null) return right(response) + return response.answer.length <= max + ? right(response) + : left( + `TextValidator.maxLength:\tanswer is greater than maximum of ${max}`, + ) + } /** * Returns a validator to check if * text length is the exact length specified. */ -const exactLengthValidator: TextFieldValidatorConstructor = (textField) => ( - response, -) => { - const { customMin, customMax } = textField.ValidationOptions - const exact = - customMin !== null - ? Number(customMin) - : customMax !== null - ? Number(customMax) - : null - if (exact === null) return right(response) - return response.answer.length === exact - ? right(response) - : left( - `TextValidator.exactLength:\tanswer is not exactly equal to ${exact}`, - ) -} +const exactLengthValidator: TextFieldValidatorConstructor = + (textField) => (response) => { + const { customMin, customMax } = textField.ValidationOptions + const exact = + customMin !== null + ? Number(customMin) + : customMax !== null + ? Number(customMax) + : null + if (exact === null) return right(response) + return response.answer.length === exact + ? right(response) + : left( + `TextValidator.exactLength:\tanswer is not exactly equal to ${exact}`, + ) + } /** * Returns the appropriate validator diff --git a/src/app/utils/random-uniform.ts b/src/app/utils/random-uniform.ts new file mode 100644 index 0000000000..d0e5dffbdc --- /dev/null +++ b/src/app/utils/random-uniform.ts @@ -0,0 +1,12 @@ +/** + * Generates a random integer between min and max (both inclusive). + * If min/max are not integers, the ceiling and floor are taken respectively. + * @param min + * @param max + * @returns Integer generated uniformly in interval + */ +export const randomUniformInt = (min: number, max: number): number => { + const roundedMin = Math.ceil(min) + const roundedMax = Math.floor(max) + return Math.floor(Math.random() * (roundedMax - roundedMin + 1)) + roundedMin +} diff --git a/src/public/main.js b/src/public/main.js index 61c622bb0a..e29766efe4 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -264,7 +264,6 @@ require('./modules/forms/services/spcp-session.client.factory.js') require('./modules/forms/services/submissions.client.factory.js') require('./modules/forms/services/toastr.client.factory.js') require('./modules/forms/services/attachment.client.service.js') -require('./modules/forms/services/betas.client.factory.js') require('./modules/forms/services/captcha.client.service.js') require('./modules/forms/services/mailto.client.factory.js') diff --git a/src/public/modules/core/resources/landing-examples.js b/src/public/modules/core/resources/landing-examples.js index f1413df722..931df4e758 100644 --- a/src/public/modules/core/resources/landing-examples.js +++ b/src/public/modules/core/resources/landing-examples.js @@ -41,44 +41,37 @@ const testims = [ { name: 'Lua Lee Hui', agency: 'Sport Singapore (SportSG)', - text: - 'Form.sg has helped us to overcome data collection limitations and with the user friendly interface on mobile devices, we have progressively moved away from paper forms and that has significantly reduced administrative efforts spent on transcribing hardcopy forms. We also love the logic feature which has gotten us creative in designing forms with customised view for our Team Nila volunteers. KUDOS to Form.sg team for the continuous effort to improve the product for us!', + text: 'Form.sg has helped us to overcome data collection limitations and with the user friendly interface on mobile devices, we have progressively moved away from paper forms and that has significantly reduced administrative efforts spent on transcribing hardcopy forms. We also love the logic feature which has gotten us creative in designing forms with customised view for our Team Nila volunteers. KUDOS to Form.sg team for the continuous effort to improve the product for us!', }, { name: 'Ang Mui Kim', agency: 'Ministry of Manpower (MOM)', - text: - 'Thank you for a good product that is easy to use and deploy. FYI - we had used it for a workshop last week to collate questions from participants and presented some scattered plots on the spot. It is easy to create and we had a few iterations including some last minute changes at the workshop itself. Cheers!', + text: 'Thank you for a good product that is easy to use and deploy. FYI - we had used it for a workshop last week to collate questions from participants and presented some scattered plots on the spot. It is easy to create and we had a few iterations including some last minute changes at the workshop itself. Cheers!', }, { name: 'Pang Sze Kiang', agency: 'Ministry of Education (MOE)', - text: - 'It is really easy to create forms using Form.sg. There is a good variety of form elements, ranging from ‘Short Text’ to ‘Dropdown’ fields. My colleagues and I are able to build customized forms that are able to meet the various needs of my branch. One big plus point is that the form responses are sent in the form of emails to only intended recipients – thus making the data collected very secure. Thank you, Form.sg!', + text: 'It is really easy to create forms using Form.sg. There is a good variety of form elements, ranging from ‘Short Text’ to ‘Dropdown’ fields. My colleagues and I are able to build customized forms that are able to meet the various needs of my branch. One big plus point is that the form responses are sent in the form of emails to only intended recipients – thus making the data collected very secure. Thank you, Form.sg!', }, { name: 'Elton Kang', agency: "People's Association (PA)", - text: - 'With Form.sg, users can easily access the form with the comfort and convenience of their mobile phones. Although the product is not all-encompassing compared to other digital form platforms out in the market, Form.sg edges out its competitors in one main and critical aspect: security. Having to interface with multiple internal and external stakeholders, data security is of paramount importance and thus far, Form.sg is most effective when we used it for workshops, surveys, registration for programmes, and collation of personal particulars.', + text: 'With Form.sg, users can easily access the form with the comfort and convenience of their mobile phones. Although the product is not all-encompassing compared to other digital form platforms out in the market, Form.sg edges out its competitors in one main and critical aspect: security. Having to interface with multiple internal and external stakeholders, data security is of paramount importance and thus far, Form.sg is most effective when we used it for workshops, surveys, registration for programmes, and collation of personal particulars.', }, { name: 'Regina Wang', agency: 'National Environment Authority (NEA)', - text: - 'We regularly hold industry briefing and will require collating industrial partners’ and external parties’ attendance. This takes a lot of effort using emails and excel. Using formsg, we cut down manpower to collate these registration details. The user experience feedback was positive and pleasant as well.', + text: 'We regularly hold industry briefing and will require collating industrial partners’ and external parties’ attendance. This takes a lot of effort using emails and excel. Using formsg, we cut down manpower to collate these registration details. The user experience feedback was positive and pleasant as well.', }, { name: 'Ji Min Sheng', agency: 'Municipal Services Office (MSO)', - text: - 'Thank you GovTech for coming up with FormSG! FormSG provides a quick and structured way to consolidate information across agencies. It has improved the productivity of our officers by allowing them to highlight cases to other agencies on-the-go, and eliminated the need for manual collation of data. The clear form structure has also minimised clarification emails.', + text: 'Thank you GovTech for coming up with FormSG! FormSG provides a quick and structured way to consolidate information across agencies. It has improved the productivity of our officers by allowing them to highlight cases to other agencies on-the-go, and eliminated the need for manual collation of data. The clear form structure has also minimised clarification emails.', }, { name: 'Jeremy Ang', agency: 'Nanyang Junior College (NYJC)', - text: - 'Our sincere thanks to GovTech for creating FormSG along with the macro script, data collation tool and SingPass verification. This is very useful on so many levels. We use it to collect data from the public, from students and from colleagues for a variety of applications, while still meeting IM8 requirements on personal data protection. The scripts and templates are easy to use and customisable and the option to integrate this with a Shared CES Inbox streamlines processes for our School Admin Team and teachers. With one click, we can aggregate the data into the format in that we need. It’s all done in less than a minute. We’ve also shared this with several other schools and they are very keen to use it too. Great job, GovTech!', + text: 'Our sincere thanks to GovTech for creating FormSG along with the macro script, data collation tool and SingPass verification. This is very useful on so many levels. We use it to collect data from the public, from students and from colleagues for a variety of applications, while still meeting IM8 requirements on personal data protection. The scripts and templates are easy to use and customisable and the option to integrate this with a Shared CES Inbox streamlines processes for our School Admin Team and teachers. With one click, we can aggregate the data into the format in that we need. It’s all done in less than a minute. We’ve also shared this with several other schools and they are very keen to use it too. Great job, GovTech!', }, ] diff --git a/src/public/modules/core/views/edit-contact-number-modal.view.html b/src/public/modules/core/views/edit-contact-number-modal.view.html index af7dd4795d..3219b041e9 100644 --- a/src/public/modules/core/views/edit-contact-number-modal.view.html +++ b/src/public/modules/core/views/edit-contact-number-modal.view.html @@ -65,7 +65,12 @@ diff --git a/src/public/modules/forms/admin/views/collaborator.client.modal.html b/src/public/modules/forms/admin/views/collaborator.client.modal.html index a5c366a479..eb7a5cb873 100644 --- a/src/public/modules/forms/admin/views/collaborator.client.modal.html +++ b/src/public/modules/forms/admin/views/collaborator.client.modal.html @@ -202,7 +202,10 @@ {{ ROLES.ADMIN }}
@@ -284,7 +287,10 @@