From 31555c80b5f077bfbdff644e133d4426a63a0438 Mon Sep 17 00:00:00 2001 From: Yuan Ruo Date: Tue, 25 Aug 2020 11:42:40 +0800 Subject: [PATCH 01/26] Introduce minimum test coverage thresholds, coveralls.io for threshold reporting and repo badge (#185) * chore(deps): install coveralls * feat(readme): add coveralls badge to readme * feat(coveralls): configure jest to collect test coverage info and report it to coveralls.io * fix(jest): only collect coverage from TS files, bump threshold to 14% * fix(jest): restore test coverage behavior This reverts commit e2909d8cdc887a7a4f7a5c6266d10e9a72939bdb. --- .gitignore | 4 ++++ README.md | 2 ++ jest.config.js | 7 +++++++ package-lock.json | 25 +++++++++++++++++++++++++ package.json | 5 +++-- 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 633513dd3e..e09590f243 100755 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ tmp/ # @shelf/jest-mongodb generated file # ======= globalConfig.json + +# Tests +# ======= +coverage/ \ No newline at end of file diff --git a/README.md b/README.md index 1bb292fbdc..c67584ab15 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # FormSG [![Build Status](https://travis-ci.com/opengovsg/formsg.svg?branch=release)](https://travis-ci.com/opengovsg/formsg) +[![Coverage Status](https://coveralls.io/repos/github/opengovsg/FormSG/badge.svg?branch=develop)](https://coveralls.io/github/opengovsg/FormSG?branch=develop) ## Table of Contents @@ -10,6 +11,7 @@ - [Prerequisites](#prerequisites) - [Running Locally](#running-locally) - [Environment variables](#environment-variables) + - [Trouble-shooting](#trouble-shooting) - [Testing](#testing) - [Testing Prerequisites](#testing-prerequisites) - [Running tests](#running-tests) diff --git a/jest.config.js b/jest.config.js index 60c92074d8..f38906c39b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,11 @@ module.exports = { // Needed to use @shelf/jest-mongodb preset. ...tsJestPreset.transform, }, + collectCoverageFrom: ['./src/**/*.{ts,js}'], + coveragePathIgnorePatterns: ['./node_modules/', './tests'], + coverageThreshold: { + global: { + statements: 12, // Increase this percentage as test coverage improves + }, + }, } diff --git a/package-lock.json b/package-lock.json index 829972bf0b..470f2cba04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9138,6 +9138,19 @@ } } }, + "coveralls": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/coveralls/-/coveralls-3.1.0.tgz", + "integrity": "sha512-sHxOu2ELzW8/NC1UP5XVLbZDzO4S3VxfFye3XYCznopHy02YjNkHcj5bKaVw2O7hVaBdBjEdQGpie4II1mWhuQ==", + "dev": true, + "requires": { + "js-yaml": "^3.13.1", + "lcov-parse": "^1.0.0", + "log-driver": "^1.2.7", + "minimist": "^1.2.5", + "request": "^2.88.2" + } + }, "create-ecdh": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", @@ -16924,6 +16937,12 @@ "webpack-sources": "^1.1.0" } }, + "lcov-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", + "integrity": "sha1-6w1GtUER68VhrLTECO+TY73I9+A=", + "dev": true + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -17355,6 +17374,12 @@ "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", "dev": true }, + "log-driver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", + "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==", + "dev": true + }, "log-symbols": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", diff --git a/package.json b/package.json index 57becacaab..1d68e7ff5c 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,11 @@ "dev": "docker-compose up --build", "docker-dev": "npm run build-frontend-dev & ts-node-dev --respawn --transpileOnly --inspect=0.0.0.0 -- src/server.ts", "test-backend": "npm run test-backend-jasmine && npm run test-backend-jest", - "test-backend-jest": "env-cmd -f tests/.test-full-env jest", + "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage", "test-backend-jest:watch": "env-cmd -f tests/.test-full-env jest --watch", "test-backend-jasmine": "env-cmd -f tests/.test-full-env --use-shell \"npm run download-binary && jasmine --config=tests/unit/backend/jasmine.json\"", "test-frontend": "jest --config=tests/unit/frontend/jest.config.js", - "test-ci": "npm run test-backend && npm run test-frontend", + "test-ci": "npm run test-backend && npm run test-frontend && coveralls < coverage/lcov.info", "test": "npm run build-backend && npm run test-backend && npm run test-frontend", "test-e2e-build": "npm run build-backend && npm run build-frontend-dev", "test-run": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/server.js\" \"localstack start --host\"", @@ -190,6 +190,7 @@ "concurrently": "^3.6.1", "copy-webpack-plugin": "^6.0.2", "core-js": "^3.6.4", + "coveralls": "^3.1.0", "css-loader": "^2.1.1", "env-cmd": "^10.1.0", "eslint": "^6.8.0", From 695fbf25b6280ccd31ae9210acab949261ebfeac Mon Sep 17 00:00:00 2001 From: Frank Chen Date: Tue, 25 Aug 2020 18:25:29 -0700 Subject: [PATCH 02/26] feat: Share form secret keys across browser tabs using BroadcastChannel (#203) * feat: Support sharing of form secret keys across browser tabs using BroadcastChannel * Add documentation about security and browser compatibility to BroadcastChannel --- .../view-responses.client.controller.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js index fd2235ffe6..80725ab622 100644 --- a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js +++ b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js @@ -47,6 +47,44 @@ function ViewResponsesController( vm.datePicker = { date: { startDate: null, endDate: null } } + // BroadcastChannel will only broadcast the message to scripts from the same origin + // (i.e. https://form.gov.sg in practice) so all data should be controlled by scripts + // originating from FormSG. This does not store any data in browser-based storage + // (e.g. cookies or localStorage) so secrets would not be retained past the user closing + // all FormSG tabs containing the form. + + // We do not use polyfills for BroadcastChannel as they usually involve localStorage, + // which is not safe for secret key handling. + + // BroadcastChannel is not available on Safari and IE 11, so this feature is not available + // on those two browsers. Current behavior where users have to upload the secret key on + // each tab will continue for users on those two browsers. + if (typeof BroadcastChannel === 'function') { + vm.privateKeyChannel = new BroadcastChannel('formsg_private_key_sharing') + vm.privateKeyChannel.onmessage = function (e) { + if (e.data.action === 'broadcastKey') { + if (vm.encryptionKey === null && vm.myform._id === e.data.formId) { + vm.unlock({ encryptionKey: e.data.encryptionKey }) + $scope.$digest() + } + } + if (e.data.action === 'requestKey') { + if (vm.encryptionKey !== null && vm.myform._id === e.data.formId) { + vm.privateKeyChannel.postMessage({ + formId: vm.myform._id, + action: 'broadcastKey', + encryptionKey: vm.encryptionKey, + }) + } + } + } + + vm.privateKeyChannel.postMessage({ + formId: vm.myform._id, + action: 'requestKey', + }) + } + // Datepicker for export CSV function vm.exportCsvDate = { value: '', @@ -266,6 +304,14 @@ function ViewResponsesController( }, ) vm.loading = false + + if (typeof vm.privateKeyChannel !== 'undefined') { + vm.privateKeyChannel.postMessage({ + formId: vm.myform._id, + action: 'broadcastKey', + encryptionKey: vm.encryptionKey, + }) + } } /** * UNLOCK RESPONSES ***/ From 5061905cba86f591045603c19936a8772d3ef04c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2020 15:42:31 +0800 Subject: [PATCH 03/26] chore(deps-dev): bump sinon from 6.3.5 to 9.0.3 (#207) Bumps [sinon](https://github.com/sinonjs/sinon) from 6.3.5 to 9.0.3. - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/master/CHANGELOG.md) - [Commits](https://github.com/sinonjs/sinon/compare/v6.3.5...v9.0.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 110 ++++++++++++++++++++-------------------------- package.json | 2 +- 2 files changed, 49 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e303ff3de..846a8e86ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4511,33 +4511,25 @@ } }, "@sinonjs/formatio": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz", - "integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz", + "integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==", "dev": true, "requires": { "@sinonjs/commons": "^1", - "@sinonjs/samsam": "^3.1.0" - }, - "dependencies": { - "@sinonjs/samsam": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz", - "integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.3.0", - "array-from": "^2.1.1", - "lodash": "^4.17.15" - } - } + "@sinonjs/samsam": "^5.0.2" } }, "@sinonjs/samsam": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.3.tgz", - "integrity": "sha512-8zNeBkSKhU9a5cRNbpCKau2WWPfan+Q2zDlcXvXyhn9EsMqgYs4qzo0XHNVlXC6ABQL8fT6nV+zzo5RTHJzyXw==", - "dev": true + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.1.0.tgz", + "integrity": "sha512-42nyaQOVunX5Pm6GRJobmzbS7iLI+fhERITnETXzzwDZh+TtDr/Au3yAvXVjFmZ4wEUaE4Y3NFZfKv0bV0cbtg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } }, "@sinonjs/text-encoding": { "version": "0.7.1", @@ -5810,12 +5802,6 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, - "array-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", - "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", - "dev": true - }, "array-includes": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", @@ -10306,9 +10292,9 @@ } }, "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true }, "diff-sequences": { @@ -17560,12 +17546,6 @@ "triple-beam": "^1.3.0" } }, - "lolex": { - "version": "2.7.5", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", - "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", - "dev": true - }, "long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -18936,15 +18916,15 @@ "dev": true }, "nise": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", - "integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.0.4.tgz", + "integrity": "sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==", "dev": true, "requires": { - "@sinonjs/formatio": "^3.2.1", + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", "@sinonjs/text-encoding": "^0.7.1", "just-extend": "^4.0.2", - "lolex": "^5.0.1", "path-to-regexp": "^1.7.0" }, "dependencies": { @@ -18954,15 +18934,6 @@ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", "dev": true }, - "lolex": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz", - "integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.7.0" - } - }, "path-to-regexp": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", @@ -22275,20 +22246,35 @@ } }, "sinon": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.3.5.tgz", - "integrity": "sha512-xgoZ2gKjyVRcF08RrIQc+srnSyY1JDJtxu3Nsz07j1ffjgXoY6uPLf/qja6nDBZgzYYEovVkFryw2+KiZz11xQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.0.3.tgz", + "integrity": "sha512-IKo9MIM111+smz9JGwLmw5U1075n1YXeAq8YeSFlndCLhAL5KGn6bLgu7b/4AYHTV/LcEMcRm2wU2YiL55/6Pg==", "dev": true, "requires": { - "@sinonjs/commons": "^1.0.2", - "@sinonjs/formatio": "^3.0.0", - "@sinonjs/samsam": "^2.1.2", - "diff": "^3.5.0", - "lodash.get": "^4.4.2", - "lolex": "^2.7.5", - "nise": "^1.4.5", - "supports-color": "^5.5.0", - "type-detect": "^4.0.8" + "@sinonjs/commons": "^1.7.2", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/formatio": "^5.0.1", + "@sinonjs/samsam": "^5.1.0", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } } }, "sisteransi": { diff --git a/package.json b/package.json index 526733f557..6d8638a4b6 100644 --- a/package.json +++ b/package.json @@ -217,7 +217,7 @@ "optimize-css-assets-webpack-plugin": "^5.0.1", "prettier": "^2.0.5", "regenerator": "^0.14.4", - "sinon": "^6.0.0", + "sinon": "^9.0.3", "stylelint": "^13.3.3", "stylelint-config-prettier": "^8.0.1", "stylelint-config-standard": "^20.0.0", From 2ca84a4f2582ebc4803140fba8b5e41fe3be2122 Mon Sep 17 00:00:00 2001 From: arshadali172 Date: Wed, 26 Aug 2020 18:06:22 +0800 Subject: [PATCH 04/26] refactor: convert webhook service to Typescript (#83) --- docs/DEPLOYMENT_SETUP.md | 2 + package-lock.json | 178 ++++++++++++++++++ package.json | 8 +- src/app/models/submission.server.model.ts | 65 +++---- ...ebhooks.service.js => webhooks.service.ts} | 162 +++++++++------- src/types/index.ts | 1 + src/types/submission.ts | 64 ++++--- src/types/webhook.ts | 21 +++ tests/.test-basic-env | 2 + tests/.test-full-env | 2 + tests/end-to-end/encrypt-submission.e2e.js | 47 +++++ tests/end-to-end/helpers/encrypt-mode.js | 46 +++++ tests/end-to-end/helpers/selectors.js | 1 + tests/end-to-end/helpers/util.js | 9 +- tests/mock-webhook-server.js | 54 ++++++ tests/unit/backend/helpers/jest-db.ts | 2 +- .../models/submission.server.model.spec.ts | 30 ++- .../backend/services/webhook.service.spec.ts | 176 +++++++++++++++++ 18 files changed, 730 insertions(+), 140 deletions(-) rename src/app/services/{webhooks.service.js => webhooks.service.ts} (53%) create mode 100644 src/types/webhook.ts create mode 100644 tests/mock-webhook-server.js create mode 100644 tests/unit/backend/services/webhook.service.spec.ts diff --git a/docs/DEPLOYMENT_SETUP.md b/docs/DEPLOYMENT_SETUP.md index 8a05ebc864..7e4e1d35f6 100644 --- a/docs/DEPLOYMENT_SETUP.md +++ b/docs/DEPLOYMENT_SETUP.md @@ -262,3 +262,5 @@ If this feature is enabled, storage mode forms will also support authentication | :--------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `MONGO_BINARY_VERSION` | Version of the Mongo binary used. Defaults to `'latest'` according to [MongoMemoryServer](https://github.com/nodkz/mongodb-memory-server) docs. | | `PWD` | Path of working directory. | +| `MOCK_WEBHOOK_CONFIG_FILE` | Path of configuration file for mock webhook server | +| `MOCK_WEBHOOK_PORT` | Port of mock webhook server | \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 846a8e86ed..8777282bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4630,6 +4630,12 @@ "@types/node": "*" } }, + "@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -5059,6 +5065,31 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" }, + "@types/request": { + "version": "2.48.5", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz", + "integrity": "sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ==", + "dev": true, + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, "@types/serve-static": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.4.tgz", @@ -5092,6 +5123,12 @@ "integrity": "sha512-flgpHJjntpBAdJD43ShRosQvNC0ME97DCfGvZEDlAThQmnerRXrLbX6YgzRBQCZTthET9eAWFAMaYP0m0Y4HzQ==", "dev": true }, + "@types/tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", + "dev": true + }, "@types/uid-generator": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/uid-generator/-/uid-generator-2.0.2.tgz", @@ -7457,6 +7494,16 @@ "integrity": "sha512-g8aeYkY7GhyyKRvQMBsJQZjhm2iCX3dKYvfrMpwVR8IxmUGrkpCBFoKbB9Rh0o3sTLCjU/1tFpZ4C7j3f+D+3g==", "dev": true }, + "binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=", + "dev": true, + "requires": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + } + }, "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", @@ -7799,6 +7846,12 @@ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, + "buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=", + "dev": true + }, "builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -8102,6 +8155,15 @@ "type-detect": "^4.0.5" } }, + "chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=", + "dev": true, + "requires": { + "traverse": ">=0.3.0 <0.4" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -9996,6 +10058,62 @@ } } }, + "decompress-zip": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/decompress-zip/-/decompress-zip-0.3.2.tgz", + "integrity": "sha512-Ab1QY4LrWMrUuo53lLnmGOby7v8ryqxJ+bKibKSiPisx+25mhut1dScVBXAYx14i/PqSrFZvR2FRRazhLbvL+g==", + "dev": true, + "requires": { + "binary": "^0.3.0", + "graceful-fs": "^4.1.3", + "mkpath": "^0.1.0", + "nopt": "^3.0.1", + "q": "^1.1.2", + "readable-stream": "^1.1.8", + "touch": "0.0.3" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -18163,6 +18281,12 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "mkpath": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/mkpath/-/mkpath-0.1.0.tgz", + "integrity": "sha1-dVSm+Nhxg0zJe1RisSLEwSTW3pE=", + "dev": true + }, "mobile-detect": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mobile-detect/-/mobile-detect-1.4.4.tgz", @@ -18909,6 +19033,34 @@ "clipboard": "^2.0.0" } }, + "ngrok": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.2.7.tgz", + "integrity": "sha512-B7K15HM0qRZplL2aO/yfxixYubH0M50Pfu0fa4PDcmXP7RC+wyYzu6YtX77BBHHCfbwCzkObX6YdO8ThpCR6Lg==", + "dev": true, + "requires": { + "@types/node": "^8.10.50", + "@types/request": "^2.48.2", + "decompress-zip": "^0.3.2", + "request": "^2.88.0", + "request-promise-native": "^1.0.7", + "uuid": "^3.3.2" + }, + "dependencies": { + "@types/node": { + "version": "8.10.62", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.62.tgz", + "integrity": "sha512-76fupxOYVxk36kb7O/6KtrAPZ9jnSK3+qisAX4tQMEuGNdlvl7ycwatlHqjoE6jHfVtXFM3pCrCixZOidc5cuw==", + "dev": true + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -24738,6 +24890,26 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "touch": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/touch/-/touch-0.0.3.tgz", + "integrity": "sha1-Ua7z1ElXHU8oel2Hyci0kYGg2x0=", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -24756,6 +24928,12 @@ "punycode": "^2.1.1" } }, + "traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=", + "dev": true + }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index 6d8638a4b6..67783286a2 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,11 @@ "build": "npm run build-backend && npm run build-frontend", "build-backend": "tsc -p tsconfig.build.json", "build-frontend": "webpack --config webpack.prod.js", - "build-frontend-dev": "webpack --config webpack.dev.js --watch", + "build-frontend-dev": "webpack --config webpack.dev.js", + "build-frontend-dev:watch": "webpack --config webpack.dev.js --watch", "start": "node dist/backend/server.js", "dev": "docker-compose up --build", - "docker-dev": "npm run build-frontend-dev & ts-node-dev --respawn --transpileOnly --inspect=0.0.0.0 -- src/server.ts", + "docker-dev": "npm run build-frontend-dev:watch & ts-node-dev --respawn --transpileOnly --inspect=0.0.0.0 -- src/server.ts", "test-backend": "npm run test-backend-jasmine && npm run test-backend-jest", "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage", "test-backend-jest:watch": "env-cmd -f tests/.test-full-env jest --watch", @@ -31,7 +32,7 @@ "test-ci": "npm run test-backend && npm run test-frontend && coveralls < coverage/lcov.info", "test": "npm run build-backend && npm run test-backend && npm run test-frontend", "test-e2e-build": "npm run build-backend && npm run build-frontend-dev", - "test-run": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/server.js\" \"localstack start --host\"", + "test-run": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/server.js\" \"localstack start --host\" \"node ./tests/mock-webhook-server.js\"", "testcafe-full-env": "testcafe -c 3 chrome:headless ./tests/end-to-end --test-meta full-env=true --app \"npm run test-run\" --app-init-delay 10000", "testcafe-basic-env": "testcafe -c 3 chrome:headless ./tests/end-to-end --test-meta basic-env=true --app \"npm run test-run\" --app-init-delay 10000", "download-binary": "node tests/end-to-end/helpers/get-mongo-binary.js", @@ -214,6 +215,7 @@ "maildev": "^1.1.0", "mini-css-extract-plugin": "^0.5.0", "mongodb-memory-server-core": "^5.1.5", + "ngrok": "^3.2.7", "optimize-css-assets-webpack-plugin": "^5.0.1", "prettier": "^2.0.5", "regenerator": "^0.14.4", diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index a593cd68be..bc5cb243dc 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -2,10 +2,10 @@ import { Model, Mongoose, Schema } from 'mongoose' import { AuthType, + IEmailSubmissionModel, IEmailSubmissionSchema, - IEncryptedSubmission, IEncryptedSubmissionSchema, - ISubmission, + IEncryptSubmissionModel, ISubmissionSchema, IWebhookResponseSchema, MyInfoAttribute, @@ -56,40 +56,8 @@ SubmissionSchema.index({ created: -1, }) -const isEncryptedSubmission = ( - submission: ISubmission, -): submission is IEncryptedSubmission => { - return submission.submissionType === SubmissionType.Encrypt -} - // Instance methods -/** - * Returns an object which represents the encrypted submission - * which will be posted to the webhook URL. - */ - -SubmissionSchema.methods.getWebhookView = function ( - this: ISubmissionSchema, -): WebhookView | null { - if (!isEncryptedSubmission(this)) { - return null - } - - const webhookData: WebhookData = { - formId: String(this.form), - submissionId: String(this._id), - encryptedContent: this.encryptedContent, - verifiedContent: this.verifiedContent, - version: this.version, - created: this.created, - } - - return { - data: webhookData, - } -} - const emailSubmissionSchema = new Schema({ recipientEmails: { type: [ @@ -115,6 +83,13 @@ const emailSubmissionSchema = new Schema({ }, }) +/** + * Returns null as email submission does not have a webhook view + */ +emailSubmissionSchema.methods.getWebhookView = function (): null { + return null +} + const webhookResponseSchema = new Schema( { webhookUrl: String, @@ -156,8 +131,26 @@ const encryptSubmissionSchema = new Schema({ webhookResponses: [webhookResponseSchema], }) -type IEmailSubmissionModel = Model -type IEncryptSubmissionModel = Model +/** + * Returns an object which represents the encrypted submission + * which will be posted to the webhook URL. + */ +encryptSubmissionSchema.methods.getWebhookView = function ( + this: ISubmissionSchema, +): WebhookView { + const webhookData: WebhookData = { + formId: String(this.form), + submissionId: String(this._id), + encryptedContent: this.encryptedContent, + verifiedContent: this.verifiedContent, + version: this.version, + created: this.created, + } + + return { + data: webhookData, + } +} export const getEmailSubmissionModel = (db: Mongoose) => { try { diff --git a/src/app/services/webhooks.service.js b/src/app/services/webhooks.service.ts similarity index 53% rename from src/app/services/webhooks.service.js rename to src/app/services/webhooks.service.ts index 908ca06072..8685cc9c0a 100644 --- a/src/app/services/webhooks.service.js +++ b/src/app/services/webhooks.service.ts @@ -1,16 +1,23 @@ -const axios = require('axios') -const _ = require('lodash') +import axios, { AxiosError, AxiosResponse } from 'axios' +import { get } from 'lodash' +import mongoose from 'mongoose' -const { WebhookValidationError } = require('../utils/custom-errors') -const mongoose = require('mongoose') -const { - getEncryptSubmissionModel, -} = require('../models/submission.server.model') -const EncryptSubmission = getEncryptSubmissionModel(mongoose) +import formsgSdk from '../../config/formsg-sdk' +import { createLoggerWithLabel } from '../../config/logger' // Prevents JSON.stringify error for circular JSONs and BigInts -const { stringifySafe } = require('../../shared/util/stringify-safe') -const formsgSdk = require('../../config/formsg-sdk') -const logger = require('../../config/logger').createLoggerWithLabel('webhooks') +import { stringifySafe } from '../../shared/util/stringify-safe' +import { + IFormSchema, + ISubmissionSchema, + IWebhookResponse, + LogWebhookParams, + WebhookParams, +} from '../../types' +import { getEncryptSubmissionModel } from '../models/submission.server.model' +import { WebhookValidationError } from '../utils/custom-errors' + +const logger = createLoggerWithLabel('webhooks') +const EncryptSubmission = getEncryptSubmissionModel(mongoose) /** * Logs webhook failure in console and database. @@ -23,9 +30,12 @@ const logger = require('../../config/logger').createLoggerWithLabel('webhooks') * @param {string} webhookParams.now Epoch for POST header * @param {string} webhookParams.signature Signature generated by FormSG SDK */ -const handleWebhookFailure = (error, webhookParams) => { +export const handleWebhookFailure = async ( + error: Error | AxiosError, + webhookParams: WebhookParams, +): Promise => { logWebhookFailure(error, webhookParams) - updateSubmissionsDb( + await updateSubmissionsDb( webhookParams.formId, webhookParams.submissionId, getFailureDbUpdate(error, webhookParams), @@ -43,9 +53,12 @@ const handleWebhookFailure = (error, webhookParams) => { * @param {string} webhookParams.now Epoch for POST header * @param {string} webhookParams.signature Signature generated by FormSG SDK */ -const handleWebhookSuccess = (response, webhookParams) => { +export const handleWebhookSuccess = async ( + response: AxiosResponse, + webhookParams: WebhookParams, +): Promise => { logWebhookSuccess(response, webhookParams) - updateSubmissionsDb( + await updateSubmissionsDb( webhookParams.formId, webhookParams.submissionId, getSuccessDbUpdate(response, webhookParams), @@ -63,14 +76,14 @@ const handleWebhookSuccess = (response, webhookParams) => { * @param {string} now Epoch for POST header * @param {string} signature Signature generated by FormSG SDK */ -const postWebhook = ({ +export const postWebhook = ({ webhookUrl, submissionWebhookView, submissionId, formId, now, signature, -}) => { +}: WebhookParams): Promise => { return axios.post(webhookUrl, submissionWebhookView, { headers: { 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({ @@ -86,11 +99,11 @@ const postWebhook = ({ // Logging for webhook success const logWebhookSuccess = ( - response, - { webhookUrl, submissionId, formId, now, signature }, -) => { - const status = _.get(response, 'status') - const loggingParams = { + response: AxiosResponse, + { webhookUrl, submissionId, formId, now, signature }: WebhookParams, +): void => { + const status = get(response, 'status') + const loggingParams: LogWebhookParams = { status, submissionId, formId, @@ -102,12 +115,12 @@ const logWebhookSuccess = ( } // Logging for webhook failure -const logWebhookFailure = ( - error, - { webhookUrl, submissionId, formId, now, signature }, -) => { - const errorMessage = _.get(error, 'message') - const loggingParams = { +export const logWebhookFailure = ( + error: Error | AxiosError, + { webhookUrl, submissionId, formId, now, signature }: WebhookParams, +): void => { + const errorMessage = get(error, 'message') + let loggingParams: LogWebhookParams = { submissionId, formId, now, @@ -118,38 +131,41 @@ const logWebhookFailure = ( if (error instanceof WebhookValidationError) { logger.error(getConsoleMessage('Webhook not attempted', loggingParams)) } else { - loggingParams.status = _.get(error, 'response.status') + loggingParams.status = get(error, 'response.status') logger.error(getConsoleMessage('Webhook POST failed', loggingParams)) } } // Updates the submission in the database with the webhook response -const updateSubmissionsDb = (formId, submissionId, updateObj) => { - EncryptSubmission.updateOne( - { _id: submissionId }, - { $push: { webhookResponses: updateObj } }, - ) - .then(({ nModified }) => { - if (nModified !== 1) { - // Pass on to catch block - throw new Error('Submission not found in database.') - } - }) - .catch((error) => { - logger.error( - getConsoleMessage('Database update for webhook status failed', { - formId, - submissionId, - updateObj: stringifySafe(updateObj), - dbErrorMessage: _.get(error, 'message'), - }), - ) - }) +const updateSubmissionsDb = async ( + formId: IFormSchema['_id'], + submissionId: ISubmissionSchema['_id'], + updateObj: IWebhookResponse, +): Promise => { + try { + const { nModified } = await EncryptSubmission.updateOne( + { _id: submissionId }, + { $push: { webhookResponses: updateObj } }, + ) + if (nModified !== 1) { + // Pass on to catch block + throw new Error('Submission not found in database.') + } + } catch (error) { + logger.error( + getConsoleMessage('Database update for webhook status failed', { + formId, + submissionId, + updateObj: stringifySafe(updateObj), + dbErrorMessage: get(error, 'message'), + }), + ) + } } // Creates a string with a title, followed by a tab, followed // by a list of 'key=value' pairs separated by spaces -const getConsoleMessage = (title, params) => { +const getConsoleMessage = (title: string, params: object) => { let consoleMessage = title + ':\t' consoleMessage += Object.entries(params) .map(([key, value]) => key + '=' + value) @@ -158,33 +174,41 @@ const getConsoleMessage = (title, params) => { } // Formats webhook success info into an object to update Submissions collection -const getSuccessDbUpdate = (response, { webhookUrl, signature }) => { - return { webhookUrl, signature, response: getFormattedResponse(response) } +const getSuccessDbUpdate = ( + response: AxiosResponse, + { webhookUrl, signature }: WebhookParams, +): IWebhookResponse => { + return { webhookUrl, signature, ...getFormattedResponse(response) } } // Formats webhook failure info into an object to update Submissions collection -const getFailureDbUpdate = (error, { webhookUrl, signature }) => { - const errorMessage = _.get(error, 'message') - const update = { webhookUrl, signature, errorMessage } +const getFailureDbUpdate = ( + error: Error | AxiosError, + { webhookUrl, signature }: WebhookParams, +): IWebhookResponse => { + const errorMessage = get(error, 'message') + let update: IWebhookResponse = { + webhookUrl, + signature, + errorMessage, + } if (!(error instanceof WebhookValidationError)) { - update.response = getFormattedResponse(_.get(error, 'response')) + const { response } = getFormattedResponse(get(error, 'response')) + update.response = response } return update } // Formats a response object for update in the Submissions collection -const getFormattedResponse = (response = {}) => { +const getFormattedResponse = ( + response: AxiosResponse, +): Pick => { return { - status: response.status, - statusText: response.statusText, - headers: stringifySafe(response.headers), - data: stringifySafe(response.data), + response: { + status: get(response, 'status'), + statusText: get(response, 'statusText'), + headers: stringifySafe(get(response, 'headers')), + data: stringifySafe(get(response, 'data')), + }, } } - -module.exports = { - postWebhook, - handleWebhookSuccess, - handleWebhookFailure, - logWebhookFailure, -} diff --git a/src/types/index.ts b/src/types/index.ts index 088c8d6436..8a3bf4d82d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,3 +15,4 @@ export * from './submission' export * from './token' export * from './user' export * from './verification' +export * from './webhook' diff --git a/src/types/submission.ts b/src/types/submission.ts index fe5901a291..ff38a680ad 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -1,4 +1,5 @@ -import { Document } from 'mongoose' +import { AxiosResponse } from 'axios' +import { Document, Model } from 'mongoose' import { MyInfoAttribute } from './field' import { AuthType, IFormSchema } from './form' @@ -11,15 +12,20 @@ export enum SubmissionType { export interface ISubmission { form: IFormSchema['_id'] authType: AuthType - myInfoFields: MyInfoAttribute + myInfoFields: MyInfoAttribute[] submissionType: SubmissionType - created: Date - lastModified: Date + created?: Date + lastModified?: Date _id: Document['_id'] -} - -export interface ISubmissionSchema extends ISubmission, Document { - getWebhookView(): WebhookView | null + recipientEmails?: string[] + responseHash?: string + responseSalt?: string + hasBounced?: boolean + encryptedContent?: string + verifiedContent?: string + version?: number + attachmentMetadata?: Map + webhookResponses?: IWebhookResponse[] } export interface WebhookData { @@ -35,37 +41,51 @@ export interface WebhookView { data: WebhookData } +export interface ISubmissionSchema extends ISubmission, Document { + getWebhookView(): WebhookView | null +} + export interface IEmailSubmission extends ISubmission { - recipientEmails?: string[] + recipientEmails: string[] responseHash: string responseSalt: string hasBounced: boolean + encryptedContent: never + verifiedContent: never + version: never + attachmentMetadata: never + webhookResponses: never + getWebhookView(): WebhookView | null } -export interface IEmailSubmissionSchema - extends IEmailSubmission, - ISubmissionSchema {} +export type IEmailSubmissionSchema = IEmailSubmission & ISubmissionSchema export interface IEncryptedSubmission extends ISubmission { + recipientEmails: never + responseHash: never + responseSalt: never + hasBounced: never encryptedContent: string verifiedContent?: string version: number attachmentMetadata?: Map - webhookResponses: IWebhookResponseSchema[] + webhookResponses: IWebhookResponse[] + getWebhookView(): WebhookView | null } -export interface IWebhookResponseSchema extends Document { +export type IEncryptedSubmissionSchema = IEncryptedSubmission & + ISubmissionSchema + +export interface IWebhookResponse { webhookUrl: string signature: string - errorMessage: string - response: { - status: number - statusText: string + errorMessage?: string + response?: Omit, 'config' | 'request' | 'headers'> & { headers: string - data: string } } -export interface IEncryptedSubmissionSchema - extends IEncryptedSubmission, - ISubmissionSchema {} +export type IEmailSubmissionModel = Model +export type IEncryptSubmissionModel = Model + +export interface IWebhookResponseSchema extends IWebhookResponse, Document {} diff --git a/src/types/webhook.ts b/src/types/webhook.ts new file mode 100644 index 0000000000..3010594240 --- /dev/null +++ b/src/types/webhook.ts @@ -0,0 +1,21 @@ +import { IFormSchema } from './form' +import { ISubmissionSchema, WebhookView } from './submission' + +export type WebhookParams = { + webhookUrl: string + submissionWebhookView: WebhookView + submissionId: ISubmissionSchema['_id'] + formId: IFormSchema['_id'] + now: number + signature: string +} + +export type LogWebhookParams = { + submissionId: ISubmissionSchema['_id'] + formId: IFormSchema['_id'] + now: number + webhookUrl: string + signature: string + status?: number + errorMessage?: string +} diff --git a/tests/.test-basic-env b/tests/.test-basic-env index b02a169934..e44e2b03ab 100644 --- a/tests/.test-basic-env +++ b/tests/.test-basic-env @@ -13,6 +13,8 @@ SERVICES=s3 CHROMIUM_BIN=/usr/bin/chromium-browser MONGO_BINARY_VERSION=3.6.12 +MOCK_WEBHOOK_CONFIG_FILE=webhook-server-config.csv +MOCK_WEBHOOK_PORT=4000 IMAGE_S3_BUCKET=local-image-bucket LOGO_S3_BUCKET=local-logo-bucket diff --git a/tests/.test-full-env b/tests/.test-full-env index 4d46aa8fdb..a25b9c2853 100644 --- a/tests/.test-full-env +++ b/tests/.test-full-env @@ -66,6 +66,8 @@ NODE_ENV=test FORMSG_SDK_MODE=test MONGO_BINARY_VERSION=3.6.12 +MOCK_WEBHOOK_CONFIG_FILE=webhook-server-config.csv +MOCK_WEBHOOK_PORT=4000 AWS_ACCESS_KEY_ID=fakeAccessKeyId AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey diff --git a/tests/end-to-end/encrypt-submission.e2e.js b/tests/end-to-end/encrypt-submission.e2e.js index 88523f2c8d..960a90ee0c 100644 --- a/tests/end-to-end/encrypt-submission.e2e.js +++ b/tests/end-to-end/encrypt-submission.e2e.js @@ -12,6 +12,9 @@ const { const { verifySubmissionE2e, clearDownloadsFolder, + verifyWebhookSubmission, + createWebhookConfig, + removeWebhookConfig, } = require('./helpers/encrypt-mode') const { allFields } = require('./helpers/all-fields') const { verifiableEmailField } = require('./helpers/verifiable-email-field') @@ -28,6 +31,7 @@ const aws = require('aws-sdk') let User let Form let Agency +let Submission let govTech let db const testSpNric = 'S6005038D' @@ -41,6 +45,7 @@ fixture('Storage mode submissions') Agency = makeModel(db, 'agency.server.model', 'Agency') User = makeModel(db, 'user.server.model', 'User') Form = makeModel(db, 'form.server.model', 'Form') + Submission = makeModel(db, 'submission.server.model', 'Submission') govTech = await Agency.findOne({ shortName: 'govtech' }).exec() // Check whether captcha is enabled in environment captchaEnabled = await getFeatureState('captcha') @@ -172,6 +177,7 @@ test.meta('full-env', 'true').before(async (t) => { await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) }) +// Basic form with verifiable email field test.meta('full-env', 'true').before(async (t) => { const formData = await getDefaultFormOptions() formData.formFields = cloneDeep(verifiableEmailField) @@ -181,6 +187,47 @@ test.meta('full-env', 'true').before(async (t) => { await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) }) +// Basic form with only one field +test.meta('full-env', 'true').before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Submit form with webhook integration', async (t) => { + // Create webhookUrl and write webhook configuration to disk for mock webhook server to access + const webhookUrl = await createWebhookConfig(t.ctx.formData.formOptions.title) + // Create form + t.ctx.form = await createForm( + t, + t.ctx.formData, + Form, + captchaEnabled, + webhookUrl, + ) + // Make and verify submission + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) + // Verify webhook submission (request body is returned and stored as webhook response) + let submission = await Submission.findOne({ form: t.ctx.form._id }) + await t + .expect(submission.webhookResponses.length) + .eql(1) + .expect(submission.webhookResponses[0].webhookUrl) + .eql(webhookUrl) + .expect(submission.webhookResponses[0].response.status) + .eql(200) + const webhookRequestData = JSON.parse( + submission.webhookResponses[0].response.data, + ) + await verifyWebhookSubmission(t, t.ctx.formData, webhookRequestData) + // Remove webhook config + await removeWebhookConfig(webhookUrl) +}) + // Creates an object with default encrypt-mode form options, with optional modifications. // Note that a new user needs to be created for each test, otherwise the extractOTP function // may get the wrong OTP due to a concurrency issue where it grabs the wrong email from the diff --git a/tests/end-to-end/helpers/encrypt-mode.js b/tests/end-to-end/helpers/encrypt-mode.js index 9b1e876667..6ea4885369 100644 --- a/tests/end-to-end/helpers/encrypt-mode.js +++ b/tests/end-to-end/helpers/encrypt-mode.js @@ -17,6 +17,7 @@ const { const fs = require('fs') const rimraf = require('rimraf') const parse = require('csv-parse/lib/sync') +const ngrok = require('ngrok') // Index of the column headers in the exported CSV. The first 4 rows are // metadata about the number of responses decrypted. @@ -25,6 +26,9 @@ const CSV_HEADER_ROW_INDEX = 4 // fields to check only start from the third column. const CSV_ANSWER_COL_INDEX = 2 +const WEBHOOK_PORT = process.env.MOCK_WEBHOOK_PORT +const WEBHOOK_CONFIG_FILE = process.env.MOCK_WEBHOOK_CONFIG_FILE + // We can't just call getResponseArray because CSVs use different // delimiters for checkbox and table. Hence we treat checbox and table // specially, and call getResponseArray for the rest. @@ -92,6 +96,30 @@ const clearDownloadsFolder = (formTile, formId) => { ) } +const createWebhookConfig = async (formTitle) => { + const encodedTitle = encodeURI(formTitle) + const webhookUrl = await ngrok.connect(WEBHOOK_PORT) + const downloadsFolder = getDownloadsFolder() + fs.writeFileSync( + `${downloadsFolder}/${WEBHOOK_CONFIG_FILE}`, + `${encodedTitle},${webhookUrl}`, + 'utf8', + ) + return webhookUrl +} + +const removeWebhookConfig = async (url) => { + await ngrok.disconnect(url) + const downloadsFolder = getDownloadsFolder() + rimraf( + `${downloadsFolder}/${WEBHOOK_CONFIG_FILE}`, + { + glob: false, + }, + emptyCallback, + ) +} + // Download the CSV and verify its contents async function checkDownloadCsv(t, formData, authData, formId) { let { formFields, formOptions } = formData @@ -200,7 +228,25 @@ const verifySubmissionE2e = async (t, form, formData, authData) => { await checkDecryptedResponses(t, formData, authData) } +const verifyWebhookSubmission = async (t, formData, webhookRequestData) => { + let { formFields } = formData + for (let i = 0; i < formFields.length; i++) { + await t + .expect(formFields[i].title) + .eql(webhookRequestData.responses[i].question) + await t + .expect(formFields[i].fieldType) + .eql(webhookRequestData.responses[i].fieldType) + await t + .expect(formFields[i].val) + .eql(webhookRequestData.responses[i].answer) + } +} + module.exports = { verifySubmissionE2e, clearDownloadsFolder, + verifyWebhookSubmission, + createWebhookConfig, + removeWebhookConfig, } diff --git a/tests/end-to-end/helpers/selectors.js b/tests/end-to-end/helpers/selectors.js index 5555ec3e35..b4e30f1023 100644 --- a/tests/end-to-end/helpers/selectors.js +++ b/tests/end-to-end/helpers/selectors.js @@ -93,6 +93,7 @@ const settingsTab = { captchaToggleLabel: Selector('#enable-captcha input').parent(), formTitleInput: Selector('#settings-name'), emailListInput: Selector('#settings-email'), + webhookUrlInput: Selector('#settings-webhook-url'), } const dataTab = { diff --git a/tests/end-to-end/helpers/util.js b/tests/end-to-end/helpers/util.js index c5b1f71737..f8e76f56fe 100644 --- a/tests/end-to-end/helpers/util.js +++ b/tests/end-to-end/helpers/util.js @@ -327,12 +327,13 @@ async function createForm( { user, formOptions, formFields, logicData }, Form, captchaEnabled, + webhookUrl, ) { const { email } = user await logInWithEmail(t, email) await addNewForm(t, formOptions) // Need to add settings first so MyInfo fields can be added - await addSettings(t, formOptions, captchaEnabled) + await addSettings(t, formOptions, captchaEnabled, webhookUrl) await addFields(t, formFields) if (logicData) { await addLogic(t, logicData, formFields) @@ -594,7 +595,7 @@ function getFormIdFromUrl(url) { return getSubstringBetween(url, '#!/', '/admin') } -async function addSettings(t, formOptions, captchaEnabled) { +async function addSettings(t, formOptions, captchaEnabled, webhookUrl) { await t.click(adminTabs.settings) // Expect auth type to be none by default, then change it @@ -615,6 +616,10 @@ async function addSettings(t, formOptions, captchaEnabled) { } } + if (webhookUrl) { + await t.typeText(settingsTab.webhookUrlInput, webhookUrl) + } + // Expect form to be inactive, then activate it await t .expect(settingsTab.formStatus.textContent) diff --git a/tests/mock-webhook-server.js b/tests/mock-webhook-server.js new file mode 100644 index 0000000000..1095de7e48 --- /dev/null +++ b/tests/mock-webhook-server.js @@ -0,0 +1,54 @@ +const express = require('express') +const bodyParser = require('body-parser') +const fs = require('fs') +const formsgSdkPackage = require('@opengovsg/formsg-sdk') +const { getDownloadsFolder } = require('./end-to-end/helpers/util') + +const WEBHOOK_PORT = process.env.MOCK_WEBHOOK_PORT +const WEBHOOK_CONFIG_FILE = process.env.MOCK_WEBHOOK_CONFIG_FILE + +// Configure formsg sdk +const formsgSdk = formsgSdkPackage({ + webhookSecretKey: process.env.SIGNING_SECRET_KEY, + mode: 'test', + verificationOptions: { + secretKey: process.env.VERIFICATION_SECRET_KEY, + transactionExpiry: 14400, // 4 hours + }, +}) + +// Create app +const app = express() +app.use(bodyParser.json()) + +// Declare post route +app.post('/', async (req, res) => { + // Fetch webhook configuration + const config = fs.readFileSync( + `${getDownloadsFolder()}/${WEBHOOK_CONFIG_FILE}`, + 'utf8', + ) + const formTitle = config.split(',')[0] + const postUri = config.split(',')[1] + const secretKey = fs.readFileSync( + `${getDownloadsFolder()}/Form Secret Key - ${decodeURI(formTitle)}.txt`, + 'utf8', + ) + // Verify Signature + try { + formsgSdk.webhooks.authenticate(req.get('X-FormSG-Signature'), postUri) + } catch (e) { + return res.status(401).send({ message: 'Unauthorized' }) + } + // Decrypt submission body + try { + const data = formsgSdk.crypto.decrypt(secretKey, req.body.data) + return res.status(200).send(data) + } catch (e) { + return res.status(500).send({ message: 'Decryption failed' }) + } +}) + +app.listen(WEBHOOK_PORT, () => + console.info(`Webhook mock server running on port ${WEBHOOK_PORT}`), +) diff --git a/tests/unit/backend/helpers/jest-db.ts b/tests/unit/backend/helpers/jest-db.ts index c3db14d9c0..866e49a6f5 100644 --- a/tests/unit/backend/helpers/jest-db.ts +++ b/tests/unit/backend/helpers/jest-db.ts @@ -56,7 +56,7 @@ const insertFormCollectionReqs = async ({ userId?: ObjectID mailName?: string mailDomain?: string -} = {}) => { +}) => { const Agency = getAgencyModel(mongoose) const User = getUserModel(mongoose) diff --git a/tests/unit/backend/models/submission.server.model.spec.ts b/tests/unit/backend/models/submission.server.model.spec.ts index ac46bb2ff0..9950990e58 100644 --- a/tests/unit/backend/models/submission.server.model.spec.ts +++ b/tests/unit/backend/models/submission.server.model.spec.ts @@ -3,22 +3,32 @@ import mongoose from 'mongoose' import getSubmissionModel from 'src/app/models/submission.server.model' +import { AuthType, SubmissionType } from '../../../../src/types' +import dbHandler from '../helpers/jest-db' + const Submission = getSubmissionModel(mongoose) // TODO: Add more tests for the rest of the submission schema. describe('Submission Schema', () => { + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + const MOCK_ENCRYPTED_CONTENT = 'abcdefg encryptedContent' describe('methods.getWebhookView', () => { - it('should return non-null view with encryptedSubmission type (without verified content)', () => { + it('should return non-null view with encryptedSubmission type (without verified content)', async () => { // Arrange const formId = new ObjectID() - const submission = new Submission({ - submissionType: 'encryptSubmission', + + const submission = await Submission.create({ + submissionType: SubmissionType.Encrypt, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 1, - created: Date.now(), + authType: AuthType.NIL, + myInfoFields: [], + webhookResponses: [], }) // Act @@ -37,14 +47,20 @@ describe('Submission Schema', () => { }) }) - it('should return null view with non-encryptSubmission type', () => { + it('should return null view with non-encryptSubmission type', async () => { // Arrange const formId = new ObjectID() - const submission = new Submission({ - submissionType: 'rubbish', + const submission = await Submission.create({ + submissionType: SubmissionType.Email, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 1, + authType: AuthType.NIL, + myInfoFields: [], + recipientEmails: [], + responseHash: 'hash', + responseSalt: 'salt', + hasBounced: false, }) // Act diff --git a/tests/unit/backend/services/webhook.service.spec.ts b/tests/unit/backend/services/webhook.service.spec.ts new file mode 100644 index 0000000000..6e46920215 --- /dev/null +++ b/tests/unit/backend/services/webhook.service.spec.ts @@ -0,0 +1,176 @@ +import axios, { AxiosError } from 'axios' +import { ObjectID } from 'bson' +import mongoose from 'mongoose' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { getEncryptedFormModel } from 'src/app/models/form.server.model' +import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import { + handleWebhookFailure, + handleWebhookSuccess, + postWebhook, +} from 'src/app/services/webhooks.service' +import { WebhookValidationError } from 'src/app/utils/custom-errors' +import { ResponseMode, SubmissionType } from 'src/types' + +const EncryptForm = getEncryptedFormModel(mongoose) +const EncryptSubmission = getEncryptSubmissionModel(mongoose) + +describe('WebhooksService', () => { + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + let testEncryptForm + let testEncryptSubmission + let testWebhookParam + let testConfig + let testSubmissionWebhookView + const MOCK_ADMIN_OBJ_ID = new ObjectID() + const MOCK_SIGNATURE = 'mock-signature' + const MOCK_WEBHOOK_URL = '/webhook-endpoint' + const MOCK_NOW = Date.now() + const ERROR_MSG = 'test-message' + const MOCK_SUCCESS_RESPONSE = { + data: { + result: 'test-result', + }, + status: 200, + statusText: 'success', + headers: {}, + config: {}, + } + const MOCK_STRINGIFIED_PARSED_RESPONSE = { + data: '{"result":"test-result"}', + status: 200, + statusText: 'success', + headers: '{}', + } + + beforeEach(async () => { + const preloaded = await dbHandler.insertFormCollectionReqs({ + userId: MOCK_ADMIN_OBJ_ID, + }) + testEncryptForm = await EncryptForm.create({ + title: 'Test Form', + admin: preloaded.user._id, + responseMode: ResponseMode.Encrypt, + publicKey: 'fake-public-key', + }) + testEncryptSubmission = await EncryptSubmission.create({ + form: testEncryptForm._id, + submissionType: SubmissionType.Encrypt, + encryptedContent: 'encrypted-content', + verifiedContent: 'verified-content', + version: 1, + authType: testEncryptForm.authType, + myInfoFields: [], + webhookResponses: [], + }) + + testSubmissionWebhookView = testEncryptSubmission.getWebhookView() + testWebhookParam = { + webhookUrl: MOCK_WEBHOOK_URL, + submissionWebhookView: testSubmissionWebhookView, + submissionId: testEncryptSubmission._id, + formId: testEncryptForm._id, + now: MOCK_NOW, + signature: MOCK_SIGNATURE, + } + testConfig = { + headers: { + 'X-FormSG-Signature': `t=${MOCK_NOW},s=${testEncryptSubmission._id},f=${testEncryptForm._id},v1=${MOCK_SIGNATURE}`, + }, + maxRedirects: 0, + } + }) + + describe('handleWebhookFailure', () => { + it('should update submission document with failed webhook response', async () => { + // Act + class MockAxiosError extends Error { + response: any + isAxiosError: boolean + toJSON: () => {} + config: object + constructor(msg, response) { + super(msg) + this.name = 'AxiosError' + this.response = response + this.isAxiosError = false + this.toJSON = () => { + return {} + } + this.config = {} + } + } + let error: AxiosError = new MockAxiosError( + ERROR_MSG, + MOCK_SUCCESS_RESPONSE, + ) + await handleWebhookFailure(error, testWebhookParam) + // Assert + let submission = await EncryptSubmission.findById( + testEncryptSubmission._id, + ) + expect(submission.webhookResponses[0]).toEqual( + expect.objectContaining({ + webhookUrl: MOCK_WEBHOOK_URL, + signature: MOCK_SIGNATURE, + errorMessage: ERROR_MSG, + response: expect.objectContaining(MOCK_STRINGIFIED_PARSED_RESPONSE), + }), + ) + }) + it('should update submission document with failed webhook validation', async () => { + // Act + const error = new WebhookValidationError(ERROR_MSG) + await handleWebhookFailure(error, testWebhookParam) + // Assert + let submission = await EncryptSubmission.findById( + testEncryptSubmission._id, + ) + expect(submission.webhookResponses[0]).toEqual( + expect.objectContaining({ + webhookUrl: MOCK_WEBHOOK_URL, + signature: MOCK_SIGNATURE, + errorMessage: ERROR_MSG, + }), + ) + }) + }) + + describe('handleWebhookSuccess', () => { + it('should update submission document with successful webhook response', async () => { + // Act + await handleWebhookSuccess(MOCK_SUCCESS_RESPONSE, testWebhookParam) + // Assert + let submission = await EncryptSubmission.findById( + testEncryptSubmission._id, + ) + expect(submission.webhookResponses[0]).toEqual( + expect.objectContaining({ + webhookUrl: MOCK_WEBHOOK_URL, + signature: MOCK_SIGNATURE, + response: expect.objectContaining(MOCK_STRINGIFIED_PARSED_RESPONSE), + }), + ) + }) + }) + + describe('postWebhook', () => { + it('should make post request with valid parameters', async () => { + // Arrange + const spyAxios = spyOn(axios, 'post') + // Act and Assert + spyAxios.and.callFake((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return MOCK_SUCCESS_RESPONSE + }) + const response = await postWebhook(testWebhookParam) + expect(response).toEqual(MOCK_SUCCESS_RESPONSE) + }) + }) +}) From 5b95a28822c30303537068b6e24db4352ef4e9bc Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Thu, 27 Aug 2020 10:27:25 +0800 Subject: [PATCH 05/26] feat: add Emergency Contact feature frontend (#142) * feat: add initial avatar-dropdown component to navbar * feat: show user profile dropdown when avatar is clicked or hovered * style: add avatar styling * feat: add on-click-outside directive * feat: open dropdown on focus or hover * feat: remove logout from navbar * style: add initial styling to navbar avatar * feat: add user details to dropdown * feat: add emergency contact UI if no contact in user * feat: add alert icon to avatar if contact is not set * feat: show contact number and edit number list item if number exists * feat: add EditContactNumberModal controller * feat: open initial edit contact number modal on edit click * feat: replace avatar in admin form navbar with AvatarDropdownComponent * feat: add initial EditContactNumberModal view * feat: add stronger regex for international number parsing * feat: add initial logic to display verification block on verify number * feat: add initial edit-contact-number-modal view * style: add initial styling for edit contact number modal * feat: add UI for verifying mobile number * feat: add otp verification success UI * feat: rip out all emergency verification ui Might be too much engineering effort to verify users, since the current verification collection is too intrinsically tied to the forms collection * fix: user schema validation to resolve with false instead of rejecting If the promise is rejected, it will resolve with the given error instead of the message. Resolving with false will correctly return the validation message. * feat(UserModel): add unique key violation hook to throw error * feat(UserModel): add `contact` key in user schema Used to store emergency contact numbers of the admin * test(UserModel): add schema tests for user mongoose model * Revert "feat: rip out all emergency verification ui" This reverts commit 0df2fe0abd9b977563dccf0f665a9a78ad2d4cf6. * feat: update copy and style modal to not be full screen * feat: open edit contact modal on login if user contact is missing * style: fix alignment of verify otp block * feat: AuthController now returns user contact in the return object * test: update e2e tests to default to a user with contact number so the emergency contact popup will not get triggered for the tests that have nothing to do with it. * style: add mobile styling * refactor: remove scss files * feat: add Emergency Contact feature backend (#151) * feat: add initial AdminVerification schema * feat(UserService): add initial interface * feat(UserController): add initial interface * feat: add and use initial UserRouter and UserRules for /user endpoint * refactor: move config.otpGenerator to utils/otp#generateOtp * feat(AdminVerification): add static `upsertOtp` method * feat(UserService): flesh out and use createContactOtp function * refactor: add `smsType` as SmsCount discriminator key This change is needed as we have more than 1 type of Sms to log now. We only used to have Verification SMSes to log, but now we also want to log admin emergency contact number SMSes. No other code changes are required, since the schema saved object shape has not changed. * refactor: rename OtpData typing to FormOtpData This is to accomodate different types of OTP data incoming to the application * feat(SmsCount): add AdminContactSmsCountSchema as discriminator * feat(SmsService): add sendAdminContactOtp function * feat(UserController): send generated OTP to emergency number * feat: store hash of contact in the DB instead of contact in plaintext * feat(AdminVerification): add both created and updated timestamps * feat(AdminVerification): add static method incrementAttemptsByAdminId * feat: add core ApplicationError for express app * feat: add UserError classes * feat(UserService): flesh out verifyContactOtp method * feat(UserController): use UserService#verifyContactOtp * feat: flesh out UserService#updateUserContact * feat: add error handling when updating user * refactor(AdminVerification): use exported user schema id constant * fix(AdminVerification): set defaults and reset otp attempts on upsert * feat: add clearCollection helper method to dbHandler * test(UserService): add tests * fix(AdminVerification): run validators on upsert in upsertOtp function * chore: update verifyContactOtp comment * test(helpers): add jest-express helper to create mock req/res * test(UserController): add tests * test(AdminVerification): add schema and statics tests * feat: use mongoose-unique-validator plugin for duplicate validation * refactor: convert user model validation to async function * refactor: export adminverification types from index * Revert "feat: use mongoose-unique-validator plugin for duplicate validation" This reverts commit 98aaa45d858825f7a2010b78d850b75f655f2a4c. Done due to Typescript transpilation to Javascript making the package thrown some errors, weirdly enough. * refactor: use const instead of var Co-authored-by: Antariksh Mahajan * feat: add hover/focus differentiator on dropdown open state * fix(MailService): fix flaky test due to autocreated submission date * feat: integrate front and backends for Emergency Contact (#164) * feat: call sendotp and verifyotp endpoints to update contact * feat(UserService): return updated user object on contact update * feat(User): add GET /user endpoint to retrieve current session user * feat(EditContactModal): show success toast after updating user * feat: update user in localstorage on update success * feat: add Auth#refreshUser to retrieve user details from backend * feat: reload user from backend on avatar-dropdown component init * feat(UserController): add route guards against unauthorized users * test(UserController): update tests to check for session guards * test(UserController): add handleFetchUser tests * test(UserService): add getPopulatedUserById tests * feat: hide contact section in dropdown if sms feature is disabled * feat: move opening of emergency contact modal to avatar dropdown * fix: cancel resend countdown on modal close * feat: update emergency modal and avatar dropdown copy * fix: remove accidental import * docs: add tracking issue number to comment * test: update tests to use mock instead of spyon * test(UserController): update tests to check service invocation args * test(AdminVerificationModel): update an expect assertion * chore: add comments for UserController and AdminVerificationModel * chore: speed up jest tests on Travis See: https://jestjs.io/docs/en/troubleshooting#tests-are-extremely-slow-on-docker-andor-continuous-integration-ci-server * chore: speed up jest tests on Travis attempt 2 Reducing maxWorkers to 2 since the free open source version only has 2 cores Co-authored-by: Antariksh Mahajan --- jest.config.js | 1 + package.json | 3 +- .../authentication.server.controller.js | 4 +- src/app/factories/sms.factory.js | 12 + .../models/admin_verification.server.model.ts | 103 +++++ src/app/models/form.server.model.ts | 6 +- src/app/models/sms_count.server.model.ts | 117 +++--- src/app/models/user.server.model.ts | 56 ++- src/app/modules/user/user.controller.ts | 124 ++++++ src/app/modules/user/user.errors.ts | 18 + src/app/modules/user/user.routes.ts | 24 ++ src/app/modules/user/user.rules.ts | 16 + src/app/modules/user/user.service.ts | 155 +++++++ .../verification/verification.service.ts | 4 +- src/app/services/sms.service.ts | 28 +- src/app/utils/otp.ts | 19 + src/config/config.ts | 21 - src/loaders/express/index.ts | 2 + src/public/main.css | 2 + src/public/main.js | 20 +- .../core/componentViews/avatar-dropdown.html | 79 ++++ .../modules/core/componentViews/navbar.html | 8 +- .../avatar-dropdown.client.component.js | 111 +++++ .../components/navbar.client.component.js | 6 +- ...-contact-number-modal.client.controller.js | 162 ++++++++ .../modules/core/css/admin-form-header.css | 7 + .../modules/core/css/avatar-dropdown.css | 239 +++++++++++ .../core/css/edit-contact-number-modal.css | 281 +++++++++++++ src/public/modules/core/css/navbar.css | 21 - .../on-click-outside.client.directive.js | 42 ++ .../modules/core/img/emergency-contact.svg | 5 + .../views/edit-contact-number-modal.view.html | 182 ++++++++ .../admin-form.client.controller.js | 14 - .../list-forms.client.controller.js | 4 +- .../admin/views/admin-form.client.view.html | 14 +- .../base/directives/ng-intl-tel-input.js | 7 +- .../users/services/auth.client.service.js | 16 + src/types/admin_verification.ts | 31 ++ src/types/form.ts | 2 +- src/types/index.ts | 1 + src/types/sms_count.ts | 34 +- src/types/user.ts | 11 +- tests/end-to-end/email-submission.e2e.js | 1 + tests/end-to-end/encrypt-submission.e2e.js | 1 + tests/end-to-end/helpers/selectors.js | 3 +- tests/end-to-end/login.e2e.js | 2 + .../authentication.server.controller.spec.js | 4 +- tests/unit/backend/helpers/jest-db.ts | 54 ++- tests/unit/backend/helpers/jest-express.ts | 40 +- .../admin_verification.server.model.spec.ts | 268 ++++++++++++ .../models/sms_count.server.model.spec.ts | 47 ++- .../backend/models/user.server.model.spec.ts | 148 +++++++ .../modules/user/user.controller.spec.ts | 388 ++++++++++++++++++ .../backend/modules/user/user.service.spec.ts | 235 +++++++++++ .../verification.controller.spec.ts | 4 +- .../verification/verification.service.spec.ts | 12 +- .../backend/services/mail.service.spec.ts | 5 +- 57 files changed, 3010 insertions(+), 214 deletions(-) create mode 100644 src/app/models/admin_verification.server.model.ts create mode 100644 src/app/modules/user/user.controller.ts create mode 100644 src/app/modules/user/user.errors.ts create mode 100644 src/app/modules/user/user.routes.ts create mode 100644 src/app/modules/user/user.rules.ts create mode 100644 src/app/modules/user/user.service.ts create mode 100644 src/app/utils/otp.ts create mode 100644 src/public/modules/core/componentViews/avatar-dropdown.html create mode 100644 src/public/modules/core/components/avatar-dropdown.client.component.js create mode 100644 src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js create mode 100644 src/public/modules/core/css/avatar-dropdown.css create mode 100644 src/public/modules/core/css/edit-contact-number-modal.css create mode 100644 src/public/modules/core/directives/on-click-outside.client.directive.js create mode 100644 src/public/modules/core/img/emergency-contact.svg create mode 100644 src/public/modules/core/views/edit-contact-number-modal.view.html create mode 100644 src/types/admin_verification.ts create mode 100644 tests/unit/backend/models/admin_verification.server.model.spec.ts create mode 100644 tests/unit/backend/models/user.server.model.spec.ts create mode 100644 tests/unit/backend/modules/user/user.controller.spec.ts create mode 100644 tests/unit/backend/modules/user/user.service.spec.ts diff --git a/jest.config.js b/jest.config.js index f38906c39b..9e0ce3ef05 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ module.exports = { preset: '@shelf/jest-mongodb', testMatch: ['**/?(*.)+(spec|test).[t]s?(x)'], modulePaths: [''], + testPathIgnorePatterns: ['/dist/', '/node_modules/'], transform: { // Needed to use @shelf/jest-mongodb preset. ...tsJestPreset.transform, diff --git a/package.json b/package.json index 67783286a2..9147f1f16b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dev": "docker-compose up --build", "docker-dev": "npm run build-frontend-dev:watch & ts-node-dev --respawn --transpileOnly --inspect=0.0.0.0 -- src/server.ts", "test-backend": "npm run test-backend-jasmine && npm run test-backend-jest", - "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage", + "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage --maxWorkers=2", "test-backend-jest:watch": "env-cmd -f tests/.test-full-env jest --watch", "test-backend-jasmine": "env-cmd -f tests/.test-full-env --use-shell \"npm run download-binary && jasmine --config=tests/unit/backend/jasmine.json\"", "test-frontend": "jest --config=tests/unit/frontend/jest.config.js", @@ -166,6 +166,7 @@ "@babel/preset-env": "^7.11.0", "@opengovsg/mockpass": "^2.4.6", "@shelf/jest-mongodb": "^1.2.2", + "@types/bcrypt": "^3.0.0", "@types/compression": "^1.7.0", "@types/convict": "^5.2.1", "@types/cookie-parser": "^1.4.2", diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js index 725a575bdc..7de2d79aba 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -22,6 +22,7 @@ const { getRequestIp } = require('../utils/request') const logger = require('../../config/logger').createLoggerWithLabel( 'authentication', ) +const { generateOtp } = require('../utils/otp') const MailService = require('../services/mail.service').default const MAX_OTP_ATTEMPTS = 10 @@ -148,7 +149,7 @@ exports.createOtp = function (req, res, next) { // 3. Save OTP to DB let email = res.locals.email - let otp = config.otpGenerator() + let otp = generateOtp() bcrypt.hash(otp, 10, function (bcryptErr, hashedOtp) { if (bcryptErr) { return res @@ -355,6 +356,7 @@ exports.signIn = function (req, res) { let userObj = { agency: agency, email: user.email, + contact: user.contact, _id: user._id, betaFlags: user.betaFlags, } diff --git a/src/app/factories/sms.factory.js b/src/app/factories/sms.factory.js index 48f83935a0..fd07ba659e 100644 --- a/src/app/factories/sms.factory.js +++ b/src/app/factories/sms.factory.js @@ -30,6 +30,18 @@ const smsFactory = ({ isEnabled, props }) => { ) } }, + async sendAdminContactOtp(recipient, otp, userId) { + if (isEnabled) { + return smsService.sendAdminContactOtp( + recipient, + otp, + userId, + twilioConfig, + ) + } else { + throw new Error(`Send Admin Contact OTP has not been enabled`) + } + }, } } diff --git a/src/app/models/admin_verification.server.model.ts b/src/app/models/admin_verification.server.model.ts new file mode 100644 index 0000000000..cbce56da2c --- /dev/null +++ b/src/app/models/admin_verification.server.model.ts @@ -0,0 +1,103 @@ +import { Mongoose, Schema } from 'mongoose' + +import { IUserSchema } from 'src/types' + +import { + IAdminVerificationModel, + IAdminVerificationSchema, + UpsertOtpParams, +} from '../../types' + +import { USER_SCHEMA_ID } from './user.server.model' + +export const ADMIN_VERIFICATION_SCHEMA_ID = 'AdminVerification' + +const AdminVerificationSchema = new Schema( + { + admin: { + type: Schema.Types.ObjectId, + ref: USER_SCHEMA_ID, + required: 'AdminVerificationSchema must have an Admin', + }, + hashedContact: { + type: String, + required: true, + }, + hashedOtp: { + type: String, + required: true, + }, + expireAt: { + type: Date, + required: true, + }, + numOtpAttempts: { + type: Number, + default: 0, + }, + numOtpSent: { + type: Number, + default: 0, + }, + }, + { + timestamps: true, + }, +) +AdminVerificationSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) + +// Statics +/** + * Upserts given OTP into AdminVerification collection. + */ +AdminVerificationSchema.statics.upsertOtp = async function ( + this: IAdminVerificationModel, + upsertParams: UpsertOtpParams, +) { + return this.findOneAndUpdate( + { admin: upsertParams.admin }, + { + $set: { ...upsertParams, numOtpAttempts: 0 }, + $inc: { numOtpSent: 1 }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true }, + ) +} + +/** + * Increments otp attempts for the given admin id. + * @param adminId the admin id to increment otp attempts for + * @returns the incremented document + */ +AdminVerificationSchema.statics.incrementAttemptsByAdminId = async function ( + this: IAdminVerificationModel, + adminId: IUserSchema['_id'], +) { + return this.findOneAndUpdate( + { admin: adminId }, + { $inc: { numOtpAttempts: 1 } }, + { new: true }, + ) +} + +const compileAdminVerificationModel = (db: Mongoose) => + db.model( + ADMIN_VERIFICATION_SCHEMA_ID, + AdminVerificationSchema, + ) + +/** + * Retrieves the AdminVerification model on the given Mongoose instance. If the + * model is not registered yet, the model will be registered and returned. + * @param db The mongoose instance to retrieve the AdminVerification model from + * @returns The agency model + */ +const getAdminVerificationModel = (db: Mongoose) => { + try { + return db.model(ADMIN_VERIFICATION_SCHEMA_ID) as IAdminVerificationModel + } catch { + return compileAdminVerificationModel(db) + } +} + +export default getAdminVerificationModel diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 62ff489d1a..5880a6059e 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -10,12 +10,12 @@ import { BasicField, Colors, FormLogoState, + FormOtpData, IEmailFormSchema, IEncryptedFormSchema, IFormSchema, IPopulatedForm, LogicType, - OtpData, Permission, ResponseMode, Status, @@ -85,7 +85,7 @@ const formSchemaOptions: SchemaOptions = { } export interface IFormModel extends Model { - getOtpData(formId: string): Promise + getOtpData(formId: string): Promise getFullFormById(formId: string): Promise } @@ -415,7 +415,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { path: 'admin', select: 'email', }) - const otpData: OtpData = { + const otpData: FormOtpData = { form: data._id, formAdmin: { email: data.admin.email, diff --git a/src/app/models/sms_count.server.model.ts b/src/app/models/sms_count.server.model.ts index a1187e97af..f702563f2b 100644 --- a/src/app/models/sms_count.server.model.ts +++ b/src/app/models/sms_count.server.model.ts @@ -1,8 +1,11 @@ -import { Model, Mongoose, Schema } from 'mongoose' +import { Mongoose, Schema } from 'mongoose' import { + IAdminContactSmsCountSchema, ISmsCount, + ISmsCountModel, ISmsCountSchema, + IVerificationSmsCountSchema, LogSmsParams, LogType, SmsType, @@ -13,69 +16,83 @@ import { USER_SCHEMA_ID } from './user.server.model' const SMS_COUNT_SCHEMA_NAME = 'SmsCount' -interface ISmsCountModel extends Model { - logSms: (logParams: LogSmsParams) => Promise -} - -const SmsCountSchema = new Schema( - { - form: { +const VerificationSmsCountSchema = new Schema({ + form: { + type: Schema.Types.ObjectId, + ref: FORM_SCHEMA_ID, + required: true, + }, + formAdmin: { + email: { type: String, required: true }, + userId: { type: Schema.Types.ObjectId, - ref: FORM_SCHEMA_ID, + ref: USER_SCHEMA_ID, required: true, }, - formAdmin: { - email: { type: String, required: true }, - userId: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, + }, +}) + +const AdminContactSmsCountSchema = new Schema({ + admin: { + type: Schema.Types.ObjectId, + ref: USER_SCHEMA_ID, + required: true, + }, +}) + +const compileSmsCountModel = (db: Mongoose) => { + const SmsCountSchema = new Schema( + { + msgSrvcSid: { + type: String, + required: true, + }, + logType: { + type: String, + enum: Object.values(LogType), + required: true, + }, + smsType: { + type: String, + enum: Object.values(SmsType), required: true, }, }, - msgSrvcSid: { - type: String, - required: true, - }, - logType: { - type: String, - enum: Object.values(LogType), - required: true, - }, - smsType: { - type: String, - enum: Object.values(SmsType), - required: true, - }, - }, - { - timestamps: { - createdAt: true, - updatedAt: false, + { + timestamps: { + createdAt: true, + updatedAt: false, + }, + discriminatorKey: 'smsType', }, - }, -) + ) -SmsCountSchema.statics.logSms = async function ( - this: ISmsCountModel, - { otpData, msgSrvcSid, smsType, logType }: LogSmsParams, -) { - const schemaData: Omit = { - ...otpData, - msgSrvcSid, - smsType, - logType, - } + SmsCountSchema.statics.logSms = async function ( + this: ISmsCountModel, + { otpData, msgSrvcSid, smsType, logType }: LogSmsParams, + ) { + const schemaData: Omit = { + ...otpData, + msgSrvcSid, + smsType, + logType, + } - const smsCount: ISmsCountSchema = new this(schemaData) + const smsCount: ISmsCountSchema = new this(schemaData) - return smsCount.save() -} + return smsCount.save() + } -const compileSmsCountModel = (db: Mongoose) => { - return db.model( + const SmsCountModel = db.model( SMS_COUNT_SCHEMA_NAME, SmsCountSchema, ) + + // Adding Discriminators + SmsCountModel.discriminator(SmsType.verification, VerificationSmsCountSchema) + SmsCountModel.discriminator(SmsType.adminContact, AdminContactSmsCountSchema) + + return SmsCountModel } /** diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index e867a7d736..3efa2e0046 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -1,3 +1,4 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js/mobile' import { Model, Mongoose, Schema } from 'mongoose' import validator from 'validator' @@ -16,25 +17,22 @@ const compileUserModel = (db: Mongoose) => { email: { type: String, trim: true, - unique: 'Account already exists with this email', + unique: true, required: 'Please enter your email', validate: { // Check if email entered exists in the Agency collection validator: async (value: string) => { - return new Promise((resolve, reject) => { - if (!validator.isEmail(value)) { - return resolve(false) - } - const emailDomain = value.split('@').pop() + if (!validator.isEmail(value)) { + return false + } - Agency.findOne({ emailDomain }, (err, agency) => { - if (err || !agency) { - return reject(err) - } else { - return resolve(true) - } - }) - }) + const emailDomain = value.split('@').pop() + try { + const agency = await Agency.findOne({ emailDomain }) + return !!agency + } catch { + return false + } }, message: 'This email is not a valid agency email', }, @@ -48,9 +46,39 @@ const compileUserModel = (db: Mongoose) => { type: Date, default: Date.now, }, + contact: { + type: String, + validate: { + // Check if phone number is valid. + validator: function (value: string) { + const phoneNumber = parsePhoneNumberFromString(value) + if (!phoneNumber) return false + return phoneNumber.isValid() + }, + message: (props) => `${props.value} is not a valid mobile number`, + }, + }, betaFlags: {}, }) + // Hooks + /** + * Unique key violation custom error middleware. + * + * Used because the `unique` schema option is not a validator, and will not + * throw a ValidationError. Instead, another error will be thrown, which will + * have to be caught here to output the expected error message. + * + * See: https://masteringjs.io/tutorials/mongoose/e11000-duplicate-key. + */ + UserSchema.post('save', function (err, doc, next) { + if (err.name === 'MongoError' && err.code === 11000) { + next(new Error('Account already exists with this email')) + } else { + next() + } + }) + return db.model(USER_SCHEMA_ID, UserSchema) } diff --git a/src/app/modules/user/user.controller.ts b/src/app/modules/user/user.controller.ts new file mode 100644 index 0000000000..c8469e8c9b --- /dev/null +++ b/src/app/modules/user/user.controller.ts @@ -0,0 +1,124 @@ +import to from 'await-to-js' +import { RequestHandler } from 'express' +import HttpStatus from 'http-status-codes' + +import { createLoggerWithLabel } from '../../../config/logger' +import SmsFactory from '../../factories/sms.factory' +import { ApplicationError } from '../core/core.errors' + +import { + createContactOtp, + getPopulatedUserById, + updateUserContact, + verifyContactOtp, +} from './user.service' + +const logger = createLoggerWithLabel('user-controller') + +/** + * Generates an OTP and sends the OTP to the given contact in request body. + * @route POST /contact/sendotp + * @returns 200 if OTP was successfully sent + * @returns 400 on OTP creation or SMS send failure + */ +export const handleContactSendOtp: RequestHandler< + {}, + {}, + { contact: string; userId: string } +> = async (req, res) => { + // Joi validation ensures existence. + const { contact, userId } = req.body + const sessionUserId = getUserIdFromSession(req.session) + + // Guard against user updating for a different user, or if user is not logged + // in. + if (!sessionUserId || sessionUserId !== userId) { + return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + } + + try { + const generatedOtp = await createContactOtp(userId, contact) + await SmsFactory.sendAdminContactOtp(contact, generatedOtp, userId) + + return res.sendStatus(HttpStatus.OK) + } catch (err) { + // TODO(#193): Send different error messages according to error. + return res.status(HttpStatus.BAD_REQUEST).send(err.message) + } +} + +/** + * Verifies given OTP with the hashed OTP data, and updates the user's contact + * number if the hash matches. + * @route POST /contact/verifyotp + * @returns 200 when user contact update success + * @returns 422 when OTP is invalid + * @returns 500 when OTP is malformed or for unknown errors + */ +export const handleContactVerifyOtp: RequestHandler< + {}, + {}, + { + userId: string + otp: string + contact: string + } +> = async (req, res) => { + // Joi validation ensures existence. + const { userId, otp, contact } = req.body + const sessionUserId = getUserIdFromSession(req.session) + + // Guard against user updating for a different user, or if user is not logged + // in. + if (!sessionUserId || sessionUserId !== userId) { + return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + } + + try { + await verifyContactOtp(otp, contact, userId) + } catch (err) { + logger.warn(err.meta ?? err) + if (err instanceof ApplicationError) { + return res.status(err.status).send(err.message) + } else { + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(err.message) + } + } + + // No error, update user with given contact. + try { + const updatedUser = await updateUserContact(contact, userId) + return res.status(HttpStatus.OK).send(updatedUser) + } catch (updateErr) { + // Handle update error. + logger.warn(updateErr) + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(updateErr.message) + } +} + +export const handleFetchUser: RequestHandler = async (req, res) => { + const sessionUserId = getUserIdFromSession(req.session) + if (!sessionUserId) { + return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + } + + // Retrieve user with id in session + const [dbErr, retrievedUser] = await to(getPopulatedUserById(sessionUserId)) + + if (dbErr || !retrievedUser) { + logger.warn( + `handleFetchUser: Unable to retrieve user ${sessionUserId}`, + dbErr, + ) + return res + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .send('Unable to retrieve user') + } + + return res.send(retrievedUser) +} + +// TODO(#212): Save userId instead of entire user collection in session. +const getUserIdFromSession = (session?: Express.Session) => { + return session?.user?._id as string | undefined +} diff --git a/src/app/modules/user/user.errors.ts b/src/app/modules/user/user.errors.ts new file mode 100644 index 0000000000..764f5c3482 --- /dev/null +++ b/src/app/modules/user/user.errors.ts @@ -0,0 +1,18 @@ +import HttpStatus from 'http-status-codes' + +import { ApplicationError } from '../core/core.errors' + +export class InvalidOtpError extends ApplicationError { + constructor(message: string, meta?: string) { + super(message, HttpStatus.UNPROCESSABLE_ENTITY, meta) + } +} + +export class MalformedOtpError extends ApplicationError { + constructor( + message: string = 'Malformed OTP. Please try again later. If the problem persists, contact us.', + meta?: string, + ) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR, meta) + } +} diff --git a/src/app/modules/user/user.routes.ts b/src/app/modules/user/user.routes.ts new file mode 100644 index 0000000000..8af4c0cca1 --- /dev/null +++ b/src/app/modules/user/user.routes.ts @@ -0,0 +1,24 @@ +import { celebrate } from 'celebrate' +import { Router } from 'express' + +import * as UserController from './user.controller' +import * as UserRules from './user.rules' + +const UserRouter = Router() + +UserRouter.get('/', UserController.handleFetchUser) + +// /contact subroute +UserRouter.post( + '/contact/sendotp', + celebrate(UserRules.forContactSendOtp), + UserController.handleContactSendOtp, +) + +UserRouter.post( + '/contact/verifyotp', + celebrate(UserRules.forContactVerifyOtp), + UserController.handleContactVerifyOtp, +) + +export default UserRouter diff --git a/src/app/modules/user/user.rules.ts b/src/app/modules/user/user.rules.ts new file mode 100644 index 0000000000..52043d7ff7 --- /dev/null +++ b/src/app/modules/user/user.rules.ts @@ -0,0 +1,16 @@ +import { Joi, Segments } from 'celebrate' + +export const forContactSendOtp = { + [Segments.BODY]: Joi.object().keys({ + contact: Joi.string().required(), + userId: Joi.string().required(), + }), +} + +export const forContactVerifyOtp = { + [Segments.BODY]: Joi.object().keys({ + userId: Joi.string().required(), + otp: Joi.string().length(6).required(), + contact: Joi.string().required(), + }), +} diff --git a/src/app/modules/user/user.service.ts b/src/app/modules/user/user.service.ts new file mode 100644 index 0000000000..955a935017 --- /dev/null +++ b/src/app/modules/user/user.service.ts @@ -0,0 +1,155 @@ +import to from 'await-to-js' +import bcrypt from 'bcrypt' +import mongoose from 'mongoose' + +import getAdminVerificationModel from '../../../app/models/admin_verification.server.model' +import { AGENCY_SCHEMA_ID } from '../../../app/models/agency.server.model' +import getUserModel from '../../../app/models/user.server.model' +import { generateOtp } from '../../../app/utils/otp' +import config from '../../../config/config' +import { IPopulatedUser, IUserSchema } from '../../../types' + +import { InvalidOtpError, MalformedOtpError } from './user.errors' + +const AdminVerification = getAdminVerificationModel(mongoose) +const User = getUserModel(mongoose) + +const DEFAULT_SALT_ROUNDS = 10 +export const MAX_OTP_ATTEMPTS = 10 + +/** + * Creates a contact OTP and saves it into the AdminVerification collection. + * @param userId the user ID the contact is to be linked to + * @param contact the contact number to send the generated OTP to + * @returns the generated OTP if saving into DB is successful + * @throws error if any error occur whilst creating the OTP or insertion of OTP into the database. + */ +export const createContactOtp = async ( + userId: IUserSchema['_id'], + contact: string, +): Promise => { + // Verify existence of userId + const admin = await User.findById(userId) + if (!admin) { + throw new Error('User id is invalid') + } + + const otp = generateOtp() + const hashedOtp = await bcrypt.hash(otp, DEFAULT_SALT_ROUNDS) + const hashedContact = await bcrypt.hash(contact, DEFAULT_SALT_ROUNDS) + + await AdminVerification.upsertOtp({ + admin: userId, + expireAt: new Date(Date.now() + config.otpLifeSpan), + hashedContact, + hashedOtp, + }) + + return otp +} + +/** + * Compares the otpToVerify and contact number with their hashed counterparts in + * the database to be retrieved with the userId. + * + * If the both hashes matches, the saved document in the database is removed and + * returned. Else, the document is kept in the database and an error is thrown. + * @param otpToVerify the OTP to verify with the hashed counterpart + * @param contactToVerify the contact number to verify with the hashed counterpart + * @param userId the id of the user used to retrieve the verification document from the database + * @returns true on success + * @throws HashMismatchError if hashes do not match, or any other errors that may occur. + */ +export const verifyContactOtp = async ( + otpToVerify: string, + contactToVerify: string, + userId: IUserSchema['_id'], +) => { + const updatedDocument = await AdminVerification.incrementAttemptsByAdminId( + userId, + ) + + // Does not exist, return expired error message. + if (!updatedDocument) { + throw new InvalidOtpError( + 'OTP has expired. Please request for a new OTP.', + `OTP for ${userId} not found in AdminVerification collection.`, + ) + } + + // Too many attempts. + if (updatedDocument.numOtpAttempts > MAX_OTP_ATTEMPTS) { + throw new InvalidOtpError( + 'You have hit the max number of attempts. Please request for a new OTP.', + `${userId} exceeded max OTP attempts`, + ) + } + + // Compare contact number with saved hash. + const [contactHashErr, isContactMatch] = await to( + bcrypt.compare(contactToVerify, updatedDocument.hashedContact), + ) + + // Compare otp with saved hash. + const [otpHashError, isOtpMatch] = await to( + bcrypt.compare(otpToVerify, updatedDocument.hashedOtp), + ) + + if (contactHashErr || otpHashError) { + throw new MalformedOtpError(null, `bcrypt error for ${userId}`) + } + + if (!isOtpMatch) { + throw new InvalidOtpError( + 'OTP is invalid. Please try again.', + `Invalid OTP attempted for ${userId}`, + ) + } + + if (!isContactMatch) { + throw new InvalidOtpError( + 'Contact number given does not match the number the OTP is sent to. Please try again with the correct contact number.', + `Invalid contact number for ${userId}`, + ) + } + + // Hashed OTP matches, remove from collection. + await AdminVerification.findOneAndRemove({ admin: userId }) + // Finally return true (as success). + return true +} + +/** + * Updates the user document with the userId with the given contact and returns + * the populated updated user. + * @param contact the contact to update + * @param userId the user id of the user document to update + * @returns the updated user with populated references + * @throws error if any db actions fail + */ +export const updateUserContact = async ( + contact: string, + userId: IUserSchema['_id'], +): Promise => { + // Retrieve user from database. + // Update user's contact details. + const admin = await User.findById(userId).populate({ + path: 'agency', + model: AGENCY_SCHEMA_ID, + }) + if (!admin) { + throw new Error('User id is invalid') + } + + admin.contact = contact + return admin.save() +} + +export const getPopulatedUserById = async ( + userId: IUserSchema['_id'], +): Promise => { + return User.findById(userId).populate({ + path: 'agency', + model: AGENCY_SCHEMA_ID, + }) +} diff --git a/src/app/modules/verification/verification.service.ts b/src/app/modules/verification/verification.service.ts index 3688f0b393..c298895592 100644 --- a/src/app/modules/verification/verification.service.ts +++ b/src/app/modules/verification/verification.service.ts @@ -2,7 +2,6 @@ import bcrypt from 'bcrypt' import _ from 'lodash' import mongoose from 'mongoose' -import { otpGenerator } from '../../../config/config' import formsgSdk from '../../../config/formsg-sdk' import * as vfnUtil from '../../../shared/util/verification' import { @@ -17,6 +16,7 @@ import smsFactory from '../../factories/sms.factory' import getFormModel from '../../models/form.server.model' import getVerificationModel from '../../models/verification.server.model' import MailService from '../../services/mail.service' +import { generateOtp } from '../../utils/otp' const Form = getFormModel(mongoose) const Verification = getVerificationModel(mongoose) @@ -135,7 +135,7 @@ export const getNewOtp = async ( ) } else { const hashCreatedAt = new Date() - const otp = otpGenerator() + const otp = generateOtp() const hashedOtp = await bcrypt.hash(otp, SALT_ROUNDS) const signedData = formsgSdk.verification.generateSignature({ transactionId, diff --git a/src/app/services/sms.service.ts b/src/app/services/sms.service.ts index 4bc53c958b..a54da1aa47 100644 --- a/src/app/services/sms.service.ts +++ b/src/app/services/sms.service.ts @@ -7,7 +7,13 @@ import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' import { isPhoneNumber } from '../../shared/util/phone-num-validation' import { VfnErrors } from '../../shared/util/verification' -import { LogSmsParams, LogType, OtpData, SmsType } from '../../types' +import { + AdminContactOtpData, + FormOtpData, + LogSmsParams, + LogType, + SmsType, +} from '../../types' import getFormModel from '../models/form.server.model' import getSmsCountModel from '../models/sms_count.server.model' @@ -134,7 +140,7 @@ const getOtpDataFromForm = async (formId: string) => { */ const send = async ( twilioConfig: TwilioConfig, - otpData: OtpData, + otpData: FormOtpData | AdminContactOtpData, recipient: string, message: string, smsType: SmsType, @@ -229,6 +235,23 @@ const sendVerificationOtp = async ( return send(twilioData, otpData, recipient, message, SmsType.verification) } +const sendAdminContactOtp = async ( + recipient: string, + otp: string, + userId: string, + defaultConfig: TwilioConfig, +) => { + logger.info(`sendAdminContactOtp: userId="${userId}"`) + + const message = `Use the OTP ${otp} to verify your emergency contact number.` + + const otpData: AdminContactOtpData = { + admin: userId, + } + + return send(defaultConfig, otpData, recipient, message, SmsType.adminContact) +} + class TwilioError extends Error { code: number status: string @@ -247,4 +270,5 @@ class TwilioError extends Error { module.exports = { sendVerificationOtp, + sendAdminContactOtp, } diff --git a/src/app/utils/otp.ts b/src/app/utils/otp.ts new file mode 100644 index 0000000000..41e53fc5c5 --- /dev/null +++ b/src/app/utils/otp.ts @@ -0,0 +1,19 @@ +import crypto from 'crypto' + +/** + * Randomly generates and returns a 6 digit OTP. + * @return 6 digit OTP string + */ +export const generateOtp = () => { + const length = 6 + const chars = '0123456789' + // Generates cryptographically strong pseudo-random data. + // The size argument is a number indicating the number of bytes to generate. + const rnd = crypto.randomBytes(length) + const d = chars.length / 256 + const digits = new Array(length) + for (let i = 0; i < length; i++) { + digits[i] = chars[Math.floor(rnd[i] * d)] + } + return digits.join('') +} diff --git a/src/config/config.ts b/src/config/config.ts index cc4ef03a74..004fbf0219 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,5 @@ import { PackageMode } from '@opengovsg/formsg-sdk/dist/types' import aws from 'aws-sdk' -import crypto from 'crypto' import { SessionOptions } from 'express-session' import { ConnectionOptions } from 'mongoose' import nodemailer from 'nodemailer' @@ -74,7 +73,6 @@ type Config = { // Functions configureAws: () => Promise - otpGenerator: () => string } // Enums @@ -383,24 +381,6 @@ const configureAws = async () => { } } -/** - * Generates OTP for passwordless login - * @return 6 digit OTP string - */ -const otpGenerator = () => { - let length = 6 - let chars = '0123456789' - // Generates cryptographically strong pseudo-random data. - // The size argument is a number indicating the number of bytes to generate. - const rnd = crypto.randomBytes(length) - const d = chars.length / 256 - const value = new Array(length) - for (let i = 0; i < length; i++) { - value[i] = chars[Math.floor(rnd[i] * d)] - } - return value.join('') -} - const config: Config = { app: appConfig, db: dbConfig, @@ -423,7 +403,6 @@ const config: Config = { siteBannerContent, adminBannerContent, configureAws, - otpGenerator, } export = config diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts index fa45b85c56..2a2ff7a16a 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -8,6 +8,7 @@ import path from 'path' import url from 'url' import mountSnsRoutes from '../../app/modules/sns/sns.routes' +import UserRouter from '../../app/modules/user/user.routes' import mountVfnRoutes from '../../app/modules/verification/verification.routes' import apiRoutes from '../../app/routes' import config from '../../config/config' @@ -124,6 +125,7 @@ const loadExpressApp = async (connection: Connection) => { }) mountSnsRoutes(app) mountVfnRoutes(app) + app.use('/user', UserRouter) app.use(sentryMiddlewares()) diff --git a/src/public/main.css b/src/public/main.css index 6d4aacebbb..d0ad4fefe1 100644 --- a/src/public/main.css +++ b/src/public/main.css @@ -13,6 +13,8 @@ @import './../../node_modules/intl-tel-input/build/css/intlTelInput.css'; @import './modules/core/css/core.css'; @import './modules/core/css/navbar.css'; +@import './modules/core/css/avatar-dropdown.css'; +@import './modules/core/css/edit-contact-number-modal.css'; @import './modules/core/css/admin-form-header.css'; @import './modules/core/css/sg-govt-banner.css'; @import './modules/core/css/landing.css'; diff --git a/src/public/main.js b/src/public/main.js index b02f16711a..34a0430d49 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -145,8 +145,10 @@ require('./modules/core/services/feature.client.factory') // Core controllers require('./modules/core/controllers/landing.client.controller.js') +require('./modules/core/controllers/edit-contact-number-modal.client.controller') // Core directives +require('./modules/core/directives/on-click-outside.client.directive') require('./modules/core/directives/route-loading-indicator.client.directive.js') require('./modules/core/services/features.client.factory.js') require('./modules/core/directives/feature-toggle.client.directive.js') @@ -156,6 +158,7 @@ require('./modules/core/config/core.client.routes.js') // Core components require('./modules/core/components/navbar.client.component.js') +require('./modules/core/components/avatar-dropdown.client.component') require('./modules/core/components/banner.client.component.js') require('./modules/core/components/sg-govt-banner.client.component.js') @@ -322,10 +325,12 @@ app.run([ 'modules/core/componentViews/navbar.html', require('./modules/core/componentViews/navbar.html'), ) + $templateCache.put( - 'modules/core/views/landing.client.view.html', - require('./modules/core/views/landing.client.view.html'), + 'modules/core/componentViews/avatar-dropdown.html', + require('./modules/core/componentViews/avatar-dropdown.html'), ) + $templateCache.put( 'modules/core/componentViews/banner.html', require('./modules/core/componentViews/banner.html'), @@ -335,6 +340,17 @@ app.run([ require('./modules/core/componentViews/sg-govt-banner.html'), ) + // Core views + $templateCache.put( + 'modules/core/views/landing.client.view.html', + require('./modules/core/views/landing.client.view.html'), + ) + + $templateCache.put( + 'modules/core/views/edit-contact-number-modal.view.html', + require('./modules/core/views/edit-contact-number-modal.view.html'), + ) + // Forms module // Forms admin componentViews diff --git a/src/public/modules/core/componentViews/avatar-dropdown.html b/src/public/modules/core/componentViews/avatar-dropdown.html new file mode 100644 index 0000000000..4f92c89fdb --- /dev/null +++ b/src/public/modules/core/componentViews/avatar-dropdown.html @@ -0,0 +1,79 @@ + diff --git a/src/public/modules/core/componentViews/navbar.html b/src/public/modules/core/componentViews/navbar.html index 974eb2aa15..6c95bfc312 100755 --- a/src/public/modules/core/componentViews/navbar.html +++ b/src/public/modules/core/componentViews/navbar.html @@ -36,12 +36,6 @@ User Guide - - - - - + diff --git a/src/public/modules/core/components/avatar-dropdown.client.component.js b/src/public/modules/core/components/avatar-dropdown.client.component.js new file mode 100644 index 0000000000..22c40735fe --- /dev/null +++ b/src/public/modules/core/components/avatar-dropdown.client.component.js @@ -0,0 +1,111 @@ +const get = require('lodash/get') + +angular.module('core').component('avatarDropdownComponent', { + templateUrl: 'modules/core/componentViews/avatar-dropdown.html', + bindings: {}, + controller: [ + '$scope', + '$state', + '$uibModal', + '$window', + 'Auth', + 'Features', + 'Toastr', + avatarDropdownController, + ], + controllerAs: 'vm', +}) + +function avatarDropdownController( + $scope, + $state, + $uibModal, + $window, + Auth, + Features, + Toastr, +) { + const vm = this + + // Preload user with current details, redirect to signin if unable to get user + vm.user = Auth.getUser() || $state.go('signin') + vm.avatarText = generateAvatarText() + + vm.isDropdownHover = false + vm.isDropdownFocused = false + + // Attempt to retrieve the most updated user. + retrieveUser() + + async function retrieveUser() { + try { + const trueUser = await Auth.refreshUser() + if (!trueUser) { + $state.go('signin') + return + } + + vm.user = trueUser + + // Early return if user already has contact information. + if (trueUser.contact) return + + const features = await Features.getfeatureStates() + // Do not proceed if sms feature is not available. + if (!features.sms) return + + // If retrieved user does not have contact, prompt user to add one. + // If user has the key in the browser's storage the modal will not be + // shown. + const hasBeenDismissed = $window.localStorage.getItem( + 'contactBannerDismissed', + ) + if (!hasBeenDismissed) { + vm.openContactNumberModal() + } + } catch (err) { + Toastr.error(err) + } + } + + vm.isDropdownOpen = false + + $scope.$watchGroup(['vm.isDropdownHover', 'vm.isDropdownFocused'], function ( + newValues, + ) { + vm.isDropdownOpen = newValues[0] || newValues[1] + }) + + vm.signOut = () => Auth.signOut() + + vm.openContactNumberModal = () => { + $uibModal + .open({ + animation: false, + keyboard: false, + backdrop: 'static', + windowClass: 'ecm-modal-window', + templateUrl: 'modules/core/views/edit-contact-number-modal.view.html', + controller: 'EditContactNumberModalController', + controllerAs: 'vm', + }) + .result.then((returnVal) => { + // Update success, update user. + if (returnVal) { + vm.user = returnVal + Auth.setUser(returnVal) + } + }) + .finally(angular.noop) + } + + function generateAvatarText() { + const userEmail = get(vm.user, 'email') + if (userEmail) { + // Get first two characters of name. + return userEmail.split('@')[0].slice(0, 2) || '?' + } + + return '?' + } +} diff --git a/src/public/modules/core/components/navbar.client.component.js b/src/public/modules/core/components/navbar.client.component.js index 9df978ba7d..f9f2c883e8 100755 --- a/src/public/modules/core/components/navbar.client.component.js +++ b/src/public/modules/core/components/navbar.client.component.js @@ -2,13 +2,11 @@ angular.module('core').component('navbarComponent', { templateUrl: 'modules/core/componentViews/navbar.html', - controller: ['Auth', '$state', NavBarController], + controller: ['$state', NavBarController], controllerAs: 'vm', }) -function NavBarController(Auth, $state) { +function NavBarController($state) { const vm = this - - vm.signOut = () => Auth.signOut() vm.activeTab = $state.current.name } diff --git a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js new file mode 100644 index 0000000000..c5bf1468ca --- /dev/null +++ b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js @@ -0,0 +1,162 @@ +angular + .module('core') + .controller('EditContactNumberModalController', [ + '$interval', + '$http', + '$timeout', + '$scope', + '$uibModalInstance', + '$window', + 'Auth', + 'Toastr', + EditContactNumberModalController, + ]) + +function EditContactNumberModalController( + $interval, + $http, + $timeout, + $scope, + $uibModalInstance, + $window, + Auth, + Toastr, +) { + const vm = this + + let countdownPromise + + const cancelCountdown = () => { + if (angular.isDefined(countdownPromise)) { + $interval.cancel(countdownPromise) + countdownPromise = undefined + vm.otp.countdown = 0 + } + } + + // The various states of verification + const VERIFY_STATE = { + IDLE: 'IDLE', + AWAIT: 'AWAITING', + SUCCESS: 'SUCCESS', + } + + // Expose so template can use it too. + vm.VERIFY_STATE = VERIFY_STATE + + // Redirect to signin if unable to get user + vm.user = Auth.getUser() || $state.go('signin') + + vm.vfnState = vm.user.contact ? VERIFY_STATE.SUCCESS : VERIFY_STATE.IDLE + + vm.contact = { + number: vm.user.contact || '', + isFetching: false, + error: '', + } + + vm.otp = { + value: '', + countdown: 0, + error: '', + isFetching: false, + } + + vm.lastVerifiedContact = vm.user.contact + + vm.resetVfnState = () => { + // Reset to success state if the number is what is currently stored. + if (vm.contact.number && vm.contact.number === vm.lastVerifiedContact) { + vm.vfnState = VERIFY_STATE.SUCCESS + } else if (vm.vfnState !== VERIFY_STATE.IDLE) { + // Reset to idle state otherwise so user can verify another number. + vm.vfnState = VERIFY_STATE.IDLE + } + + if (vm.contact.error) { + vm.contact.error = '' + } + + cancelCountdown() + } + + vm.resetOtpErrors = () => { + if (vm.otp.error) { + vm.otp.error = '' + } + } + + vm.sendOtp = async () => { + if (vm.otp.countdown > 0) return + + vm.contact.isFetching = true + // Clear previous values, for when resend OTP is clicked. + vm.otp.value = '' + $scope.otpForm.otp.$setPristine() + $scope.otpForm.otp.$setUntouched() + $http + .post('/user/contact/sendotp', { + contact: vm.contact.number, + userId: vm.user._id, + }) + .then(() => { + vm.contact.isFetching = false + vm.vfnState = VERIFY_STATE.AWAIT + vm.otp.countdown = 30 + countdownPromise = $interval( + () => { + vm.otp.countdown-- + }, + 1000, + 30, + ) + }) + .catch((err) => { + // Show error message + vm.contact.error = err.data || 'Failed to send OTP. Please try again' + }) + .finally(() => { + vm.contact.isFetching = false + }) + } + + vm.verifyOtp = async () => { + vm.otp.isFetching = true + // Check with backend if the otp is correct + $http + .post('/user/contact/verifyotp', { + contact: vm.contact.number, + otp: vm.otp.value, + userId: vm.user._id, + }) + .then((response) => { + vm.otp.isFetching = false + vm.vfnState = VERIFY_STATE.SUCCESS + + // Close modal after lag to show success and show toast. + // Return user back to main controller to update. + $timeout(() => { + Toastr.success('Emergency contact number successfully added') + vm.closeModal(response.data) + }, 1000) + }) + .catch((err) => { + // Show error message + vm.otp.error = err.data || 'Failed to verify OTP. Please try again' + }) + .finally(() => { + vm.otp.isFetching = false + vm.otp.value = '' + $scope.otpForm.otp.$setPristine() + $scope.otpForm.otp.$setUntouched() + }) + } + + vm.closeModal = function (data) { + cancelCountdown() + // Add flag into localstorage so the banner does not open again. + // The flag will be cleared on user logout. + $window.localStorage.setItem('contactBannerDismissed', true) + $uibModalInstance.close(data) + } +} diff --git a/src/public/modules/core/css/admin-form-header.css b/src/public/modules/core/css/admin-form-header.css index 439580431b..6695bbfb6d 100644 --- a/src/public/modules/core/css/admin-form-header.css +++ b/src/public/modules/core/css/admin-form-header.css @@ -26,6 +26,13 @@ #admin-form #form-header .right-spacer { margin-right: 15px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +#admin-form #form-header .right-spacer .navbar__avatar { + margin: -10px 0; } #admin-form #form-header #header-myforms a { diff --git a/src/public/modules/core/css/avatar-dropdown.css b/src/public/modules/core/css/avatar-dropdown.css new file mode 100644 index 0000000000..124646e7bc --- /dev/null +++ b/src/public/modules/core/css/avatar-dropdown.css @@ -0,0 +1,239 @@ +.navbar__avatar { + position: relative; + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + height: 80px; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + padding: 10px; +} + +.navbar__avatar__avatar { + display: block; + color: #fff; + text-transform: uppercase; + border: 2px solid #484848; + background-color: #484848; + border-radius: 50%; + outline: none; + height: 44px; + width: 44px; + font-size: 16px; + cursor: pointer; + position: relative; +} + +.navbar__avatar__avatar--alert { + position: absolute; + bottom: 12px; + right: 4px; + width: 20px; + height: 20px; + border: 2px solid #a94442; + border-radius: 50%; + background-color: #a94442; + color: white; + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; +} + +.navbar__avatar__avatar--alert::after { + content: ''; + width: 22px; + height: 22px; + border: 2px solid white; + border-radius: 50%; + position: absolute; +} + +.navbar__avatar--focused { + background-color: #f0f0f0; +} + +.navbar__dropdown { + cursor: initial; + z-index: 1000; + width: 280px; + position: absolute; + right: 0; + top: 80px; + -webkit-box-shadow: 1px 3px 5px 0 rgba(48, 51, 54, 0.2); + box-shadow: 1px 3px 5px 0 rgba(48, 51, 54, 0.2); + background-color: #fff; +} + +.navbar__dropdown--clickable { + cursor: pointer; +} + +.navbar__dropdown--clickable:hover, +.navbar__dropdown--clickable:focus, +.navbar__dropdown--clickable:active { + background-color: #fdfdfd; +} + +.navbar__dropdown__user { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 26px 24px !important; +} + +.navbar__dropdown__user__avatar { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + color: #fff; + text-transform: uppercase; + height: 40px; + width: 40px; + font-size: 14px; + border: 2px solid #484848; + background-color: #484848; + border-radius: 50%; + outline: none; + margin-right: 16px; +} + +.navbar__dropdown__user__details { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.navbar__dropdown__user__details--email { + max-width: 250px; + overflow-wrap: break-word; +} + +.navbar__dropdown__user__contact { + padding: 0 !important; +} + +.navbar__dropdown__user__contact--missing { + padding: 6px 16px; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: #f2dede; + font-size: 14px; + letter-spacing: -0.04px; + color: #a94442; +} + +.navbar__dropdown__user__contact--missing i { + margin-right: 16px; +} + +.navbar__dropdown__user__contact__details { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 16px 24px; + -webkit-box-pack: justify; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.navbar__dropdown__user__contact__details--label { + font-size: 16px; + font-weight: bold; + font-stretch: normal; + font-style: normal; + line-height: 1.25; + letter-spacing: normal; + color: #323232; + margin-right: 16px; +} + +.navbar__dropdown__user__contact__details--link { + font-size: 16px; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1.25; + letter-spacing: -0.3px; + color: #2f60ce; + text-decoration: underline; + cursor: pointer; +} + +.navbar__dropdown__user__contact__details--link:focus, +.navbar__dropdown__user__contact__details--link:active, +.navbar__dropdown__user__contact__details--link:hover { + color: #264da4; +} + +.navbar__dropdown__user__contact__details--value { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: end; + -ms-flex-pack: end; + justify-content: flex-end; +} + +.navbar__dropdown__user__contact__details--value div:first-child { + margin-right: 8px; +} + +.navbar__dropdown__logout { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 18px; + color: #2f60ce; + font-weight: bold; + cursor: pointer; +} + +.navbar__dropdown__logout--text { + padding-right: 9px; +} + +.navbar__dropdown__logout:focus, +.navbar__dropdown__logout:active, +.navbar__dropdown__logout:hover { + color: #264da4; +} + +.navbar__dropdown ul { + list-style: none; + padding: 0; + margin: 0; +} + +.navbar__dropdown ul li { + padding: 18px 24px; + border-bottom: 1px solid #dcdcdc; +} diff --git a/src/public/modules/core/css/edit-contact-number-modal.css b/src/public/modules/core/css/edit-contact-number-modal.css new file mode 100644 index 0000000000..1fdd25316d --- /dev/null +++ b/src/public/modules/core/css/edit-contact-number-modal.css @@ -0,0 +1,281 @@ +.ecm-modal-window { + display: -webkit-box !important; + display: -ms-flexbox !important; + display: flex !important; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.ecm-modal-window .modal-content { + border-radius: 0; + border-width: 0; +} + +.ecm-modal-window .modal-dialog { + width: 50%; +} + +@media screen and (max-width: 1024px) { + .ecm-modal-window .modal-dialog { + width: 80%; + } +} + +@media screen and (max-width: 768px) { + .ecm-modal-window .modal-dialog { + width: 90%; + } +} + +#edit-contact-modal { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; +} + +#edit-contact-modal .bx-loader { + font-size: 30px; +} + +#edit-contact-modal .close-modal { + text-align: right; + font-size: 28px; + color: #979797; + padding: 12px; + background: none; + border: none; + position: absolute; + top: 0; + right: 0; +} + +#edit-contact-modal .close-modal:focus, +#edit-contact-modal .close-modal:hover, +#edit-contact-modal .close-modal:active { + color: #7e7e7e; +} + +#edit-contact-modal input { + width: 100%; + border: solid 2px #b8b8b8; + color: #484848; + display: block; + border-radius: 0; + outline: none; + letter-spacing: 0.2px; +} + +#edit-contact-modal input.ng-touched.ng-invalid { + border: 2px solid #a94442; +} + +#edit-contact-modal input:focus, +#edit-contact-modal input:active { + border: 2px solid #2f60ce; +} + +#edit-contact-modal .ecm__content { + max-width: 575px; + color: #323232; + text-align: center; + padding: 24px; +} + +#edit-contact-modal .ecm__content__image { + width: 80px; + margin: 20px; +} + +#edit-contact-modal .ecm__content__header { + font-size: 24px; + font-weight: bold; + font-stretch: normal; + font-style: normal; + line-height: normal; + letter-spacing: -0.4px; + text-align: center; +} + +#edit-contact-modal .ecm__content__subheader { + margin-top: 8px; + margin-bottom: 48px; + font-size: 18px; + line-height: 1.39; + letter-spacing: -0.3px; + text-align: center; +} + +#edit-contact-modal .ecm__form { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .ecm__form { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + } +} + +#edit-contact-modal .ecm__form__input { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + margin-right: 12px; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .ecm__form__input { + margin-right: 0; + margin-bottom: 8px; + } +} + +#edit-contact-modal .ecm__form__input input { + line-height: 1.5; + height: 54px; + font-size: 20px; +} + +#edit-contact-modal .ecm__form__input .intl-tel-input { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; +} + +#edit-contact-modal .ecm__otp { + color: #2f60ce; + margin-left: 24px; + text-align: left; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .ecm__otp { + margin-left: 0; + } +} + +#edit-contact-modal .ecm__otp__header { + font-size: 18px; + font-weight: bold; + line-height: 1.67; +} + +#edit-contact-modal .ecm__otp__subheader { + font-size: 18px; + line-height: 1.28; + margin-bottom: 16px; +} + +#edit-contact-modal .ecm__otp__input { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + margin-bottom: 8px; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .ecm__otp__input { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + } +} + +#edit-contact-modal .ecm__otp__input--input { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + margin-right: 12px; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .ecm__otp__input--input { + margin-right: 0; + margin-bottom: 8px; + } +} + +#edit-contact-modal .ecm__otp__input button { + margin-top: 0; +} + +#edit-contact-modal .ecm__otp__resend button { + font-size: 18px; + line-height: 1.67; + color: #2f60ce; + background: none; + border: none; + text-decoration: underline; +} + +#edit-contact-modal .ecm__otp__resend button:disabled { + opacity: 0.5; +} + +#edit-contact-modal .ecm__otp__resend button:hover, +#edit-contact-modal .ecm__otp__resend button:active, +#edit-contact-modal .ecm__otp__resend button:focus { + color: #264da4; +} + +#edit-contact-modal .ecm__otp__resend span { + opacity: 0.5; +} + +#edit-contact-modal .ecm__vfn-button--success { + background-color: #04aa80; +} + +#edit-contact-modal .ecm__vfn-button--success:disabled { + opacity: 1; +} + +#edit-contact-modal .ecm__button { + min-width: 140px; +} + +#edit-contact-modal .ecm__button--verify { + min-width: 110px; +} + +#edit-contact-modal .otp__form { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + background-color: #e9ecf5; + padding: 32px; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .otp__form { + padding: 12px; + } +} + +@media screen and (max-width: 425px) { + #edit-contact-modal .otp__form img { + display: none; + } +} diff --git a/src/public/modules/core/css/navbar.css b/src/public/modules/core/css/navbar.css index 55a290e6e6..4fc824eb6f 100644 --- a/src/public/modules/core/css/navbar.css +++ b/src/public/modules/core/css/navbar.css @@ -108,24 +108,3 @@ height: 76px; } } - -.form-navbar__link--logout { - display: -webkit-inline-box; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - font-size: 18px; - color: #2f60ce; - font-weight: bold; - cursor: pointer; -} - -.form-navbar__link--logout__text { - padding-right: 9px; -} - -.form-navbar__link--logout:hover { - color: #82a0e2; -} diff --git a/src/public/modules/core/directives/on-click-outside.client.directive.js b/src/public/modules/core/directives/on-click-outside.client.directive.js new file mode 100644 index 0000000000..7ee35d17c9 --- /dev/null +++ b/src/public/modules/core/directives/on-click-outside.client.directive.js @@ -0,0 +1,42 @@ +// Retrieved from https://codepen.io/robmazan/pen/gbeagj + +angular + .module('core') + .directive('onClickOutside', [ + '$document', + '$parse', + '$timeout', + onClickOutside, + ]) + +function onClickOutside($document, $parse, $timeout) { + return { + restrict: 'A', + compile: function (tElement, tAttrs) { + const fn = $parse(tAttrs.onClickOutside) + + return function (scope, iElement, iAttrs) { + function eventHandler(ev) { + if (iElement[0].contains(ev.target)) { + $document.one('click touchend', eventHandler) + } else { + scope.$apply(function () { + fn(scope) + }) + } + } + scope.$watch(iAttrs.onClickWatcher, function (activate) { + if (activate) { + $timeout(function () { + // Need to defer adding the click handler, otherwise it would + // catch the click event from ng-click and trigger handler expression immediately + $document.one('click touchend', eventHandler) + }) + } else { + $document.off('click touchend', eventHandler) + } + }) + } + }, + } +} diff --git a/src/public/modules/core/img/emergency-contact.svg b/src/public/modules/core/img/emergency-contact.svg new file mode 100644 index 0000000000..c63f2b983c --- /dev/null +++ b/src/public/modules/core/img/emergency-contact.svg @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 0000000000..4ee3cdad13 --- /dev/null +++ b/src/public/modules/core/views/edit-contact-number-modal.view.html @@ -0,0 +1,182 @@ +
+ +
+ +
+ Emergency mobile number +
+
+ Please verify your mobile number so we can contact you in the unlikely + case of an urgent form issue. You can change this number later in user + settings. +
+
+ +
+ +
+ + +
+
+ + Invalid mobile number +
+
+ + Please fill in required field +
+
+ + Please verify your mobile number. +
+
+ +
+
+ + {{vm.contact.error}} +
+
+ + +
+
+ +
+
+ + +
+ +
+
+ Verify your mobile number +
+
+ A text message with a verification code was just sent to you. The + code will be valid for 10 minutes. +
+
+ +
+ + + +
+
+ + Please fill in required field +
+
+ + OTP must be 6 digits +
+
+ +
+
+ + {{vm.otp.error}} +
+
+ + +
+ + +
+ +
+
+ + +
+ + + in {{ vm.otp.countdown}} seconds + +
+
+
+ +
+
+
diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index 68a5dd1405..95a1c0fd40 100644 --- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js +++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js @@ -102,20 +102,6 @@ function AdminFormController( $scope.viewTabs = getViewTabs($scope.userCanEdit) /* Top bar within form */ - - // Massage user for display at top bar within form - $scope.userShort = '?' - if ('email' in $scope.user) { - let userEmail = $scope.user.email - let userName = userEmail.split('@')[0] - if (userName.length >= 1) { - $scope.userShort = userName.charAt(0) - } - if (userName.length >= 2) { - $scope.userShort += userName.charAt(1) - } - } - $scope.lastModifiedString = '-' $scope.$watch( '[myform.lastModified]', diff --git a/src/public/modules/forms/admin/controllers/list-forms.client.controller.js b/src/public/modules/forms/admin/controllers/list-forms.client.controller.js index 03c51d2d64..253be9a92e 100644 --- a/src/public/modules/forms/admin/controllers/list-forms.client.controller.js +++ b/src/public/modules/forms/admin/controllers/list-forms.client.controller.js @@ -31,14 +31,14 @@ function ListFormsController( ) { const vm = this - vm.bannerContent = $window.siteBannerContent || $window.adminBannerContent // Hidden buttons on forms that appear after clicking more vm.showFormBtns = [] // Duplicated form outline on newly dup forms vm.duplicatedForms = [] - vm.user = Auth.getUser() + // Redirect to signin if unable to get user + vm.user = Auth.getUser() || $state.go('signin') // Brings user to edit form page vm.editForm = function (form) { diff --git a/src/public/modules/forms/admin/views/admin-form.client.view.html b/src/public/modules/forms/admin/views/admin-form.client.view.html index 15dc2afc2e..d96fe6bca8 100644 --- a/src/public/modules/forms/admin/views/admin-form.client.view.html +++ b/src/public/modules/forms/admin/views/admin-form.client.view.html @@ -14,21 +14,19 @@
- +
@@ -160,18 +158,18 @@
diff --git a/src/public/modules/forms/base/directives/ng-intl-tel-input.js b/src/public/modules/forms/base/directives/ng-intl-tel-input.js index a3abb91839..3f1170772f 100644 --- a/src/public/modules/forms/base/directives/ng-intl-tel-input.js +++ b/src/public/modules/forms/base/directives/ng-intl-tel-input.js @@ -94,7 +94,12 @@ function ngIntlTelInput(ngIntlTelInput, $log, $window, $parse) { const onlyNumRegex = /^[0-9]*$/ if (scope.allowIntlNumbers || !value || value.match(onlyNumRegex)) { - return value + // Allow international numbers and thus only remove alphabets. + // Only allow phone like digits + const transformedValue = value.replace(/([^\d+()-])/g, '') + ctrl.$setViewValue(transformedValue) + ctrl.$render() + return transformedValue } // Remove all non-digit characters from value diff --git a/src/public/modules/users/services/auth.client.service.js b/src/public/modules/users/services/auth.client.service.js index 8780ac8a0b..9be1957080 100644 --- a/src/public/modules/users/services/auth.client.service.js +++ b/src/public/modules/users/services/auth.client.service.js @@ -26,6 +26,7 @@ function Auth($q, $http, $state, $window) { verifyOtp, getUser, setUser, + refreshUser, signOut, } return authService @@ -53,6 +54,19 @@ function Auth($q, $http, $state, $window) { } } + function refreshUser() { + return $http + .get('/user') + .then(({ data }) => { + setUser(data) + return data + }) + .catch(() => { + setUser(null) + return null + }) + } + function checkUser(credentials) { let deferred = $q.defer() $http.post('/auth/checkuser', credentials).then( @@ -97,6 +111,8 @@ function Auth($q, $http, $state, $window) { $http.get('/auth/signout').then( function () { $window.localStorage.removeItem('user') + // Clear contact banner on logout + $window.localStorage.removeItem('contactBannerDismissed') $state.go('landing') }, function (error) { diff --git a/src/types/admin_verification.ts b/src/types/admin_verification.ts new file mode 100644 index 0000000000..a432ee041b --- /dev/null +++ b/src/types/admin_verification.ts @@ -0,0 +1,31 @@ +import { Document, Model } from 'mongoose' + +import { IUserSchema } from './user' + +export interface IAdminVerification { + admin: IUserSchema['_id'] + hashedContact: string + hashedOtp: string + expireAt: Date + numOtpAttempts?: number + numOtpSent?: number + _id?: Document['_id'] +} + +export interface IAdminVerificationSchema extends IAdminVerification, Document { + _id: Document['_id'] + createdAt?: Date + updatedAt?: Date +} + +export type UpsertOtpParams = Pick< + IAdminVerificationSchema, + 'hashedOtp' | 'hashedContact' | 'admin' | 'expireAt' +> +export interface IAdminVerificationModel + extends Model { + upsertOtp: (params: UpsertOtpParams) => Promise + incrementAttemptsByAdminId: ( + adminId: IUserSchema['_id'], + ) => Promise +} diff --git a/src/types/form.ts b/src/types/form.ts index 333778d2c0..3d21fb1b98 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -32,7 +32,7 @@ export enum ResponseMode { } // Typings -export type OtpData = { +export type FormOtpData = { form: IFormSchema['_id'] formAdmin: { email: IUserSchema['email'] diff --git a/src/types/index.ts b/src/types/index.ts index 8a3bf4d82d..ba8cab0532 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,4 +15,5 @@ export * from './submission' export * from './token' export * from './user' export * from './verification' +export * from './admin_verification' export * from './webhook' diff --git a/src/types/sms_count.ts b/src/types/sms_count.ts index 65e10d3e7e..f92524560c 100644 --- a/src/types/sms_count.ts +++ b/src/types/sms_count.ts @@ -1,10 +1,11 @@ -import { Document } from 'mongoose' +import { Document, Model } from 'mongoose' -import { IFormSchema, OtpData } from './form' -import { IUserSchema } from './user' +import { FormOtpData, IFormSchema } from './form' +import { AdminContactOtpData, IUserSchema } from './user' export enum SmsType { verification = 'VERIFICATION', + adminContact = 'ADMIN_CONTACT', } export enum LogType { @@ -13,18 +14,13 @@ export enum LogType { } export type LogSmsParams = { - otpData: OtpData + otpData: FormOtpData | AdminContactOtpData msgSrvcSid: string smsType: SmsType logType: LogType } export interface ISmsCount { - form: IFormSchema['_id'] - formAdmin: { - email: string - userId: IUserSchema['_id'] - } // The Twilio SID used to send the SMS. Not to be confused with msgSrvcName. msgSrvcSid: string logType: LogType @@ -34,3 +30,23 @@ export interface ISmsCount { } export interface ISmsCountSchema extends ISmsCount, Document {} + +export interface IVerificationSmsCount extends ISmsCount { + form: IFormSchema['_id'] + formAdmin: { + email: string + userId: IUserSchema['_id'] + } +} + +export interface IVerificationSmsCountSchema extends ISmsCountSchema {} + +export interface IAdminContactSmsCount extends ISmsCount { + admin: IUserSchema['_id'] +} + +export interface IAdminContactSmsCountSchema extends ISmsCountSchema {} + +export interface ISmsCountModel extends Model { + logSms: (logParams: LogSmsParams) => Promise +} diff --git a/src/types/user.ts b/src/types/user.ts index aa981d52d7..5198386695 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -2,15 +2,22 @@ import { Document } from 'mongoose' import { IAgencySchema } from './agency' +export type AdminContactOtpData = { + admin: IUserSchema['_id'] +} + export interface IUser { email: string agency: IAgencySchema['_id'] + contact?: string created?: Date betaFlag?: {} - _id: Document['_id'] + _id?: Document['_id'] } -export interface IUserSchema extends IUser, Document {} +export interface IUserSchema extends IUser, Document { + _id: Document['_id'] +} export interface IPopulatedUser extends IUserSchema { agency: IAgencySchema diff --git a/tests/end-to-end/email-submission.e2e.js b/tests/end-to-end/email-submission.e2e.js index 2af65bedab..60a4c46aae 100644 --- a/tests/end-to-end/email-submission.e2e.js +++ b/tests/end-to-end/email-submission.e2e.js @@ -255,6 +255,7 @@ const getDefaultFormOptions = async ({ const user = await User.create({ email: String(Date.now()) + '@data.gov.sg', agency: govTech._id, + contact: '+6587654321', }) return { user, diff --git a/tests/end-to-end/encrypt-submission.e2e.js b/tests/end-to-end/encrypt-submission.e2e.js index 960a90ee0c..c514006bbd 100644 --- a/tests/end-to-end/encrypt-submission.e2e.js +++ b/tests/end-to-end/encrypt-submission.e2e.js @@ -242,6 +242,7 @@ const getDefaultFormOptions = async ({ const user = await User.create({ email: String(Date.now()) + '@data.gov.sg', agency: govTech._id, + contact: '+6587654321', }) return { user, diff --git a/tests/end-to-end/helpers/selectors.js b/tests/end-to-end/helpers/selectors.js index b4e30f1023..5f402d0497 100644 --- a/tests/end-to-end/helpers/selectors.js +++ b/tests/end-to-end/helpers/selectors.js @@ -24,7 +24,8 @@ const signInPage = { const formList = { createFormBtn: Selector('#list-form #create-new, #list-form #welcome-btn'), welcomeMessage: Selector('#list-form #welcome'), - logOutBtn: Selector('.form-navbar__link--logout'), + avatarDropdown: Selector('.navbar__avatar'), + logOutBtn: Selector('.navbar__dropdown__logout'), } const createFormModal = { startFromScratchBtn: Selector('#start-from-scratch-button'), diff --git a/tests/end-to-end/login.e2e.js b/tests/end-to-end/login.e2e.js index f1dfedcb3d..ed4aaacfb0 100644 --- a/tests/end-to-end/login.e2e.js +++ b/tests/end-to-end/login.e2e.js @@ -30,6 +30,7 @@ fixture('login') return new User({ email, agency: govTech._id, + contact: '+6587654321', }) .save() .catch((error) => console.error(error)) @@ -197,6 +198,7 @@ test let otp = await extractOTP(email) await enterOTPAndExpect({ t, otp, isValid: true, email }) + await t.click(formList.avatarDropdown) await t.click(formList.logOutBtn) await t .expect(getPageUrl()) diff --git a/tests/unit/backend/controllers/authentication.server.controller.spec.js b/tests/unit/backend/controllers/authentication.server.controller.spec.js index edcce4a5ca..0b8673429e 100644 --- a/tests/unit/backend/controllers/authentication.server.controller.spec.js +++ b/tests/unit/backend/controllers/authentication.server.controller.spec.js @@ -17,8 +17,8 @@ describe('Authentication Controller', () => { 'dist/backend/app/controllers/authentication.server.controller', { mongoose: Object.assign(mongoose, { '@noCallThru': true }), - '../../config/config': { - otpGenerator: () => TEST_OTP, + '../utils/otp': { + generateOtp: () => TEST_OTP, }, '../services/mail.service': { sendNodeMail: mockSendNodeMail, diff --git a/tests/unit/backend/helpers/jest-db.ts b/tests/unit/backend/helpers/jest-db.ts index 866e49a6f5..996ca47731 100644 --- a/tests/unit/backend/helpers/jest-db.ts +++ b/tests/unit/backend/helpers/jest-db.ts @@ -18,6 +18,7 @@ const connect = async () => { useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true, + useFindAndModify: false, }) return conn } @@ -42,6 +43,46 @@ const clearDatabase = async () => { } } +const clearCollection = async (collection: string) => { + await mongoose.connection.collections[collection].deleteMany({}) +} + +const insertDefaultAgency = async ({ + mailDomain = 'test.gov.sg', +}: { + mailDomain?: string +} = {}) => { + const Agency = getAgencyModel(mongoose) + const agency = await Agency.create({ + shortName: 'govtest', + fullName: 'Government Testing Agency', + emailDomain: [mailDomain], + logo: '/invalid-path/test.jpg', + }) + + return agency +} + +const insertUser = async ({ + agencyId, + userId, + mailDomain = 'test.gov.sg', + mailName = 'test', +}: { + agencyId: ObjectID + userId?: ObjectID + mailName?: string + mailDomain?: string +}) => { + const User = getUserModel(mongoose) + + return User.create({ + email: `${mailName}@${mailDomain}`, + _id: userId, + agency: agencyId, + }) +} + /** * Inserts a default agency and user document into their respective collections. * This is required to create a Form document, as Form pre-validation hook @@ -56,16 +97,10 @@ const insertFormCollectionReqs = async ({ userId?: ObjectID mailName?: string mailDomain?: string -}) => { - const Agency = getAgencyModel(mongoose) +} = {}) => { const User = getUserModel(mongoose) - const agency = await Agency.create({ - shortName: 'govtest', - fullName: 'Government Testing Agency', - emailDomain: [mailDomain], - logo: '/invalid-path/test.jpg', - }) + const agency = await insertDefaultAgency({ mailDomain }) const user = await User.create({ email: `${mailName}@${mailDomain}`, @@ -80,7 +115,10 @@ const dbHandler = { connect, closeDatabase, clearDatabase, + insertDefaultAgency, + insertUser, insertFormCollectionReqs, + clearCollection, } export default dbHandler diff --git a/tests/unit/backend/helpers/jest-express.ts b/tests/unit/backend/helpers/jest-express.ts index f61f9fc168..1d1a3c141a 100644 --- a/tests/unit/backend/helpers/jest-express.ts +++ b/tests/unit/backend/helpers/jest-express.ts @@ -1,16 +1,30 @@ -import { Response } from 'express' +import { Request, Response } from 'express' -interface MockResponse extends Response { - sendStatus: jest.Mock - send: jest.Mock - status: jest.Mock - json: jest.Mock +const mockRequest = ({ + body, + session, +}: { + body: Record + session?: any +}) => { + return { + body, + session, + } as Request } -export const mockResponse = () => - ({ - sendStatus: jest.fn(), - send: jest.fn(), - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as MockResponse) +const mockResponse = () => { + const mockRes = {} as Response + mockRes.status = jest.fn().mockReturnThis() + mockRes.send = jest.fn().mockReturnThis() + mockRes.sendStatus = jest.fn().mockReturnThis() + mockRes.json = jest.fn() + return mockRes +} + +const expressHandler = { + mockRequest, + mockResponse, +} + +export default expressHandler diff --git a/tests/unit/backend/models/admin_verification.server.model.spec.ts b/tests/unit/backend/models/admin_verification.server.model.spec.ts new file mode 100644 index 0000000000..d14b12ccdb --- /dev/null +++ b/tests/unit/backend/models/admin_verification.server.model.spec.ts @@ -0,0 +1,268 @@ +import { ObjectID } from 'bson' +import mongoose from 'mongoose' + +import getAdminVerificationModel from 'src/app/models/admin_verification.server.model' +import { + IAdminVerification, + UpsertOtpParams, +} from 'src/types/admin_verification' + +import dbHandler from '../helpers/jest-db' + +const AdminVerification = getAdminVerificationModel(mongoose) + +describe('AdminVerification Model', () => { + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('Schema', () => { + const DEFAULT_PARAMS: IAdminVerification = { + admin: new ObjectID(), + expireAt: new Date(), + hashedContact: 'mockHashedContact', + hashedOtp: 'mockHashedOtp', + } + it('should create and save successfully', async () => { + // Act + const actual = await AdminVerification.create(DEFAULT_PARAMS) + + // Assert + // All fields should exist + // Object Id should be defined when successfully saved to MongoDB. + expect(actual._id).toBeDefined() + expect(actual.createdAt).toBeInstanceOf(Date) + expect(actual.updatedAt).toBeInstanceOf(Date) + expect(actual.toObject()).toMatchObject({ + ...DEFAULT_PARAMS, + // Add defaults + numOtpAttempts: 0, + numOtpSent: 0, + }) + }) + + it('should create and save successfully with defaults overriden', async () => { + // Arrange + const customParams: IAdminVerification = { + ...DEFAULT_PARAMS, + numOtpAttempts: 10, + } + + // Act + const actual = await AdminVerification.create(customParams) + + // Assert + // All fields should exist + // Object Id should be defined when successfully saved to MongoDB. + expect(actual._id).toBeDefined() + expect(actual.createdAt).toBeInstanceOf(Date) + expect(actual.updatedAt).toBeInstanceOf(Date) + expect(actual).toEqual( + expect.objectContaining({ + ...customParams, + // Add defaults that has not been overridden. + numOtpSent: 0, + }), + ) + }) + + it('should throw validation error on missing admin', async () => { + // Arrange + const missingAdminParams = { ...DEFAULT_PARAMS, admin: undefined } + + // Act + const actualPromise = AdminVerification.create(missingAdminParams) + + // Assert + await expect(actualPromise).rejects.toThrowError( + 'AdminVerificationSchema must have an Admin', + ) + }) + + it('should throw validation error on missing hashedContact', async () => { + // Arrange + const missingContactParams = { + ...DEFAULT_PARAMS, + hashedContact: undefined, + } + + // Act + const actualPromise = AdminVerification.create(missingContactParams) + + // Assert + await expect(actualPromise).rejects.toThrowError( + mongoose.Error.ValidationError, + ) + }) + + it('should throw validation error on missing hashedOtp', async () => { + // Arrange + const missingOtpParams = { + ...DEFAULT_PARAMS, + hashedOtp: undefined, + } + + // Act + const actualPromise = AdminVerification.create(missingOtpParams) + + // Assert + await expect(actualPromise).rejects.toThrowError( + mongoose.Error.ValidationError, + ) + }) + + it('should throw validation error on missing expireAt', async () => { + // Arrange + const missingExpireParams = { + ...DEFAULT_PARAMS, + expireAt: undefined, + } + + // Act + const actualPromise = AdminVerification.create(missingExpireParams) + + // Assert + await expect(actualPromise).rejects.toThrowError( + mongoose.Error.ValidationError, + ) + }) + }) + + describe('Statics', () => { + describe('upsertOtp', () => { + it('should create successfully when document does not exist', async () => { + // Arrange + const params: UpsertOtpParams = { + admin: new ObjectID(), + expireAt: new Date(), + hashedContact: 'mockHashedContact', + hashedOtp: 'mockHashedOtp', + } + // Should have no documents yet. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + + // Act + const actual = await AdminVerification.upsertOtp(params) + + // Assert + // Add defaults to expected result + const expected = { ...params, numOtpAttempts: 0, numOtpSent: 1 } + // Should now have one document. + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + expect(actual).toEqual(expect.objectContaining(expected)) + }) + + it('should update successfully when a document already exists', async () => { + // Arrange + // Insert mock document into collection. + const adminId = new ObjectID() + const oldExpireAt = new Date() + const newExpireAt = new Date(Date.now() + 9000000) + const oldNumOtpSent = 3 + await AdminVerification.create({ + admin: adminId, + expireAt: oldExpireAt, + hashedContact: 'oldMockHashedContact', + hashedOtp: 'oldMockHashedOtp', + numOtpAttempts: 10, + numOtpSent: oldNumOtpSent, + }) + // Should have the added document. + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + + const upsertParams: UpsertOtpParams = { + admin: adminId, + expireAt: newExpireAt, + hashedContact: 'mockHashedContact', + hashedOtp: 'mockHashedOtp', + } + + // Act + const actual = await AdminVerification.upsertOtp(upsertParams) + // Assert + // Add defaults to expected result + // numOtpAttempts should be reset, but the numOtpSent should be + // incremented. + const expected = { + ...upsertParams, + numOtpAttempts: 0, + numOtpSent: oldNumOtpSent + 1, + } + // Should still only have one document. + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + expect(actual).toEqual(expect.objectContaining(expected)) + }) + + it('should throw error if validation fails due to invalid upsert parameters', async () => { + // Arrange + const invalidParams: UpsertOtpParams = { + // Invalid admin parameter. + admin: undefined, + expireAt: new Date(), + hashedContact: 'mockHashedContact', + hashedOtp: 'mockHashedOtp', + } + // Should have no documents yet. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + + // Act + const actualPromise = AdminVerification.upsertOtp(invalidParams) + + // Assert + await expect(actualPromise).rejects.toThrowError( + 'AdminVerificationSchema must have an Admin', + ) + }) + }) + + describe('incrementAttemptsByAdminId', () => { + it('should increment successfully', async () => { + // Arrange + // Insert mock document into collection. + const adminId = new ObjectID() + const initialOtpAttempts = 5 + const adminVerificationParams = { + admin: adminId, + expireAt: new Date(), + hashedContact: 'oldMockHashedContact', + hashedOtp: 'oldMockHashedOtp', + numOtpAttempts: initialOtpAttempts, + numOtpSent: 3, + } + await AdminVerification.create(adminVerificationParams) + // Should have the added document. + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + + // Act + const actualPromise = AdminVerification.incrementAttemptsByAdminId( + adminId, + ) + + // Assert + // Exactly the same as initial params, but with numOtpAttempts + // incremented by 1. + await expect(actualPromise).resolves.toEqual( + expect.objectContaining({ + ...adminVerificationParams, + numOtpAttempts: initialOtpAttempts + 1, + }), + ) + }) + + it('should return null if document cannot be retrieved', async () => { + // Arrange + // Should have no documents yet. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + const freshAdminId = new ObjectID() + + // Act + const actualPromise = AdminVerification.incrementAttemptsByAdminId( + freshAdminId, + ) + + // Assert + await expect(actualPromise).resolves.toBeNull() + }) + }) + }) +}) diff --git a/tests/unit/backend/models/sms_count.server.model.spec.ts b/tests/unit/backend/models/sms_count.server.model.spec.ts index dea0d88f07..5643225324 100644 --- a/tests/unit/backend/models/sms_count.server.model.spec.ts +++ b/tests/unit/backend/models/sms_count.server.model.spec.ts @@ -3,7 +3,7 @@ import { cloneDeep, merge, omit } from 'lodash' import mongoose from 'mongoose' import getSmsCountModel from 'src/app/models/sms_count.server.model' -import { ISmsCount, LogType, SmsType } from 'src/types' +import { IVerificationSmsCount, LogType, SmsType } from 'src/types' import dbHandler from '../helpers/jest-db' @@ -24,10 +24,10 @@ describe('SmsCount', () => { beforeEach(async () => await dbHandler.clearDatabase()) afterAll(async () => await dbHandler.closeDatabase()) - describe('Schema', () => { + describe('VerificationCount Schema', () => { it('should create and save successfully', async () => { // Arrange - const smsCountParams = createSmsCountParams() + const smsCountParams = createVerificationSmsCountParams() // Act const validSmsCount = new SmsCount(smsCountParams) @@ -49,9 +49,12 @@ describe('SmsCount', () => { it('should save successfully, but not save fields that is not defined in the schema', async () => { // Arrange - const smsCountParamsWithExtra = merge(createSmsCountParams(), { - extra: 'somethingExtra', - }) + const smsCountParamsWithExtra = merge( + createVerificationSmsCountParams(), + { + extra: 'somethingExtra', + }, + ) // Act const validSmsCount = new SmsCount(smsCountParamsWithExtra) @@ -75,7 +78,7 @@ describe('SmsCount', () => { it('should reject if form key is missing', async () => { // Arrange - const malformedParams = omit(createSmsCountParams(), 'form') + const malformedParams = omit(createVerificationSmsCountParams(), 'form') const malformedSmsCount = new SmsCount(malformedParams) // Act + Assert @@ -86,7 +89,10 @@ describe('SmsCount', () => { it('should reject if formAdmin.email is missing', async () => { // Arrange - const malformedParams = omit(createSmsCountParams(), 'formAdmin.email') + const malformedParams = omit( + createVerificationSmsCountParams(), + 'formAdmin.email', + ) const malformedSmsCount = new SmsCount(malformedParams) // Act + Assert @@ -97,7 +103,10 @@ describe('SmsCount', () => { it('should reject if formAdmin.userId is missing', async () => { // Arrange - const malformedParams = omit(createSmsCountParams(), 'formAdmin.userId') + const malformedParams = omit( + createVerificationSmsCountParams(), + 'formAdmin.userId', + ) const malformedSmsCount = new SmsCount(malformedParams) // Act + Assert @@ -108,7 +117,10 @@ describe('SmsCount', () => { it('should reject if logType is missing', async () => { // Arrange - const malformedParams = omit(createSmsCountParams(), 'logType') + const malformedParams = omit( + createVerificationSmsCountParams(), + 'logType', + ) const malformedSmsCount = new SmsCount(malformedParams) // Act + Assert @@ -119,7 +131,7 @@ describe('SmsCount', () => { it('should reject if logType is invalid', async () => { // Arrange - const malformedParams = createSmsCountParams() + const malformedParams = createVerificationSmsCountParams() // @ts-ignore malformedParams.logType = 'INVALID_LOG_TYPE' const malformedSmsCount = new SmsCount(malformedParams) @@ -132,7 +144,10 @@ describe('SmsCount', () => { it('should reject if smsType is missing', async () => { // Arrange - const malformedParams = omit(createSmsCountParams(), 'smsType') + const malformedParams = omit( + createVerificationSmsCountParams(), + 'smsType', + ) const malformedSmsCount = new SmsCount(malformedParams) // Act + Assert @@ -143,7 +158,7 @@ describe('SmsCount', () => { it('should reject if smsType is invalid', async () => { // Arrange - const malformedParams = createSmsCountParams() + const malformedParams = createVerificationSmsCountParams() // @ts-ignore malformedParams.smsType = 'INVALID_SMS_TYPE' const malformedSmsCount = new SmsCount(malformedParams) @@ -234,14 +249,16 @@ describe('SmsCount', () => { }) }) -const createSmsCountParams = ({ +const createVerificationSmsCountParams = ({ logType = LogType.success, smsType = SmsType.verification, }: { 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/tests/unit/backend/models/user.server.model.spec.ts b/tests/unit/backend/models/user.server.model.spec.ts new file mode 100644 index 0000000000..48512e9f0f --- /dev/null +++ b/tests/unit/backend/models/user.server.model.spec.ts @@ -0,0 +1,148 @@ +import { omit } from 'lodash' +import mongoose from 'mongoose' + +import getUserModel from 'src/app/models/user.server.model' +import { IAgencySchema, IUser } from 'src/types' + +import dbHandler from '../helpers/jest-db' + +const User = getUserModel(mongoose) + +const AGENCY_DOMAIN = 'example.com' +const VALID_USER_EMAIL = `test@${AGENCY_DOMAIN}` +// Obtained from Twilio's +// https://www.twilio.com/blog/2018/04/twilio-test-credentials-magic-numbers.html +const VALID_CONTACT = '+15005550006' + +describe('User Model', () => { + let agency: IAgencySchema + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + agency = await dbHandler.insertDefaultAgency({ mailDomain: AGENCY_DOMAIN }) + }) + afterEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('Schema', () => { + it('should create and save successfully', async () => { + // Arrange + const validParams: IUser = { + email: VALID_USER_EMAIL, + agency: agency._id, + contact: VALID_CONTACT, + } + + // Act + const saved = await User.create(validParams) + + // Assert + // All fields should exist + // Object Id should be defined when successfully saved to MongoDB. + expect(saved._id).toBeDefined() + expect(saved.created).toBeInstanceOf(Date) + // Retrieve object and compare to params, remove indeterministic keys + const actualSavedObject = omit(saved.toObject(), [ + '_id', + 'created', + '__v', + ]) + expect(actualSavedObject).toEqual(validParams) + }) + + it('should throw error when contact number is invalid', async () => { + const invalidNumber = 'not a number' + const invalidParams: IUser = { + email: VALID_USER_EMAIL, + agency: agency._id, + contact: invalidNumber, + } + + // Act + const user = new User(invalidParams) + + // Assert + await expect(user.save()).rejects.toThrowError( + `${invalidNumber} is not a valid mobile number`, + ) + }) + + it('should throw error when agency reference is missing', async () => { + // Act + const user = new User({ + email: VALID_USER_EMAIL, + // Note no agency reference. + contact: VALID_CONTACT, + }) + + // Assert + await expect(user.save()).rejects.toThrowError('Agency is required') + }) + + it('should throw error when email is not a valid agency', async () => { + const invalidAgencyDomain = 'example.net' + const invalidParams: IUser = { + email: `test@${invalidAgencyDomain}`, + agency: agency._id, + contact: VALID_CONTACT, + } + + // Act + const user = new User(invalidParams) + + // Assert + await expect(user.save()).rejects.toThrowError( + 'This email is not a valid agency email', + ) + }) + + it('should throw error when email is not unique', async () => { + // Arrange + // Create a user first + const validParams: IUser = { + email: VALID_USER_EMAIL, + agency: agency._id, + contact: VALID_CONTACT, + } + const saved = await User.create(validParams) + expect(saved._id).toBeDefined() + + // Create user with same email again + // Act + const duplicateUser = new User(validParams) + + // Assert + await expect(duplicateUser.save()).rejects.toThrowError( + 'Account already exists with this email', + ) + }) + + it('should throw error when email is missing', async () => { + // Act + Assert + const user = new User({ + // Note missing email + agency: agency._id, + contact: VALID_CONTACT, + }) + + // Assert + await expect(user.save()).rejects.toThrowError('Please enter your email') + }) + + it('should throw error when email is invalid', async () => { + const invalidParams: IUser = { + email: 'not an email', + agency: agency._id, + contact: VALID_CONTACT, + } + + // Act + const user = new User(invalidParams) + + // Assert + await expect(user.save()).rejects.toThrowError( + 'This email is not a valid agency email', + ) + }) + }) +}) diff --git a/tests/unit/backend/modules/user/user.controller.spec.ts b/tests/unit/backend/modules/user/user.controller.spec.ts new file mode 100644 index 0000000000..17f5c090b8 --- /dev/null +++ b/tests/unit/backend/modules/user/user.controller.spec.ts @@ -0,0 +1,388 @@ +import HttpStatus from 'http-status-codes' +import { mocked } from 'ts-jest/utils' + +import SmsFactory from 'src/app/factories/sms.factory' +import * as UserController from 'src/app/modules/user/user.controller' +import { InvalidOtpError } from 'src/app/modules/user/user.errors' +import * as UserService from 'src/app/modules/user/user.service' +import { IPopulatedUser, IUser, IUserSchema } from 'src/types' + +import expressHandler from '../../helpers/jest-express' + +jest.mock('src/app/modules/user/user.service') +jest.mock('src/app/factories/sms.factory') +const MockUserService = mocked(UserService) +const MockSmsFactory = mocked(SmsFactory) + +describe('user.controller', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const VALID_SESSION_USER_ID = 'mockSessionUserId' + const INVALID_SESSION_USER = 'invalidSessionUser' + + describe('handleContactSendOtp', () => { + const MOCK_REQ = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: VALID_SESSION_USER_ID, + }, + session: { + user: { + _id: VALID_SESSION_USER_ID, + }, + }, + }) + it('should return 200 when successful', async () => { + const mockRes = expressHandler.mockResponse() + const expectedOtp = '123456' + + // Mock UserService and SmsFactory to pass without errors. + MockUserService.createContactOtp.mockResolvedValueOnce(expectedOtp) + MockSmsFactory.sendAdminContactOtp.mockResolvedValueOnce(true) + + // Act + await UserController.handleContactSendOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + // Check passed in params. + expect(MockUserService.createContactOtp).toBeCalledWith( + MOCK_REQ.body.userId, + MOCK_REQ.body.contact, + ) + expect(MockSmsFactory.sendAdminContactOtp).toBeCalledWith( + MOCK_REQ.body.contact, + expectedOtp, + MOCK_REQ.body.userId, + ) + expect(mockRes.sendStatus).toBeCalledWith(HttpStatus.OK) + }) + + it('should return 401 when user id is not in session', async () => { + // Arrange + const reqWithoutSession = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: VALID_SESSION_USER_ID, + }, + }) + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleContactSendOtp( + reqWithoutSession, + mockRes, + jest.fn(), + ) + + // Assert + // Should trigger unauthorized response. + expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.send).toBeCalledWith('User is unauthorized.') + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 401 when user id does not match param', async () => { + const reqWithDiffUserParam = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: INVALID_SESSION_USER, + }, + session: { + user: { + _id: VALID_SESSION_USER_ID, + }, + }, + }) + + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleContactSendOtp( + reqWithDiffUserParam, + mockRes, + jest.fn(), + ) + + // Assert + // Should trigger unauthorized response. + expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.send).toBeCalledWith('User is unauthorized.') + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 400 when sending of OTP fails', async () => { + const mockRes = expressHandler.mockResponse() + const expectedError = new Error('mock error') + + // Mock UserService to pass without errors. + MockUserService.createContactOtp.mockResolvedValueOnce('123456') + // Mock SmsFactory to throw error. + MockSmsFactory.sendAdminContactOtp.mockRejectedValueOnce(expectedError) + + // Act + await UserController.handleContactSendOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(HttpStatus.BAD_REQUEST) + expect(mockRes.send).toBeCalledWith(expectedError.message) + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 400 when creating of OTP fails', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedError = new Error('mock error') + + // Mock UserService to throw error. + MockUserService.createContactOtp.mockRejectedValueOnce(expectedError) + + // Act + await UserController.handleContactSendOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(HttpStatus.BAD_REQUEST) + expect(mockRes.send).toBeCalledWith(expectedError.message) + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + }) + + describe('handleContactVerifyOtp', () => { + const MOCK_UPDATED_USER: IUser = { + agency: 'mockAgency', + email: 'mockEmail', + _id: VALID_SESSION_USER_ID, + } + + const MOCK_REQ = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: VALID_SESSION_USER_ID, + otp: '123456', + }, + session: { + user: { + _id: VALID_SESSION_USER_ID, + }, + }, + }) + it('should return 200 with updated user when successful', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Mock all UserService calls to pass. + MockUserService.verifyContactOtp.mockResolvedValueOnce(true) + MockUserService.updateUserContact.mockResolvedValueOnce( + MOCK_UPDATED_USER as IUserSchema, + ) + + // Act + await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + // Expect services to be called with correct arguments. + expect(MockUserService.verifyContactOtp).toBeCalledWith( + MOCK_REQ.body.otp, + MOCK_REQ.body.contact, + MOCK_REQ.body.userId, + ) + expect(MockUserService.updateUserContact).toBeCalledWith( + MOCK_REQ.body.contact, + MOCK_REQ.body.userId, + ) + expect(mockRes.status).toBeCalledWith(HttpStatus.OK) + expect(mockRes.send).toBeCalledWith(MOCK_UPDATED_USER) + }) + + it('should return 401 when user id is not in session', async () => { + // Arrange + const reqWithoutSession = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: VALID_SESSION_USER_ID, + otp: '123456', + }, + }) + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleContactVerifyOtp( + reqWithoutSession, + mockRes, + jest.fn(), + ) + + // Assert + // Should trigger unauthorized response. + expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.send).toBeCalledWith('User is unauthorized.') + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 401 when user id does not match param', async () => { + const reqWithDiffUserParam = expressHandler.mockRequest({ + body: { + contact: 'abc', + userId: INVALID_SESSION_USER, + otp: '123456', + }, + session: { + user: { + _id: VALID_SESSION_USER_ID, + }, + }, + }) + + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleContactVerifyOtp( + reqWithDiffUserParam, + mockRes, + jest.fn(), + ) + + // Assert + // Should trigger unauthorized response. + expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.send).toBeCalledWith('User is unauthorized.') + // Service functions should not be called. + expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 500 when updating user contact fails', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedError = new Error('mock update error') + + // Mock verify to pass. + MockUserService.verifyContactOtp.mockResolvedValueOnce(true) + // Mock update to fail. + MockUserService.updateUserContact.mockRejectedValueOnce(expectedError) + + // Act + await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.send).toBeCalledWith(expectedError.message) + expect(MockUserService.verifyContactOtp).toHaveBeenCalledTimes(1) + expect(MockUserService.updateUserContact).toHaveBeenCalledTimes(1) + }) + + it('should return correct status and message when verifying contact throws ApplicationError', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedError = new InvalidOtpError('mock error') + + // Mock UserService to throw error. + MockUserService.verifyContactOtp.mockRejectedValueOnce(expectedError) + + // Act + await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(expectedError.status) + expect(mockRes.send).toBeCalledWith(expectedError.message) + expect(MockUserService.verifyContactOtp).toHaveBeenCalledTimes(1) + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + + it('should return 500 when verifying contact throws non-ApplicationError', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + // Non ApplicationError instantiation. + const expectedError = new Error('mock error') + + // Mock UserService to throw error. + MockUserService.verifyContactOtp.mockRejectedValueOnce(expectedError) + + // Act + await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.send).toBeCalledWith(expectedError.message) + expect(MockUserService.verifyContactOtp).toHaveBeenCalledTimes(1) + expect(MockUserService.updateUserContact).not.toHaveBeenCalled() + }) + }) + + describe('handleFetchUser', () => { + const MOCK_REQ = expressHandler.mockRequest({ + body: {}, + session: { + user: { + _id: VALID_SESSION_USER_ID, + }, + }, + }) + it('should fetch user in session successfully', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const mockPopulatedUser = { + agency: {}, + email: 'mockEmail', + _id: VALID_SESSION_USER_ID, + } + + // Mock resolved value. + MockUserService.getPopulatedUserById.mockResolvedValueOnce( + mockPopulatedUser as IPopulatedUser, + ) + + // Act + await UserController.handleFetchUser(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.send).toBeCalledWith(mockPopulatedUser) + }) + + it('should return 401 when user id is not in session', async () => { + // Arrange + const reqWithoutSession = expressHandler.mockRequest({ + body: {}, + }) + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleFetchUser( + reqWithoutSession, + mockRes, + jest.fn(), + ) + + // Assert + // Should trigger unauthorized response. + expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.send).toBeCalledWith('User is unauthorized.') + }) + + it('should return 500 when retrieved user is null', async () => { + // Arrange + // Mock resolve to null. + MockUserService.getPopulatedUserById.mockResolvedValueOnce(null) + const mockRes = expressHandler.mockResponse() + + // Act + await UserController.handleFetchUser(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.send).toBeCalledWith('Unable to retrieve user') + }) + }) +}) diff --git a/tests/unit/backend/modules/user/user.service.spec.ts b/tests/unit/backend/modules/user/user.service.spec.ts new file mode 100644 index 0000000000..c37e4e24d8 --- /dev/null +++ b/tests/unit/backend/modules/user/user.service.spec.ts @@ -0,0 +1,235 @@ +import { ObjectID } from 'bson' +import mongoose from 'mongoose' +import { ImportMock } from 'ts-mock-imports' + +import getAdminVerificationModel from 'src/app/models/admin_verification.server.model' +import * as UserService from 'src/app/modules/user/user.service' +import * as OtpUtils from 'src/app/utils/otp' +import { IAgencySchema, IUserSchema } from 'src/types' + +import dbHandler from '../../helpers/jest-db' + +const AdminVerification = getAdminVerificationModel(mongoose) + +describe('user.service', () => { + // Obtained from Twilio's + // https://www.twilio.com/blog/2018/04/twilio-test-credentials-magic-numbers.html + const MOCK_CONTACT = '+15005550006' + const MOCK_OTP = '123456' + const USER_ID = new ObjectID() + + let defaultAgency: IAgencySchema + let defaultUser: IUserSchema + + beforeAll(async () => { + await dbHandler.connect() + + // Insert user into collections. + const { agency, user } = await dbHandler.insertFormCollectionReqs({ + userId: USER_ID, + }) + + defaultAgency = agency.toObject() + defaultUser = user.toObject() + }) + beforeEach( + async () => + await dbHandler.clearCollection( + AdminVerification.collection.collectionName, + ), + ) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('createContactOtp', () => { + it('should create a new AdminVerification document and return otp', async () => { + // Arrange + // All calls to generateOtp will return MOCK_OTP. + ImportMock.mockFunction(OtpUtils, 'generateOtp', MOCK_OTP) + // Should have no documents prior to this. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + + // Act + const actualOtp = await UserService.createContactOtp( + USER_ID, + MOCK_CONTACT, + ) + + // Assert + expect(actualOtp).toEqual(MOCK_OTP) + // An AdminVerification document should have been created. + // Tests on the schema will be done in the schema's tests. + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + }) + + it('should throw error when userId is invalid', async () => { + // Arrange + const invalidUserId = new ObjectID() + + // Act + Assert + await expect( + UserService.createContactOtp(invalidUserId, MOCK_CONTACT), + ).rejects.toThrowError('User id is invalid') + }) + }) + + describe('verifyContactOtp', () => { + it('should successfully verify otp', async () => { + // Arrange + // Add a AdminVerification document to verify against. + await UserService.createContactOtp(USER_ID, MOCK_CONTACT) + await expect(AdminVerification.countDocuments()).resolves.toEqual(1) + + // Act + const verifyPromise = UserService.verifyContactOtp( + MOCK_OTP, + MOCK_CONTACT, + USER_ID, + ) + + // Assert + // Resolves successfully. + await expect(verifyPromise).resolves.toEqual(true) + // AdminVerification document should be removed. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + }) + + it('should throw error when AdminVerification document cannot be retrieved', async () => { + // Arrange + // No OTP requested; should have no documents prior to acting. + await expect(AdminVerification.countDocuments()).resolves.toEqual(0) + + // Act + const verifyPromise = UserService.verifyContactOtp( + MOCK_OTP, + MOCK_CONTACT, + USER_ID, + ) + // Act + Assert + await expect(verifyPromise).rejects.toThrowError( + 'OTP has expired. Please request for a new OTP.', + ) + }) + + it('should throw error when verification has been attempted too many times', async () => { + // Arrange + // Insert new AdminVerification document with initial MAX_OTP_ATTEMPTS. + await UserService.createContactOtp(USER_ID, MOCK_CONTACT) + await AdminVerification.findOneAndUpdate( + { admin: USER_ID }, + { $inc: { numOtpAttempts: UserService.MAX_OTP_ATTEMPTS } }, + ) + + // Act + const verifyPromise = UserService.verifyContactOtp( + MOCK_OTP, + MOCK_CONTACT, + USER_ID, + ) + + // Assert + await expect(verifyPromise).rejects.toThrowError( + 'You have hit the max number of attempts. Please request for a new OTP.', + ) + }) + + it('should throw error when OTP hash does not match', async () => { + // Arrange + // Insert new AdminVerification document. + await UserService.createContactOtp(USER_ID, MOCK_CONTACT) + const invalidOtp = '654321' + + // Act + const verifyPromise = UserService.verifyContactOtp( + invalidOtp, + MOCK_CONTACT, + USER_ID, + ) + + // Assert + await expect(verifyPromise).rejects.toThrowError( + 'OTP is invalid. Please try again.', + ) + }) + + it('should throw error when contact hash does not match', async () => { + // Arrange + // Insert new AdminVerification document. + await UserService.createContactOtp(USER_ID, MOCK_CONTACT) + const invalidContact = '123456' + + // Act + const verifyPromise = UserService.verifyContactOtp( + MOCK_OTP, + invalidContact, + USER_ID, + ) + + // Assert + await expect(verifyPromise).rejects.toThrowError( + 'Contact number given does not match the number the OTP is sent to. Please try again with the correct contact number.', + ) + }) + }) + + describe('updateUserContact', () => { + it('should update user successfully', async () => { + // Arrange + // Create new user + const user = await dbHandler.insertUser({ + agencyId: defaultAgency._id, + mailName: 'updateUserContact', + }) + // User should not have contact + expect(user.contact).toBeUndefined() + + // Act + const updatedUser = await UserService.updateUserContact( + MOCK_CONTACT, + user._id, + ) + + // Assert + expect(updatedUser.contact).toEqual(MOCK_CONTACT) + // Returned document's agency should be populated. + expect(updatedUser.agency.toObject()).toEqual(defaultAgency) + }) + + it('should throw error if userId is invalid', async () => { + // Arrange + const invalidUserId = new ObjectID() + + // Act + const updatePromise = UserService.updateUserContact( + MOCK_CONTACT, + invalidUserId, + ) + + // Assert + await expect(updatePromise).rejects.toThrowError('User id is invalid') + }) + }) + + describe('getPopulatedUserById', () => { + it('should return populated user successfully', async () => { + // Arrange + const expected = { + ...defaultUser, + agency: defaultAgency, + } + + // Act + const actual = await UserService.getPopulatedUserById(USER_ID) + + // Assert + expect(actual.toObject()).toEqual(expected) + }) + + it('should return null when user cannot be found', async () => { + // Act + const userPromise = UserService.getPopulatedUserById(new ObjectID()) + + // Assert + await expect(userPromise).resolves.toBeNull() + }) + }) +}) diff --git a/tests/unit/backend/modules/verification/verification.controller.spec.ts b/tests/unit/backend/modules/verification/verification.controller.spec.ts index 2f26a5da88..f0dfc229d3 100644 --- a/tests/unit/backend/modules/verification/verification.controller.spec.ts +++ b/tests/unit/backend/modules/verification/verification.controller.spec.ts @@ -13,7 +13,7 @@ import { } from 'src/app/modules/verification/verification.controller' import * as vfnService from 'src/app/modules/verification/verification.service' -import { mockResponse } from '../../helpers/jest-express' +import expressHandler from '../../helpers/jest-express' jest.mock('src/app/modules/verification/verification.service') const mockVfnService = mocked(vfnService, true) @@ -24,7 +24,7 @@ const MOCK_FIELD_ID = 'fieldId' const MOCK_ANSWER = 'answer' const MOCK_OTP = 'otp' const MOCK_DATA = 'data' -const mockRes = mockResponse() +const mockRes = expressHandler.mockResponse() const Verification = getVerificationModel(mongoose) const notFoundError = 'TRANSACTION_NOT_FOUND' const waitOtpError = 'WAIT_FOR_OTP' diff --git a/tests/unit/backend/modules/verification/verification.service.spec.ts b/tests/unit/backend/modules/verification/verification.service.spec.ts index ae39e714bc..e435856193 100644 --- a/tests/unit/backend/modules/verification/verification.service.spec.ts +++ b/tests/unit/backend/modules/verification/verification.service.spec.ts @@ -14,7 +14,7 @@ import { verifyOtp, } from 'src/app/modules/verification/verification.service' import MailService from 'src/app/services/mail.service' -import { otpGenerator } from 'src/config/config' +import { generateOtp } from 'src/app/utils/otp' import formsgSdk from 'src/config/formsg-sdk' import { BasicField, IUserSchema, IVerificationSchema } from 'src/types' @@ -25,8 +25,8 @@ const Verification = getVerificationModel(mongoose) const MOCK_FORM_TITLE = 'Verification service tests' // Set up mocks -jest.mock('src/config/config') -const mockOtpGenerator = mocked(otpGenerator, true) +jest.mock('src/app/utils/otp') +const mockGenerateOtp = mocked(generateOtp, true) jest.mock('src/config/formsg-sdk') const mockFormsgSdk = mocked(formsgSdk, true) jest.mock('src/app/factories/sms.factory') @@ -219,7 +219,7 @@ describe('Verification service', () => { ], expireAt: new Date(Date.now() + 6e5), // so it won't expire in tests }) - mockOtpGenerator.mockReturnValue(mockOtp) + mockGenerateOtp.mockReturnValue(mockOtp) mockBcrypt.hash.mockReturnValue(Promise.resolve(hashedOtp)) mockFormsgSdk.verification.generateSignature.mockReturnValue(signedData) }) @@ -257,7 +257,7 @@ describe('Verification service', () => { transaction.fields[0].hashRetries = 1 await transaction.save() await getNewOtp(transaction, transaction.fields[0]._id, mockAnswer) - expect(mockOtpGenerator).toHaveBeenCalled() + expect(mockGenerateOtp).toHaveBeenCalled() expect(mockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) expect( mockFormsgSdk.verification.generateSignature.mock.calls[0][0], @@ -288,7 +288,7 @@ describe('Verification service', () => { transaction.fields[1].hashRetries = 1 await transaction.save() await getNewOtp(transaction, transaction.fields[1]._id, mockAnswer) - expect(mockOtpGenerator).toHaveBeenCalled() + expect(mockGenerateOtp).toHaveBeenCalled() expect(mockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) expect( mockFormsgSdk.verification.generateSignature.mock.calls[0][0], diff --git a/tests/unit/backend/services/mail.service.spec.ts b/tests/unit/backend/services/mail.service.spec.ts index 8c118f61af..1b8d7b74af 100644 --- a/tests/unit/backend/services/mail.service.spec.ts +++ b/tests/unit/backend/services/mail.service.spec.ts @@ -372,9 +372,10 @@ describe('mail.service', () => { }, }, } as IPopulatedForm, - submission: ({ + submission: { id: 'mockSubmissionId', - } as unknown) as ISubmissionSchema, + created: new Date(), + } as ISubmissionSchema, responsesData: [ { question: 'some question', From 3a47f8ca4d3f76d84f17fa1a7ec6c82ccfbe8c5b Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Thu, 27 Aug 2020 12:19:28 +0800 Subject: [PATCH 06/26] fix: pass missing $state param into EditContactNumberModalController (#216) --- .../controllers/edit-contact-number-modal.client.controller.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js index c5bf1468ca..51a0bb4543 100644 --- a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js +++ b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js @@ -1,6 +1,7 @@ angular .module('core') .controller('EditContactNumberModalController', [ + '$state', '$interval', '$http', '$timeout', @@ -13,6 +14,7 @@ angular ]) function EditContactNumberModalController( + $state, $interval, $http, $timeout, From 4647f12e1ea77d45dcd4e4ab0412bf9f826a4c30 Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Thu, 27 Aug 2020 13:36:51 +0800 Subject: [PATCH 07/26] fix: fix linting not working on frontend code (#217) * chore: fix eslint not working on frontend code * lint: fix lint on the frontend --- .eslintrc | 63 ++++++++++++++++++ .eslintrc.js | 64 ------------------- .stylelintrc.js | 2 +- package.json | 4 +- src/public/.eslintrc | 29 +++++++++ src/public/.eslintrc.js | 39 ----------- .../core/services/gtag.client.service.js | 8 +-- .../admin-form.client.controller.js | 1 - .../create-form-modal.client.controller.js | 6 +- .../edit-fields-modal.client.controller.js | 2 +- .../view-responses.client.controller.js | 12 ++-- .../verify-secret-key.client.directive.js | 12 +++- .../field-attachment.client.component.js | 26 +++++--- .../components/field-date.client.component.js | 26 +++++--- .../submit-form.client.controller.js | 2 +- .../forms/services/form-api.client.factory.js | 2 +- .../authentication.client.controller.js | 12 ++-- 17 files changed, 158 insertions(+), 152 deletions(-) create mode 100644 .eslintrc delete mode 100644 .eslintrc.js create mode 100644 src/public/.eslintrc delete mode 100644 src/public/.eslintrc.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..ad26071a37 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,63 @@ +{ + "env": { + "commonjs": true, + "es6": true, + "node": true + }, + "extends": ["eslint:recommended", "plugin:prettier/recommended"], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly", + "_": true + }, + "parserOptions": { + "ecmaVersion": 2018 + }, + "overrides": [ + { + "files": ["*.ts"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "modules": true + } + }, + "plugins": ["@typescript-eslint", "import", "simple-import-sort"], + "rules": { + // Rules for auto sort of imports + "simple-import-sort/sort": [ + "error", + { + "groups": [ + // Side effect imports. + ["^\\u0000"], + // Packages. + // Things that start with a letter (or digit or underscore), or + // `@` followed by a letter. + ["^@?\\w"], + // Root imports + ["^(src)(/.*|$)"], + // Parent imports. Put `..` last. + ["^\\.\\.(?!/?$)", "^\\.\\./?$"], + // Other relative imports. Put same-folder imports and `.` last. + ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"] + ] + } + ], + "sort-imports": "off", + "import/order": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", + "@typescript-eslint/no-var-requires": "warn", + "@typescript-eslint/no-unused-vars": "error", + "no-unused-vars": "off" + } + } + ], + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-console": "warn" + } +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index d795062786..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,64 +0,0 @@ -module.exports = { - env: { - commonjs: true, - es6: true, - node: true, - }, - extends: ['eslint:recommended', 'plugin:prettier/recommended'], - globals: { - Atomics: 'readonly', - SharedArrayBuffer: 'readonly', - _: true, - }, - parserOptions: { - ecmaVersion: 2018, - }, - overrides: [ - { - files: ['*.ts'], - parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - ecmaFeatures: { - modules: true, - }, - }, - plugins: ['@typescript-eslint', 'import', 'simple-import-sort'], - rules: { - // Rules for auto sort of imports - 'simple-import-sort/sort': [ - 'error', - { - groups: [ - // Side effect imports. - ['^\\u0000'], - // Packages. - // Things that start with a letter (or digit or underscore), or - // `@` followed by a letter. - ['^@?\\w'], - // Root imports - ['^(src)(/.*|$)'], - // Parent imports. Put `..` last. - ['^\\.\\.(?!/?$)', '^\\.\\./?$'], - // Other relative imports. Put same-folder imports and `.` last. - ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], - ], - }, - ], - 'sort-imports': 'off', - 'import/order': 'off', - 'import/first': 'error', - 'import/newline-after-import': 'error', - 'import/no-duplicates': 'error', - '@typescript-eslint/no-var-requires': 'warn', - '@typescript-eslint/no-unused-vars': 'error', - 'no-unused-vars': 'off', - }, - }, - ], - rules: { - 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'no-console': 'warn', - }, - ignorePatterns: ['dist/**', 'src/public/**', '*.html'], -} diff --git a/.stylelintrc.js b/.stylelintrc.js index aab441d761..54faa4f916 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -4,5 +4,5 @@ module.exports = { 'no-descending-specificity': [true, { severity: 'warning' }], 'selector-type-no-unknown': [true, { ignore: ['custom-elements'] }], }, - ignoreFiles: ['dist/**', "**/*.html"], + ignoreFiles: ['dist/**', '**/*.html', 'coverage/**'], } diff --git a/package.json b/package.json index 9147f1f16b..9940a77b62 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,11 @@ "test-e2e-basic": "env-cmd -f tests/.test-basic-env --use-shell \"npm run download-binary && npm run testcafe-basic-env\"", "test-e2e": "npm run test-e2e-build && npm run test-e2e-full && npm run test-e2e-basic", "test-e2e-ci": "npm run test-e2e-full && npm run test-e2e-basic", - "lint-code": "eslint './src/**' --quiet --fix", + "lint-code": "eslint src/ --quiet --fix", "lint-style": "stylelint '*/**/*.css' --quiet --fix", "lint-html": "htmlhint && prettier --write './src/public/**/*.html' --ignore-path './dist/**' --loglevel silent", "lint": "npm run lint-code && npm run lint-style && npm run lint-html", - "lint-ci": "eslint './src/**' --quiet && stylelint '*/**/*.css' --quiet && htmlhint && prettier --c './src/public/**/*.html' --ignore-path './dist/**'" + "lint-ci": "eslint src/ --quiet && stylelint '*/**/*.css' --quiet && htmlhint && prettier --c './src/public/**/*.html' --ignore-path './dist/**'" }, "husky": { "hooks": { diff --git a/src/public/.eslintrc b/src/public/.eslintrc new file mode 100644 index 0000000000..52ced10dec --- /dev/null +++ b/src/public/.eslintrc @@ -0,0 +1,29 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "jquery": true + }, + "extends": ["plugin:angular/johnpapa"], + "globals": { + "angular": true + }, + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": { + "angular/controller-name": 1, + "angular/controller-as-route": 1, + "angular/controller-as": 1, + "angular/window-service": 1, + "angular/module-getter": 1, + "angular/no-run-logic": 1, + "angular/module-setter": 1, + "angular/file-name": "off", + "angular/function-type": 2, + "angular/document-service": 1, + "angular/timeout-service": 1, + "angular/interval-service": 1, + "angular/no-service-method": 0 + } +} diff --git a/src/public/.eslintrc.js b/src/public/.eslintrc.js deleted file mode 100644 index d67ced3429..0000000000 --- a/src/public/.eslintrc.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = { - env: { - browser: true, - commonjs: true, - es6: true, - node: true, - jquery: true - }, - extends: [ - 'eslint:recommended', - 'plugin:angular/johnpapa', - 'plugin:prettier/recommended', - ], - globals: { - angular: true, - _: true, - }, - parserOptions: { - ecmaVersion: 2018, - }, - rules: { - "angular/controller-name": 1, - "angular/controller-as-route": 1, - "angular/file-name": 1, - "angular/controller-as": 1, - "angular/window-service": 1, - "angular/module-getter": 1, - "angular/no-run-logic": 1, - "angular/module-setter": 1, - "angular/file-name": "off", - "angular/function-type": 2, - "angular/document-service": 1, - "angular/timeout-service": 1, - "angular/interval-service": 1, - "angular/no-service-method": 0, - - "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] - }, -} diff --git a/src/public/modules/core/services/gtag.client.service.js b/src/public/modules/core/services/gtag.client.service.js index af844fa90e..4060bcc4a8 100644 --- a/src/public/modules/core/services/gtag.client.service.js +++ b/src/public/modules/core/services/gtag.client.service.js @@ -55,11 +55,7 @@ function GTag(Auth, $rootScope, $window) { * start with a slash (/) character. * @return {Void} */ - const _gtagPageview = ({ - pageTitle, - pagePath, - pageLocation - }) => { + const _gtagPageview = ({ pageTitle, pagePath, pageLocation }) => { if (GATrackingID) { $window.gtag('config', GATrackingID, { page_title: pageTitle, @@ -489,4 +485,4 @@ function GTag(Auth, $rootScope, $window) { } return gtagService -} \ No newline at end of file +} diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index 95a1c0fd40..353796f527 100644 --- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js +++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js @@ -71,7 +71,6 @@ function AdminFormController( $window, FormApi, ) { - // Banner message on form builder routes $scope.bannerContent = $window.siteBannerContent || $window.adminBannerContent diff --git a/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js b/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js index d2a088d632..c70299efae 100644 --- a/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js @@ -302,7 +302,8 @@ function CreateFormModalController( } const generateMailToUri = (title, secretKey) => { - return 'mailto:?subject=' + + return ( + 'mailto:?subject=' + $window.encodeURIComponent(`Shared Secret Key for ${title}`) + '&body=' + $window.encodeURIComponent( @@ -317,8 +318,9 @@ function CreateFormModalController( All you need to do is keep this email as a record, and please do not share this key with anyone else. Thank you for helping to safekeep my form! - ` + `, ) + ) } vm.handleMailToClick = () => { diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js index e1fbae8534..2ddb1afd91 100644 --- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js @@ -244,7 +244,7 @@ function EditFieldsModalController( selectedDateValidation: temp, } } - + vm.triggerDateChangeTracker = function () { const field = vm.field field.isValidateDate = !field.isValidateDate diff --git a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js index 80725ab622..437d8ceb4b 100644 --- a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js +++ b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js @@ -47,15 +47,15 @@ function ViewResponsesController( vm.datePicker = { date: { startDate: null, endDate: null } } - // BroadcastChannel will only broadcast the message to scripts from the same origin - // (i.e. https://form.gov.sg in practice) so all data should be controlled by scripts - // originating from FormSG. This does not store any data in browser-based storage - // (e.g. cookies or localStorage) so secrets would not be retained past the user closing + // BroadcastChannel will only broadcast the message to scripts from the same origin + // (i.e. https://form.gov.sg in practice) so all data should be controlled by scripts + // originating from FormSG. This does not store any data in browser-based storage + // (e.g. cookies or localStorage) so secrets would not be retained past the user closing // all FormSG tabs containing the form. - // We do not use polyfills for BroadcastChannel as they usually involve localStorage, + // We do not use polyfills for BroadcastChannel as they usually involve localStorage, // which is not safe for secret key handling. - + // BroadcastChannel is not available on Safari and IE 11, so this feature is not available // on those two browsers. Current behavior where users have to upload the secret key on // each tab will continue for users on those two browsers. diff --git a/src/public/modules/forms/admin/directives/verify-secret-key.client.directive.js b/src/public/modules/forms/admin/directives/verify-secret-key.client.directive.js index 844204701c..0eb2a17b28 100644 --- a/src/public/modules/forms/admin/directives/verify-secret-key.client.directive.js +++ b/src/public/modules/forms/admin/directives/verify-secret-key.client.directive.js @@ -12,7 +12,7 @@ function verifySecretKeyDirective(FormSgSdk) { if (isActivationModal === true) { return 'modules/forms/admin/directiveViews/verify-secret-key-activation.client.view.html' } else { - return 'modules/forms/admin/directiveViews/verify-secret-key.client.view.html' + return 'modules/forms/admin/directiveViews/verify-secret-key.client.view.html' } }, restrict: 'E', @@ -63,8 +63,14 @@ function verifySecretKeyDirective(FormSgSdk) { $scope.isAcknowledgementWrong = () => { const inputAcknowledgement = $scope.inputAcknowledgement if (!inputAcknowledgement) return true - return inputAcknowledgement.toLowerCase().replace(/\./g, "").replace(/ +/g, ' ').trim() !== - 'i understand the consequences and have stored my secret key in 2 other places' + return ( + inputAcknowledgement + .toLowerCase() + .replace(/\./g, '') + .replace(/ +/g, ' ') + .trim() !== + 'i understand the consequences and have stored my secret key in 2 other places' + ) //Allow regardless of whether user types in upper / lowercase, ignore full stops and multiple spaces } diff --git a/src/public/modules/forms/base/components/field-attachment.client.component.js b/src/public/modules/forms/base/components/field-attachment.client.component.js index b61f9a2e68..c05af19a8e 100644 --- a/src/public/modules/forms/base/components/field-attachment.client.component.js +++ b/src/public/modules/forms/base/components/field-attachment.client.component.js @@ -24,16 +24,18 @@ function attachmentFieldComponentController(FileHandler, $timeout) { vm.fileAttached = false vm.isLoading = false - vm.beforeResizingImages = function (fieldId) { + vm.beforeResizingImages = function (_fieldId) { vm.isLoading = true } - vm.uploadFile = function (file, errFiles, fieldId) { + vm.uploadFile = function (file, errFiles, _fieldId) { let err if (errFiles.length > 0) { err = errFiles[0].$error if (err === 'maxSize') { const currentSize = (errFiles[0].size / 1000000).toFixed(2) - showAttachmentError(`${currentSize} MB / ${vm.field.attachmentSize} MB: File size exceeded`) + showAttachmentError( + `${currentSize} MB / ${vm.field.attachmentSize} MB: File size exceeded`, + ) } else if (err === 'resize') { showAttachmentError(`An error has occurred while resizing your image`) } else { @@ -46,7 +48,9 @@ function attachmentFieldComponentController(FileHandler, $timeout) { let fileExt = FileHandler.getFileExtension(file.name) if (FileHandler.isInvalidFileExtension(fileExt)) { - showAttachmentError(`Your file's extension ending in *${fileExt} is not allowed`) + showAttachmentError( + `Your file's extension ending in *${fileExt} is not allowed`, + ) return } @@ -63,7 +67,9 @@ function attachmentFieldComponentController(FileHandler, $timeout) { $timeout(() => { if (invalidFiles.length > 0) { const stringOfInvalidExtensions = invalidFiles.join(', ') - showAttachmentError(`The following file extensions in your zip are not valid: ${stringOfInvalidExtensions}`) + showAttachmentError( + `The following file extensions in your zip are not valid: ${stringOfInvalidExtensions}`, + ) } else { saveFileToField(file) } @@ -71,15 +77,15 @@ function attachmentFieldComponentController(FileHandler, $timeout) { }) .catch(() => { $timeout(() => { - showAttachmentError('An error has occurred while parsing your zip file') + showAttachmentError( + 'An error has occurred while parsing your zip file', + ) }) }) } vm.attachmentIsDisabled = (field) => { - return ( - (vm.isadminpreview && !vm.isLoading) || field.disabled - ) + return (vm.isadminpreview && !vm.isLoading) || field.disabled } vm.removeFile = function () { @@ -105,7 +111,7 @@ function attachmentFieldComponentController(FileHandler, $timeout) { $timeout(() => { showAttachmentError( 'Upload failed. If you are using online storage such as Google Drive, ' + - 'download your file before attaching the downloaded version' + 'download your file before attaching the downloaded version', ) }) } diff --git a/src/public/modules/forms/base/components/field-date.client.component.js b/src/public/modules/forms/base/components/field-date.client.component.js index a33d1fa7b5..2143a1919b 100644 --- a/src/public/modules/forms/base/components/field-date.client.component.js +++ b/src/public/modules/forms/base/components/field-date.client.component.js @@ -8,7 +8,7 @@ angular.module('forms').component('dateFieldComponent', { bindings: { field: '<', forms: '<', - isValidateDate: '<' + isValidateDate: '<', }, controller: dateFieldComponentController, controllerAs: 'vm', @@ -33,13 +33,19 @@ function dateFieldComponentController() { const todayIsoFormat = moment .tz('Asia/Singapore') .format('YYYY/MM/DD HH:mm:ss') - if (get(vm.field, 'dateValidation.selectedDateValidation') === 'Disallow past dates') { + if ( + get(vm.field, 'dateValidation.selectedDateValidation') === + 'Disallow past dates' + ) { // Get GMT+8 time information from moment.tz // but convert it to JS date using the format string // as js date does not contain tz information vm.dateOptions.minDate = new Date(todayIsoFormat) } - if (get(vm.field, 'dateValidation.selectedDateValidation') === 'Disallow future dates') { + if ( + get(vm.field, 'dateValidation.selectedDateValidation') === + 'Disallow future dates' + ) { vm.dateOptions.maxDate = new Date(todayIsoFormat) } if (get(vm.field, 'dateValidation.customMinDate')) { @@ -54,14 +60,18 @@ function dateFieldComponentController() { vm.isDateInvalid = () => { const date = vm.field.fieldValue ? moment(vm.field.fieldValue) : null - const minDate = vm.dateOptions.minDate ? moment(vm.dateOptions.minDate).startOf('day') : null - const maxDate = vm.dateOptions.maxDate ? moment(vm.dateOptions.maxDate).startOf('day') : null + const minDate = vm.dateOptions.minDate + ? moment(vm.dateOptions.minDate).startOf('day') + : null + const maxDate = vm.dateOptions.maxDate + ? moment(vm.dateOptions.maxDate).startOf('day') + : null if (date && minDate && maxDate) { return date < minDate || date > maxDate - } - if (date && minDate){ + } + if (date && minDate) { return date < minDate - } + } if (date && maxDate) { return date > maxDate } diff --git a/src/public/modules/forms/base/controllers/submit-form.client.controller.js b/src/public/modules/forms/base/controllers/submit-form.client.controller.js index 39dcf09d9c..b04ffad3fa 100644 --- a/src/public/modules/forms/base/controllers/submit-form.client.controller.js +++ b/src/public/modules/forms/base/controllers/submit-form.client.controller.js @@ -27,7 +27,7 @@ function SubmitFormController(FormData, SpcpSession, $window, $document, GTag) { // Show banner content if available if ($window.siteBannerContent) { vm.banner = { - msg: $window.siteBannerContent + msg: $window.siteBannerContent, } } else if ($window.isGeneralMaintenance) { // Show banner for SingPass forms diff --git a/src/public/modules/forms/services/form-api.client.factory.js b/src/public/modules/forms/services/form-api.client.factory.js index 036defa88f..f44ed3fef8 100644 --- a/src/public/modules/forms/services/form-api.client.factory.js +++ b/src/public/modules/forms/services/form-api.client.factory.js @@ -43,7 +43,7 @@ function FormApi($resource, FormErrorService, FormFields) { const interceptor = { request: (config) => { if (get(config, 'data.form.editFormField.field')) { - set(config, 'data.form.editFormField.field.isNewClient', true) + set(config, 'data.form.editFormField.field.isNewClient', true) // TODO: Remove isNewClient() after 31 Aug 2020 (#2437) } if (get(config, 'data.form')) { diff --git a/src/public/modules/users/controllers/authentication.client.controller.js b/src/public/modules/users/controllers/authentication.client.controller.js index 8b515bb210..5fde795d4d 100755 --- a/src/public/modules/users/controllers/authentication.client.controller.js +++ b/src/public/modules/users/controllers/authentication.client.controller.js @@ -37,22 +37,20 @@ function AuthenticationController($state, $timeout, $window, Auth, GTag) { // boolean to enable/disable email submit button vm.isSubmitEmailDisabled = false - - /** + /** * Action on keyup for email input */ - vm.handleEmailKeyUp = function (e) { + vm.handleEmailKeyUp = function (e) { if (e.keyCode == 13) { - vm.isSubmitEmailDisabled === false && vm.checkEmail() + vm.isSubmitEmailDisabled === false && vm.checkEmail() // condition vm.isSubmitEmailDisabled == false is necessary to prevent retries using enter key // when submit button is disabled } else { vm.removeError(e) vm.isSubmitEmailDisabled = false - } - } - + } + } /** * Redirects user with active session to target page From 525c1bb8e042e74adcca429df0f544d97a6c821a Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Thu, 27 Aug 2020 16:21:22 +0800 Subject: [PATCH 08/26] feat: standardize logger format and output (#211) * feat: update application logger to enforce a logging format * feat: update all logger calls to the new enforced signature * feat: add stack trace into prod logs * fix: remove wrongly negated check for logging * feat(logger): add greater clarity to what errorHunter does * refactor(request): remove redundant req.get check * test(AdminFormController): add dummy req.get function not used in the test, but getRequestIp for logging uses it * Merge branch 'develop' into feat/standardize-logger # Conflicts: # src/app/controllers/authentication.server.controller.js # src/app/services/webhooks.service.ts --- package-lock.json | 6 + package.json | 2 + .../admin-console.server.controller.js | 91 ++++++++-- .../admin-forms.server.controller.js | 143 ++++++++++----- .../authentication.server.controller.js | 127 ++++++++++---- src/app/controllers/core.server.controller.js | 46 ++++- .../email-submissions.server.controller.js | 163 ++++++++++++------ .../encrypt-submissions.server.controller.js | 105 ++++++++--- .../controllers/forms.server.controller.js | 13 +- .../controllers/myinfo.server.controller.js | 90 ++++++---- .../public-forms.server.controller.js | 21 ++- src/app/controllers/spcp.server.controller.js | 83 +++++++-- .../submissions.server.controller.js | 84 ++++++--- src/app/factories/spcp-myinfo.factory.js | 34 +++- src/app/modules/sns/sns.controller.ts | 10 +- src/app/modules/user/user.controller.ts | 13 +- .../verification/verification.controller.ts | 42 ++++- src/app/services/mail.service.ts | 63 ++++--- src/app/services/sms.service.ts | 87 ++++++++-- src/app/services/webhooks.service.ts | 69 ++++---- .../FieldValidatorInterface.class.js | 30 +++- src/app/utils/request.ts | 2 +- src/config/config.ts | 60 ++++--- .../util/FeatureManager.class.ts | 18 +- src/config/logger.ts | 135 +++++++++++++-- src/loaders/express/error-handler.ts | 15 +- src/loaders/index.ts | 16 +- src/loaders/mongoose.ts | 45 +++-- src/server.ts | 13 +- src/types/webhook.ts | 10 -- tests/unit/backend/.eslintrc | 8 +- .../admin-forms.server.controller.spec.js | 1 + .../backend/services/webhook.service.spec.ts | 14 +- 33 files changed, 1214 insertions(+), 445 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8777282bba..7b70e02c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5129,6 +5129,12 @@ "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", "dev": true }, + "@types/triple-beam": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.2.tgz", + "integrity": "sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==", + "dev": true + }, "@types/uid-generator": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/uid-generator/-/uid-generator-2.0.2.tgz", diff --git a/package.json b/package.json index 9940a77b62..0bd5e6105d 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "sortablejs": "^1.8.4", "text-encoding": "^0.7.0", "toastr": "^2.1.4", + "triple-beam": "^1.3.0", "tweetnacl": "^1.0.1", "twilio": "^3.33.1", "ui-select": "^0.19.8", @@ -182,6 +183,7 @@ "@types/nodemailer": "^6.4.0", "@types/nodemailer-direct-transport": "^1.0.31", "@types/puppeteer-core": "^2.0.0", + "@types/triple-beam": "^1.3.2", "@types/uid-generator": "^2.0.2", "@types/uuid": "^8.0.0", "@types/validator": "^13.0.0", diff --git a/src/app/controllers/admin-console.server.controller.js b/src/app/controllers/admin-console.server.controller.js index 06531c9f11..7bb1dea1d5 100644 --- a/src/app/controllers/admin-console.server.controller.js +++ b/src/app/controllers/admin-console.server.controller.js @@ -19,9 +19,7 @@ const FormStatisticsTotal = getFormStatisticsTotalModel(mongoose) const Form = getFormModel(mongoose) const _ = require('lodash') -const logger = require('../../config/logger').createLoggerWithLabel( - 'admin-console', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const { getRequestIp } = require('../utils/request') // Examples search-specific constants @@ -572,8 +570,18 @@ exports.getExampleFormsUsingAggregateCollection = function (req, res) { lookupFormStatisticsInfo, projectSubmissionInfo, req.query, - (err, status, result) => { - if (err) logger.error(getRequestIp(req), req.url, req.headers, err) + (error, status, result) => { + if (error) + logger.error({ + message: 'Failed to retrieve example forms', + meta: { + action: 'getExampleFormsUsingAggregateCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error, + }) return res.status(status).send(result) }, ) @@ -594,8 +602,19 @@ exports.getExampleFormsUsingSubmissionsCollection = function (req, res) { lookupSubmissionInfo, groupSubmissionsByFormId, req.query, - (err, status, result) => { - if (err) logger.error(getRequestIp(req), req.url, req.headers, err) + (error, status, result) => { + if (error) { + logger.error({ + message: 'Failed to retrieve example forms', + meta: { + action: 'getExampleFormsUsingSubmissionsCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error, + }) + } return res.status(status).send(result) }, ) @@ -673,8 +692,19 @@ exports.getSingleExampleFormUsingSubmissionCollection = function (req, res) { getSingleExampleFormUsing( req.params.formId, createFormIdSubmissionQuery, - (err, status, result) => { - if (err) logger.error(getRequestIp(req), req.url, req.headers, err) + (error, status, result) => { + if (error) { + logger.error({ + message: 'Failed to retrieve a single example form', + meta: { + action: 'getSingleExampleFormUsingSubmissionCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error, + }) + } return res.status(status).send(result) }, ) @@ -691,7 +721,18 @@ exports.getSingleExampleFormUsingAggregateCollection = function (req, res) { req.params.formId, createFormIdStatsQuery, (err, status, result) => { - if (err) logger.error(getRequestIp(req), req.url, req.headers, err) + if (err) { + logger.error({ + message: 'Failed to retrieve single example form', + meta: { + action: 'getSingleExampleFormUsingAggregateCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + } return res.status(status).send(result) }, ) @@ -760,21 +801,33 @@ exports.getLoginStats = function (req, res) { }, }, ], - function (err, loginStats) { - if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + function (error, loginStats) { + if (error) { + logger.error({ + message: 'Failed to retrieve billing records', + meta: { + action: 'getLoginStats', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send('Error in retrieving billing records') } else if (!loginStats) { return res.status(HttpStatus.NOT_FOUND).send('No billing records found') } else { - logger.info( - 'Billing search for', - esrvcId, - 'by', - req.session.user && req.session.user.email, - ) + logger.info({ + message: `Billing search for ${esrvcId} by ${ + req.session.user && req.session.user.email + }`, + meta: { + action: 'getLoginStats', + }, + }) + return res.send({ loginStats, }) diff --git a/src/app/controllers/admin-forms.server.controller.js b/src/app/controllers/admin-forms.server.controller.js index 0b2c8e41bb..1b89e1e8dc 100644 --- a/src/app/controllers/admin-forms.server.controller.js +++ b/src/app/controllers/admin-forms.server.controller.js @@ -10,9 +10,7 @@ const JSONStream = require('JSONStream') const HttpStatus = require('http-status-codes') const get = require('lodash/get') -const logger = require('../../config/logger').createLoggerWithLabel( - 'admin-forms', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const errorHandler = require('./errors.server.controller') const { getRequestIp } = require('../utils/request') const { FormLogoState } = require('../../types') @@ -67,6 +65,17 @@ function makeModule(connection) { */ function respondOnMongoError(req, res, err) { if (err) { + logger.error({ + message: 'Responding to Mongo error', + meta: { + action: 'respondOnMongoError', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + let statusCode if (err.name === 'ValidationError') { statusCode = HttpStatus.UNPROCESSABLE_ENTITY @@ -80,7 +89,6 @@ function makeModule(connection) { statusCode = HttpStatus.REQUEST_TOO_LONG // HTTP 413 Payload Too Large } else { statusCode = HttpStatus.INTERNAL_SERVER_ERROR - logger.error(getRequestIp(req), req.url, req.headers, err) } return res.status(statusCode).send({ @@ -109,15 +117,21 @@ function makeModule(connection) { if (logo.fileId) { form.customLogo = `${logoBucketUrl}/${logo.fileId}` } else { - logger.error( - `logo is in an invalid state. fileId should always be defined for CUSTOM state but is ${logo.fileId} for form ${form._id}`, - ) + logger.error({ + message: `Logo is in an invalid state. fileId should always be defined for CUSTOM state but is ${logo.fileId} for form ${form._id}`, + meta: { + action: 'updateCustomLogoInForm', + }, + }) } break default: - logger.error( - `logo is in an invalid state. Only NONE, DEFAULT and CUSTOM are allowed but state is ${logo.state} for form ${form._id}`, - ) + logger.error({ + message: `logo is in an invalid state. Only NONE, DEFAULT and CUSTOM are allowed but state is ${logo.state} for form ${form._id}`, + meta: { + action: 'updateCustomLogoInForm', + }, + }) } } } @@ -291,11 +305,14 @@ function makeModule(connection) { if (!_.isEmpty(updatedForm.editFormField)) { if (!_.isEmpty(updatedForm.form_fields)) { // form_fields should not exist in updatedForm - logger.error( - `formId="${form._id}", ip=${getRequestIp( - req, - )}, message="form_fields should not exist in updatedForm"`, - ) + logger.error({ + message: 'form_fields should not exist in updatedForm', + meta: { + action: 'makeModule.update', + ip: getRequestIp(req), + formId: form._id, + }, + }) return res .status(HttpStatus.BAD_REQUEST) .send({ message: 'Invalid update to form' }) @@ -305,11 +322,15 @@ function makeModule(connection) { updatedForm.editFormField, ) if (error) { - logger.error( - `formId="${form._id}", ip=${getRequestIp( - req, - )}, message="${error}"`, - ) + logger.error({ + message: 'Error getting edited form fields', + meta: { + action: 'makeModule.update', + ip: getRequestIp(req), + formId: form._id, + }, + error, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: error }) } form.form_fields = formFields @@ -322,7 +343,12 @@ function makeModule(connection) { updatedForm.customLogo !== undefined && form.customLogo !== updatedForm.customLogo ) { - logger.info(`Custom logo being updated for form ${form._id}`) + logger.info({ + message: `Custom logo being updated for form ${form._id}`, + meta: { + action: 'makeModule.update', + }, + }) } _.extend(form, updatedForm) @@ -510,7 +536,16 @@ function makeModule(connection) { count, ) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Error counting documents in FormFeedback', + meta: { + action: 'makeModule.countFeedback', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) @@ -530,30 +565,42 @@ function makeModule(connection) { FormFeedback.find({ formId: req.form._id }) .cursor() .on('error', function (err) { - logger.error( - `Error streaming feedback from MongoDB:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error streaming feedback from MongoDB', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: 'Error retrieving from database.', }) }) .pipe(JSONStream.stringify()) .on('error', function (err) { - logger.error( - `Error converting feedback to JSON:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error converting feedback to JSON', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: 'Error converting feedback to JSON', }) }) .pipe(res.type('json')) .on('error', function (err) { - logger.error( - `Error writing feedback to HTTP stream:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error writing feedback to HTTP stream', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: 'Error writing feedback to HTTP stream', }) @@ -657,12 +704,14 @@ function makeModule(connection) { }, function (err, presignedPostObject) { if (err) { - logger.error( - `Presigning post data encountered an error, ip=${getRequestIp( - req, - )}`, - err, - ) + logger.error({ + message: 'Presigning post data encountered an error', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send(err) } else { return res.status(HttpStatus.OK).send(presignedPostObject) @@ -702,12 +751,14 @@ function makeModule(connection) { }, function (err, presignedPostObject) { if (err) { - logger.error( - `Presigning post data encountered an error:\tip=${getRequestIp( - req, - )}`, - err, - ) + logger.error({ + message: 'Presigning post data encountered an error', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send(err) } else { return res.status(HttpStatus.OK).send(presignedPostObject) diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js index 7de2d79aba..4061d3e112 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -19,9 +19,7 @@ const config = require('../../config/config') const defaults = require('../../config/defaults').default const PERMISSIONS = require('../utils/permission-levels.js') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel( - 'authentication', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const { generateOtp } = require('../utils/otp') const MailService = require('../services/mail.service').default @@ -62,11 +60,16 @@ exports.validateDomain = function (req, res, next) { } // Agency not found if (!agency) { - logger.error( - `Agency not found:\temail=${email} emailDomain=${emailDomain} ip=${getRequestIp( - req, - )}`, - ) + logger.error({ + message: 'Agency not found', + meta: { + action: 'validateDomain', + email, + emailDomain, + ip: getRequestIp(req), + }, + error: err, + }) return res .status(HttpStatus.UNAUTHORIZED) .send( @@ -174,7 +177,16 @@ exports.createOtp = function (req, res, next) { { w: 1, upsert: true, new: true }, function (updateErr, updatedRecord) { if (updateErr) { - logger.error(getRequestIp(req), req.url, req.headers, updateErr) + logger.error({ + message: 'Token update error', + meta: { + action: 'createOtp', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: updateErr, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send( @@ -208,10 +220,26 @@ exports.sendOtp = async function (req, res) { otp, ipAddress: getRequestIp(req), }) - logger.info(`Login OTP sent:\temail=${recipient} ip=${getRequestIp(req)}`) + logger.info({ + message: 'Login OTP sent', + meta: { + action: 'sendOtp', + ip: getRequestIp(req), + email: recipient, + }, + }) return res.status(HttpStatus.OK).send(`OTP sent to ${recipient}!`) } catch (err) { - logger.error('Mail otp error', getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Mail otp error', + meta: { + action: 'sendOtp', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send( @@ -246,11 +274,15 @@ exports.verifyOtp = function (req, res, next) { { w: 1, upsert: false, new: true }, function (updateErr, updatedRecord) { if (updateErr) { - logger.error( - `Failed to validate OTP (updateErr)\temail=${email} ip=${getRequestIp( - req, - )}`, - ) + logger.error({ + message: 'Error updating Token in database', + meta: { + action: 'verifyOtp', + email, + ip: getRequestIp(req), + }, + error: updateErr, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send( @@ -258,15 +290,27 @@ exports.verifyOtp = function (req, res, next) { ) } if (!updatedRecord) { - logger.info(`Expired OTP\temail=${email} ip=${getRequestIp(req)}`) + logger.info({ + message: 'Expired OTP', + meta: { + action: 'verifyOtp', + ip: getRequestIp(req), + email, + }, + }) return res .status(HttpStatus.UNPROCESSABLE_ENTITY) .send('OTP has expired. Click Resend to receive a new OTP.') } if (updatedRecord.numOtpAttempts > MAX_OTP_ATTEMPTS) { - logger.info( - `Exceeded max OTP attempts\temail=${email} ip=${getRequestIp(req)}`, - ) + logger.info({ + message: 'Exceeded max OTP attempts', + meta: { + action: 'verifyOtp', + ip: getRequestIp(req), + email, + }, + }) return res .status(HttpStatus.UNPROCESSABLE_ENTITY) .send( @@ -278,7 +322,14 @@ exports.verifyOtp = function (req, res, next) { isCorrect, ) { if (bcryptErr) { - logger.error(`Malformed OTP\temail=${email} ip=${getRequestIp(req)}`) + logger.error({ + message: 'Malformed OTP', + meta: { + action: 'verifyOtp', + ip: getRequestIp(req), + }, + error: bcryptErr, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send( @@ -288,11 +339,15 @@ exports.verifyOtp = function (req, res, next) { if (isCorrect) { Token.findOneAndRemove({ email: email }, function (removeErr) { if (removeErr) { - logger.error( - `Failed to validate OTP (removeErr)\temail=${email} ip=${getRequestIp( - req, - )}`, - ) + logger.error({ + message: 'Error removing Token in database', + meta: { + action: 'verifyOtp', + email, + ip: getRequestIp(req), + }, + error: removeErr, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send( @@ -303,7 +358,14 @@ exports.verifyOtp = function (req, res, next) { } }) } else { - logger.info(`Invalid OTP\temail=${email} ip=${getRequestIp(req)}`) + logger.info({ + message: 'Invalid OTP', + meta: { + action: 'verifyOtp', + email, + ip: getRequestIp(req), + }, + }) return res .status(HttpStatus.UNAUTHORIZED) .send('OTP is invalid. Please try again.') @@ -363,9 +425,14 @@ exports.signIn = function (req, res) { // Add user info to session req.session.user = userObj - logger.info( - `Successful Login:\temail=${user.email} ip=${getRequestIp(req)}`, - ) + logger.info({ + message: 'Successful login', + meta: { + action: 'signIn', + email, + ip: getRequestIp(req), + }, + }) return res.status(HttpStatus.OK).send(userObj) }, ) diff --git a/src/app/controllers/core.server.controller.js b/src/app/controllers/core.server.controller.js index 520acaa0a9..aa03f39b21 100755 --- a/src/app/controllers/core.server.controller.js +++ b/src/app/controllers/core.server.controller.js @@ -4,7 +4,7 @@ const HttpStatus = require('http-status-codes') const config = require('../../config/config') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel('core') +const logger = require('../../config/logger').createLoggerWithLabel(module) const getFormStatisticsTotalModel = require('../models/form_statistics_total.server.model') .default @@ -48,7 +48,16 @@ exports.formCountUsingAggregateCollection = (req, res) => { ], function (err, [result]) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Mongo form statistics aggregate error', + meta: { + action: 'formCountUsingAggregateCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) } else if (result) { res.json(_.get(result, 'numActiveForms', 0)) @@ -83,7 +92,16 @@ exports.formCountUsingSubmissionsCollection = (req, res) => { ], function (err, forms) { if (err) { - logger.error(req.ip, req.url, req.headers, err) + logger.error({ + message: 'Mongo submission aggregate error', + meta: { + action: 'formCountUsingSubmissionsCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) } else { res.json(forms.length) @@ -101,7 +119,16 @@ exports.userCount = (req, res) => { let User = getUserModel(mongoose) User.estimatedDocumentCount(function (err, ct) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Mongo user count error', + meta: { + action: 'userCount', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) } else { res.json(ct) } @@ -117,7 +144,16 @@ exports.submissionCount = (req, res) => { let Submission = getSubmissionModel(mongoose) Submission.estimatedDocumentCount(function (err, ct) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Mongo submission count error', + meta: { + action: 'submissionCount', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) } else { let totalCount = ct + config.submissionsTopUp res.json(totalCount) diff --git a/src/app/controllers/email-submissions.server.controller.js b/src/app/controllers/email-submissions.server.controller.js index 77270307dd..7256edf23e 100644 --- a/src/app/controllers/email-submissions.server.controller.js +++ b/src/app/controllers/email-submissions.server.controller.js @@ -23,9 +23,7 @@ const config = require('../../config/config') const { getProcessedResponses, } = require('../modules/submission/submission.service') -const logger = require('../../config/logger').createLoggerWithLabel( - 'email-submissions', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const MailService = require('../services/mail.service').default const { sessionSecret } = config @@ -54,11 +52,15 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }, }) } catch (err) { - logger.error( - `formId=${_.get(req, 'form._id')} ip="${getRequestIp( - req, - )}" busboy error=${err}`, - ) + logger.error({ + message: 'Busboy error', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: _.get(req, 'form._id'), + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Required headers are missing', }) @@ -101,11 +103,15 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { req.body = JSON.parse(val) } catch (err) { // Invalid form data - logger.error( - `Error 400 - Failed to parse body for email submission: formId=${ - req.form._id - } ip=${getRequestIp(req)} error='${err}'`, - ) + logger.error({ + message: 'Invalid form data', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + error: err, + }) return res.sendStatus(HttpStatus.BAD_REQUEST) } } @@ -114,11 +120,14 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { // responses successfully retrieved busboy.on('finish', async function () { if (limitReached) { - logger.error( - `Error 413 - Content is too large: formId=${ - req.form._id - } ip=${getRequestIp(req)}`, - ) + logger.error({ + message: 'Content is too large', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + }) return res .status(HttpStatus.REQUEST_TOO_LONG) .send({ message: 'Your submission is too large.' }) @@ -139,22 +148,29 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { .update(concatenatedResponse) .digest('hex') : undefined - const ip = req.get('cf-connecting-ip') || req.ip - logger.info( - `[submissionHashes] formId=${_.get( - req, - 'form._id', - )} ip=${ip} uin=${hashedUinFin} submission=${hashedSubmission}`, - ) + + logger.info({ + message: 'Submission successfully hashed', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + formId: req.form._id, + ip: getRequestIp(req), + uin: hashedUinFin, + submission: hashedSubmission, + }, + }) try { const areAttachmentsValid = await attachmentsAreValid(attachments) if (!areAttachmentsValid) { - logger.error( - `formId="${_.get(req, 'form._id')}" ip=${getRequestIp( - req, - )} Error 400: Invalid attachments`, - ) + logger.error({ + message: 'Invalid attachments', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Some files were invalid. Try uploading another file.', }) @@ -171,12 +187,17 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { return next() } catch (error) { - logger.error( - `formId=${_.get(req, 'form._id')} ip=${getRequestIp( - req, - )} uin=${hashedUinFin} submission=${hashedSubmission} receiveSubmission error:\t`, + logger.error({ + message: 'receiveSubmission error', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + uin: hashedUinFin, + submission: hashedSubmission, + }, error, - ) + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) @@ -184,11 +205,15 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }) busboy.on('error', (err) => { - logger.error( - `formId=${_.get(req, 'form._id')} ip="${getRequestIp( - req, - )}" multipart error=${err}`, - ) + logger.error({ + message: 'Multipart error', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + error: err, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) @@ -213,7 +238,18 @@ exports.validateEmailSubmission = function (req, res, next) { req.body.parsedResponses = getProcessedResponses(form, req.body.responses) delete req.body.responses // Prevent downstream functions from using responses by deleting it } catch (err) { - logger.error(`ip="${getRequestIp(req)}" error=`, err) + logger.error({ + message: + err instanceof ConflictError + ? 'Conflict - Form has been updated' + : 'Error processing responses', + meta: { + action: 'validateEmailSubmission', + ip: getRequestIp(req), + formId: req.form._id, + }, + error: err, + }) if (err instanceof ConflictError) { return res.status(HttpStatus.CONFLICT).send({ message: @@ -461,7 +497,16 @@ const getVerifiedPrefix = (isUserVerified) => { * of the submission */ function onSubmissionEmailFailure(err, req, res, submission) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Error submitting email form', + meta: { + action: 'onSubmissionEmailFailure', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.', @@ -551,10 +596,13 @@ exports.saveMetadataToDb = function (req, res, next) { .then((submission) => { logger.info({ message: 'Saved submission to MongoDB', - submissionId: submission.id, - formId: form._id, - ip: getRequestIp(req), - responseHash: submission.responseHash, + meta: { + action: 'saveMetadataToDb', + submissionId: submission.id, + formId: form._id, + ip: getRequestIp(req), + responseHash: submission.responseHash, + }, }) req.submission = submission return next() @@ -590,10 +638,13 @@ exports.sendAdminEmail = async function (req, res, next) { try { logger.info({ message: 'Sending admin mail', - submissionId: submission.id, - formId: form._id, - ip: getRequestIp(req), - submissionHash: submission.responseHash, + meta: { + action: 'sendAdminEmail', + submissionId: submission.id, + formId: form._id, + ip: getRequestIp(req), + submissionHash: submission.responseHash, + }, }) await MailService.sendSubmissionToAdmin({ @@ -607,7 +658,17 @@ exports.sendAdminEmail = async function (req, res, next) { return next() } catch (err) { - logger.warn('sendAdminEmail error', err) + logger.warn({ + message: 'Error sending submission to admin', + meta: { + action: 'sendAdminEmail', + submissionId: submission.id, + formId: form._id, + ip: getRequestIp(req), + submissionHash: submission.responseHash, + }, + error: err, + }) return onSubmissionEmailFailure(err, req, res, submission) } } diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index daf23d9647..6060c6ab82 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -17,9 +17,7 @@ const { checkIsEncryptedEncoding } = require('../utils/encryption') const { ConflictError } = require('../utils/custom-errors') const { getRequestIp } = require('../utils/request') const { isMalformedDate, createQueryWithDateParam } = require('../utils/date') -const logger = require('../../config/logger').createLoggerWithLabel( - 'encrypt-submissions', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const { aws: { attachmentS3Bucket, s3 }, } = require('../../config/config') @@ -41,11 +39,15 @@ exports.validateEncryptSubmission = function (req, res, next) { // Check if the encrypted content is base64 checkIsEncryptedEncoding(req.body.encryptedContent) } catch (error) { - logger.error( - `Error 400 - Invalid encryption: formId=${form._id} ip=${getRequestIp( - req, - )} error='${error}'`, - ) + logger.error({ + message: 'Invalid encryption', + meta: { + action: 'validateEncryptSubmission', + ip: getRequestIp(req), + formId: form._id, + }, + error, + }) return res .status(HttpStatus.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) @@ -56,7 +58,15 @@ exports.validateEncryptSubmission = function (req, res, next) { req.body.parsedResponses = getProcessedResponses(form, req.body.responses) delete req.body.responses // Prevent downstream functions from using responses by deleting it } catch (err) { - logger.error(`ip="${getRequestIp(req)}" error=`, err) + logger.error({ + message: 'Error processing responses', + meta: { + action: 'validateEncryptSubmission', + ip: getRequestIp(req), + formId: form._id, + }, + error: err, + }) if (err instanceof ConflictError) { return res.status(HttpStatus.CONFLICT).send({ message: @@ -96,7 +106,16 @@ exports.prepareEncryptSubmission = (req, res, next) => { * of the submission */ function onEncryptSubmissionFailure(err, req, res, submission) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Encrypt submission error', + meta: { + action: 'onEncryptSubmissionFailure', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.', @@ -160,7 +179,13 @@ exports.saveResponseToDb = function (req, res, next) { }) .promise() .catch((err) => { - logger.error('Attachment upload error: ', err) + logger.error({ + message: 'Attachment upload error', + meta: { + action: 'saveResponseToDb', + }, + error: err, + }) return onEncryptSubmissionFailure(err, req, res, submission) }), ) @@ -248,7 +273,16 @@ exports.getMetadata = function (req, res) { .allowDiskUse(true) // prevents out-of-memory for large search results (max 100MB) .exec((err, result) => { if (err || !result) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Failure retrieving metadata from database', + meta: { + action: 'getMetadata', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) @@ -297,7 +331,16 @@ exports.getEncryptedResponse = function (req, res) { }, ).exec(async (err, response) => { if (err || !response) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Failure retrieving encrypted submission from database', + meta: { + action: 'getEncryptedResponse', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) @@ -364,10 +407,14 @@ exports.streamEncryptedResponses = async function (req, res) { .lean() .cursor() .on('error', function (err) { - logger.error( - `Error streaming submissions from MongoDB:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error streaming submissions from database', + meta: { + action: 'streamEncryptedResponse', + ip: getRequestIp(req), + }, + error: err, + }) res.status(500).send({ message: 'Error retrieving from database.', }) @@ -376,20 +423,28 @@ exports.streamEncryptedResponses = async function (req, res) { // seperated by a newline. .pipe(JSONStream.stringify(false)) .on('error', function (err) { - logger.error( - `Error converting submissions to JSON:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error converting submissions to JSON', + meta: { + action: 'streamEncryptedResponse', + ip: getRequestIp(req), + }, + error: err, + }) res.status(500).send({ message: 'Error converting submissions to JSON', }) }) .pipe(res.type('application/x-ndjson')) .on('error', function (err) { - logger.error( - `Error writing submissions to HTTP stream:\t ip=${getRequestIp(req)}`, - err, - ) + logger.error({ + message: 'Error writing submissions to HTTP stream', + meta: { + action: 'streamEncryptedResponse', + ip: getRequestIp(req), + }, + error: err, + }) res.status(500).send({ message: 'Error writing submissions to HTTP stream', }) diff --git a/src/app/controllers/forms.server.controller.js b/src/app/controllers/forms.server.controller.js index 196d5af2e8..aefb057ee9 100644 --- a/src/app/controllers/forms.server.controller.js +++ b/src/app/controllers/forms.server.controller.js @@ -8,7 +8,7 @@ const _ = require('lodash') const HttpStatus = require('http-status-codes') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel('forms') +const logger = require('../../config/logger').createLoggerWithLabel(module) const getFormModel = require('../models/form.server.model').default const Form = getFormModel(mongoose) @@ -116,7 +116,16 @@ exports.formById = async function (req, res, next) { return next() } } catch (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Error retrieving form from database', + meta: { + action: 'formById', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) return next(err) } } diff --git a/src/app/controllers/myinfo.server.controller.js b/src/app/controllers/myinfo.server.controller.js index 4683e5c393..6d33f72f8d 100644 --- a/src/app/controllers/myinfo.server.controller.js +++ b/src/app/controllers/myinfo.server.controller.js @@ -12,7 +12,7 @@ const HttpStatus = require('http-status-codes') const { sessionSecret } = require('../../config/config') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel('myinfo') +const logger = require('../../config/logger').createLoggerWithLabel(module) const getMyInfoHashModel = require('../models/myinfo_hash.server.model').default const MyInfoHash = getMyInfoHashModel(mongoose) @@ -50,21 +50,19 @@ exports.addMyInfo = (myInfoService) => async (req, res, next) => { }), ) if (fetchError) { - if (CircuitBreaker.isOurError(fetchError)) { - logger.error( - `Circuit breaker tripped for formId ${formId} with singpassEserviceId ${esrvcId}:\t ip=${getRequestIp( - req, - )}`, - fetchError, - ) - } else { - logger.error( - `Error retrieving from MyInfo for formId ${formId} with singpassEserviceId ${esrvcId}:\t ip=${getRequestIp( - req, - )}`, - fetchError, - ) - } + const logMessage = CircuitBreaker.isOurError(fetchError) + ? 'Circuit breaker tripped' + : 'Error retrieving from MyInfo' + logger.error({ + message: logMessage, + meta: { + action: 'addMyInfo', + ip: getRequestIp(req), + formId, + esrvcId, + }, + error: fetchError, + }) res.locals.myInfoError = true return next() } @@ -107,17 +105,30 @@ exports.addMyInfo = (myInfoService) => async (req, res, next) => { (err) => { if (err) { res.locals.myInfoError = true - logger.error(`Error writing to DB:\t ip=${getRequestIp(req)}`, err) + logger.error({ + message: 'Error writing to DB', + meta: { + action: 'addMyInfo', + ip: getRequestIp(req), + formId, + }, + error: err, + }) } return next() }, ) }) .catch((error) => { - logger.error( - `Error hashing MyInfo fields:\t ip=${getRequestIp(req)}`, + logger.error({ + message: 'Error hashing MyInfo fields', + meta: { + action: 'addMyInfo', + ip: getRequestIp(req), + formId, + }, error, - ) + }) res.locals.myInfoError = true return next() }) @@ -176,11 +187,14 @@ exports.verifyMyInfoVals = function (req, res, next) { { uinFin: hashedUinFin, form: formObjId }, (err, hashedObj) => { if (err) { - logger.error( - `[MyInfo] Unable to connect to database: ip=${getRequestIp( - req, - )} ${err}`, - ) + logger.error({ + message: 'Error retrieving MyInfo hash from database', + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + }, + error: err, + }) return res.status(HttpStatus.SERVICE_UNAVAILABLE).send({ message: 'MyInfo verification unavailable, please try again later.', spcpSubmissionFailure: true, @@ -188,11 +202,14 @@ exports.verifyMyInfoVals = function (req, res, next) { } if (!hashedObj) { - logger.error( - `[MyInfo] Unable to find hashes for form: ${formObjId} ip=${getRequestIp( - req, - )}`, - ) + logger.error({ + message: `Unable to find MyInfo hashes for ${formObjId}`, + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + formId: formObjId, + }, + }) return res.status(HttpStatus.GONE).send({ message: 'MyInfo verification expired, please refresh and try again.', @@ -233,11 +250,14 @@ exports.verifyMyInfoVals = function (req, res, next) { .filter(([_, compare]) => compare === false) .map(([clientField, _]) => clientField.attr) - logger.error( - `[MyInfo] Hash did not match for form:\t${formObjId}\tfields:\t${hashFailedAttrs} ip=${getRequestIp( - req, - )}`, - ) + logger.error({ + message: `Hash did not match for form ${formObjId}`, + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + failedFields: hashFailedAttrs, + }, + }) return res.status(HttpStatus.UNAUTHORIZED).send({ message: 'MyInfo verification failed.', spcpSubmissionFailure: true, diff --git a/src/app/controllers/public-forms.server.controller.js b/src/app/controllers/public-forms.server.controller.js index 0ffd76c4a9..73116bf8fc 100644 --- a/src/app/controllers/public-forms.server.controller.js +++ b/src/app/controllers/public-forms.server.controller.js @@ -4,9 +4,7 @@ const mongoose = require('mongoose') const HttpStatus = require('http-status-codes') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel( - 'public-forms', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const getFormFeedbackModel = require('../models/form_feedback.server.model') .default const getFormModel = require('../models/form.server.model').default @@ -56,7 +54,13 @@ exports.redirect = async function (req, res) { redirectPath, }) } catch (err) { - logger.error(`Error fetching metatags, ${err}`) + logger.error({ + message: 'Error fetching metatags', + meta: { + action: 'redirect', + }, + error: err, + }) } res.redirect('/#!/' + redirectPath) } @@ -88,7 +92,14 @@ exports.submitFeedback = function (req, res) { }, function (err) { if (err) { - logger.error(`ip=${getRequestIp(req)}`, err) + logger.error({ + message: 'Error creating form feedback', + meta: { + action: 'submitFeedback', + ip: getRequestIp(req), + }, + error: err, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send('Form feedback could not be created') diff --git a/src/app/controllers/spcp.server.controller.js b/src/app/controllers/spcp.server.controller.js index 91b8ece05e..fdfbfd4f34 100644 --- a/src/app/controllers/spcp.server.controller.js +++ b/src/app/controllers/spcp.server.controller.js @@ -10,7 +10,7 @@ const HttpStatus = require('http-status-codes') const axios = require('axios') const { getRequestIp } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel('spcp') +const logger = require('../../config/logger').createLoggerWithLabel(module) const { mapDataToKey } = require('../../shared/util/verified-content') const getFormModel = require('../models/form.server.model').default const getLoginModel = require('../models/login.server.model').default @@ -32,7 +32,14 @@ const addLoginToDB = function (form) { esrvcId: form.esrvcId, }) return login.save().catch((err) => { - logger.error('Error adding login to database:', err) + logger.error({ + message: 'Error adding login to database', + meta: { + action: 'addLoginToDB', + formId: form._id, + }, + error: err, + }) }) } @@ -136,7 +143,16 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { } authClient.getAttributes(samlArt, destination, (err, data) => { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Error retrieving attributes from auth client', + meta: { + action: 'handleOOBAuthenticationWith', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) } const { attributes } = data const { userName, userInfo } = extractUser(attributes) @@ -240,7 +256,14 @@ exports.validateESrvcId = (req, res) => { // The error page should have the title 'SingPass - System Error Page' const title = getSubstringBetween(data, '', '') if (title === null) { - logger.error({ Error: 'Could not find title', redirectURL, data }) + logger.error({ + message: 'Could not find title', + meta: { + action: 'validateESrvcId', + redirectUrl: redirectURL, + data, + }, + }) return res.status(HttpStatus.BAD_GATEWAY).send({ message: 'Singpass returned incomprehensible content', }) @@ -265,10 +288,13 @@ exports.validateESrvcId = (req, res) => { .catch((err) => { const { statusCode } = err.response || {} logger.error({ - Error: 'Could not contact singpass to validate eservice id', - redirectURL, - err, - statusCode, + message: 'Could not contact singpass to validate eservice id', + meta: { + action: 'validateESrvcId', + redirectUrl: redirectURL, + statusCode, + }, + error: err, }) return res.status(HttpStatus.SERVICE_UNAVAILABLE).send({ message: 'Failed to contact Singpass', @@ -319,9 +345,19 @@ exports.addSpcpSessionInfo = (authClients) => { // add session info if logged in authClient.verifyJWT(jwt, (err, payload) => { if (err) { - // Do not specify userName to call MyInfo endpoint with if jwt is invalid - // Client will inform the form-filler to log in with SingPass again - logger.error(getRequestIp(req), req.url, req.headers, err) + // Do not specify userName to call MyInfo endpoint with if jwt is + // invalid. + // Client will inform the form-filler to log in with SingPass again. + logger.error({ + message: 'Failed to verify JWT with auth client', + meta: { + action: 'addSpcpSessionInfo', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) } else { const { userName } = payload // For use in addMyInfo middleware @@ -373,11 +409,15 @@ exports.encryptedVerifiedFields = (signingSecretKey) => { res.locals.verified = encryptedVerified return next() } catch (error) { - logger.error( - `Error 400 - Unable to encrypt verified content: formId=${ - req.form._id - } error='${error}' ip=${getRequestIp(req)}`, - ) + logger.error({ + message: 'Unable to encrypt verified content', + meta: { + action: 'encryptedVerifiedFields', + formId: req.form._id, + ip: getRequestIp(req), + }, + error, + }) return res .status(HttpStatus.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) @@ -438,7 +478,16 @@ exports.isSpcpAuthenticated = (authClients) => { let jwt = req.cookies[jwtName] authClient.verifyJWT(jwt, (err, payload) => { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Failed to verify JWT with auth client', + meta: { + action: 'isSpcpAuthenticated', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) res.status(HttpStatus.UNAUTHORIZED).send({ message: 'User is not SPCP authenticated', spcpSubmissionFailure: true, diff --git a/src/app/controllers/submissions.server.controller.js b/src/app/controllers/submissions.server.controller.js index 0de3f8ac7d..232d53b047 100644 --- a/src/app/controllers/submissions.server.controller.js +++ b/src/app/controllers/submissions.server.controller.js @@ -11,9 +11,7 @@ const HttpStatus = require('http-status-codes') const { getRequestIp } = require('../utils/request') const { isMalformedDate, createQueryWithDateParam } = require('../utils/date') -const logger = require('../../config/logger').createLoggerWithLabel( - 'authentication', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const MailService = require('../services/mail.service').default const GOOGLE_RECAPTCHA_URL = 'https://www.google.com/recaptcha/api/siteverify' @@ -34,11 +32,14 @@ exports.captchaCheck = (captchaPrivateKey) => { message: 'Captcha not set-up', }) } else if (!req.query.captchaResponse) { - logger.error( - `[${ - req.form._id - }] Error 400: Missing captchaResponse ip=${getRequestIp(req)}`, - ) + logger.error({ + message: 'Missing captchaResponse param', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Captcha was missing. Please refresh and submit again.', }) @@ -53,11 +54,14 @@ exports.captchaCheck = (captchaPrivateKey) => { }) .then(({ data }) => { if (!data.success) { - logger.error( - `Error 400 - Incorrect captchaResponse: formId=${ - req.form._id - } ip=${getRequestIp(req)}`, - ) + logger.error({ + message: 'Incorrect captcha response', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Captcha was incorrect. Please submit again.', }) @@ -66,12 +70,15 @@ exports.captchaCheck = (captchaPrivateKey) => { }) .catch((err) => { // Problem with the verificationUrl - maybe it timed out? - logger.error( - `[${req.form._id}] Error verifying captcha, ip=${getRequestIp( - req, - )}`, - err, - ) + logger.error({ + message: 'Error verifying captcha', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send({ message: 'Could not verify captcha. Please submit again in a few minutes.', @@ -168,11 +175,16 @@ const sendEmailAutoReplies = async function (req) { autoReplyMailDatas: autoReplyEmails, }) } catch (err) { - logger.error( - `Mail autoreply error for formId=${form._id} submissionId=${ - submission.id - } ip=${getRequestIp(req)}:\t${err}`, - ) + logger.error({ + message: 'Failed to send autoreply emails', + meta: { + action: 'sendEmailAutoReplies', + ip: getRequestIp(req), + formId: req.form._id, + submissionId: submission.id, + }, + error: err, + }) // We do not deal with failed autoreplies return Promise.resolve() } @@ -206,7 +218,17 @@ exports.count = function (req, res) { Submission.countDocuments(query, function (err, count) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) + logger.error({ + message: 'Error counting submission documents from database', + meta: { + action: 'count', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) @@ -226,8 +248,14 @@ exports.sendAutoReply = function (req, res) { // We do not handle failed autoreplies const autoReplies = [sendEmailAutoReplies(req, res)] return Promise.all(autoReplies).catch((err) => { - logger.error( - `Autoreply error for formId=${form._id} submissionId=${submission.id}:\t${err}`, - ) + logger.error({ + message: 'Error sending autoreply', + meta: { + action: 'sendAutoReply', + formId: form._id, + submissionId: submission.id, + }, + error: err, + }) }) } diff --git a/src/app/factories/spcp-myinfo.factory.js b/src/app/factories/spcp-myinfo.factory.js index 8444884b75..4d7979def6 100644 --- a/src/app/factories/spcp-myinfo.factory.js +++ b/src/app/factories/spcp-myinfo.factory.js @@ -9,13 +9,16 @@ const fs = require('fs') const SPCPAuthClient = require('@opengovsg/spcp-auth-client') const { MyInfoGovClient } = require('@opengovsg/myinfo-gov-client') const MyInfoService = require('../services/myinfo.service') -const logger = require('../../config/logger').createLoggerWithLabel( - 'spcp-myinfo-config', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const spcpFactory = ({ isEnabled, props }) => { if (isEnabled && props) { - logger.info('Configuring SingPass client...') + logger.info({ + message: 'Configuring SingPass client...', + meta: { + action: 'spcpFactory', + }, + }) let singPassAuthClient = new SPCPAuthClient({ partnerEntityId: props.spPartnerEntityId, idpLoginURL: props.spIdpLoginUrl, @@ -26,7 +29,12 @@ const spcpFactory = ({ isEnabled, props }) => { spcpCert: fs.readFileSync(props.spIdpCertPath), extract: SPCPAuthClient.extract.SINGPASS, }) - logger.info('Configuring CorpPass client...') + logger.info({ + message: 'Configuring CorpPass client...', + meta: { + action: 'spcpFactory', + }, + }) let corpPassAuthClient = new SPCPAuthClient({ partnerEntityId: props.cpPartnerEntityId, idpLoginURL: props.cpIdpLoginUrl, @@ -37,7 +45,12 @@ const spcpFactory = ({ isEnabled, props }) => { spcpCert: fs.readFileSync(props.cpIdpCertPath), extract: SPCPAuthClient.extract.CORPPASS, }) - logger.info('Configuring MyInfo client...') + logger.info({ + message: 'Configuring MyInfo client...', + meta: { + action: 'spcpFactory', + }, + }) let myInfoConfig = { realm: config.app.title, singpassEserviceId: props.spEsrvcId, @@ -58,9 +71,12 @@ const spcpFactory = ({ isEnabled, props }) => { myInfoConfig.mode = 'stg' myInfoGovClient = new MyInfoGovClient(myInfoConfig) } else { - logger.warn( - `\n!!! WARNING !!!\nNo MyInfo keys detected.\nRequests to MyInfo will not work.\nThis should NEVER be seen in production.\nFalling back on MockPass.`, - ) + logger.warn({ + message: `\n!!! WARNING !!!\nNo MyInfo keys detected.\nRequests to MyInfo will not work.\nThis should NEVER be seen in production.\nFalling back on MockPass.`, + meta: { + action: 'spcpFactory', + }, + }) myInfoConfig.appId = 'STG2-' + myInfoConfig.singpassEserviceId myInfoConfig.privateKey = fs.readFileSync( './node_modules/@opengovsg/mockpass/static/certs/key.pem', diff --git a/src/app/modules/sns/sns.controller.ts b/src/app/modules/sns/sns.controller.ts index 10d0b55a35..6d67d5e9aa 100644 --- a/src/app/modules/sns/sns.controller.ts +++ b/src/app/modules/sns/sns.controller.ts @@ -6,7 +6,7 @@ import { ISnsNotification } from '../../../types' import * as snsService from './sns.service' -const logger = createLoggerWithLabel('sns-controller') +const logger = createLoggerWithLabel(module) /** * Validates that a request came from Amazon SNS, then updates the Bounce * collection. @@ -28,7 +28,13 @@ const handleSns = async ( await snsService.updateBounces(req.body) return res.sendStatus(HttpStatus.OK) } catch (err) { - logger.warn(err) + logger.warn({ + message: 'Error updating bounces', + meta: { + action: 'handleSns', + }, + error: err, + }) return res.sendStatus(HttpStatus.BAD_REQUEST) } } diff --git a/src/app/modules/user/user.controller.ts b/src/app/modules/user/user.controller.ts index c8469e8c9b..4c47c4d1d6 100644 --- a/src/app/modules/user/user.controller.ts +++ b/src/app/modules/user/user.controller.ts @@ -13,7 +13,7 @@ import { verifyContactOtp, } from './user.service' -const logger = createLoggerWithLabel('user-controller') +const logger = createLoggerWithLabel(module) /** * Generates an OTP and sends the OTP to the given contact in request body. @@ -106,10 +106,13 @@ export const handleFetchUser: RequestHandler = async (req, res) => { const [dbErr, retrievedUser] = await to(getPopulatedUserById(sessionUserId)) if (dbErr || !retrievedUser) { - logger.warn( - `handleFetchUser: Unable to retrieve user ${sessionUserId}`, - dbErr, - ) + logger.warn({ + message: `Unable to retrieve user ${sessionUserId}`, + meta: { + action: 'handleFetchUser', + }, + error: dbErr, + }) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send('Unable to retrieve user') diff --git a/src/app/modules/verification/verification.controller.ts b/src/app/modules/verification/verification.controller.ts index 2eb989c717..20c0485176 100644 --- a/src/app/modules/verification/verification.controller.ts +++ b/src/app/modules/verification/verification.controller.ts @@ -6,7 +6,7 @@ import { VfnErrors } from '../../../shared/util/verification' import * as verificationService from './verification.service' -const logger = createLoggerWithLabel('verification') +const logger = createLoggerWithLabel(module) /** * When a form is loaded publicly, a transaction is created, and populated with the field ids of fields that are verifiable. * If no fields are verifiable, then it did not create a transaction and returns an empty object. @@ -27,7 +27,13 @@ export const createTransaction: RequestHandler< ? res.status(HttpStatus.CREATED).json(transaction) : res.sendStatus(HttpStatus.OK) } catch (error) { - logger.error(`createTransaction: ${error}`) + logger.error({ + message: 'Error creating transaction', + meta: { + action: 'createTransaction', + }, + error, + }) return handleError(error, res) } } @@ -46,7 +52,13 @@ export const getTransactionMetadata: RequestHandler<{ ) return res.status(HttpStatus.OK).json(transaction) } catch (error) { - logger.error(`getTransaction: ${error}`) + logger.error({ + message: 'Error retrieving transaction metadata', + meta: { + action: 'getTransactionMetadata', + }, + error, + }) return handleError(error, res) } } @@ -68,7 +80,13 @@ export const resetFieldInTransaction: RequestHandler< await verificationService.resetFieldInTransaction(transaction, fieldId) return res.sendStatus(HttpStatus.OK) } catch (error) { - logger.error(`resetFieldInTransaction: ${error}`) + logger.error({ + message: 'Error resetting field in transaction', + meta: { + action: 'resetFieldInTransaction', + }, + error, + }) return handleError(error, res) } } @@ -90,7 +108,13 @@ export const getNewOtp: RequestHandler< await verificationService.getNewOtp(transaction, fieldId, answer) return res.sendStatus(HttpStatus.CREATED) } catch (error) { - logger.error(`getNewOtp: ${error}`) + logger.error({ + message: 'Error retrieving new OTP', + meta: { + action: 'getNewOtp', + }, + error, + }) return handleError(error, res) } } @@ -113,7 +137,13 @@ export const verifyOtp: RequestHandler< const data = await verificationService.verifyOtp(transaction, fieldId, otp) return res.status(HttpStatus.OK).json(data) } catch (error) { - logger.error(`verifyOtp: ${error}`) + logger.error({ + message: 'Error verifying OTP', + meta: { + action: 'verifyOtp', + }, + error, + }) return handleError(error, res) } } diff --git a/src/app/services/mail.service.ts b/src/app/services/mail.service.ts index bb1b782438..8c976fcbe4 100644 --- a/src/app/services/mail.service.ts +++ b/src/app/services/mail.service.ts @@ -1,8 +1,8 @@ +import { String } from 'aws-sdk/clients/cloudhsm' import { isEmpty } from 'lodash' import moment from 'moment-timezone' import Mail from 'nodemailer/lib/mailer' import validator from 'validator' -import { Logger } from 'winston' import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' @@ -24,7 +24,7 @@ import { isToFieldValid, } from '../utils/mail' -const mailLogger = createLoggerWithLabel('mail') +const logger = createLoggerWithLabel(module) type SendMailOptions = { mailId?: string @@ -53,7 +53,6 @@ type MailServiceParams = { appUrl?: string transporter?: Mail senderMail?: string - logger?: Logger } type AutoReplyMailData = { @@ -102,27 +101,25 @@ export class MailService { * * E.g. `FormSG ` */ - #senderFromString: string - /** - * Logger to log any errors encounted while sending mail. - */ - #logger: Logger + #senderFromString: String constructor({ appName = config.app.title, appUrl = config.app.appUrl, transporter = config.mail.transporter, senderMail = config.mail.mailFrom, - logger = mailLogger, }: MailServiceParams = {}) { - this.#logger = logger - // Email validation if (!validator.isEmail(senderMail)) { const invalidMailError = new Error( `MailService constructor: senderMail: ${senderMail} is not a valid email`, ) - this.#logger.error(invalidMailError) + logger.error({ + message: `senderMail: ${senderMail} is not a valid email`, + meta: { + action: 'constructor', + }, + }) throw invalidMailError } @@ -139,35 +136,51 @@ export class MailService { * @param sendOptions Extra options to better identify mail, such as form or mail id. */ #sendNodeMail = async (mail: MailOptions, sendOptions?: SendMailOptions) => { - const emailLogString = `mailId: ${sendOptions?.mailId}\t Email from:${mail?.from}\t subject:${mail?.subject}\t formId: ${sendOptions?.formId}` + const logMeta = { + action: '#sendNodeMail', + mailId: sendOptions?.mailId, + mailFrom: mail?.from, + mailSubject: mail?.subject, + formId: sendOptions?.formId, + } // Guard against missing mail info. if (!mail || isEmpty(mail.to)) { - this.#logger.error(`mailError: undefined mail. ${emailLogString}`) + logger.error({ + message: 'Undefined mail', + meta: logMeta, + }) return Promise.reject(new Error('Mail undefined error')) } // Guard against invalid emails. if (!isToFieldValid(mail.to)) { - this.#logger.error( - `mailError: ${mail.to} is not a valid email. ${emailLogString}`, - ) + logger.error({ + message: `${mail.to} is not a valid email`, + meta: logMeta, + }) return Promise.reject(new Error('Invalid email error')) } - this.#logger.info(emailLogString) - this.#logger.profile(emailLogString) - try { + logger.info({ + message: 'Attempting to send mail', + meta: logMeta, + }) const response = await this.#transporter.sendMail(mail) - this.#logger.info(`mailSuccess:\t${emailLogString}`) + + logger.info({ + message: 'Mail successfully sent', + meta: logMeta, + }) return response } catch (err) { // Pass errors to the callback - this.#logger.error( - `mailError ${err.responseCode}:\t${emailLogString}`, - err, - ) + logger.error({ + message: 'Send mail failure', + meta: logMeta, + error: err, + }) return Promise.reject(err) } } diff --git a/src/app/services/sms.service.ts b/src/app/services/sms.service.ts index a54da1aa47..ff2bc93d2b 100644 --- a/src/app/services/sms.service.ts +++ b/src/app/services/sms.service.ts @@ -17,7 +17,7 @@ import { import getFormModel from '../models/form.server.model' import getSmsCountModel from '../models/sms_count.server.model' -const logger = createLoggerWithLabel('sms') +const logger = createLoggerWithLabel(module) const SmsCount = getSmsCountModel(mongoose) const Form = getFormModel(mongoose) const secretsManager = new SecretsManager({ region: config.aws.region }) @@ -59,9 +59,14 @@ const getCredentials = async ( } } } catch (err) { - logger.error( - `getCredentials: msgSrvcName="${msgSrvcName}" error="${err.stack}"`, - ) + logger.error({ + message: 'Error retrieving credentials', + meta: { + action: 'getCredentials', + msgSrvcName, + }, + error: err, + }) } return null } @@ -106,13 +111,25 @@ const getTwilio = async ( } // Add it to the cache twilioClientCache.set(msgSrvcName, result) - logger.info(`getTwilio: added msgSrvcName="${msgSrvcName}" to cache`) + logger.info({ + message: `Added ${msgSrvcName} to cache`, + meta: { + action: 'getTwilio', + msgSrvcName, + }, + }) return result } } catch (err) { - logger.error( - `getTwilio: Defaulting to central twilio client. msgSrvcName="${msgSrvcName}" error="${err.stack}"`, - ) + logger.warn({ + message: + 'Failed to retrieve from cache. Defaulting to central Twilio client', + meta: { + action: 'getTwilio', + msgSrvcName, + }, + error: err, + }) } } return defaultConfig @@ -173,15 +190,26 @@ const send = async ( logType: LogType.success, } SmsCount.logSms(logParams).catch((err) => { - logger.error('Error adding smsCount to database: ', err, logParams) + logger.error({ + message: 'Error logging sms count to database', + meta: { + action: 'send', + ...logParams, + }, + error: err, + }) }) return true }) .catch((err) => { - logger.warn( - `send: SMS Message error with errorCode=${err.code} message=${err.message} status=${err.status}`, - ) + logger.error({ + message: 'SMS send error', + meta: { + action: 'send', + }, + error: err, + }) // Log failure const logParams: LogSmsParams = { @@ -191,7 +219,14 @@ const send = async ( logType: LogType.failure, } SmsCount.logSms(logParams).catch((err) => { - logger.error('Error adding smsCount to database: ', err, logParams) + logger.error({ + message: 'Error logging sms count to database', + meta: { + action: 'send', + ...logParams, + }, + error: err, + }) }) // Invalid number error code, throw a more reasonable error for error @@ -220,12 +255,24 @@ const sendVerificationOtp = async ( formId: string, defaultConfig: TwilioConfig, ) => { - logger.info(`sendVerificationOtp: formId="${formId}"`) + logger.info({ + message: `Sending verification OTP for ${formId}`, + meta: { + action: 'sendVerificationOtp', + formId, + }, + }) const otpData = await getOtpDataFromForm(formId) if (!otpData) { - const errMsg = `sendVerificationOtp: Unable to retrieve otpData from formId="${formId}` - logger.error(errMsg) + const errMsg = `Unable to retrieve otpData from ${formId}` + logger.error({ + message: errMsg, + meta: { + action: 'sendVerificationOtp', + formId, + }, + }) throw new Error(errMsg) } const twilioData = await getTwilio(otpData.msgSrvcName, defaultConfig) @@ -241,7 +288,13 @@ const sendAdminContactOtp = async ( userId: string, defaultConfig: TwilioConfig, ) => { - logger.info(`sendAdminContactOtp: userId="${userId}"`) + logger.info({ + message: `Sending admin contact verification OTP for ${userId}`, + meta: { + action: 'sendAdminContactOtp', + userId, + }, + }) const message = `Use the OTP ${otp} to verify your emergency contact number.` diff --git a/src/app/services/webhooks.service.ts b/src/app/services/webhooks.service.ts index 8685cc9c0a..45077effa2 100644 --- a/src/app/services/webhooks.service.ts +++ b/src/app/services/webhooks.service.ts @@ -10,13 +10,12 @@ import { IFormSchema, ISubmissionSchema, IWebhookResponse, - LogWebhookParams, WebhookParams, } from '../../types' import { getEncryptSubmissionModel } from '../models/submission.server.model' import { WebhookValidationError } from '../utils/custom-errors' -const logger = createLoggerWithLabel('webhooks') +const logger = createLoggerWithLabel(module) const EncryptSubmission = getEncryptSubmissionModel(mongoose) /** @@ -103,15 +102,19 @@ const logWebhookSuccess = ( { webhookUrl, submissionId, formId, now, signature }: WebhookParams, ): void => { const status = get(response, 'status') - const loggingParams: LogWebhookParams = { - status, - submissionId, - formId, - now, - webhookUrl, - signature, - } - logger.info(getConsoleMessage('Webhook POST succeeded', loggingParams)) + + logger.info({ + message: 'Webhook POST succeeded', + meta: { + action: 'logWebhookSuccess', + status, + submissionId, + formId, + now, + webhookUrl, + signature, + }, + }) } // Logging for webhook failure @@ -119,20 +122,30 @@ export const logWebhookFailure = ( error: Error | AxiosError, { webhookUrl, submissionId, formId, now, signature }: WebhookParams, ): void => { - const errorMessage = get(error, 'message') - let loggingParams: LogWebhookParams = { + let logMeta = { + action: 'logWebhookFailure', submissionId, formId, now, webhookUrl, signature, - errorMessage, } + if (error instanceof WebhookValidationError) { - logger.error(getConsoleMessage('Webhook not attempted', loggingParams)) + logger.error({ + message: 'Webhook not attempted', + meta: logMeta, + error, + }) } else { - loggingParams.status = get(error, 'response.status') - logger.error(getConsoleMessage('Webhook POST failed', loggingParams)) + logger.error({ + message: 'Webhook POST failed', + meta: { + ...logMeta, + status: get(error, 'response.status'), + }, + error, + }) } } @@ -152,27 +165,19 @@ const updateSubmissionsDb = async ( throw new Error('Submission not found in database.') } } catch (error) { - logger.error( - getConsoleMessage('Database update for webhook status failed', { + logger.error({ + message: 'Database update for webhook status failed', + meta: { + action: 'updateSubmissionsDb', formId, submissionId, updateObj: stringifySafe(updateObj), - dbErrorMessage: get(error, 'message'), - }), - ) + }, + error, + }) } } -// Creates a string with a title, followed by a tab, followed -// by a list of 'key=value' pairs separated by spaces -const getConsoleMessage = (title: string, params: object) => { - let consoleMessage = title + ':\t' - consoleMessage += Object.entries(params) - .map(([key, value]) => key + '=' + value) - .join(' ') - return consoleMessage -} - // Formats webhook success info into an object to update Submissions collection const getSuccessDbUpdate = ( response: AxiosResponse, diff --git a/src/app/utils/field-validation/validators/FieldValidatorInterface.class.js b/src/app/utils/field-validation/validators/FieldValidatorInterface.class.js index 0ff128388e..8cad9b3119 100644 --- a/src/app/utils/field-validation/validators/FieldValidatorInterface.class.js +++ b/src/app/utils/field-validation/validators/FieldValidatorInterface.class.js @@ -1,5 +1,5 @@ const logger = require('../../../../config/logger').createLoggerWithLabel( - 'FieldValidatorInterface', + module, ) /** @@ -41,14 +41,20 @@ class FieldValidatorInterface { */ logIfInvalid(isValid, message) { if (!isValid) { - let stmt = `formId="${this.formId}" fieldId="${this.formField._id}"` + const logMeta = { + action: 'logIfInvalid', + formId: this.formId, + fieldId: this.formField._id, + } if (this.formField.fieldType) { - stmt += ` fieldType="${this.formField.fieldType}"` + logMeta.fieldType = this.formField.fieldType } else if (this.formField.columnType) { - stmt += ` fieldType="table" columnType="${this.formField.columnType}"` + logMeta.columnType = this.formField.columnType } - stmt += ` message="Invalid field: ${message}"` - logger.error(stmt) + logger.error({ + message: `Invalid field: ${message}`, + meta: logMeta, + }) } } @@ -65,9 +71,15 @@ class FieldValidatorInterface { this.formField.fieldType === 'checkbox' ) { if (!Object.prototype.hasOwnProperty.call(this.response, 'answerArray')) { - logger.info( - `formId="${this.formId}" fieldId="${this.formField._id}" fieldType="${this.formField.fieldType}" message="answerArray does not exist"`, - ) + logger.info({ + message: 'answerArray does not exist', + meta: { + action: 'logIfAnswerArrayDoesNotExist', + formId: this.formId, + fieldId: this.formField._id, + fieldType: this.formField.fieldType, + }, + }) } } } diff --git a/src/app/utils/request.ts b/src/app/utils/request.ts index 227dda7d8a..f8e0c1ca0c 100644 --- a/src/app/utils/request.ts +++ b/src/app/utils/request.ts @@ -1,5 +1,5 @@ import { Request } from 'express' export const getRequestIp = (req: Request) => { - return req.get('cf-connecting-ip') || req.ip + return req.get('cf-connecting-ip') ?? req.ip } diff --git a/src/config/config.ts b/src/config/config.ts index 004fbf0219..aa09b3c805 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -10,7 +10,7 @@ import SMTPPool from 'nodemailer/lib/smtp-pool' import defaults from './defaults' import { createLoggerWithLabel } from './logger' -const configLogger = createLoggerWithLabel('config') +const logger = createLoggerWithLabel(module) // Typings type AppConfig = { @@ -115,7 +115,12 @@ const submissionsTopUp = parseInt(process.env.SUBMISSIONS_TOP_UP, 10) || 0 */ const cspReportUri = process.env.CSP_REPORT_URI || 'undefined' if (!process.env.CSP_REPORT_URI) { - configLogger.warn('Content Security Policy reporting is not configured.') + logger.warn({ + message: 'Content Security Policy reporting is not configured.', + meta: { + action: 'init', + }, + }) } /** @@ -123,9 +128,15 @@ if (!process.env.CSP_REPORT_URI) { */ const chromiumBin = process.env.CHROMIUM_BIN if (!chromiumBin) { - throw new Error( - 'Path to Chromium executable missing - please specify in CHROMIUM_BIN environment variable', - ) + const errMsg = + 'Path to Chromium executable missing - please specify in CHROMIUM_BIN environment variable' + logger.error({ + message: errMsg, + meta: { + action: 'init', + }, + }) + throw new Error(errMsg) } /** @@ -141,9 +152,16 @@ if ( !formsgSdkMode || !['staging', 'production', 'development', 'test'].includes(formsgSdkMode) ) { - throw new Error( - 'FORMSG_SDK_MODE not found or invalid. Please specify one of: "staging" | "production" | "development" | "test" in the environment variable.', - ) + const errMsg = + 'FORMSG_SDK_MODE not found or invalid. Please specify one of: "staging" | "production" | "development" | "test" in the environment variable.' + + logger.error({ + message: errMsg, + meta: { + action: 'init', + }, + }) + throw new Error(errMsg) } // Optional environment variables @@ -254,23 +272,25 @@ const mailConfig: MailConfig = (function () { transporter = nodemailer.createTransport(options) } else if (process.env.SES_PORT) { - configLogger.warn( - '\n!!! WARNING !!!', - '\nNo SES credentials detected.', - '\nUsing Nodemailer to send to local SMTP server instead.', - '\nThis should NEVER be seen in production.', - ) + logger.warn({ + message: + '\n!!! WARNING !!!\nNo SES credentials detected.\nUsing Nodemailer to send to local SMTP server instead.\nThis should NEVER be seen in production.', + meta: { + action: 'init.mailConfig', + }, + }) transporter = nodemailer.createTransport({ port: Number(process.env.SES_PORT), ignoreTLS: true, }) } else { - configLogger.warn( - '\n!!! WARNING !!!', - '\nNo SES credentials detected.', - '\nUsing Nodemailer Direct Transport instead.', - '\nThis should NEVER be seen in production.', - ) + logger.warn({ + message: + '\n!!! WARNING !!!\nNo SES credentials detected.\nUsing Nodemailer Direct Transport instead.\nThis should NEVER be seen in production.', + meta: { + action: 'init.mailConfig', + }, + }) // Falls back to direct transport transporter = nodemailer.createTransport(directTransport({})) } diff --git a/src/config/feature-manager/util/FeatureManager.class.ts b/src/config/feature-manager/util/FeatureManager.class.ts index dfcb33b3ad..0a3326b7ed 100644 --- a/src/config/feature-manager/util/FeatureManager.class.ts +++ b/src/config/feature-manager/util/FeatureManager.class.ts @@ -5,7 +5,7 @@ import _ from 'lodash' import { createLoggerWithLabel } from '../../logger' import { FeatureNames, IFeatureManager, RegisterableFeature } from '../types' -const logger = createLoggerWithLabel('feature-manager') +const logger = createLoggerWithLabel(module) convict.addFormat(validator.url) interface RegisteredFeature { @@ -61,11 +61,19 @@ export default class FeatureManager { // Inform whether feature is enabled or not if (isEnabled) { - logger.info(`${name} feature will be enabled on app`) + logger.info({ + message: `${name} feature will be enabled on app`, + meta: { + action: 'register', + }, + }) } else { - logger.warn( - `\n!!! WARNING !!! \nEnv vars for ${name} are not detected. \n${name} feature will be disabled on app`, - ) + logger.warn({ + message: `\n!!! WARNING !!! \nEnv vars for ${name} are not detected. \n${name} feature will be disabled on app`, + meta: { + action: 'register', + }, + }) } } diff --git a/src/config/logger.ts b/src/config/logger.ts index 92a3d50694..b361f48449 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,24 +1,65 @@ -// Configuration for `winston` package. import hasAnsi from 'has-ansi' import omit from 'lodash/omit' +import logform from 'logform' +import path from 'path' +import tripleBeam from 'triple-beam' import { inspect } from 'util' import { v4 as uuidv4 } from 'uuid' -import { format, LoggerOptions, loggers, transports } from 'winston' +import { format, Logger, LoggerOptions, loggers, transports } from 'winston' import WinstonCloudWatch from 'winston-cloudwatch' import defaults from './defaults' +// Params to enforce the logging format. +type CustomLoggerParams = { + message: string + meta: { + action: string + [other: string]: any + } + error?: Error +} + // Cannot use config due to logger being instantiated first, and // having circular dependencies. const isDev = ['development', 'test'].includes(process.env.NODE_ENV) const customCloudWatchGroup = process.env.CUSTOM_CLOUDWATCH_LOG_GROUP const awsRegion = process.env.AWS_REGION || defaults.aws.region -// Config to make winston logging like console logging, allowing multiple -// arguments. +// A variety of helper functions to make winston logging like console logging, +// allowing multiple arguments. // Retrieved from // https://github.com/winstonjs/winston/issues/1427#issuecomment-583199496. +/** + * Hunts for errors in the given object passed to the logger. + * Assigns the `error` key the found error. + * + * Note: Winston relies on their `logform` package for formatting, which in turn + * uses the `triplebeam` package symbols. + */ +const errorHunter = logform.format((info) => { + if (info.error) return info + + const splat = info[tripleBeam.SPLAT as any] || [] + info.error = splat.find((obj: any) => obj instanceof Error) + + return info +}) + +/** + * Formats the error in the transformable info to a console.error-like format. + */ +const errorPrinter = logform.format((info) => { + if (!info.error) return info + + // Handle case where Error has no stack. + const errorMsg = info.error.stack || info.error.toString() + info.message += `\n${errorMsg}` + + return info +}) + /** * Standard function to check if the passed in value is a primitive type. * @param val the value to check @@ -54,7 +95,6 @@ const formatWithInspect = (val: any): string => { * string representation, mainly used for console logging. */ export const customFormat = format.printf((info) => { - const stackTrace = info.stack ? `\n${info.stack}` : '' let duration = info.durationMs || info.responseTime duration = duration ? `duration=${duration}ms ` : '' @@ -63,7 +103,7 @@ export const customFormat = format.printf((info) => { const obj = omit(info, ['level', 'timestamp', Symbol.for('level')]) return `${info.timestamp} ${info.level} [${ info.label - }]: ${formatWithInspect(obj)} ${duration}${stackTrace}` + }]: ${formatWithInspect(obj)} ${duration}` } // Handle multiple arguments passed into logger @@ -75,9 +115,39 @@ export const customFormat = format.printf((info) => { const rest = splatArgs.map((data: any) => formatWithInspect(data)).join(' ') const msg = formatWithInspect(info.message) - return `${info.timestamp} ${info.level} [${info.label}]: ${msg} ${duration}${rest}${stackTrace}` + return `${info.timestamp} ${info.level} [${info.label}]: ${msg}\t${duration}\t${rest}` }) +/** + * This is required as JSON.stringify(new Error()) returns an empty object. This + * function converts the error in `info.error` into a readable JSON stack trace. + * + * Function courtesy of + * https://github.com/winstonjs/winston/issues/1243#issuecomment-463548194. + */ +const jsonErrorReplacer = (_key: never, value: any) => { + if (value instanceof Error) { + return Object.getOwnPropertyNames(value).reduce((all, valKey) => { + if (valKey === 'stack') { + return { + ...all, + at: value[valKey] + .split('\n') + .filter((va) => va.trim().slice(0, 5) != 'Error') + .map((va, i) => `stack ${i} ${va.trim().slice(3).trim()}`), + } + } else { + return { + ...all, + [valKey]: value[valKey], + } + } + }, {}) + } else { + return value + } +} + /** * Creates logger options for use by winston. * @param label The label of the logger @@ -87,9 +157,13 @@ const createLoggerOptions = (label: string): LoggerOptions => { return { level: 'debug', format: format.combine( + format.errors({ stack: true }), format.label({ label }), format.timestamp(), - isDev ? format.combine(format.colorize(), customFormat) : format.json(), + errorHunter(), + isDev + ? format.combine(format.colorize(), errorPrinter(), customFormat) + : format.json({ replacer: jsonErrorReplacer }), ), transports: [ new transports.Console({ @@ -101,12 +175,49 @@ const createLoggerOptions = (label: string): LoggerOptions => { } /** - * Create a new winston logger with given label - * @param label The label of the logger + * Returns a label from the given module + * @example loaders/index.ts + * @param callingModule + */ +const getModuleLabel = (callingModule: NodeModule) => { + // Remove the file extension from the filename and split with path separator. + const parts = callingModule.filename.replace(/\.[^/.]+$/, '').split(path.sep) + // Join the last two parts of the file path together. + return path.join(parts[parts.length - 2], parts.pop()) +} + +/** + * Overrides the given winston logger with a new signature, so as to enforce a + * log format. + * @param logger the logger to override + */ +const createCustomLogger = (logger: Logger) => { + return { + info: ({ message, meta }: Omit) => + logger.info(message, { meta }), + warn: ({ message, meta, error }: CustomLoggerParams) => { + if (error) { + return logger.warn(message, { meta }, error) + } + return logger.warn(message, { meta }) + }, + error: ({ message, meta, error }: CustomLoggerParams) => { + if (error) { + return logger.error(message, { meta }, error) + } + return logger.error(message, { meta }) + }, + } +} + +/** + * Create a new winston logger with given module + * @param callingModule the module to create a logger for */ -export const createLoggerWithLabel = (label: string) => { +export const createLoggerWithLabel = (callingModule: NodeModule) => { + const label = getModuleLabel(callingModule) loggers.add(label, createLoggerOptions(label)) - return loggers.get(label) + return createCustomLogger(loggers.get(label)) } /** diff --git a/src/loaders/express/error-handler.ts b/src/loaders/express/error-handler.ts index 14dd381fd9..b586ed5a46 100644 --- a/src/loaders/express/error-handler.ts +++ b/src/loaders/express/error-handler.ts @@ -5,7 +5,7 @@ import get from 'lodash/get' import { createLoggerWithLabel } from '../../config/logger' -const expressLogger = createLoggerWithLabel('express') +const logger = createLoggerWithLabel(module) const errorHandlerMiddlewares = () => { // Assume 'not found' in the error msgs is a 404. this is somewhat silly, but valid, you can do whatever you like, set properties, use instanceof etc. @@ -35,12 +35,17 @@ const errorHandlerMiddlewares = () => { ) // formId is only present for Joi validated routes that require it let formId = get(req, 'form._id', null) - expressLogger.error( - `Joi validation error: form=${formId} error=${errorMessage}`, - ) + logger.error({ + message: 'Joi validation error', + meta: { + action: 'genericErrorHandlerMiddleware', + formId, + }, + error: err, + }) return res.status(HttpStatus.BAD_REQUEST).send(errorMessage) } - expressLogger.error(err) + logger.error(err) return res .status(HttpStatus.INTERNAL_SERVER_ERROR) .send({ message: genericErrorMessage }) diff --git a/src/loaders/index.ts b/src/loaders/index.ts index 40d6d88607..45623dd071 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -3,14 +3,24 @@ import { createLoggerWithLabel } from '../config/logger' import expressLoader from './express' import mongooseLoader from './mongoose' -const logger = createLoggerWithLabel('server') +const logger = createLoggerWithLabel(module) export default async () => { const connection = await mongooseLoader() - logger.info('MongoDB Intialized') + logger.info({ + message: 'MongoDB Initialized', + meta: { + action: 'init', + }, + }) const app = expressLoader(connection) - logger.info('Express Intialized') + logger.info({ + message: 'Express Initialized', + meta: { + action: 'init', + }, + }) return app } diff --git a/src/loaders/mongoose.ts b/src/loaders/mongoose.ts index 99336bc1d0..e1eea03469 100644 --- a/src/loaders/mongoose.ts +++ b/src/loaders/mongoose.ts @@ -4,21 +4,27 @@ import mongoose, { Connection } from 'mongoose' import config from '../config/config' import { createLoggerWithLabel } from '../config/logger' -const logger = createLoggerWithLabel('mongoose') +const logger = createLoggerWithLabel(module) export default async (): Promise => { let usingMockedDb = config.db.uri === undefined // Mock out the database if we're developing locally if (usingMockedDb) { - logger.warn( - '\n!!! WARNING !!!', - '\nNo database configuration detected', - '\nUsing mock development database instead.', - '\nThis should NEVER be seen in production.', - ) + logger.warn({ + message: + '!!! WARNING !!!\nNo database configuration detected\nUsing mock development database instead.\nThis should NEVER be seen in production.', + meta: { + action: 'init', + }, + }) if (!process.env.MONGO_BINARY_VERSION) { - logger.error('Environment var MONGO_BINARY_VERSION is missing') + logger.error({ + message: 'Environment var MONGO_BINARY_VERSION is missing', + meta: { + action: 'init', + }, + }) process.exit(1) } @@ -48,14 +54,31 @@ export default async (): Promise => { mongoose.connection.on('error', (err) => { // No need to reconnect here since mongo config has auto reconnect, we log. - logger.error('@MongoDB: DB error:', err) + logger.error({ + message: '@MongoDB: DB connection error', + meta: { + action: 'init', + }, + error: err, + }) }) mongoose.connection.on('open', function () { - logger.info('@MongoDB: DB connected') + logger.info({ + message: '@MongoDB: DB connected', + meta: { + action: 'init', + }, + }) }) + mongoose.connection.on('close', function (str) { - logger.info('@MongoDB: DB disconnected: ' + str) + logger.info({ + message: `@MongoDB: DB disconnected: ${str}`, + meta: { + action: 'init', + }, + }) }) // Seed the db with govtech agency if using the mocked db diff --git a/src/server.ts b/src/server.ts index 11cbdf047c..2339986064 100755 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import config from './config/config' import { createLoggerWithLabel } from './config/logger' import loadApp from './loaders' -const logger = createLoggerWithLabel('server') +const logger = createLoggerWithLabel(module) const initServer = async () => { const app = await loadApp() @@ -12,10 +12,13 @@ const initServer = async () => { await config.configureAws() app.listen(config.port) - logger.info('--') - logger.info('Environment: ' + config.nodeEnv) - logger.info('Port: ' + config.port) - logger.info('--') + + logger.info({ + message: `[${config.nodeEnv}] Connected to port ${config.port}`, + meta: { + action: 'initServer', + }, + }) } initServer() diff --git a/src/types/webhook.ts b/src/types/webhook.ts index 3010594240..0fe4aa8299 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -9,13 +9,3 @@ export type WebhookParams = { now: number signature: string } - -export type LogWebhookParams = { - submissionId: ISubmissionSchema['_id'] - formId: IFormSchema['_id'] - now: number - webhookUrl: string - signature: string - status?: number - errorMessage?: string -} diff --git a/tests/unit/backend/.eslintrc b/tests/unit/backend/.eslintrc index 5a9b68ebee..8e33c17583 100644 --- a/tests/unit/backend/.eslintrc +++ b/tests/unit/backend/.eslintrc @@ -8,5 +8,11 @@ "spec": true, "expectAsync": true }, - "overrides": [{ "files": ["*.ts"], "extends": ["plugin:jest/recommended"] }] + "overrides": [ + { + "files": ["*.ts"], + "env": { "jest": true, "jasmine": false }, + "extends": ["plugin:jest/recommended"] + } + ] } diff --git a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js index d3aa045332..fff894ab1f 100644 --- a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js @@ -45,6 +45,7 @@ describe('Admin-Forms Controller', () => { }, headers: {}, ip: '127.0.0.1', + get: () => {}, } }) diff --git a/tests/unit/backend/services/webhook.service.spec.ts b/tests/unit/backend/services/webhook.service.spec.ts index 6e46920215..5b40e1d804 100644 --- a/tests/unit/backend/services/webhook.service.spec.ts +++ b/tests/unit/backend/services/webhook.service.spec.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios' import { ObjectID } from 'bson' import mongoose from 'mongoose' import dbHandler from 'tests/unit/backend/helpers/jest-db' +import { mocked } from 'ts-jest/utils' import { getEncryptedFormModel } from 'src/app/models/form.server.model' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' @@ -13,6 +14,9 @@ import { import { WebhookValidationError } from 'src/app/utils/custom-errors' import { ResponseMode, SubmissionType } from 'src/types' +jest.mock('axios') +const mockAxios = mocked(axios, true) + const EncryptForm = getEncryptedFormModel(mongoose) const EncryptSubmission = getEncryptSubmissionModel(mongoose) @@ -161,15 +165,17 @@ describe('WebhooksService', () => { describe('postWebhook', () => { it('should make post request with valid parameters', async () => { // Arrange - const spyAxios = spyOn(axios, 'post') - // Act and Assert - spyAxios.and.callFake((url, data, config) => { + mockAxios.post.mockImplementationOnce((url, data, config) => { expect(url).toEqual(MOCK_WEBHOOK_URL) expect(data).toEqual(testSubmissionWebhookView) expect(config).toEqual(testConfig) - return MOCK_SUCCESS_RESPONSE + return Promise.resolve(MOCK_SUCCESS_RESPONSE) }) + + // Act const response = await postWebhook(testWebhookParam) + + // Assert expect(response).toEqual(MOCK_SUCCESS_RESPONSE) }) }) From 19dfddfcababee58940f2b9f33a233e7ef34f9cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2020 16:41:01 +0800 Subject: [PATCH 09/26] chore(deps-dev): bump eslint from 6.8.0 to 7.7.0 (#220) Bumps [eslint](https://github.com/eslint/eslint) from 6.8.0 to 7.7.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/master/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v6.8.0...v7.7.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 382 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 189 insertions(+), 195 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b70e02c51..9bab0f4f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8210,12 +8210,6 @@ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", "dev": true }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, "check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", @@ -8450,12 +8444,6 @@ } } }, - "cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", - "dev": true - }, "clipboard": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz", @@ -11006,22 +10994,23 @@ } }, "eslint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", - "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.7.0.tgz", + "integrity": "sha512-1KUxLzos0ZVsyL81PnRN335nDtQ8/vZUD6uMtWbF+5zDtjKcsklIi78XoE0MVL93QvWTu+E5y44VyyCsOMBrIg==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "ajv": "^6.10.0", - "chalk": "^2.1.0", - "cross-spawn": "^6.0.5", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", "debug": "^4.0.1", "doctrine": "^3.0.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^1.4.3", - "eslint-visitor-keys": "^1.1.0", - "espree": "^6.1.2", - "esquery": "^1.0.1", + "enquirer": "^2.3.5", + "eslint-scope": "^5.1.0", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^1.3.0", + "espree": "^7.2.0", + "esquery": "^1.2.0", "esutils": "^2.0.2", "file-entry-cache": "^5.0.1", "functional-red-black-tree": "^1.0.1", @@ -11030,30 +11019,28 @@ "ignore": "^4.0.6", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", - "inquirer": "^7.0.0", "is-glob": "^4.0.0", "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.3.0", - "lodash": "^4.17.14", + "levn": "^0.4.1", + "lodash": "^4.17.19", "minimatch": "^3.0.4", - "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", - "optionator": "^0.8.3", + "optionator": "^0.9.1", "progress": "^2.0.0", - "regexpp": "^2.0.1", - "semver": "^6.1.2", - "strip-ansi": "^5.2.0", - "strip-json-comments": "^3.0.1", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", "table": "^5.2.3", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, "dependencies": { "ajv": { - "version": "6.12.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", - "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz", + "integrity": "sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -11063,11 +11050,57 @@ } }, "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -11077,14 +11110,11 @@ "ms": "^2.1.1" } }, - "eslint-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true }, "globals": { "version": "12.4.0", @@ -11095,38 +11125,116 @@ "type-fest": "^0.8.1" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, - "regexpp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", "dev": true, "requires": { - "ansi-regex": "^4.1.0" + "ansi-regex": "^5.0.0" } }, "strip-json-comments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", - "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } } } }, @@ -11425,14 +11533,28 @@ } }, "espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz", + "integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==", "dev": true, "requires": { - "acorn": "^7.1.1", + "acorn": "^7.4.0", "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "acorn": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz", + "integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==", + "dev": true + }, + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } } }, "esprima": { @@ -11450,9 +11572,9 @@ }, "dependencies": { "estraverse": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz", - "integrity": "sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", "dev": true } } @@ -11852,17 +11974,6 @@ } } }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, "extglob": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", @@ -13843,111 +13954,6 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" }, - "inquirer": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz", - "integrity": "sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^3.0.0", - "cli-cursor": "^3.1.0", - "cli-width": "^2.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.15", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.5.3", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -18929,12 +18935,6 @@ "integrity": "sha512-yL5VE97+OXn4+Er3THSmTdCFCtx5hHWzrolvH+JObZnUYwuaG7XV+Ch4fR2cIrcYI0tFHxS7iyFYl14bW8y2sA==", "dev": true }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true - }, "nan": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", @@ -21913,12 +21913,6 @@ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", "dev": true }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true - }, "run-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", diff --git a/package.json b/package.json index 0bd5e6105d..227db6d9f4 100644 --- a/package.json +++ b/package.json @@ -197,7 +197,7 @@ "coveralls": "^3.1.0", "css-loader": "^2.1.1", "env-cmd": "^10.1.0", - "eslint": "^6.8.0", + "eslint": "^7.7.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-html": "^6.0.2", From 6bc47886f6d61754827eb9f624d5d75c0529e5f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2020 16:42:11 +0800 Subject: [PATCH 10/26] fix(deps): bump opossum from 5.0.0 to 5.0.1 (#221) Bumps [opossum](https://github.com/nodeshift/opossum) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/nodeshift/opossum/releases) - [Changelog](https://github.com/nodeshift/opossum/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeshift/opossum/compare/v5.0.0...v5.0.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bab0f4f5b..5fec33ebfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19650,9 +19650,9 @@ } }, "opossum": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/opossum/-/opossum-5.0.0.tgz", - "integrity": "sha512-wW5h73TzpDZ3XsdbxQhF3RXXZxHZb5pVDCo3QCe6c2OOd8RupD4XUYXtNCRKoyebde2+MdX2PFR6dQwAmloRkA==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/opossum/-/opossum-5.0.1.tgz", + "integrity": "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg==" }, "optimist": { "version": "0.6.1", diff --git a/package.json b/package.json index 227db6d9f4..f1c3cca1d6 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "node-jose": "^1.0.0", "nodemailer": "^6.4.11", "nodemailer-direct-transport": "~3.3.2", - "opossum": "^5.0.0", + "opossum": "^5.0.1", "proxyquire": "^2.1.0", "puppeteer-core": "^5.2.1", "raven-js": "^3.27.0", From b60ed8054e3468a2bdb07cacb43a6ebac0c23759 Mon Sep 17 00:00:00 2001 From: Frank Chen Date: Sat, 29 Aug 2020 23:51:25 -0700 Subject: [PATCH 11/26] feat: Bulk download of storage mode attachments in a zip file (#141) * feat: Bulk download of storage mode attachments in a zip file * Switch fetch to and unify download and decrypt code path to Submissions controller * Remove extraneous import * Fix off-by-one error in questionCount * feat(filename): have filename start with RefNo instead of Response * fix lint errors Co-authored-by: liangyuanruo --- .../response-attachment.client.component.js | 22 ++++----- .../view-responses.client.controller.js | 47 ++++++++++++++++++- .../forms/admin/css/view-responses.css | 9 +++- .../views/view-responses.client.view.html | 9 ++++ .../services/submissions.client.factory.js | 16 +++++++ 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/public/modules/forms/admin/components/response-components/response-attachment.client.component.js b/src/public/modules/forms/admin/components/response-components/response-attachment.client.component.js index cc4b675710..35476b7b83 100644 --- a/src/public/modules/forms/admin/components/response-components/response-attachment.client.component.js +++ b/src/public/modules/forms/admin/components/response-components/response-attachment.client.component.js @@ -1,5 +1,4 @@ const { triggerFileDownload } = require('../../../helpers/util') -const { decode: decodeBase64 } = require('@stablelib/base64') angular.module('forms').component('responseAttachmentComponent', { templateUrl: @@ -9,23 +8,22 @@ angular.module('forms').component('responseAttachmentComponent', { encryptionKey: '<', }, controllerAs: 'vm', - controller: ['FormSgSdk', '$timeout', responseAttachmentComponentController], + controller: [ + 'Submissions', + '$timeout', + responseAttachmentComponentController, + ], }) -function responseAttachmentComponentController(FormSgSdk, $timeout) { +function responseAttachmentComponentController(Submissions, $timeout) { const vm = this vm.downloadAndDecryptAttachment = function () { vm.hasDownloadError = false - fetch(vm.field.downloadUrl) - .then((response) => response.json()) - .then((data) => { - data.encryptedFile.binary = decodeBase64(data.encryptedFile.binary) - return FormSgSdk.crypto.decryptFile( - vm.encryptionKey.secretKey, - data.encryptedFile, - ) - }) + Submissions.downloadAndDecryptAttachment( + vm.field.downloadUrl, + vm.encryptionKey.secretKey, + ) .then((bytesArray) => { // Construct a downloadable link and click on it to download the file let blob = new Blob([bytesArray]) diff --git a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js index 437d8ceb4b..0c46d4c9a6 100644 --- a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js +++ b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js @@ -1,6 +1,8 @@ 'use strict' const processDecryptedContent = require('../../helpers/process-decrypted-content') +const { triggerFileDownload } = require('../../helpers/util') +const JSZip = require('jszip') const SHOW_PROGRESS_DELAY_MS = 3000 @@ -38,6 +40,7 @@ function ViewResponsesController( vm.isEncryptResponseMode = vm.myform.responseMode === responseModeEnum.ENCRYPT vm.encryptionKey = null // will be set to an instance of EncryptionKey when form is unlocked successfully vm.csvDownloading = false // whether CSV export is in progress + vm.attachmentDownloadUrls = new Map() // Three views: // 1 - Unlock view for verifying form password @@ -171,8 +174,9 @@ function ViewResponsesController( submissionId, }).then((response) => { if (vm.encryptionKey !== null) { - const { content, verified, attachmentMetadata } = response + vm.attachmentDownloadUrls = new Map() + const { content, verified, attachmentMetadata } = response let displayedContent try { @@ -200,6 +204,10 @@ function ViewResponsesController( } // Populate S3 presigned URL for attachments if (attachmentMetadata[field._id]) { + vm.attachmentDownloadUrls.set(questionCount - 1, { + url: attachmentMetadata[field._id], + filename: field.answer, + }) field.downloadUrl = attachmentMetadata[field._id] } }) @@ -215,6 +223,43 @@ function ViewResponsesController( }) } + vm.downloadAllAttachments = function () { + var zip = new JSZip() + let downloadPromises = [] + + for (const [questionNum, metadata] of vm.attachmentDownloadUrls) { + downloadPromises.push( + Submissions.downloadAndDecryptAttachment( + metadata.url, + vm.encryptionKey.secretKey, + ).then((bytesArray) => { + zip.file( + 'Question ' + questionNum + ' - ' + metadata.filename, + bytesArray, + ) + }), + ) + } + + Promise.all(downloadPromises) + .then(() => { + zip.generateAsync({ type: 'blob' }).then((blob) => { + triggerFileDownload( + blob, + 'RefNo ' + + vm.tableParams.data[vm.currentResponse.index].refNo + + '.zip', + ) + }) + }) + .catch((error) => { + console.error(error) + Toastr.error( + 'An error occurred while downloading the attachments in a ZIP file. Try downloading them separately.', + ) + }) + } + vm.nextRespondent = function () { if (vm.currentResponse.number <= 1) { // This is the last response diff --git a/src/public/modules/forms/admin/css/view-responses.css b/src/public/modules/forms/admin/css/view-responses.css index 46978472b5..8ff6086fbb 100644 --- a/src/public/modules/forms/admin/css/view-responses.css +++ b/src/public/modules/forms/admin/css/view-responses.css @@ -455,10 +455,17 @@ padding: 30px 25px; } -#responses-tab #detailed-responses #respondent-timestamp div.row:first-child { +#responses-tab + #detailed-responses + #respondent-timestamp + div.row:not(:last-child) { padding-bottom: 10px; } +#responses-tab #detailed-responses #respondent-timestamp a { + cursor: pointer; +} + #responses-tab #detailed-responses #question-answer-grp { padding-top: 48px; } diff --git a/src/public/modules/forms/admin/views/view-responses.client.view.html b/src/public/modules/forms/admin/views/view-responses.client.view.html index 5006c43cf5..f8759cf922 100644 --- a/src/public/modules/forms/admin/views/view-responses.client.view.html +++ b/src/public/modules/forms/admin/views/view-responses.client.view.html @@ -168,6 +168,15 @@ {{ vm.decryptedResponse.submissionTime }}
+
diff --git a/src/public/modules/forms/services/submissions.client.factory.js b/src/public/modules/forms/services/submissions.client.factory.js index 4b3b1306b7..b51d1fffd4 100644 --- a/src/public/modules/forms/services/submissions.client.factory.js +++ b/src/public/modules/forms/services/submissions.client.factory.js @@ -7,6 +7,7 @@ const { fixParamsToUrl } = require('../helpers/util') const ndjsonStream = require('../helpers/ndjsonStream') const fetchStream = require('fetch-readablestream') const { forOwn } = require('lodash') +const { decode: decodeBase64 } = require('@stablelib/base64') const NUM_OF_METADATA_ROWS = 4 @@ -19,6 +20,7 @@ angular '$window', 'GTag', 'responseModeEnum', + 'FormSgSdk', SubmissionsFactory, ]) @@ -29,6 +31,7 @@ function SubmissionsFactory( $window, GTag, responseModeEnum, + FormSgSdk, ) { const submitAdminUrl = '/:formId/adminform/submissions' const publicSubmitUrl = '/v2/submissions/:responseMode/:formId' @@ -173,6 +176,19 @@ function SubmissionsFactory( ) return deferred.promise }, + /** + * Triggers a download of a single attachment when given an S3 presigned url and a secretKey + * @param {String} url URL pointing to the location of the encrypted attachment + * @param {String} secretKey An instance of EncryptionKey for decrypting the attachment + * @returns {Promise} A Promise containing the contents of the file as a Blob + */ + downloadAndDecryptAttachment: function (url, secretKey) { + return $http.get(url).then((response) => { + let data = response.data + data.encryptedFile.binary = decodeBase64(data.encryptedFile.binary) + return FormSgSdk.crypto.decryptFile(secretKey, data.encryptedFile) + }) + }, /** * Triggers a download of file responses when called * @param {String} params.formId ID of the form From f54a99e774f66c12754945b56d3b7298e2113862 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2020 09:51:03 +0800 Subject: [PATCH 12/26] chore(deps-dev): bump @types/mongoose from 5.7.25 to 5.7.36 (#230) Bumps [@types/mongoose](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mongoose) from 5.7.25 to 5.7.36. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mongoose) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91081ba08c..e734e62497 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4906,9 +4906,9 @@ } }, "@types/mongodb": { - "version": "3.5.22", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.22.tgz", - "integrity": "sha512-5rfIExCN1hfyey8OEgpeRSwQyjLcMegtmO54Ag3KQLbRT41ydob3BYhb6uEIsTm7NbZqYPb2k+21Nw9sjON18w==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.26.tgz", + "integrity": "sha512-p0X2VJgIBNHfNBdZdzzG8eQ/3bf6mQoXDT0UhVyVEdSzXEa1+2pFcwGvEZp72sjztyBwfRKlgrXMjCVavLcuGg==", "dev": true, "requires": { "@types/bson": "*", @@ -4916,9 +4916,9 @@ } }, "@types/mongoose": { - "version": "5.7.25", - "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.7.25.tgz", - "integrity": "sha512-GKCTqiAdpKqVoZHPgYcx2pEjlyS2B+SSvjnEwk+/wSglGnhO2W5W5w8+9wdtO5Rl2c0xBMGtGmng7XmTmDKOvg==", + "version": "5.7.36", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.7.36.tgz", + "integrity": "sha512-ggFXgvkHgCNlT35B9d/heDYfSqOSwTmQjkRoR32sObGV5Xjd0N0WWuYlLzqeCg94j4hYN/OZxZ1VNNLltX/IVQ==", "dev": true, "requires": { "@types/mongodb": "*", diff --git a/package.json b/package.json index 0ebfca43e5..9be7aeb789 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "@types/helmet": "0.0.47", "@types/ip": "^1.1.0", "@types/jest": "^26.0.9", - "@types/mongoose": "^5.7.24", + "@types/mongoose": "^5.7.36", "@types/node": "^14.0.13", "@types/nodemailer": "^6.4.0", "@types/nodemailer-direct-transport": "^1.0.31", From 545915d7dd1662b0a48c437d08d0ee751a907579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2020 10:39:38 +0800 Subject: [PATCH 13/26] fix(deps): bump axios from 0.19.2 to 0.20.0 (#218) Bumps [axios](https://github.com/axios/axios) from 0.19.2 to 0.20.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.19.2...v0.20.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 23 +++++++++++++++++++---- package.json | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e734e62497..27cbe53e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6112,11 +6112,18 @@ "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==" }, "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", + "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", "requires": { - "follow-redirects": "1.5.10" + "follow-redirects": "^1.10.0" + }, + "dependencies": { + "follow-redirects": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + } } }, "axios-mock-adapter": { @@ -25210,6 +25217,14 @@ "xmlbuilder": "^13.0.2" }, "dependencies": { + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, "qs": { "version": "6.9.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", diff --git a/package.json b/package.json index 9be7aeb789..da2cd201dd 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "async": "~1.5.2", "await-to-js": "^2.1.1", "aws-sdk": "^2.734.0", - "axios": "^0.19.2", + "axios": "^0.20.0", "bcrypt": "^5.0.0", "bluebird": "^3.5.2", "body-parser": "^1.18.3", From 1f83dbd4c8c9b0ed0522093514109f6d2392a3c0 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Mon, 31 Aug 2020 10:56:24 +0800 Subject: [PATCH 14/26] feat: mailto option after form activation (#213) * refactor: convert MailTo to service * feat: pass toast options to updateFormStatusAndSave * style: add line break after form url * feat: allow skip linky * feat: add mailto url in long toastr * style: make url blue * chore: lint * feat: use translations --- src/public/main.js | 1 + src/public/modules/core/css/core.css | 5 ++ .../activate-form-modal.client.controller.js | 32 +++++++++++-- .../create-form-modal.client.controller.js | 45 +++++------------ .../settings-form.client.directive.js | 8 +++- .../forms/services/mailto.client.factory.js | 48 +++++++++++++++++++ .../forms/services/toastr.client.factory.js | 6 ++- 7 files changed, 104 insertions(+), 41 deletions(-) create mode 100644 src/public/modules/forms/services/mailto.client.factory.js diff --git a/src/public/main.js b/src/public/main.js index 34a0430d49..69d0f14e4f 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -273,6 +273,7 @@ require('./modules/forms/services/rating.client.service.js') require('./modules/forms/services/betas.client.factory.js') require('./modules/forms/services/verification.client.factory.js') require('./modules/forms/services/captcha.client.service.js') +require('./modules/forms/services/mailto.client.factory.js') /** * Users module diff --git a/src/public/modules/core/css/core.css b/src/public/modules/core/css/core.css index 7eddef8403..38e7a2287e 100755 --- a/src/public/modules/core/css/core.css +++ b/src/public/modules/core/css/core.css @@ -583,6 +583,11 @@ body #toast-container > .toast-error > .toast-message > a { text-decoration: underline; } +body #toast-container > .toast-success > .toast-message > a { + color: #2560eb; + text-decoration: underline; +} + /* Custom css for icons */ i.glyphicon-question-sign { diff --git a/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js b/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js index 3c758b3ac2..404c131053 100644 --- a/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js @@ -1,3 +1,5 @@ +const dedent = require('dedent-js') + angular .module('forms') .controller('ActivateFormController', [ @@ -5,6 +7,7 @@ angular '$timeout', 'SpcpValidateEsrvcId', 'externalScope', + 'MailTo', ActivateFormController, ]) @@ -13,8 +16,9 @@ function ActivateFormController( $timeout, SpcpValidateEsrvcId, externalScope, + MailTo, ) { - let { updateFormStatusAndSave, checks } = externalScope + let { updateFormStatusAndSave, checks, formParams } = externalScope const vm = this @@ -110,10 +114,28 @@ function ActivateFormController( if (encryptionKey !== null) { vm.remainingChecks-- vm.savingStatus = 1 - updateFormStatusAndSave('Congrats! Your form has gone live.').then(() => { - vm.savingStatus = 2 - vm.closeActivateFormModal() - }) + MailTo.generateMailToUri( + formParams.title, + encryptionKey.secretKey, + formParams._id, + ) + .then((mailToUri) => { + const toastMessage = dedent` + Congrats! Your form has gone live.
+ + Email secret key to colleagues for safekeeping. + + ` + return updateFormStatusAndSave(toastMessage, { + timeOut: 10000, + extendedTimeOut: 10000, + skipLinky: true, + }) + }) + .then(() => { + vm.savingStatus = 2 + vm.closeActivateFormModal() + }) } } } diff --git a/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js b/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js index c70299efae..1e29a581cd 100644 --- a/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js @@ -2,7 +2,6 @@ const { triggerFileDownload } = require('../../helpers/util') const { templates } = require('../constants/covid19') const Form = require('../../viewmodels/Form.class') -const dedent = require('dedent-js') /** * Determine the form title when duplicating a form @@ -34,7 +33,6 @@ angular '$state', '$timeout', '$uibModal', - '$window', 'Toastr', 'responseModeEnum', 'createFormModalOptions', @@ -44,6 +42,7 @@ angular 'GTag', 'FormSgSdk', 'externalScope', + 'MailTo', CreateFormModalController, ]) @@ -52,7 +51,6 @@ function CreateFormModalController( $state, $timeout, $uibModal, - $window, Toastr, responseModeEnum, createFormModalOptions, @@ -62,6 +60,7 @@ function CreateFormModalController( GTag, FormSgSdk, externalScope, + MailTo, ) { const vm = this @@ -220,13 +219,17 @@ function CreateFormModalController( vm.formData.responseMode === responseModeEnum.ENCRYPT ) { vm.formStatus = 2 - $timeout(() => { - const { publicKey, secretKey } = FormSgSdk.crypto.generate() - vm.publicKey = publicKey - vm.secretKey = secretKey - vm.mailToUri = generateMailToUri(vm.formData.title, secretKey) - vm.formStatus = 3 - }) + const { publicKey, secretKey } = FormSgSdk.crypto.generate() + vm.publicKey = publicKey + vm.secretKey = secretKey + MailTo.generateMailToUri(vm.formData.title, secretKey).then( + (mailToUri) => { + $timeout(() => { + vm.mailToUri = mailToUri + vm.formStatus = 3 + }) + }, + ) } else if ( (vm.formStatus === 1 && vm.formData.responseMode === responseModeEnum.EMAIL) || @@ -301,28 +304,6 @@ function CreateFormModalController( } } - const generateMailToUri = (title, secretKey) => { - return ( - 'mailto:?subject=' + - $window.encodeURIComponent(`Shared Secret Key for ${title}`) + - '&body=' + - $window.encodeURIComponent( - dedent` - Dear collaborator, - - I am sharing my form's secret key with you for safekeeping and backup. This is an important key that is needed to access all form responses. - - Form title: ${title} - Secret key: ${secretKey} - - All you need to do is keep this email as a record, and please do not share this key with anyone else. - - Thank you for helping to safekeep my form! - `, - ) - ) - } - vm.handleMailToClick = () => { GTag.clickSecretKeyMailto(vm.formData.title) } diff --git a/src/public/modules/forms/admin/directives/settings-form.client.directive.js b/src/public/modules/forms/admin/directives/settings-form.client.directive.js index e6983a2f27..c408811662 100644 --- a/src/public/modules/forms/admin/directives/settings-form.client.directive.js +++ b/src/public/modules/forms/admin/directives/settings-form.client.directive.js @@ -242,7 +242,7 @@ function settingsFormDirective( } // Toggle form status between PUBLIC and PRIVATE - const updateFormStatusAndSave = (toastText) => { + const updateFormStatusAndSave = (toastText, toastOptions = {}) => { $scope.tempForm.status = $scope.tempForm.status === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE' $scope.btnLiveState = 2 // pressed; loading @@ -252,7 +252,7 @@ function settingsFormDirective( $timeout(() => { $scope.btnLiveState = 1 // unpressed }, 1000) - Toastr.success(toastText || 'Form updated') + Toastr.success(toastText || 'Form updated', toastOptions) }, 1000) }) } @@ -269,6 +269,10 @@ function settingsFormDirective( externalScope: () => ({ updateFormStatusAndSave, checks, + formParams: { + _id: $scope.myform._id, + title: $scope.myform.title, + }, }), }, }) diff --git a/src/public/modules/forms/services/mailto.client.factory.js b/src/public/modules/forms/services/mailto.client.factory.js new file mode 100644 index 0000000000..9c6703eb41 --- /dev/null +++ b/src/public/modules/forms/services/mailto.client.factory.js @@ -0,0 +1,48 @@ +const dedent = require('dedent-js') + +angular.module('forms').factory('MailTo', ['$window', '$translate', MailTo]) + +function MailTo($window, $translate) { + /** + * Generates a mailto URI which opens the client's default email application + * with an email to share their secret key. This requires the form title and + * secret key at minimum, and optionally the form ID. + * @param {string} title + * @param {string} secretKey + * @param {string} [formId] + * @return {Promise} + */ + const generateMailToUri = (title, secretKey, formId) => { + return $translate(['LINKS.APP.ROOT']).then((translations) => { + const appUrl = translations['LINKS.APP.ROOT'] + // Construct body piece by piece to avoid confusion with order of operations + let body = dedent` + Dear collaborator, + + I am sharing my form's secret key with you for safekeeping and backup. This is an important key that is needed to access all form responses. + + Form title: ${title}` + if (formId) { + // Note the newline at the start + body += dedent` + + Form URL: ${appUrl}/${formId}` + } + // Note the newline at the start + body += dedent` + + Secret key: ${secretKey} + + All you need to do is keep this email as a record, and please do not share this key with anyone else. + + Thank you for helping to safekeep my form!` + return ( + 'mailto:?subject=' + + $window.encodeURIComponent(`Shared Secret Key for ${title}`) + + '&body=' + + $window.encodeURIComponent(body) + ) + }) + } + return { generateMailToUri } +} diff --git a/src/public/modules/forms/services/toastr.client.factory.js b/src/public/modules/forms/services/toastr.client.factory.js index a1c791af8c..97b12088f6 100644 --- a/src/public/modules/forms/services/toastr.client.factory.js +++ b/src/public/modules/forms/services/toastr.client.factory.js @@ -36,9 +36,11 @@ function Toastr($filter) { let toastrNotification = { success: function (message, options = {}, title = '') { - const linkyfiedMessage = $filter('linky')(message, '_blank') + if (!options.skipLinky) { + message = $filter('linky')(message, '_blank') + } toastr.success( - linkyfiedMessage + successIconHtml, + message + successIconHtml, title, Object.assign({}, defaultSuccessOptions, options), ) From 414fd747fabfd5a485719158b9d8842d2d1aa9c7 Mon Sep 17 00:00:00 2001 From: arshadali172 Date: Mon, 31 Aug 2020 12:43:14 +0800 Subject: [PATCH 15/26] refactor: typify utils (#171) * Typify custom errors * Typify permission levels * Move errors to their own files * Update file paths * Update WebhookValidationError to be extended from ApplicationError * Update ConflictError to extend from ApplicationError * JSDoc the custom errors * Fix rebase errors Co-authored-by: Arshad Ali --- .../email-submissions.server.controller.js | 4 ++-- .../encrypt-submissions.server.controller.js | 4 ++-- .../controllers/webhooks.server.controller.js | 2 +- .../modules/submission/submission.errors.ts | 13 +++++++++++++ .../modules/submission/submission.service.ts | 2 +- src/app/modules/webhooks/webhook.errors.ts | 13 +++++++++++++ src/app/services/webhooks.service.ts | 2 +- src/app/utils/custom-errors.js | 18 ------------------ src/app/utils/permission-levels.js | 7 ------- src/app/utils/permission-levels.ts | 7 +++++++ src/shared/util/webhook-validation.ts | 2 +- .../backend/services/webhook.service.spec.ts | 2 +- 12 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 src/app/modules/submission/submission.errors.ts create mode 100644 src/app/modules/webhooks/webhook.errors.ts delete mode 100644 src/app/utils/custom-errors.js delete mode 100644 src/app/utils/permission-levels.js create mode 100644 src/app/utils/permission-levels.ts diff --git a/src/app/controllers/email-submissions.server.controller.js b/src/app/controllers/email-submissions.server.controller.js index 7256edf23e..285e081acb 100644 --- a/src/app/controllers/email-submissions.server.controller.js +++ b/src/app/controllers/email-submissions.server.controller.js @@ -10,7 +10,7 @@ const { getEmailSubmissionModel } = require('../models/submission.server.model') const emailSubmission = getEmailSubmissionModel(mongoose) const HttpStatus = require('http-status-codes') const { getRequestIp } = require('../utils/request') -const { ConflictError } = require('../utils/custom-errors') +const { ConflictError } = require('../modules/submission/submission.errors') const { MB } = require('../constants/filesize') const { attachmentsAreValid, @@ -251,7 +251,7 @@ exports.validateEmailSubmission = function (req, res, next) { error: err, }) if (err instanceof ConflictError) { - return res.status(HttpStatus.CONFLICT).send({ + return res.status(err.status).send({ message: 'The form has been updated. Please refresh and submit again.', }) diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index 6060c6ab82..1462540713 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -14,7 +14,7 @@ const Submission = getSubmissionModel(mongoose) const encryptSubmission = getEncryptSubmissionModel(mongoose) const { checkIsEncryptedEncoding } = require('../utils/encryption') -const { ConflictError } = require('../utils/custom-errors') +const { ConflictError } = require('../modules/submission/submission.errors') const { getRequestIp } = require('../utils/request') const { isMalformedDate, createQueryWithDateParam } = require('../utils/date') const logger = require('../../config/logger').createLoggerWithLabel(module) @@ -68,7 +68,7 @@ exports.validateEncryptSubmission = function (req, res, next) { error: err, }) if (err instanceof ConflictError) { - return res.status(HttpStatus.CONFLICT).send({ + return res.status(err.status).send({ message: 'The form has been updated. Please refresh and submit again.', }) diff --git a/src/app/controllers/webhooks.server.controller.js b/src/app/controllers/webhooks.server.controller.js index 30bd7430a4..9b08b94d42 100644 --- a/src/app/controllers/webhooks.server.controller.js +++ b/src/app/controllers/webhooks.server.controller.js @@ -6,7 +6,7 @@ const { handleWebhookFailure, logWebhookFailure, } = require('../services/webhooks.service') -const { WebhookValidationError } = require('../utils/custom-errors') +const { WebhookValidationError } = require('../modules/webhooks/webhook.errors') /** * POST submission to a specified URL. Only works for encrypted submissions. diff --git a/src/app/modules/submission/submission.errors.ts b/src/app/modules/submission/submission.errors.ts new file mode 100644 index 0000000000..49ac049b57 --- /dev/null +++ b/src/app/modules/submission/submission.errors.ts @@ -0,0 +1,13 @@ +import HttpStatus from 'http-status-codes' + +import { ApplicationError } from '../core/core.errors' + +/** + * A custom error class thrown by the submission controllers + * when some form fields are missing from the submission + */ +export class ConflictError extends ApplicationError { + constructor(message: string, meta?: string) { + super(message, HttpStatus.CONFLICT, meta) + } +} diff --git a/src/app/modules/submission/submission.service.ts b/src/app/modules/submission/submission.service.ts index fbfdcfb8fb..63ce51ee1e 100644 --- a/src/app/modules/submission/submission.service.ts +++ b/src/app/modules/submission/submission.service.ts @@ -5,9 +5,9 @@ import { getVisibleFieldIds, } from '../../../shared/util/logic' import { FieldResponse, IFieldSchema, IFormSchema } from '../../../types' -import { ConflictError } from '../../utils/custom-errors' import validateField from '../../utils/field-validation' +import { ConflictError } from './submission.errors' import { ProcessedFieldResponse } from './submission.types' import { getModeFilter } from './submission.utils' diff --git a/src/app/modules/webhooks/webhook.errors.ts b/src/app/modules/webhooks/webhook.errors.ts new file mode 100644 index 0000000000..2ddd84bbee --- /dev/null +++ b/src/app/modules/webhooks/webhook.errors.ts @@ -0,0 +1,13 @@ +import HttpStatus from 'http-status-codes' + +import { ApplicationError } from '../core/core.errors' + +/** + * A custom error class thrown by the webhook server controller + * if the submissionWebhookView is null or the webhookUrl is an invalid URL + */ +export class WebhookValidationError extends ApplicationError { + constructor(message: string, meta?: string) { + super(message, HttpStatus.UNPROCESSABLE_ENTITY, meta) + } +} diff --git a/src/app/services/webhooks.service.ts b/src/app/services/webhooks.service.ts index 45077effa2..53e2f978eb 100644 --- a/src/app/services/webhooks.service.ts +++ b/src/app/services/webhooks.service.ts @@ -13,7 +13,7 @@ import { WebhookParams, } from '../../types' import { getEncryptSubmissionModel } from '../models/submission.server.model' -import { WebhookValidationError } from '../utils/custom-errors' +import { WebhookValidationError } from '../modules/webhooks/webhook.errors' const logger = createLoggerWithLabel(module) const EncryptSubmission = getEncryptSubmissionModel(mongoose) diff --git a/src/app/utils/custom-errors.js b/src/app/utils/custom-errors.js deleted file mode 100644 index b92b238f9b..0000000000 --- a/src/app/utils/custom-errors.js +++ /dev/null @@ -1,18 +0,0 @@ -class ConflictError extends Error { - constructor(message) { - super(message) - this.name = 'ConflictError' - } -} - -class WebhookValidationError extends Error { - constructor(msg) { - super(msg) - this.name = 'WebhookValidationError' - } -} - -module.exports = { - ConflictError, - WebhookValidationError, -} diff --git a/src/app/utils/permission-levels.js b/src/app/utils/permission-levels.js deleted file mode 100644 index f1c3432251..0000000000 --- a/src/app/utils/permission-levels.js +++ /dev/null @@ -1,7 +0,0 @@ -const PERMISSION_LEVELS = { - READ: 'read', - WRITE: 'write', - DELETE: 'delete', -} - -module.exports = PERMISSION_LEVELS diff --git a/src/app/utils/permission-levels.ts b/src/app/utils/permission-levels.ts new file mode 100644 index 0000000000..cd14baaeae --- /dev/null +++ b/src/app/utils/permission-levels.ts @@ -0,0 +1,7 @@ +enum PERMISSION_LEVELS { + READ = 'read', + WRITE = 'write', + DELETE = 'delete', +} + +export default PERMISSION_LEVELS diff --git a/src/shared/util/webhook-validation.ts b/src/shared/util/webhook-validation.ts index 454f21e8aa..9cf34d2819 100644 --- a/src/shared/util/webhook-validation.ts +++ b/src/shared/util/webhook-validation.ts @@ -1,7 +1,7 @@ import { promises as dns } from 'dns' import ip from 'ip' -import { WebhookValidationError } from '../../app/utils/custom-errors' +import { WebhookValidationError } from '../../app/modules/webhooks/webhook.errors' import { isValidHttpsUrl } from './url-validation' diff --git a/tests/unit/backend/services/webhook.service.spec.ts b/tests/unit/backend/services/webhook.service.spec.ts index 5b40e1d804..e15db7db57 100644 --- a/tests/unit/backend/services/webhook.service.spec.ts +++ b/tests/unit/backend/services/webhook.service.spec.ts @@ -6,12 +6,12 @@ import { mocked } from 'ts-jest/utils' import { getEncryptedFormModel } from 'src/app/models/form.server.model' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import { WebhookValidationError } from 'src/app/modules/webhooks/webhook.errors' import { handleWebhookFailure, handleWebhookSuccess, postWebhook, } from 'src/app/services/webhooks.service' -import { WebhookValidationError } from 'src/app/utils/custom-errors' import { ResponseMode, SubmissionType } from 'src/types' jest.mock('axios') From 8572cf29bca99cd071e1e0f03d99c93aecda1356 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2020 16:09:23 +0800 Subject: [PATCH 16/26] fix(deps): bump validator from 11.1.0 to 13.1.1 (#209) Bumps [validator](https://github.com/chriso/validator.js) from 11.1.0 to 13.1.1. - [Release notes](https://github.com/chriso/validator.js/releases) - [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/chriso/validator.js/compare/11.1.0...13.1.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 13 ++++++++++--- package.json | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27cbe53e10..6a6f75ec3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8932,6 +8932,13 @@ "integrity": "sha512-3E8cLYkHKQDTCF4MYOzReVeHrObfulUR5hw/Ur/hE/CPwXRcJHZO7Jt0JyE86IpvAZsgrOjMpVy93USjrubVqg==", "requires": { "validator": "^11.1.0" + }, + "dependencies": { + "validator": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-11.1.0.tgz", + "integrity": "sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==" + } } }, "cookie": { @@ -25751,9 +25758,9 @@ } }, "validator": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-11.1.0.tgz", - "integrity": "sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==" + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.1.1.tgz", + "integrity": "sha512-8GfPiwzzRoWTg7OV1zva1KvrSemuMkv07MA9TTl91hfhe+wKrsrgVN4H2QSFd/U/FhiU3iWPYVgvbsOGwhyFWw==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index da2cd201dd..5b1baf6d91 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "uuid": "^8.3.0", - "validator": "^11.1.0", + "validator": "^13.1.1", "web-streams-polyfill": "^2.1.1", "whatwg-fetch": "^3.0.0", "winston": "^3.2.1", From 53a923636f84fe391b4a2d4c38a11153d96e7ee3 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Mon, 31 Aug 2020 16:31:05 +0800 Subject: [PATCH 17/26] refactor(utils/attachment): typescriptify (#166) * refactor: allow Buffer in file-validation types * refactor: add attachment response type * refactor: migrate attachment util to typescript * refactor: shift bcrypt types to dev deps * fix: update BasicField enum * test: test attachmentsAreValid * refactor: pass responses array instead of whole req * test: test everything else * refactor: remove unused zip files * refactor: use it instead of test * refactor: replace array access with assignment * refactor: use should for all tests * refactor: use async/await instead of .resolves --- package-lock.json | 3 +- package.json | 1 - .../email-submissions.server.controller.js | 2 +- .../utils/{attachment.js => attachment.ts} | 99 +++--- src/shared/util/file-validation.ts | 4 +- src/types/response.ts | 6 + tests/unit/backend/resources/govtech.jpg | Bin 0 -> 142030 bytes tests/unit/backend/resources/invalid.py | 1 + tests/unit/backend/resources/nested.zip | Bin 1086 -> 0 bytes .../unit/backend/resources/nestedInvalid.zip | Bin 0 -> 940 bytes tests/unit/backend/resources/nestedValid.zip | Bin 0 -> 482 bytes tests/unit/backend/utils/attachment.spec.ts | 282 ++++++++++++++++++ .../backend/utils/file-validation.spec.js | 2 +- 13 files changed, 356 insertions(+), 44 deletions(-) rename src/app/utils/{attachment.js => attachment.ts} (54%) create mode 100644 tests/unit/backend/resources/govtech.jpg create mode 100644 tests/unit/backend/resources/invalid.py delete mode 100644 tests/unit/backend/resources/nested.zip create mode 100644 tests/unit/backend/resources/nestedInvalid.zip create mode 100644 tests/unit/backend/resources/nestedValid.zip create mode 100644 tests/unit/backend/utils/attachment.spec.ts diff --git a/package-lock.json b/package-lock.json index 6a6f75ec3f..4343e0bec5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4610,7 +4610,8 @@ "@types/bcrypt": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", - "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==" + "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==", + "dev": true }, "@types/body-parser": { "version": "1.19.0", diff --git a/package.json b/package.json index 5b1baf6d91..9b008d9c74 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@opengovsg/ng-file-upload": "^12.2.14", "@opengovsg/spcp-auth-client": "^1.3.0", "@stablelib/base64": "^1.0.0", - "@types/bcrypt": "^3.0.0", "JSONStream": "^1.3.5", "ajv": "^5.2.3", "angular": "~1.8.0", diff --git a/src/app/controllers/email-submissions.server.controller.js b/src/app/controllers/email-submissions.server.controller.js index 285e081acb..8a7827beea 100644 --- a/src/app/controllers/email-submissions.server.controller.js +++ b/src/app/controllers/email-submissions.server.controller.js @@ -183,7 +183,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { } handleDuplicatesInAttachments(attachments) - addAttachmentToResponses(req, attachments) + addAttachmentToResponses(req.body.responses, attachments) return next() } catch (error) { diff --git a/src/app/utils/attachment.js b/src/app/utils/attachment.ts similarity index 54% rename from src/app/utils/attachment.js rename to src/app/utils/attachment.ts index d09c2ce828..a7821450af 100644 --- a/src/app/utils/attachment.js +++ b/src/app/utils/attachment.ts @@ -1,6 +1,14 @@ -const fileValidation = require('../../shared/util/file-validation') -const _ = require('lodash') -const { FilePlatforms } = require('../../shared/constants') +import { flattenDeep, sumBy } from 'lodash' + +import { FilePlatforms } from '../../shared/constants' +import * as fileValidation from '../../shared/util/file-validation' +import { BasicField, FieldResponse, IAttachmentResponse } from '../../types' + +export interface IAttachmentInfo { + filename: string + content: Buffer + fieldId: string +} /** * Checks an array of attachments to see ensure that every @@ -8,10 +16,12 @@ const { FilePlatforms } = require('../../shared/constants') * internal isInvalidFileExtension checker function, and * zip files are checked recursively. * - * @param {File[]} attachments - Array of file objects - * @return {boolean} Whether all attachments are valid + * @param attachments - Array of file objects + * @return Whether all attachments are valid */ -const attachmentsAreValid = async (attachments) => { +export const attachmentsAreValid = async ( + attachments: IAttachmentInfo[], +): 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( @@ -28,7 +38,7 @@ const attachmentsAreValid = async (attachments) => { try { const results = await Promise.all(promises) - return _.flattenDeep(results).length === 0 + return flattenDeep(results).length === 0 } catch (err) { // Consume error here, all errors should cause attachments to be considered // invalid. @@ -36,26 +46,38 @@ const attachmentsAreValid = async (attachments) => { } } +const isAttachmentResponseFromMap = ( + attachmentMap: Record, + response: FieldResponse, +): response is IAttachmentResponse => { + return !!attachmentMap[response._id] +} + /** * Adds the attachment's content, filename to each response, * based on their fieldId. * The response's answer is also changed to the attachment's filename. * - * @param {Object} req - Express request object - * @param {File[]} attachments - Array of file objects - * @return {Void} + * @param responses - Array of responses received + * @param attachments - Array of file objects */ -const addAttachmentToResponses = (req, attachments) => { +export const addAttachmentToResponses = ( + responses: FieldResponse[], + attachments: IAttachmentInfo[], +): void => { // Create a map of the attachments with fieldId as keys - const attachmentMap = attachments.reduce((acc, attachment) => { + const attachmentMap: Record< + IAttachmentInfo['fieldId'], + IAttachmentInfo + > = attachments.reduce((acc, attachment) => { acc[attachment.fieldId] = attachment return acc }, {}) - if (req.body.responses) { + if (responses) { // matches responses to attachments using id, adding filename and content to response - req.body.responses.forEach((response) => { - if (attachmentMap[response._id]) { + responses.forEach((response) => { + if (isAttachmentResponseFromMap(attachmentMap, response)) { const file = attachmentMap[response._id] response.answer = file.filename response.filename = file.filename @@ -65,9 +87,11 @@ const addAttachmentToResponses = (req, attachments) => { } } -const areAttachmentsMoreThan7MB = (attachments) => { +export const areAttachmentsMoreThan7MB = ( + attachments: IAttachmentInfo[], +): boolean => { // Check if total attachments size is < 7mb - const totalAttachmentSize = _.sumBy(attachments, (a) => a.content.byteLength) + const totalAttachmentSize = sumBy(attachments, (a) => a.content.byteLength) return totalAttachmentSize > 7000000 } @@ -76,10 +100,11 @@ const areAttachmentsMoreThan7MB = (attachments) => { * to for example 1-abc.txt, 2-abc.txt. * One of the duplicated files will not have its name changed. * Two abc.txt will become 1-abc.txt and abc.txt - * @param {File[]} attachments - Array of file objects - * @return {Void} + * @param attachments - Array of file objects */ -const handleDuplicatesInAttachments = (attachments) => { +export const handleDuplicatesInAttachments = ( + attachments: IAttachmentInfo[], +): void => { const names = new Map() // fill up the map, the key: filename and value: count @@ -101,24 +126,22 @@ const handleDuplicatesInAttachments = (attachments) => { }) } -const mapAttachmentsFromParsedResponses = (responses) => { - // look for attachments in parsedResponses - // Could be undefined if it is not required, or hidden - return responses - .filter( - (response) => - response.fieldType === 'attachment' && response.content !== undefined, - ) - .map((response) => ({ - filename: response.filename, - content: response.content, - })) +const isAttachmentResponse = ( + response: FieldResponse, +): response is IAttachmentResponse => { + return ( + response.fieldType === BasicField.Attachment && + (response as IAttachmentResponse).content !== undefined + ) } -module.exports = { - attachmentsAreValid, - addAttachmentToResponses, - areAttachmentsMoreThan7MB, - handleDuplicatesInAttachments, - mapAttachmentsFromParsedResponses, +export const mapAttachmentsFromParsedResponses = ( + responses: FieldResponse[], +): Pick[] => { + // look for attachments in parsedResponses + // Could be undefined if it is not required, or hidden + return responses.filter(isAttachmentResponse).map((response) => ({ + filename: response.filename, + content: response.content, + })) } diff --git a/src/shared/util/file-validation.ts b/src/shared/util/file-validation.ts index f48a450a22..1426d7df0c 100644 --- a/src/shared/util/file-validation.ts +++ b/src/shared/util/file-validation.ts @@ -104,12 +104,12 @@ export const isInvalidFileExtension = (ext: string) => */ export const getInvalidFileExtensionsInZip = ( platform: FilePlatforms, -): ((file: File) => Promise) => { +): ((file: File | Buffer) => Promise) => { const dataFormat = platform === FilePlatforms.Browser ? 'blob' : 'nodebuffer' // We wrap this checker into a closure because the data format // needs to be different for frontend vs backend. - const checkZipForInvalidFiles = async function (file: File) { + const checkZipForInvalidFiles = async function (file: File | Buffer) { const zip = await JSZip.loadAsync(file) const invalidFileExtensions = [] zip.forEach((relativePath, fileEntry) => { diff --git a/src/types/response.ts b/src/types/response.ts index 24fb110d74..d8ef434586 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -13,6 +13,11 @@ export interface ISingleAnswerResponse extends IBaseResponse { answer: string } +export interface IAttachmentResponse extends ISingleAnswerResponse { + filename: string + content: Buffer +} + export interface ICheckboxResponse extends IBaseResponse { answerArray: string[] } @@ -25,6 +30,7 @@ export type FieldResponse = | ISingleAnswerResponse | ICheckboxResponse | ITableResponse + | IAttachmentResponse interface IClientSubmission { attachments: AttachmentsMap diff --git a/tests/unit/backend/resources/govtech.jpg b/tests/unit/backend/resources/govtech.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dd9c1ee96881c01023c291f3b0343b2a8ccdf2c7 GIT binary patch literal 142030 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⋙Ex9rl>zWIN|{-uv{kwSun@HNhlos} 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 OviNBor4-5~#~aCv#8{Xm=!H=;dLR?C1l zoLm`4l6Ar#Q$b*<1E=ur~IzsJ+RfAPBAB&6J93}3!{_i{+Wo1`hc z&0cNVmsPUNk0u-Y?$sY)A{#Du_5sd4MaIKokZyKLY&!$3LCTQzP}#DTSnCt@s`LV~ zEPCy~9r*B1UL*9@&1 z7Sv*(yx7<*YcwcniSs2ld2HWEqD38(>_HaOZvts`(m~%y%+_y~I;QB%9&c}Z4g(fm z;L5cMIp5@q_F(T-e@3$9C~!~TA8ac}jEGbAdF-#%Ig?Cs9%x@a&G%yXsIrp#2bhu? zGEL;Tjt_J*t-w{c9qbdYowrhR+%@QtU-mntk&E;7HQC_+T1%BLti-cgPR+v_*$_u2 zKt>onw9nN13yXe*ko6D_0$-@AQL_dwmm*9xo@Pk=A(b}ha;MeAu~B1@WTGj7{}0 z+`}HNy0i@Kl%s?eMRu)@VNO$If8YykTWz&`DNbnP6^jj@ae9FGIooi7Fk)SH>+T`;-;6 z$YbLAvi*TpScI{`DYg_36Iy51Js5!IOz01=Sa=t+eEsq~lo>hC_yMlIo5)dSg zDA$Vm9uZ%6UQmN=`<{jH8}U3?G4En_10K$p&;`Li(%ix(`UH9vT#y+PEn@a^K3Lw1 zXKam5p><4Fgu?vwaDIq-Rzb})F>Mvn3Ea6)FQm;jqzr8jzoMxm@N>u4TbWAk-EiO6 zcNcqMfit>|WtJ>Vx1RH%S+uQLNeBF(uq8uA;-2*F`j zF?j|RW|nb|Yibl3CkALWieCNZkboo04k(6Xqc6l=Uv|u@n4C*0*)ev%v18u}A54Qz ziyH+)#Ff>o+#6mdpf|*gWiusB|E#P^6fCg^yjzA{<8|m!ehl={>=Rlw%4p zb-*f{Zi#Tl&i5*6Jkc1-oovm(p2A-7Kfw1dHkSbG8CX>Uk~k@AOyU0nI<#jXSBSI9 zdB5rCiDehN`P3F&UsZs3QCwm@{UHsLCzR4pE`&bf=bYg(Xk?AfDT#+m6}}4TTesN? zC65$Tc4I(=j%@R$Q>>&LCR>>CJEgtOn={}|9}X&p#%gSl^q%3T8J!-#H!{>9{-H>X z5$-+qNqGn8uT4I|m^4f0j!IzkpgJWHb>_Y=j@1GkKPD5{F@Cag8DxrQCy67c6r;}* z7AUXtQJ!;L^no1{2W<8ku^!q;Tjx0%QtA(I`$JkFaMvhIub$06aUUTk$A7> z1ix+~%&Syh$9q>u%IO79iLm+z$}i=~U_RxDu{~Xqt;0F4keP2e3tt6?Z?~T4{{i~g zT?|6yI-0vQdUWOWveBP|jYEySrwK26CUo?B9pu6HE@?8~H5^xsn^jdh*6J}H`&`;D z&o;CXJ`YU~i<)lMV1!r4d-)gYQT*TOB6ud9)GcEH^l#!SI;>g~G#ET$LX_g2!d|iZ zJT$L@GhS>`&2;Xbu^XcO)$R@0rE@=@ILx zPQTmE#=?*nEm)OzIk$o|bSs^3EEN8cZ09RWbkh3_-{M2v@Dthmmdjam6_ETkd~d2? zs{GmPw4oCO4u8O1RWouKOKPs#g;Tx%+^BVp_;c0FgHfFpFnCQi<%IbB8`>FN3B%8< zdeLO)MlxNm-goFejsV3e&y+;io$%XGLQ~^l|4Y2aNc_~Y6?&O2y?3iFr^!iEhSGzJ z4s5sV<5jUQd={m5Hch`YI)54NAAI+;niL&FmrpsUH~u^&0i1lh+Y1pt=0y~JKHWJ# zp;geiwAG!QD@jFWh)b%mzsYGAkD-L6nQBYd4^%{7EMk+Ckg;;E9K0O+I6HvHP$v%g z%7HW6dHF=2P4%;5U;HR{jQ2A}b-}A#%r7R8Jh+9&j?CS6f-EiB#k@VG9r9lSEr2V# zi{untA52eNF*flucmQJ*MWO)!5+C3PqcxTiihwz0!ammuc$DuD`YnI4 zN>2Q_-UMd!Ri9(hF*D^NW)MkYI`&;>gv7J-$y$HMOT@LV6D>S+sQ7DQ0FeWN7k*F+7B{v_kDmZlJQA zk6iw5BPR;92M?avc?Ra~$1>qya<bPid3rnfF=I;Kmm?#A5p+bPX;;G3;dgp){> zh|1sijyn!}+6Q}(0$F8tP!S8p(dK71zRN##!{nI^RLT9Hn#}PWP?65}dZ{iS*F*%$ z+#P&HCs8fMtn}Y;@K|la=n{72C$?>lb$C;Vanx06PV0g8J(5u?1n)MG0Z*S~T{q;{ zdgRZESGZo8O(WJER;f|1F0SV0u+Xxx$tR8hbeh4%YS&Vrk>vxy%i-~a z;!E~j^tf%M{7Y%3;N$xH?s;C=2gS&cUsw{TX8tT&c+y9uK+)Iiv|I=?Zq1$!eyT2c z2N6%gEZh6qkJPt_+F3GIG?7iV;xM(>cI5i#DH`Z^^hc4ju4tr#vx>Zu`jkiZnJ*P6 zQwDMlqeK4yM`##$#IvcCD~u^%yoIQ+wo0kElmu;;G&A&w$|Ky6Ezxj0pEXxCLYvdT zmpRfL|EQE;C_hy49C~TZ;smm&Wg?rbn0v2_=_{UEk)K7Uv{N*h-!*K(%Hc6nv@M#a z(Y1n6>I#_lE!5ZFO`>BNEm^$K21{tS8-=7X*8Fgt`&&= z0cMFZv{QEJKHtP4f_JDG`M0FnK50ZjBl65sE8Wj)5>;8kd8FnE1Aj%k=xV1fL^NRv zsMuv{ZPmZBOr{iIQ^p5PI?K-Cbh(VWS z{X6*WUm{i(uHEww8|b>&3%89gZKE&(GHWqr;7QmByOrcuttMiyjWs0+|L*UVS zMebDD$z&^6%*_!`2(mbjIuZ>LY8Y=8L$zHMcb-8_sEjYRy2xScykM>aN~s3Pio4CK z3K`lGO0sl?Cv^uFlw|b7-X(b^bj;rr?(y=C2FbE)W_*=|y{?oMk1y>QQZi|=nPKrx z#gfBF6m%6?s)Rc#R2A8h#d)P174VJ1dkmPQ?RGZ;yiBL5nmr|V{C@udeyTchAxbI` zNrfbF`Q9%^5hhRLg*m?~BBvuG@>@WM=93yW|IY75Gk94ONF5A`nmkAElkTKS zUkD_aOopOk6uE^S@zh{Q0|0oJLQ^?mMDz4=HPs(U@(u6QV9X*vi;1I3%RVoVepbZ( zWeOjW4GZmj<#RDED;{Pdj%P!yr71|uZ^UnboP%ak?>$V@CAk;VKQo`ZPZ_{o35!_B z=#t2%Ct2UNmx%awa4K)j7(OPS{gPVKs8>$yv(1a0q4*-V0(dX^X7<|oB@=1Yq#^PA zM6$iSX#y`?b!I19fK9-2`e}8oO2GhKalxAp#mZhn21dh<3Y=qbSQsW|&iG)ho*)y# z+a!_L2F$Xn>{az8j6k7G_myG2d5ne7B>|6O1U4x1;Kq`bHL`)7$_%bi zPBn=ec-FSHvzYx9b$n zGP`po@}n)qD<3LXxBH*c;K3ytRFOZgedQ+Vi)u^mo44SmTGO;sGe*9Yr$EklJ$@Ru zm2gH&t$Xy7_lmuDf=692i(E^XsDz2?O_og}ZR5@d3nA`)?OL@4YlS>JRf4oVfKd_lB^tIN9Qmo zpx9EQ3GigYG*JreYO#prGye(Z-^q(bL=(I*5r_Uv=4@}#`>Zqd{hl1{ZOb~=One;k zG29lOWFpgc*T4{tEBM4(`Z@J04>9Nf{wVfcM3DhNQSx58|4}MDlq`V^vQMZ%$uk2k zqT^U!QAp814)}V0yTx^3qUzK}5L)+gNKlxZ1PY#QQH_zuz}F5$4gWWMVakP{SISuU?pfRk=DhC z?XrM;QJiMfqvq7Ypxre)n3`3?N&f@n@bM-TJceI0ng*9~m7u|CsX|+wnFb6KD^%7% zU0Jiz_4L$P`byV~;QqK)$_Lez{Zuv1jW@*gKRHaYSdRkScL=Z*e@#kahExP*gLalHnWb~I2o%TL z1w!J?#)1Rxy!1?UdA&5SuL(8gR{DKCjTUh=?cS-T1EgKT`7d6a6{2u1SrTa@cPvf* z05KDgBn&7%tqh8zi|~d3t!U{LiiJuRYGgwx7|?a0M(jjj55_NDtaZ3=bU!ITJlp?F zFLNxwC(FPEzK^%f-8VX>ER$d{oZydjNNVNaG->)oEc+H73g^X2w9F5L*xPuR;`_^W zq7S{je6msCMjq+;^TytQB(?uJfQeBu}e^iZWx6u}rcH8IeZf;m;FNxCcgt zpfgxhhVp>(n5hjSqG^}SzS^)VxP2LC9U*E7ZT7bB)nJqj8Os!wQblu*lQwM>M?D#- z_tzpusGzbwX=ezrZpj~2;*7hp^TRD>Jwk5rQHr=*lF?n0(JH7UEiAkp-RQ8|{?a7A zc@YiJ&}3)JHMCtG7>J-v>-BG$nl&__#dp{rZVWkem8uBq*Tm7qjf~&o@vWrT6gEzg z8Vr^g!M8P{Tb5!VMo}c2QXeX7#}OA$QIKjgV_!IrQ*PbGq=0i?@hsQPK z*WVydTXb%y7;Uj~I#|8@Af)h10eL(^kaO>{fg%SDLOZ$bO=9j!kX8Cj2bRN;z#_qJ z9I|802GF-)+bVzsbM%NJzL!+2wNuWu7{{wK7iJmqh%JtBXsae3{y92$r^Wdjwq`j>T%$1}vH)Hl!_Yd7z>ng3n<+>8mc z3L+wh_76~qlCp`4q2$s|lDQZ;uIc#0#L%yhzN0U=%<5i_ zqTNUdj+w`Uzw(s0dsCBv0WkV-!pPZ6PHLbuwYm3z11f{qVr4GUACvNmlBWeH;T3uI zn{XU7*lo3o4{INv@BYBKdaW$uDdip1&^f;9#vmFR;IIWU5En8W8_hzR@_Pl1Vx7tv z-f2jRqPh^crM})DVC#eHR{XX)HM6`adzkJ!%6C3`8~nVxtvG>3%=4%$^Air5yjQ|; zB?&I?Qu6tAU#E}Ml;uEK4-8Lb*B}J?YKeb}VtCZ|h5b;Q|_T9neGeS0x9zZPf zGixd#$aKeH6TTE4{=wez@TnX+e;SY4J6z!^HQM`NH;8Uz?%n8a-N@X%sS&Eg3QZn8 zpJO{WSgQ5{JjH`S?b5u{`)|d(Y}QQO4%oKS2LVi~TGCDzZQe1FB)ka;_A&P>aa4d zu+zra0uV@abo7rZB)!QBQH;TaGH1r5LR}qJBTX+DtnUCUwQ|)7LWhkcY@QPNIO+Ib zsULk?tEJ63E#{vJfv$=Fd5;291D&Q6qW3WN2Ylo{_4293Co?C&pwK zr&{y_bbwv{$a0$8ha{zS&le))ZM4jaRq6huuTv5L+l_L}xrtK_T@F2I(!0PdChU9G z0csw>r~iLFoIaG@o#n*2xTqt7urAfcjsn$b4%~oBS z;(y*BK03W#87Ip}Ut%T#`r860D&jdqiv6^E^xMF!tJF0nyVOWpyP&LQ@p&BW6YXs@ za%%tBdw{bnCMH(+Z>7+qS-Y~WS8IO&2!ApHTV6IJ%F9D97CK7GxJt;MU-)4;ItIQRb-u_QCTXA=@ty++ ziutx@KcRlS{{@DqpSi5;G|V~LvY=G*{0Ta+(iq_4<;vICaZYR@DLpUG=ol&1z!adf z>gq#EmQBrsGdGnw+abX!Q@TX#4@rsiw;>XawEIXQVkTl}3=60>M*nLH@Mf=V(3_13m4OAY&V~)>g-Wm4m=jLOF zfp)>ZiIJuSF2#?fTGiEveV=VlJdI6;cy|>e=>}-8UDAYbj+jq+g{Y{cPs zjvrh;Rc4JlLtj6Z5NnXW)bEv}5yWEr@K`4P@E)Tt$XJY6Oi9NMb{N}cl1rL0=P{tr zmk%wXlDB8;NL&@7=im0+e;k`+MGszm3{*(7fp3M}v?IlAS}8kJZ}#wLL@tYNo(LlD zKvJOH=j%Oh$mFihwO9UnK)Wzm3H>iEKnM*)?#Z&z?3m6};bR?dG(xb~saOHbGOdLQtx}AApLO z1c4DsDHrm&lj&saI6#-&*$YG8Fp}#-5=Xalwre8dLIy>+F?CK&A*J^84}kPgrh`L+ zt;RCav4<%0SKYr>JY` zaDbEHhU1g)8vZLDhz#njw%*%EBD(2n*f{NG!Hn_A>2O-FEXX!PRg}g1ZK~KKXEZ>{ zK4h>we2@J~3@3(l62;yeMVgG>k99YvYtxn+fGsSjme`uDHh7>xgBc?!vG?<*mIf0p?Dj6eNbroAGi z{8o@Q086^1eFUV5H))_0#prI)n)epvIf;8|Z?8B^qJaF{!ccn~x;`llRZbFR#z`V4 zL2#H=MU2Qf#Utf`(UM`jH5nkAp!0Ip%f=42Yln`adMtk=6?{E%{gA=6cV zc#1_D^@eaeJ%Rn*<2n)3Ev<|QZKpiJTyJd=aQ%_v^yss`Px%({`6XjKrWLL(`|JHF zu9m`EhD$2hhPWTpbm%pd(}4yt-^{7`j|yj0DG`yMb(^JU*tJ7PWUgN_T5k)+l=iVk z8Ungpm>(}m1w%$9j@uWCvF`j|&`_^(Oa#m-KGT2U(j7gV!~zdnZFwCMM*rNS^@hWC zJib3Mc>*;(1wd8g=3o`zj1!tkC6sm1x7RG0fCW?BMA+}VxtEqXstfJG0Uc7JbVkhw zunY$Li}8K57P_-Rd&#$5*hzL!s0}Q9XyJR~W_KJv?jR$$)V6X5D==MB!{A;uZ0fVj zf#fjFZFTDcxj#U*9#iUJAbeMe+juf-ZWN5vMQk+VRg7r z?MP!kp?8~!_==&K{`3Zo7}@hmDy7^l74XC;_X6e2WTGPDi~fFg=k}U$b3&dj4L6re z=9^38j|zH_ zrpL$GY528U&M6j6f)!f!87c6zz}}cAlPOejx>1=i3qSAPVLIIrXSx3l>sIVf`i{53 zZb?HUcXW-uAIv(tRTuiw7piBi09hHi%fAXUW@4IVxcSa3^P@2dBL>`nbV097d~^ta zMvPAUm*c_z`;YGbP7@ez{~IU>rpNyV3W_KD|BOmUF(dyYiZB*E=B6=r{6C@yUtgBd z3HB?*|EH#)Z*!A>O-LJFlK&@^s@+aAT#}u`;ru_KpaLGx#hR^Q_z{TzfYNI0HHimD z>T);hpHWc9PXgj#k8m{Ztp6FMutfWj3nc4j=Ki}6R1Y&i1NIjMjkKL8;Xrl$3QXu~cB|#&-{Wpz0nTGM7 zKxj@C;eQh$0hx;b1OhNCzdYXhi%Js4_m3a|YHEs<1%T$7?ci1dpeODDOh|wf=m9Xf0Wcr{i}7zNy6oS7fEoZ`u?D2V zZh&(DB8!Uzd-K;j#_M0y|M5-A5B&p-1Ar*32oubh47?fjEdrnJNjBsE0>cKlf{VcP zQr>jEU|V=~Z6!zmU~O;yUqJXUGz0=bi^IqQPbLK#0d#HfDgV!I{SPRFf}dF-6G)Tw zQUQswxUzu84dlOd3q8x76pGME2DZTKkOC8;y$&A+|L;u!m?YrPTKfZ-*x(?_;)1m~ zJ*;*;0E_>nSO4_~+Fw5y0afIGJ^26I|B?VS3hcbBCXx;UL%09;*CDat@CDvlTLI=o zvv75Re-DCiqQynQ9wlDr$C<{6JcRiQ<8DHBL|%umS=`>sbXqTH&I+{H z7?ko_ssMDOBk6{`%V5d{iK&`_i7MbWGciC}`-Ez>LC@c)g8axDeA!C!Lh_0=e@aa{H~JCz_D)BEp9b8L}9C z154ItL}QV~QjvHRp*X@$v*UP0w+cBdPT1jtgo(ED9Coo-IEmA1Naj*9Xe3Y2?Imxq zCT`nok-Y%}+nb|_ih|Dq&q5P*Iz|ckEDi%YcMcPOQ90_)mfc6%;T!vFpZ!k*v1ES=;v%0jGnw!g*8&Sc!KE%R* zxmR5Fk%X5d6gT#&z0MBEq~G4c8R3XB$mri2-M7J^XY%$2W+wNSBO9rzsN%;aii`Cm zN>gz85SA-2ZYW`NvKi*z_GSX+u;kIN6gFfubfiL( z61Pyg25(#RuJw3d2tVWwF3vQ`y2mf)Hr0z3|J_yAvxf5RiBn^2LoX#`S3w>l6Wm^D zh;Un#@fax_po*TsPV6p47w$d0H6`xe0p974q6~I|3(gOr!0{(@+RnZ{6}6ZoX7ytx zeZSY(N_&NzxrI2r$V9xPFA_*A@>XYm$P1$4z8?!xz=?BV%9WI~*Nl1R8fQ$x5xn-WUDX4Q_{0L-c&@zeRi|KK zl<9#mzP};gs+-P?K2|gJul3J2YSKm1IjS%45(ovwiwTWB-?u69=cvxGNZs<=0Ykid z+6pCw(+uby*Vy8X z^PNJO&(jF>A}}sy@haMbge2bXZ%^x)wP2ARW5z~O&WZzs-?8{j_WN?p;$Ux&s%tBF zZ14p}#V<&TVu`i<`?s8UM;Fg1tGx7bp(&3rEtXCM>oF|(Agko~8OuCd zAACihMZVnHJfd+HU5v&&K67@3X@cm`$vSygqZk#c*e%;C5BaK}NfZo^rmYoB^G7KN zmAi1YcJmVuUm?2Q44~#pq-rh#_#}ys)nP<5VR)B0=9)4vis1Ls8$&)E62O$-4U3|Q zmFtQp;W#TI{J;+}>Lv6SMbwl&tzc5#ldY8|W!Xt&ssm7gR zYunP1HuW-?pe@ZWvC!UHN^HM15TsDX3%fx4A5^_}P*dF(HhMxrLJeI41VWP{N)eDG zAib%8pdzS9RYXt-y$B=_q=^(k>C$`w6)85V5Q=muf`Up(AP|&Zr3LT#efPV6+{sLm z$(+gTea_x%uk}37TC1N=t>>NjRWFUcXE>`o$l*EYMX$GI(J@l2mxawycc=qj$_35z z4Fm(khfGgs&}6;@#migG#49G9?iUwb0Q{nO!MY4kp|*;KErPMz_2EmBGuAMkZ&4Fa zD9QJ1JBxqFiZQ8A`cdKgi9OI*sjM7z&@9ZB4QI#3_*(SV5Rz*i77Lx?G~JM!cdag@ z%e;5?j-(~rm90io{$dk>d3Bp>yT@3#B&PxbU9S5*{Jh42hUl}Jht^~mxr*j=@_zN) zVC#PQ^9AzF)BkP&GNOQQx4s`^(8B#{1yWG{rA@eCeZ02Jj`|a27B&a|JY&0fNF^xR zT;Hhai&4PYLyB?nISw7!>S%`T&Gn{*m>0R9gn}1^{7AU9!^cUI!pG zs$Z|t>%vaDx$vLyK5aUebEtaOT@YNit{~ddB&UwCwo@Fp-y3KLimqdLkjhJyf~jd+ zxnbwyp}3A5WXN%BY|}%FfmF!r*T7t;qivwOn9GTL-uifa+bKb*M%E3vPg4Cr;4fd^ z5y(pt91zh<_??7Z#oHr1ZuO@Uyhu1fx=a_lgMPEZ_8nVf>6QB3%E0G$bHd5_#9av= zHtNYqQm}k?U8DE#YnRd4?+Wqj5260jIm4;0{9g=T72i7hJKGU~NalICA>?KFSyiy~ zo%0u*sZsfb4}tHW@&^3)8Y6mR1hhhN?Jzk?+V?yp8fCI9T?r>4_$TRhUDbLg3e%I8 z=ly@aujQx+@-y;&$0w}XP090ZZhSlVK+KlPrKnX&`-(lvO6aTk?r$(qDo}W$67YMq z2I<{0>v~+nHgEQBlka|>VS>P?b;t|#vjCt)g2O2Qf?`TV5{wtGC^%X46A-<-rV0E$*F%~qa(0U8kow(oU?`|9 zIJ!J#(0FpnEZ$}u-WhjR8{VzPSNGTek2VsLQS3%+qMMLgnq< zOJ+CHt5mJ+xOCcER0$sFiV0QMCMK0@#-JC5I{PJ6)u}I+YI-2n&}aDH=;b_zp))KD z1y8Xl(e)OUv8S9a5vdm<>U13%T@S9>Or&$BLVT2=tud${-!4H#OLQ&z)BH}Vc)f}1 zdkH6Qj9mAdF}RpvBtFFp5?eywe-XvcV7WuwC)bv8pPUX+d#ntSfdJJY{*D9GxSfkv zF-f6)*epj6pMEut^NoFyNnWdzO6cvkyxs-5t`HWMr>3iHDxB^9X{1h&qw=TxG03LM zI)F5Y-cj4f&s8zw?;w7Q4`F9Mi-kCgy-T`$edQcp_U&cq!WonPGqT5cdGPO{|GDMp zoYtXOAF~vvdh+)=?@gfwWFOCyq<+7{{bH3$?;Nw`(9=dIG-6StM>*D%sFk z(C?Ct>9@6&rp^R6oy009z3TYQ<=byC2#*_Tzs%+*P?oA88zlDGc8=7snbri5bQ@Lg zWB`LIi4IcTd~oA_UK0?5kb@cEcp-j=GaN7njs|XN%TuS>D)c|l;~xsw zt8K=;Z?X?KQ5S#vn0JVrWD^ovWeEs)Bv8nF9k(uUUGLy^f)sk3dk4c04SQ*CBUvY@ zBMTIoe38FceLV4irE}v<=$^|*-OdJ#qU^Z+mx-ye-wF;o$diK$Dg3@&79t#WkelbTSMRXUyI8GnUZIJ@n zo9n}6yCkH7>J%^SMd7)iJ5S_)s4*jxzCWv^4zPy8Jxs!s<8KtsV>8 zPeprRtAq8e*$UWF)K{Awa2gw;z%Dl&^2nT5sHXKtUVQR3;r{IEt8(?7rJ87(k;&86 zrkWpR5!HkM%zPVO*g8ZGq|3(Pb!aYVR`*-1moA*T9oCxA>SrRU{_Y@ZJT5P$UnG59 z&BIhi_S}fylbGXfPE^br$%CE`#nEIs^yXQM6R|AC^6?R$pc(omby=S!IW}&zC=-GU z!2R_8xV>@xvv+`CBqr+MRnbPzhi{>GI(~GNbd#WcAETZ?_=Y8n!^}c1CPo?_9?PBL-j=y{6OD*c0 zW!)R}3P{;9Ep==d)#F&RJZZ)zGU zvr_2({~3Gg^gllpFBznyaiPZ%`z?sto~b%lV?Hv$rEAfYT@*DFm8m9)Ah>50ogigs z!V2SKQ$pL$MVIih>af`^Ca?ViRx(pBCB?c(vgUX+H->LKmS@Y9iiN+jFOrxP5tQ}P zI3HYK;%yp-5(ikU61`^MzG7KP7pDkx<0mIjs{`4CgF!FkA3be_T&|2kQ>5`S-3e{- z(%uufNT>Jp^lgh=YWH?F?>sj@)igSQU!MfkALipjTU~5i^m{V(c+Is!!7%XjRLYrT z>jr;Xk}62II0(G8zU;7yoY)&^zD(1ygooWKKTrFK((16;^6z#&3G7^8`e-YOQ=wMp z4av^=e4qV}{m#VoZVRP(#c;9 zk@7V_h*{?3**3g__}A7BHSa>sCblN2qDr$Gy;2&dmBw!Sww3W6HdYQ}E^5`=^jiuK zOq-_2?>6=xLD0KbKNRZ5@r_7`bxx{_^K)PwBeNnxl-V!TblQpJ^;bZ&J`lhFc;BnH z<*2@XvQ{}uO~$1@pb*9KS9>Ppl<<|I?D?yr_hQ!MRp($4jQr09@eDTODb#em{O@jv z!+H1Y3oU!?$~Ju&x1}OcsjYD^kyjg(wAROoee@_)(6cBLuBR5TKeU_{r442<$dLW` z(Yt(;{n&<{#+3&<@~HTt4t?WLxMB}m`BE?os^-%47@{FB3M&utb+Oe5^0R13GuWE5 z4Lik8m9Q$K%g1Y?r}{{J7n0yR{Q06qg{`?S#zAq7k85T;ZEAu5w7e<*-23)B}E{;)UeIcgr67cKXAp|`;s%a&IQdT*_Gaz#AYa5Z05n_g0&=D!Hd|& zG!4$|NOm?Uz0rR@T6FStx+P8p z+}==<n2TL;0|KR8z9JwV`ZnDn zWpHW3#@~XpK9cB0%h5-WQxs*PPT2H^SFd?x2Pxmby;0NfJJR&wN<2%4%UnE9tWO5V zn?WF!9;Xmz_I1kI74;fvz4V#?{v9E#)_-&pnRC28p8DrWLN-|(duVyDq(Z#U~tK=7~i>YUs5H!7x~;bjq-xQg65Gr^zOr4Q+@17SGo6AwQ{w;%~uvng0NR~20OEZp4+QG+$c83)R1kCYrFpzXR-Ak zIvWqKU=5|j5*JF}yWX&WE7Ll#u``7PIm>##Gj6Xj0 zv4J*}#G{6rTQO5biJ$y4_$rLNm4!Wa+D2BB%3x`+na+>li8BwdH62`MH|}STv+#Kv zDY9$yIBBRkN)k!1S=ktHUQuoExF;qZ&Dp4Th=>gAhjzO$S_gSjD()N5KV=#q>bR$y zg-;Ejy}{<$d30lqo;!%PWBP)$@jXeZ-IL-}-c&Q*_Ick*33H8VOG~Z{@6Au0$pXh# zd`XkJ1R)-Ce@N=Yy$imKHC>};$;1Ol``0A8>8zCs4_qnZj8WErU_$}y{;)`KnM*gB zL_>8ir)-}7iDV^JD+H)Q%J{@3?FgT{d#zoctzX}6zzqnR<4gWaL<`tFnw&Swti(t0 zvWDC^nU$!^uW}{k_Ty~Uo(ZGTLi zP4-9Z>pM@+lhlvNWX1O1!uJO)Up9KAj_p;WsPx+TaE+SJ$Kc@&-@bg{454Z4dCV)9 zq#bbn1188>`o|DdYqK~+EKd%bk(u=!iL8=m9d2WgIv~Owf@gVLPp((MMF@Iy)Xn?j zC272pOPFzJ$XFmx# z9%u@*E^*aXH>E`6n0uW;w)TfI4kO1C`h|WK(Xbvi-Iu#jL{K^XB>0PyU9_q%bGSYr*Mfqmi69?)$q> z{meR>PWZ~V;dv;Z;otOB%`WgkU$Ug1KRVU_S`W-VUxuexGCnlsfdouW?lxAdNduZ5UV{v-Xot=-C1NdG_S<_xE6(f@nylK_BY?fltkp*tg#I<-wX?bqlgFm4@MssMEo}d zQ{?n&fAN=6M81RMzGqIF8mHYE=hlx`1jdTW8I0%81(4A-wez?x2T^1nFdA@QGGFy_ zlt!|Yu5DFf5`#@Bub7Ao;|O7iKo5M?=e;)rPxhETD!KL|0BU=H(BmcGxTf9*g~&@w z$$S*c>$b9sZ?{Q5CU$Dhr1%j_!ezm4)llw0(5|^7BU9J=@&gC_!MRyK*I3w0w@f}) zl%sY0N!vHeTA8T$PTt}r79<4LEtjFLCc$6Ak*)w)nG4u3bP7*YTR&ayD=gJh#FB&cT%I6~j|335oiVPTHbI6zElfb~}}Q z9>QJx%>Q`q6zhWRWTg#Nlf)*uWl8wjKBOd(bll&(LD~d^6BSc``VExOT6=hDCVXVE zd22qb0Alhrx9bnBA$RaUCNGt+bOa&rPgxR^I!kJL6W_9(LOH{}ewJcfMTy44xsYs8 zl3lh%9JnF<-76zqeOCqyrN$ssS*_#?=?c2*$5B+Y$O8h!Z`^zI2aC43vcUA0+A^)8wCNVC`d@LM#%0CBj_~ctYLK_Z-G!XohzmYDBaBkj(9p1A4 z-IKt~i`hT@3|8gt)n)z&J0UCLKYENc5xHb5g4$T=@{g`=hd6he#dO$LQvm>u04M+O zBYG4OUXfBfz;K2}{kN~h+jC~kNZ7holf{A21a+l9{OE6RQxlkZVxG=x^b+7ch8rCE zBis7Sc>VKf<-6D31x=a;8P_jgCk;7D5du)^Lc;iYPaVK`jw`JbN_${jU3w}YA}^d! zU&CUhEiV5>H(=Dk*qrJr)8pLBUYixJp+LNR45;adaJ)T^6KEve>i_PU{sJ{mZGx(j z!_W8czx*t{UP!teuRv*pHS(IrlEnRoL{X$k>JV=m8u%`Hq;Gl3p5w6>9$0Y09Upu# zp)ZkU_Gvlq<~`3~7FPk!p5Z`X4w-Q+|8C(5iEJS>?+&W%3X6WqhfL!TLf^RBMw32f zwUcAlOwad^*r$_l3-H%n!&lh!H90gkBsnW2J3-QkgaFVG3OZtLMOG5>Digrjs;(}2 zG?V!K;4y%4V64BlH%2YvgivYxW3%m{_^6r`p_G6@)B|b1s0-;eJvq9$9@kreD8%{T z5(dHFJHe z)qYTtF-^B_7>mg4xF&Jj;=H!<8CxHf)c6Qj!N@shu5?) z9^lzM*Uo4xz_G4&1F!YG^;dT6IhOS?f`-9+c6ch0nQB5Q&`nJfDQ@y|&N#zwk8p?; zc*KEZ$w(#b0{a?`mRaQg3;Tu%on0xEH_e^4jW~eeQk@ z0ts)GH>4rQe|k$x)tM+6-L_+0{u^~O;6&b{d8N3{;~1V3D?#L-ksG9Tw00wOPlZf+==tisVMn>6T1c=bwAVwc}0LCEjD`0ueJW>>5-26{FFakhsmO+=f>j0+fAkQ%bN8UgRe>{*_E7?wTOy4g>cO zFjb4Mr^$J#1!73@E~adoc`C9w8QL|txWObksc9GBb_+zPkQhV7&0ON1&pL6gSoC zYIbw1KmW!`OD8*v0v}BHwE;GZ2B*Rc_o5E?70u!9;uGlI5QK>s0beB*t!xA*Z@n-r z%R~7pv7HLU(dD4UfGU2{skKw^5-++?flXifc;@BUp|BS4U9K0}io2mP)AfP2>Y@6sbbosUZ{%)8{hszL= ziea=jMG~NJL1$C?HV#`3a_PEe<`EVb>*M1Uj9~#%dLa{C8FnPCLPL*2uWFI6T-1}{ z?~{3+3do~B@Mj^ENB}@w=h;iDHw>?U{?8wYfIGD5SA2g$gxlN?kF+!g$_7F-*4IJbY-};Xc{gMl9PHkaUMtZeQnc;F z#w}M=II^mirmr6j^(X>XKuwY+bpW=Ya_73*;RwcmM?GxjjNHfY@~Y|mum#Y)i>hon z4PP8CY+`U}Pg`W4_UWuy?O0J#4C+=p5oANdZLT9#LI%u zc(e=bHM`YQjxf8V}2wq5QLmdQ}TYEv5de`v=6A`#fXI;P9t2K%=-##i(s# zs(v08RQ1zh6e%#U8#;aD^uOE5%3+Z#K0-fpq{}byXf^$7cyZ}{!{H7Cy*M51 zn^gPty@(1X_$v6Y)q}$x^mlD;^4#o+Q%L=rm*WAv59pr{)-A-fj`n)c41@bxTO?-) z2_2Yjp*HMjvr)o626Of_>mD@n5Y**Q+dw|K9kyr;MJ&ynv{i>bF;`=KPZ8CLU2Er! z`Um`;-{VO~)bC@)RwG{XJX$u~BRaLvjP{N|rffU%=}y%Lp3ueZzaU{mhgNmu$fvgJ z?Xn}wF37jI)P-{WCo;-PobK8xAmubeKc4o6wV|HIYAPqs<#zg&M>r|D)3!iE*0P}l zX4!!6iQ{hf+UM0jO_PV`QJjAhn*MxC={~CiSR37JgYFW*mgy;KH^}W%6r-A=Pt$LN zm&`T_Xt#$c$y&nDBBSu}zL~v;x5Z>7;AP z#+&JI|AzOAM2Mg!?xaC0H$tMhd{9nWsCc->)vB@g(rwk{m?XK_y_0)9^D(tFQ}?r+<&77nv~_4T{GjT!T)XihyL44(A;mb z{qJ{-%(>s-a9Vs|GBUcZxy{6uma=6=03>^#SunO`7a!T;w#_sGHWRhz%*d}JM5o1V zCXpG4sGAk*+?xdV!s6ki-Ew4$-q_al0(^lvukI}gYX5G1zvAUaq8;-YZh_-})@BDX zyt<@U)v~`e*CG*YY<3}aHi;QQbQ&4hi8zLEI%-(KO*ZX<Oho}b~)Zzg0+9?@qkshf!!Ln?)r1}?Sq1<#l9tdxCAS2G^*W# zNvs;%9&YocU1^&h>IuFGCMELxpTfp0|DPI6-iL5)N{1&?y{ckuG(J9|P3EhPJlND? zCt>wnnjQ9pCb>GFTkJ~f=-DJ|rPz(N-%Tmtv;8Rqz40K#1x9&Y)T+&8^Pr%_x4c;< z)Pd!+>v=@$DSG(1GKG=LpuPAB+{H@mkdw3?%qIEqJ6SqcWBR_gva$E6N>=rZ!}0Bx zQyqtNr0a*8&J@vRf0dt^u-}e!VADGqguX1eQKNIgxL5cweIi(M#;e-o>kGClgJ_M> z(y^P5U1qpH?j z55+IFYR!T}`T-EgpY|=r4eIEFAH7#QiLHn9DrEk1?Zv<8vcG(L3Un0z(o$hdo$|wn zEl#=eX^fv%mb zA?^BjxKnqL8>$se>)%x`dtRqgA`|y?Z7Do&bMnde+?XHV^9m_nI*xCieiHmBAzQPR z^5muKa%*Zjw+r?0-YST%ZM~%puzyw7LE>6?91i6>4;nY$5Sqm2utv1r;xW_O!G-AJ4-2@*EvN$s@cuY$=5VbwRS&nW1y)5wWW+8( zLvyfUL2e6L?lLl*yf&SiYYjoc?gt#@{>H2c8jBBc)ddbL6N^oBJZ4u#Mp}D{ zPF+6|gsOLNtcXD#y|ag@qpRB8W-rkpK755YiB1RmLvWbNTJT13fgIX(bI)jbm)4nu z%;(XGIP%3eu8}wI5HHrZ7swaalw`8((5NDPT|IJrG9RPmypex@@Vu&CD{e2gQg+aK z5QL`7uUW&VOY5v?!(A?-@h5MsB%V(joiAe%-DNaT!keuF2gZxMma%s z!H)iALRfpxXPc0gY^Cvz(@`yI%5+sQ56!r0Tngc(@xjdA%`Ssx)o0Ln$Puc=&A{$| zR;|#pVQlVAg>E(g5=7F-9({22XKQqL+7~DP-RRC*8*c4A^1@_xygnunp@R6kFE9Mn zX2^5npeTtYtzNSWKT@W#d70JfB4hvPZHL;L8z=)wj*~ws zN^B|Vk`K#sKg=JTTh#n9+)dQ^bZl2`zo^A0U-$cEdKIO7M*-#)$2;2iLiX4^9JpKR z*LLN6-n|!)>^iS#=SUz~Y7B!iZXE2o3(niws>yAQF{pFYQl$7$e~mkfC>`PSc=99t zVp=;1QZ_tT+tIci-JvdF_2CFHY(s9lxCJj^^p<(=|Le(Ml4!9*ZuzlR9(j z`2`blQGuVN71GMOwzq}-<-V)(F-Vq9Q8Ahgo7EC!Vr0UKdHIQm8DW@fh3wIZaYpIS zmoLSLD)CZHu?U5|*zd($U$C0jmBOQk*dmvFTc?WxWA*ecPC24c%w^a_pfEcJjctKs?g8*5)HP?S__fluZa& z;?hxbP48?vO>&Wx`E>bP@P>NwWpkL3-M0{6&JnO9XV}gNR=a;XuNvT)&{mW*??RMh zMS;R=UcJ^6b!_ofERVz9U-aqQd^Jo5`dym>-C~8Z@mTwi_J_kK%Z;gDRM|wU;IEpt zck5IRf9k{+7~6DN4)Yq+-tjoetuf`W62ba3sriOFZ!O1c^}OBbn?(V6sFEJu8Pl-lo(@L`u|zPc%3tF8n7jEer;DJF|JmwN46h7M9L zFK~ZwiXHPXpp_~`T@*%RrAB<*^gFpk3K#d>>Rz23a4IufoS~d_g~m$lurnU-*35p? zJlXD0gGEC=$NQ3;CjdZ`fV2$^n&}OIQP~8rs+x{*Mi2fp%~MoEl0w@r_xHupgo0%k zuC1w{FOIU<$)^;XdXYhd)BH}31?jm`_48kON2nkepXziztV=8VPK>^X0GPV~heY*o zeu(|Vu76dm3a8ak;}xKp1EsU$uR#H?Pr}J2Guo_qy0k7Ve8%Taml;^JZ9X-i0a|`U zd=}wtOjgf-l?2Q>B~O!hgdetVZ}^9xwbkd5M*4_18q~nn)}5@LdcN7bgZUjFY;FOD zf5L*N!Nz$2?RP;HOanHS;JG!_Vid)zNWs}I@I2j>Z^@Iv^Y9rg_(+xLu3p7+g{SU* zW6vVgE(VV8aj~5WuTfE4~)%y10#v8iEDT0R~`+#<(lFi zyZ@~XOOiA;9$u1r`5a5{i2w3(?axbfq5|L}t-vSnySqD9Td2vbFILA0+kJjPOkOs2 zoq!lXbhI`>%FOI_G2T4nXQCIeN@zLMQY z=Zg6St^-^b{E`}Rb9;Yy?WLJ&4_DXOpjmS2*rsrl@pFq1?vycGBl}=otz`e*l;lv* z{c##^J+Xo24Vu`)wDTVmbJNH~{}^`Pyn@ zeH)8X_=J~83MlSl?GIyhmMpoTp;yFoa-eDHm#uf>bdKDm34DA4Je6f%!3sYQjrYu!YexP9l*T%PD|-^UaYNj8C0x6V%04+r*X;h8 z6sMzBap^y5l$sE0*m9zb4F3~Nk+z!%?0~=3o<+oN zcedOumf}+rh_T|sfWXU?8&+gJp=WSxyFh9nPO7(MlCZGKe9xG|`x9*FBY9!}llCG$ za;9-eME(58vZ&Uy5xXx-3IL2kxi61z89qeogpfMVwQAT^*L{S_M_-`*D#9dTMz;44 z-?JeE`8W`D;f;gl>8Y_f55sw36xixjsRdqcvDZ}?C;6w%1v?gA zLjIwvztkVA!qt(;f|l7`m+dsk_MEolos%dnBM!LSh$CPXrfaq5m& zCmN`q#?Q>x$9dLyd{R0vD$Y?a zkV>*ZA||^_NhbA@jC2vtmv7-}!-D#06c_@1$9^!O(BWZE;%epx+t!D(`e<(Ku}TR{ zvMBZAh`Km^Fga4saFqawO1762Lhp9ctven*IbUoKW%woju2nc;msy({kF ztDR=w> z{K_P12p}d6w$}4*&#Q6&;el+fIUcx9Ty=++R&pbG4;VVzN-=tRRqG(&liQcs$r#wo zZcPEH;x!zX`bOs0TZ^+nBB9pY0bzG(dktIHIQMo$t+3R)U2F^#{*FQ*$?ZFuMget0 zDYpjHyFKgn{qAn|E=gfiM7Bdb{SrUlM@@A!{m!B5S@rlq_FB>CJ4#($f9E&cf|D(f zPS16TH7So3vwJtYy7at3c`cU~m^=(E_gH&?46vBa*K^{|B`fg-}vk9fR z4YD6?$SgRLp?j@WeHF;-?OTxvpwK+E(xs#XIQ)xr%5<|6jcf*E`Q)Z!4|ZqHHA(v? z`hF@H2mY6g^*xjOk&0NlaY~Za0CYo@uNk8}XcWJnzgzdIu}L3JW<@s~b{;?UBCJgc zl#9|X!c~es$^lVj`qbRqIFrvQ*psx`%(rvs1~^gJ45 zY-v?ZGe8TrlykiIIh%LxP`0lCN_@RSQkez7Ln#--E`K!{M~suI-VrFqLX#R?5@s2W zB!HcBA*E_={1AKOz-@qUaD=?}_b&|h6LGVQ=$~E4PsaJaanSAbq`ltgctqY_-`qb; z53XcA2x>9;@`UUbJ=yD6gMz~W7r>>#bvEWwM}#_tpvg~&hjo@;$(%NWvv2k zw)v)`Q(pTrh()bdgA3@{CfnM3fjjWcR%KRo3!CDNSW zPkvM&HhrN}f7Kw;dV_~@xC-{pBoOqfs`RRQ-Cp~upnrE+V5vSnXNiAxuli1LN{t82 zC#cN^92J#&d{X!X)P~3<`=~Qw+e^4n2A8`gr*Nd~dA72RNT#_nTad}*1PmLxJQ$rv zbw@cpha5R--Cru%;+Z9H>`oL^Tiu)zY2O%~ZOPR|P^rww78TIiTnZi+8b8##Y$9PC z`s5Mxqyq2Lh7fT^Ob2;N&Mzt}9ETP-p7^>5WqhhI&MoQbKcH0mbgPBCi{->6w0KlP z`_p8dJ64I|)8}n!S|)?)M(YP5QR+XYmBYF=zxWdzH>4eco0iAC-z_&jS3o$r9gj3a zh!h|jn^r;4v2lcjf=Ud!Nd4GY*9Ba+?VFF#m*`jCq*zrt>w2xxRIc}C(GaiyP>Q>YY3GDu2EZuNWwF?_ScLmS(FDaizVEfvGPVzoq$8Fnj zdmy90B+)~qvXsHnO!(c&!^CTZl5&d{xY#GENKTuAMyJUNX;y=NO|E>S{5JxXljH6z zxk5q{3{_hhnm?+1v)#EOqipQmTh6|gObe3F+||utK7Z3B724WIp6m1pS>NIDxbmw&ys8Shd`Ry0p5M%Fnp7Phh0vpCu%Y{AI+4mQhiJwM z7;3SW{;4q6@smQP$YWAbgB5N{*?eJi^_vfgFu(vg*=5#eblYJNMNqxax7gf$)?D8C zY4XbsT4Ta9qpNXtGNy=V>{rDTwgOfm3yZTYvpROnv&^HTle2Nj&2gLKf!TR^U{Mgk zAr|?|hJipqP`$R(`e@J240|EZapClEB2YvfzxsH%f5bZLpSE?^-61bQ)oN>a|q@1c8KKzSN zY>Twc+t<)T=Z5^EhaYM{&LSzLGko_N5S-4}o? zP)t@o_F% zdHS2@OKi=QUl;vM{^^+ect~nBsYRdtu9aY+dq(X1{2MCLf8Bwn)2#~mxBZa#H|&0k zP)DNminx0r0m8h!9#httQ$N%Zt2R=^wjsW+pPybqx6!E~KsF9BR#Z=^0pCr6Z^>d` z{5ub`D2PSS@2vcY_7maaeyfX%J0MQABSb&fbW0#sUn`tNh1R4r+M4|}9PoW^rL6_M z)G=1V>NLLJwYgrR{uW0`AWceKdEi;h*_RXNmF(3L(otT5fk8jIH;F9`2-P;!xitx$ z&}XuLveRFKiOz7lMHLbf;#1gi!JvP?iSz!gj1y%XLNt%PAJ2L3N6X@TRf8BZ*KmDr zg{#jYxTzWwTCK{yL%VV5RL$``>7s7!9iz1Nupw4o%X{e!)hzJFZmg#%KVP`La1>dhIvjxwEH#X!2@_DgmLPZP8TGvz3S1|U4^5R+kb z2)4VeDFz1P3+!@?Z)>V+*&YD5@IWaFMt&cAjG#fw7-xAaZ>fLs^wc>DTI?DfaXoA# zf9Z~VAGnND1%JgL_pQss^8M8>h*k;>x8kdyKlr(zJ7CRJ;`CTy!r(H!pPN>qx0v#6G9`j#ddyN=NEJ|GSJjDa^n9+-`Rxm z0C)eYnuTZezOBE-SntQ~6BXGL+Z3UfdzT%VAoTg}J zBtBUbJDogsX(1vhz<(;`QdKs1*gA*${QAR65txCa!mAcn^}eLEOFT#FYVSvOhyM0? zM8m&@ij``wHD?!lZ)UluI0E5iP!!`{0>^rt zha9&iHX(s6MBxLAaX2I2s*y$PdhUBfE4+0|F>|wQ*KI5)TGIR6);1K3 zl6TZz!xGk#Q|5J!J_?*Pg70;x*ZN*8`RZia6}!P#`P0HHFT(yoP}J08r+9Akbf)M} z!BYyp-?>bdsB`JE!(A6Xk3P#**QQ7J1aWRtIK2*lRx9Hv_(qgwy9NuDHG=ojMyyP-69tqd*Z$@c!vNbrR z$3jxVwqUb!Uy%CNQv)0fCln1WFzQrh^qeqz^9%VHEQgXDhBc`8+Kl?aeU1RNSWv?% zJ3l7m#kq=lW+`x~!KJqNl(OjTuR(W`kd1?8!9T^4sSGMrdZ|#{6;EVKS<>bG6d+)f z`fYcPDNaJ6Li?h}s#@muC7t8o-t|oEbUIx!G;U8o}24Pi)tCw#f0)jEI=cClDG=>g>1cogPW zlDZ_DS?YDMCtAWup?XplFU;||!Wjmm3l1kQdS=o!LeZCFmYDyh)1| zqR8Bh|AT5JZ}6CDu0#h|bp;YD4jj&&Tc9+B$3-#hZ+?z~i%d&6o35XZ5=kkHu*8Q9 zrkJk|UIj!80giI?ujaM~SMcHy*A?tXqWXNnb8FY z>sfkUkR&;8`SEY7zD$t&!pd0(ToKw)RR_5{sk<71`~I`Zx-G3@a*0O+7|y(ZfTNq| z>^2BrT)InU6x9CGbJs$oAf}IkTeNrR$|@kLvf37Zwt_z&50=}@T5sdvIqw(V+T7MH z1zbCsWiT`ha@By6P#v_bOhO_;oAk(^Gy-v6sjkl=?II*`9^dZqpBl)qCv~ZQLwFaO26^;^)HmmxDeK(g1_L^@wXqmblW8PCxfz}F6up3K9pIS zL|ecjlIU+fT}qk;g)|b`FSQ%KdtKS}`a)WXY;mfpiZzydJp_KUh`4#@LeqL@(RxQk zW)p3;OmndJ>_pv^`#(Zr9Ew2)RM894mYVm&qAeHhb@dZ_>a7Px?2 zK*|b4E#kEph-*wp9ExV(1ssD(a!deARTahi~ zn2T@r^_&K2Q;*VrfA_gD2817~I28HM&8mNrcz47c;)7fXCN~!e()DQrz8iT*q1y+3 zvX^xOx4$66R+EpW@!#ggoW_3b9qO=fh}KAS7~Ami87H2pC?tG&qM5AaFu|{n{wZ)V zPvbz>hs~&mn%$P=U;?{zkERa*NC1fZ1Da0i0<&;{-KfzCvAEN`{r62csCv0+H=(CD z1`=)QTc8f+UpOGu35r5ul&ZiCI&J`O`KFOHZ-oxY$n zMDfvCnskdBwgx$2Lx~o(ic>>sd|x#D#3Khg!UvotIhfl==_%6^;&EZTV=nqhb|UAV z>2gmFz5)d=mZBP+8Kqa&C_H*j%!En^c8)eBIQvm!QU0%hQ2Nh$(T7g2q9YXwK9VBs z@tSSB3KLurMtw{2PVJ8!c8zwR`xyEJU*ENDYKz;^)|ao_xT=vak;gxg3Q8!bA?{J%!FKTgAsiradB5=;v1u(evJ8AxRrx+*<$vF_*E@0kDW!{lNK{Hq#AO*gVZs~b+{famuodvfX zE!qU|wq=22OlyjgicYNoM8D?L`diBkKmpr04tL5c2|EN`?fD0K3cFQ^(3!oA!(Bzr zUk$=>+H=^gjImtPI^3RXmVSBEa_d+v^zfV%wX8Ei^YID1CphFkHMk(*Cli_ZmpE;d zl&-#OgkSxLTNK!+8kQ_M_eZq!{3ik3BUxdx=?7UGcv!HJ)L!Sw}PxAT?`*0tq=i61MSsXBB5ReHzg7A2SCKi~qlc1A6bwUWWvA4U-t# znbMgCkzyFk|6bDdE}Eo@VQ$K}7@_sIhu7^NsM49z8dcs`YS(%eLaJGt`$C~??@9B2 z{B1Z5B&c?w92K-d=gk0fNQ`@+@>uyvJM7o5uF7-VbVw5VN=wmPfXU{{47>%0J}2Gl zgze|<+5}%${s&V2VAO7oyRaC7WSM1QiG0ngEE=T7QrCJI9s-FWotLDoK&Vxu_zgai zZk&HT-Z?($zV&N*YsLGoE&u7YL$4rQ%}?5a-|Ul!V6h_=)gR-# z$KNJDU1_e)4KA|LS5IuN^F7yj@-3w=MxBWzQfUlIy323i9Pw;~>X4tQq^11KTi{mB z3BVsa`4QiHHc)u#t1c8`8l z{;JezJ3f2%)2>x$_rEz^z0_j<_c^dmjW2)ATR!>wtOi)kXi#H26~gA8fWYDuH?3Z> zfc3j8EukFB6UYtul;`MtT0}7KqW$SC_<-S9Fzmbx;L7!!=abfYXwvISdTJBTn|3h6 z#R6_~0?Eeh*d=eL%6Av&Wm#Wi)cMT% zCh1EL@?^9*JZRnA+}z)M@@;mNW7ch`IzrLhP@(uJz8X!H}YeKe`3yh{s2#T>FZmq)?6* zEG=zCnraU>FIogM#Qp;AhK2JEMSUCN0PYL8-RJdOSGm#0 zz=YX|i2Ysp3q+$9Q%~S$qv_|PhK(2`z|;Oa-zGb_ts)R#Z+Q-6E8kgY$3Ecf>WS)lEg2`p;ngk`cOT!-H zCY_3r{)hVpXr}$I5?X?bUafxWXzZ;0G-iM7i!dEFRRz#GV@D(xEQ#kwC7b9)%w4c& zNtT4Lp(RlW?;M#tAU-zga*wuFb@9CVKKkQ!k9I3NUW8NLu42_ElIW#w8C$es`;l~l zf38ll_3_o@N;4gs#DMz+MTy1%-38!qtBmvsp^{;2rl%d;(^B<|uO~~t!C2AW5fF@i zuc>+05-N-!)l>)alrB0V7?x~md~wDx+FSAdjWh*(PkYV_eGDGToHnvcltL zgke5e5!;@$ZW?6`a)?yVVGfLekC3y~U3A{d&6LH?e0jEl#(mg*QNxwNhwF6=^TzgV9>msMNba?IQqE3Or|?I$)(2)yIRGl1&ePqDS8hY%uyJg z7*{a}Ur2cClH9*i6RE4s?4iELZjVT|EsI z7;RlsRAXNmG`(YLE$w*r{u()Jrj}eMb*bpi>}yrQu0KIveMBnsT$@p(#K1ugC`FdI z{S-;Zrt!vuy%WFKwxd_2oQxNGY{AADr4nWF z;`rJnKdo?Q2tBG+KTOP*x;6TujP1&kAE{Ps64?xS-Ie)Q4Qfv|Q&$sAJ+b=VUM{3` zqI4}@+jI=&AzY(z>5#;u=1GZntS_wi<@C6vio@Kc42=5uK3dlG1{M(ar9Hez&5~hy zc5&CJ-;4~05QeZR)CByMNwT*Sm1wr5a;VN78SciPLOd%M+7Z}xQ3BdVid_;aQEJOt zD_KoU9-^8%pbJ#V`0BE@AjitVj!D-+!cR3PMO%dcJ9g^!o1m;NE~i^ens(@iA`#EO zL?$?e=0Um8dTm>{JY?J@+Wb=2F**h*-@mUqOTv?!4TDjs21paku>BM1KdVHH8QRIl z=VwlxePZ;}i0?+2(N1D^9WTFT(7;o=L@$@2>bk@RKH<)4MkU{O(dMD@*a~;n8Ana3 zI2QAe!cn7A8WSTSZ2bZrlJ{3h-Dg1JXF;Lq0CUpz1?9{d@IB)vrvgLMeoo`p# zl2b`08d||AsA^2`cb1$$-RffkDr0jKN`h6DVlUDx;IErBtz{)5rD(K-@K*>ve-sIf z#U%|?+cIJ>5op(C_R|ltRFRg_I}pbvUHP$i@xlu`(rW&Ga*}AOi1W6>rhlL%6>Z)s zGWlyX;R?05s^A40bSU#y-&ZDyk}mXLhx4HHY=3>hH=5YAI7W@~60h&LZ*AHMaZGm% zGCs&^A#{myx+p$Ff%7gk(%<8+tZMRLg)2AB+fPvUULx4?xnm5(?_2CUR+=}wvrp-b z?$q=4vNY}O|LD%jD9dJQWK73N$L?17If$0n+xRq19+zeHOtxooJl|$j~pYJp)C>Qo=)LtTmyWh>DuJ z?x}EBUEmorQAvF#A-@IG@kKCGWQzOJR82bdB6fkXQSDvo9q(CB4wLE-Mmoq1amQcX z=XZDGN{ua^(l&e41rvJ(%`XB?dpo_vv~2UuPYMaONKiIr(#_CLICQc^+>Zv0km4?HwvQQ_lY4U2RLUB+Bx($}1|FH}sruE$5iaWZUWZuFo1nITQ2f zfZb=tX9nhTKGpk*Kn>&ZIlHyaRZyl=dium(#DG`ksQegveOMRYx;{RusA$NUTztvh zf+#9PywCdeZ?dH<3-j-1vUlcRH0r@Y?EZDO>nvAXksl^^wLUPTKb8v`2v{h-z8lI2 zQ|^4`eO_g)p6a0-cu%z=d=^iQRz3$jyAQ%S_;IGyW3Y(}V^hFlVS`C1UgY?3=T_@9 zjOKBl!WHoc~U(>Q#vc_ zlau~|93EM#@_YyHNkbDyVy+Cxyau%`*)%UmIqH_4zpe`C^g37(o{<^AlEYu?UIn_ZMCGYRvUjoXgMUbaX%X4Vb_tllCYAX&3Rvl9 z+ODb}m<6@;szZSnibq}QqPl?RBmxZiQ`kD;MW+!uGq8xRcGgh%PfT6vfNy*ROgA|>)=F(QT#Z=P2^06 z`%wo3!7J25IhY2jD;E%?LR_v+b;Z073j-7Nk}VS%_fGeC3*>GPk#?XGh<vb)gI^8cm{tyR&{k^PGm7SGUV-=LkTxf z-&uvci<(yuKV#9v%q<-iy-FH6A7t#L?yF-@c&^JFD6F5DqUN`6;1}6-brYvy^5h`( zf{~&OqqYY2gB~40RnJ>%pyQrg873AJIi8n2agl+GUFNromqpCW7kvbw+!>uas(N6h zIRCW7fNl4u*$oL=JoTY7k1z5P`$_mG_-6#b;klV1KQ`vqDc6-E(4qHbQ% zm#Z5A4V8UN=~2!=(1g=AhDK2&r=Z>!Q>5 zRQlMa$KYk=jgpztcyP`UNm|CIIWz^*imd?a_zE8C2=xQ`#Y){M6FKk>bpb znw|5xHh)qchmSrIv@(k`p1b)7S)EcM8LL4(k8jQ-yF_R5f$FRt@TO6fhw#2aXP>2G zI%jWK$98JoFZEJdHhXxYc*h>-{RYmGFccor;B}54H6MKToL&Q_C^`Q9?ay}nT&kqd zVb^82GA$Bnl+Ap1_B|HV%bY3W?Z^pXG5VE4#a8lTb4@qu>R>`?;0tB~3BE_S{Ow`} z@BD89Gc%wOHpartjr=J(Pxh|V=k{MIy1aLdi}L}umRCyuuDPJGe6`Ge6a!Q?5_c9L zH;y3pwJ~<~%sXnEP}D=9syD#z-Fj8QG-!0d`*&+zK)*?qU|EBJ<<=&3dPVA-``B%7 zkxskp8{v^Ir9$3IulL_YYKb*ieSC7l zMPR#gFq`H&%Juc}@R;SqDro!KZ9mzp6TT$RYVUC)&L5txzeOF(?N%h?I)Eu%ca+r+ zgTHze<+v*iO~}s3c*G&i82HS&9#C<*3BGK!Y_10w>>mSc5zF{j9%g4(*0cgcK2*9o zc(766LUgOdZO`}brsQzW>P#VwHUa8KP#HqWSu&qt@(to}J5sM0A{_*np zZ)f39MLgb@T=rM1;{M0tH)2gq>3tD&)SJXmDAkaBSw^d zMV>N>qbWuviZAHWh64VL<0Qa&xjCT#MVlyxoLeK1Iyq} zDW|foNn+3O0hV&NHa2S3Q=065?^weXn0jj1_SKPoebJF`l&&gU-YnCK*1Kv-5#Vs1 zhU$k8z32Zj;;Zc|DAa%Dx-F)?E4Lyv<|8Lu^m732bS0^QQvYs!IdVJm3DAfPuC!XA z6t}3?o9a3OzC{gXP5Zg-x+tsv0>+6==NB0e#RRlEiRdqWx_3o=JxG61``Dp{1BNjU zmhR12IC~FmsO2sLt7zXUg%{FUzl_Z+M?DrdWiU5XvIv7cy8+1mIe*M&zq=g4f@VRU z2Du%$0$N;1h}MttpS(qOi<5WGwq+WTGpV@Q?y;~eLL!}FGKeaacGPL&%kAQpc|}B- zK2d!0^X9h;F6i-G1+Z?mr_dVWd09cZ`pY=#WQUWB%*ry@y!;{UM=6!l$DQ#&h*A|z zJKfP=qx6bPvJY~+|J5S!KB`Iv$?yGkIdACufVSl~8bJ=1RKc;ZvR&h-lo9Jf3E2rT z(;fkw{iGu35=+qaW7JD`4;Cxq&}6obQ3N%s=}WQ6?~Tl_yPgw`9fWxunqK@7>^PbU zrHTLs=z&|=TP}O$G)&A}a=}o;iji7bdWm*4M?dlCJ0JW>p54 z_7382xyiw%x#?JU0jjLUfCm78IU}_Gr|~L{LX&X)O~=j47kQZrUyt8J!}K5?OkUv+ z=}T|2Q#GmJEh8`1i0-vi-k1`K8be(`;ZFB@NH$}!I@A`?)*@8q2Me1xm;R9!4-+qD zvk8O{_YMLK6~h!1=(y?Bo1*$q{ZF@Aq|Gq0v@j#_S3Y%&bAzjWQge#RZ_;?^+c7?j z)a2++s5-1oKU1i|-T@J@%9b-M?0shnk2`z(b8tDjzYL#gEJ;$M*8_^(U@4|95n8pB zJ5cTT#MH}&1}!kSJ2i(AP4GX7-`BVJU`}PY zHbGS=x|+=fF3?1{Di1}3i^5-u_&^5EgryRj+k{2kjRDYdgVMv?eX@9c-u2|6$|~zl zYMZ=QJzCgn-V8dP;_pde*Klm~Aku*f@)CBQjg4!Qm)3tR5U|-LMJye=&zxBC_4Ax7 zFRpZ_IlZPBo}K!*+U93HMu486fqR$b&%SG=Q}zINFB0P_s&)_lQJU68$oVe0kh9`e zRPXfn=9ip7bjYeOALfwlip{6eE`YJdF?Zsu}o7QgtXj2kWQ!txCkArXSyWisSbz{2kQ8TXk<8Y!3u|Op#uahMOza`;>@6+g)YJASV?uwkXv*Q3I@g zqOH=@^o2S=^Sqs%mM0~IrR=1ykaBNNXyVh;9p5^$L(3R@V=BW@Q9L6kQu$kG@)XI! zJ9O$M@y5e(IwQRw>nJX(ryN^(R%+b3u9-WkC4I2%7$D`!FJoA0d}HUDv^C)QDO6VLX!s)0}=8wK*#$|Zp?D!0UZzQV$jM3$nlTYo_ z4pLHDeWNCJZVl#Fx%&smcI3-yhUWyX-Ztq|SM2C|-xRj@4ist8rxPahcywq1h!$ z-$5aN z8kf4MXsM-g?xi=8qWN)9>Y~CokK8_5#|Qvv!Q*AtSNRV-V2}*2n5(m{zO}V^ZQtB1 z6il0h#{+%{#vM{W1br6U9o0Yf$}S!wF@LIrf;hnd_M@TolUrx!dN%7so|OFs^xep0 zY1cuqqx*@`rRj8*Vme=@y?YWO~n$1t@cCJCMkOLoL8r`*M z+k*FzH%fXnvZ`N2_~9W(Q3*dW<#f9~==n5fCumMw2i>&CarQi6t$^qiK+Uxuf}{v- zEzd=5Ne4!%;-Q__p<$eQtrDYOAqpj8vvU2_^R!5tqXo$@yU*CsrttpDNlE9 z1)hJP=gVL2hsC$=6;^Ypyiu;EV}hq7IgJw0CCAGn)pKUhsnX5*maCm}&1qQlO;-WX zEE+%&X5I-&a+!3DNAU|rS=`S2rHQ(!vGG>-sm|y#77(kZl*q+N20ivEUJ(e>mm|Hu zx@A6HY|yf*_BRhoupXhB{!>{U%+_9MxtI_%Sv}tln>9< zxtCJb67+n@N~j=Ra&KiY{igIpkHiy=KohdEg@8JrnhW!_+8CB=58%9&9GgzYs>2+z zF6Ee8J#A1Gkl0v#H3~h^neQe;ct^NVe9NZ$!bGv5XSid=HP17G8u@(}R?OsLM;{&$u@vf`;8NxHOX=)HC;gNqz8-O`tYrMZP%!Xfm{-)6Fwq^ zx(j`V;xVh!7|R*07T_e0(xi3Smh3aiK#{b4%R- zF3kNnF(@udPZANXZFTeGAE85C<}hoxxLmf1%w_q*@~MP&8~f|&2C`ym##CRa5{iTX zd%`Ov#JHs3~VGyfhgKs4#p@*ha0_y<()SR0eAFBzUxjNOBno4vgQZr65ne5$== zuG<94v0<%XnUTDeG-0+CexHUq4NMY;(CG>9fLBL*c@r;Cl25#)As@q)Uiyn#vY@~s zLE2wSLHzNl70Or49%imfk2ucS~Oj~lhWf-xKtmPG0 zP5mBzcsYHxH8xN6ZU+sgPb_5d4kmqn32kJ?bV0jxh%);Tt~nm>-2LR<7GV~Z{_v^5 zbm4r;rV9*3rl(_THJ)N}uFw1^Q;F*}S>f{5m-ldA-av5kh7#fY2RjVP8HgNupz|A* zQ$U;#+8)}wuW&N7CDY5Uv6sv1x7vq+MntE%no>~LiPWSsI2@$_>Wdk^bUvHt4+>Bx zTp4#&Ebd}YrXvnbZ&H#Lap#$VoNjAI@#KhtV`3fo$9=sYkIGQ%(_5t0HKSUpDimU zoqAaUd;oHOu2p;_rZXQXrV2%{Ld82S*UsyfqRFMAtcH6v$6fKL=(n5b!#-M5K+WKY zUDXaKPWv4L4QHjuUn}I*wGkZ)Y$e62!CTEF3R?i4pgMEcnCEp#JL#iA z7QD_@wa8U9Qd>HG(P^0YVSIEAV-ysh2}w<&aE!~r3nez!riSbpKBgM z)+KE+3H*_6v^5k%#=?qDrB^bc2fqny{2f zee(yih^RQI+_7BcrtIrC)IvRbm$Vmz7z=W=I0@Xlw^udr2;LuLm^W*2PDC4kiX3Tp z%sNU56UgDNVfQd#EvLw1wS5y#aPXR+;H^u{ZNE+2!m@!)x2HVAygbsIZ2X*822dKm z+OqqOHEml4m8m5hBgNj#uSRlUvfB^lB^9=8=DKrQ*W&Ajp(~_An?M6N!x79GOw+(U z5o)wJBmqXfIyqyJOSAm5C(^_CGy*^_7mjS4rkq|fNbi@B9NvK=1z!8v*l?j;t%I)&VwB(ZaZs$IH47nFqiT7sd6xxP%_5>ybR>AH(W~i1MITEDw4eY-la zUXm0|Kp(6~RAs8t4|b6{+Ii-c8)%4e9g_O@CT|4fJ^0*I+om9h9_rB zwa+22e<0G=iTS5F!i?%_M9%eB_^X_=$%AZy>Th(4X?UEy5&t=%z||npX8(y*6q1D7 zK6*sp9f6|bo4gimOJm?RX#8%#MbE2-R3bw(+2S*su?4JLi~|U8DE2aPk<1u)Z{TSO z$py|pJIQ5V{8q8)=`0sy2pSO<3Miuyv`M&g9|nsZGaZ#cEJfx%xbAVUtTi;Dkv-)S zazONj#E>$BM(loyLk$~*7^z2v#xF;#aFNcZHA64Y)nG=6%W!KrJwL|ee$0#Rm??C zWqhmHrK_N`5nrk|O`vvmvxt%; zaL&B*AsWhT;2fFS4(y>vW^}EP%2wLjt)wCbIdAfdb5uNG({c2@FecaG-)*EvR6?ihXY&ezX3CUrYr`5V|oQJP>_ND`_6n0niP zRn*AASLtp?yt88_Omx36c`Ytc!6yg>-r3;5-?;o#0Wr$1?n3BNx2BS23l)O(--z~_ zcC9mbu9C;;Lm2gkl8>@atO%|BuM8}t;j(qk;4lKx88=%x=;6DQWdjB47$GwqeO^N4 z8N1kYWSY?llz*TdiXT%JTl2Mosv9Yh=CGU9&V$AEyJVcH`Ssv*&|Rk1eb_ zcY9ZdGVYcGL%_Vn?eXJIg%$xVg<=0^%|OcBcK1hJ@{(hNK`h6a*ElCQJ;buuZz?y9 zWn8rS{K+xLNkfM*I}coJaL`BQ9&nSCaPA*ndHKEulxE~?XQ5Rxyl83oji7>h?SKr* z4Zt8wb}sW&NY4RkB!)-XjHA)ui?ER}+JB(%`tW z3VJ`tG-0&YS_EcXjIQ`U3zFEUmfzN8J(|k zKurJ4>zU*1z~IeZV`FTMGt<1+Cgyw1&@2SIaJwey<5Ee&my-@az~hEoc8x7H8A$}$?;oi5)yQd25XYHK@_JCC9AP0zD+>3EFjSDhm?;Lv&Y7V-xd)!>=R^Tca`ip;}|<<3=yjZV>2akyVRDl zV98>|mYOv9FpDsEZGI--pJ7U>)Je6Y?T*wH{J%&3za7)j_QMW*i0?cx^=8l8EtSK7 z5z`rN?TvSInL2WJwsRL0KT^_TW8{S0(QB&Da%kmx7w42p4UDy1u=1zaK-m0-t^PI6 zkFD1Y{M6-Um%@jYVBQDX@>X5^`R^B4Z8*P}pdT4H!KT8}VPunxL8moxn(A%jzq`h zd>f^y`cCfZJp1k%5Yn2QlSWH^>`mZFcu4}U5zgW42E_4#5oZ~R&i;-<;M-gW4j2}o7 z5w=HSDqtE1bS#ncbjodyh!4w`p(;pwabI|IKPCN}d!Djh0`pXu_7}jOZzNCNV>c*} zav0arYakYeK9fo#pu-zi=Pt|&L>3ZKx_T{y_+ygw)Q1VgXebtrVp7%&_=MnSOf++1dQ5d^~E^Y9#RbHL(2XJ|=vN@eyv z?&o~;0Yx_$HnZ*iykL1wma}I8f(CKNcq+?l&Zr`(Gn#t-h1DmPIjD1hP>$r=g-_-%ZF`hDJJs~jy!$6c?|BFj=Cb00^5Zl_Du zmi+}JA0a^$4t`c;bwl00o-^-CmLNotDyP1p8r>Nd%<~gG$`J?pgJ(w3CMGZkE_~e; zpa`s*9bQ!`d|!F!>$$fU(+Mk!kIhmBzmHL%YK4Jd^4y_5_bwl##GeP@7C}&EC$E7K z+&RPR+VA3iGn_IdVPjwTp1Hx6fmg5B6aZfcOtMDA@j?yYj8sW`TY&ST3@jh-jM!eq zoyyz;<iSi`n$Ueb^=0y7d&D-zS~ILemq7_v;^3HoX*0i01*>FB;-@emD$M<9Etu zS#x67i_3?8cZ!}W0|y=U_jM0j-S5fVI51*1tw=grvo*FBNL6PyBCQA|3)EX;p4eS> zl@R`cpGUUFce+C`IbL$PIs)e)Pe(sFt@!5{EN(ppw{lXP4+TMuA`Jv~=!&ow;lC7j z^8rr`%esF%FE>0vE+vi`Jt(|tq`U^C6Gl?mH-cfZ6=q#{YBNAk9K`YC7T>ftl35TK zH61LZL*lJbw6=ITk+@Zy%_<>6I(sbSdae0@t^eiS{+B-Wq%gR0XDVZSAcU6G&B zbj3pS<%xxBln?5n8NeNtkvKDtPDs!w@jSqY>NA}Rfvw3~Cu#5WALzKeKfT8!+46w0Zbt9Bf9oCS7cj-P3cFVlb|4z^)q#yzDCILhOBeex zzXT>E;mGVMnWk(~LC-m&?F2QdaMGuz@Mx49!Aj~fMt99Ns5`p+b )evSEqpPqc&oxw z-wU~MP8$dRu1vj)tjqEwo|V=1CH58|IWMg14$P+S)P5L?sx1t1A)B6lIs3K8FP@-5 z&!A=n*P4&Hv3v4t5I=zed1%njvFOf%qJXUUbSv*2y7{IAd=(ZaTuj>nXGO?F2&S3m zoRE(ZCP*yAvY#ER<(r~`Yq?i+#HCsK<_Wlpb`X}Q>Bp1xZZVfG@ThNGXz=jb^^MCB zb4Wuc-6SsWJJW@W7dl@+fHkkVRA3RpRrR|U>HD4j!CurelMp*4K>tSBx_Pe=&QhOCnnM& z05SLbvjFhbcO%cgs6743tgxoi7hp?}CA*F3SBVIMphnA)sDOxMmPZ@ZLsCQd3rj>> zVM@b45VC)`Zl2v5pOtdq;AQ7dwA|m}siTbgntlrRy8rF9zvk literal 0 HcmV?d00001 diff --git a/tests/unit/backend/resources/invalid.py b/tests/unit/backend/resources/invalid.py new file mode 100644 index 0000000000..01606bc69d --- /dev/null +++ b/tests/unit/backend/resources/invalid.py @@ -0,0 +1 @@ +print('Hello! I am a file with a file extension which is invalid for FormSG attachments. I am for testing purposes.') \ No newline at end of file diff --git a/tests/unit/backend/resources/nested.zip b/tests/unit/backend/resources/nested.zip deleted file mode 100644 index 062c37ab79cdd84a044d47b3fb8c4657b37f5948..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1086 zcmWIWW@h1H0D*_4CH`~n%dU@OVqgGaVFnq7%)GM1oXnKOyc964SCv^18p6rIoc8ca z?A*s!VoNKy85miA8?0X{0hHx@4f1lDV0A2D9sEL6A0JFER A?EnA( literal 0 HcmV?d00001 diff --git a/tests/unit/backend/resources/nestedValid.zip b/tests/unit/backend/resources/nestedValid.zip new file mode 100644 index 0000000000000000000000000000000000000000..97602491c5e1b5fd9e6975ce19a7a854cdffb01b GIT binary patch literal 482 zcmWIWW@h1H00D_q$-w&}c2O&UY!K#TkYOlG%*jmAtI8}04dG;94w&W=55lDt+zgB? zUl|z~SVVvd1K>uSD=qN{3cvxvh^&Hiuo2D=uf(PSF@_P0OmfT+cVHSK0XJ|-BZvuc z71&5t24;|v7{;-(ffO|_AP%+Ni5Xtl-3bmopgXaK9I@Vh4Rk-z-ezK8VweZi Kpa+a01_l88!c { + it('should return true when given a single valid file', async () => { + const isValid = await attachmentsAreValid([validSingleFile]) + expect(isValid).toBe(true) + }) + + it('should return true when given a multiple valid files', async () => { + const isValid = await attachmentsAreValid([ + validSingleFile, + validSingleFile, + ]) + expect(isValid).toBe(true) + }) + + it('should return false when given a single invalid file', async () => { + const isValid = await attachmentsAreValid([invalidSingleFile]) + expect(isValid).toBe(false) + }) + + it('should return false when given a multiple invalid files', async () => { + const isValid = await attachmentsAreValid([ + invalidSingleFile, + invalidSingleFile, + ]) + expect(isValid).toBe(false) + }) + + it('should return false when given a mix of valid and invalid files', async () => { + const isValid = await attachmentsAreValid([ + validSingleFile, + invalidSingleFile, + ]) + expect(isValid).toBe(false) + }) + + it('should return true when given a single valid zip', async () => { + const isValid = await attachmentsAreValid([zipOnlyValid]) + expect(isValid).toBe(true) + }) + + it('should return true when given multiple valid zips', async () => { + const isValid = await attachmentsAreValid([zipOnlyValid, zipOnlyValid]) + expect(isValid).toBe(true) + }) + + it('should return false when given a zip with only invalid files', async () => { + const isValid = await attachmentsAreValid([zipOnlyInvalid]) + expect(isValid).toBe(false) + }) + + it('should return false when given a zip with a mix of valid and invalid files', async () => { + const isValid = await attachmentsAreValid([zipWithValidAndInvalid]) + expect(isValid).toBe(false) + }) + + it('should return false when given multiple invalid zips', async () => { + const isValid = await attachmentsAreValid([ + zipOnlyInvalid, + zipWithValidAndInvalid, + ]) + expect(isValid).toBe(false) + }) + + it('should return false when given a mix of valid and invalid zips', async () => { + const isValid = await attachmentsAreValid([zipOnlyValid, zipOnlyInvalid]) + expect(isValid).toBe(false) + }) + + it('should return true when given nested zips with valid filetypes', async () => { + const isValid = await attachmentsAreValid([zipNestedValid]) + expect(isValid).toBe(true) + }) + + it('should return false when given nested zips with invalid filetypes', async () => { + const isValid = await attachmentsAreValid([zipNestedInvalid]) + expect(isValid).toBe(false) + }) +}) + +describe('addAttachmentToResponses', () => { + it('should add attachments to responses correctly when inputs are valid', () => { + const firstAttachment = validSingleFile + const secondAttachment = zipOnlyValid + const firstResponse = getResponse( + firstAttachment.fieldId, + firstAttachment.filename, + ) + const secondResponse = getResponse( + secondAttachment.fieldId, + secondAttachment.filename, + ) + addAttachmentToResponses( + [firstResponse, secondResponse], + [firstAttachment, secondAttachment], + ) + expect(firstResponse.answer).toBe(firstAttachment.filename) + expect((firstResponse as IAttachmentResponse).filename).toBe( + firstAttachment.filename, + ) + expect((firstResponse as IAttachmentResponse).content).toEqual( + firstAttachment.content, + ) + expect(secondResponse.answer).toBe(secondAttachment.filename) + expect((secondResponse as IAttachmentResponse).filename).toBe( + secondAttachment.filename, + ) + expect((secondResponse as IAttachmentResponse).content).toEqual( + secondAttachment.content, + ) + }) + + it('should overwrite answer with filename when they are different', () => { + const attachment = validSingleFile + const response = getResponse(attachment.fieldId, MOCK_ANSWER) + addAttachmentToResponses([response], [attachment]) + expect(response.answer).toBe(attachment.filename) + expect((response as IAttachmentResponse).filename).toBe(attachment.filename) + expect((response as IAttachmentResponse).content).toEqual( + attachment.content, + ) + }) + + it('should do nothing when responses are empty', () => { + const responses = [] + addAttachmentToResponses(responses, [validSingleFile]) + expect(responses).toEqual([]) + }) + + it('should do nothing when there are no attachments', () => { + const responses = [getResponse(validSingleFile.fieldId, MOCK_ANSWER)] + addAttachmentToResponses(responses, []) + expect(responses).toEqual([ + getResponse(validSingleFile.fieldId, MOCK_ANSWER), + ]) + }) +}) + +describe('areAttachmentsMoreThan7MB', () => { + it('should pass attachments when they are smaller than 7MB', () => { + expect(areAttachmentsMoreThan7MB([validSingleFile, zipOnlyValid])).toBe( + false, + ) + }) + + it('should fail when a single attachment is larger than 7MB', () => { + const modifiedBigFile = cloneDeep(validSingleFile) + modifiedBigFile.content = Buffer.alloc(7000001) + expect(areAttachmentsMoreThan7MB([modifiedBigFile])).toBe(true) + }) + + it('should fail when attachments add up to more than 7MB', () => { + const modifiedBigFile1 = cloneDeep(validSingleFile) + const modifiedBigFile2 = cloneDeep(validSingleFile) + modifiedBigFile1.content = Buffer.alloc(3500000) + modifiedBigFile2.content = Buffer.alloc(3500001) + expect( + areAttachmentsMoreThan7MB([modifiedBigFile1, modifiedBigFile2]), + ).toBe(true) + }) +}) + +// Note that if e.g. you have three attachments called abc.txt, abc.txt +// and 1-abc.txt, they will not be given unique names, i.e. one of the abc.txt +// will be renamed to 1-abc.txt so you end up with abc.txt, 1-abc.txt and 1-abc.txt. +describe('handleDuplicatesInAttachments', () => { + it('should make filenames unique by appending count when there are duplicates', () => { + const attachments = [ + cloneDeep(validSingleFile), + cloneDeep(validSingleFile), + cloneDeep(validSingleFile), + ] + handleDuplicatesInAttachments(attachments) + const newFilenames = attachments.map((att) => att.filename) + // Expect uniqueness + expect(newFilenames.length).toBe(new Set(newFilenames).size) + expect(newFilenames).toContain(validSingleFile.filename) + expect(newFilenames).toContain(`1-${validSingleFile.filename}`) + expect(newFilenames).toContain(`2-${validSingleFile.filename}`) + }) +}) + +describe('mapAttachmentsFromParsedResponses', () => { + it('should filter fields out when they are not attachments', () => { + const response = getResponse(String(new ObjectId()), MOCK_ANSWER) + response.fieldType = BasicField.YesNo + expect(mapAttachmentsFromParsedResponses([response])).toEqual([]) + }) + + it('should correctly extract filename and content when inputs are valid', () => { + const firstAttachment = validSingleFile + const secondAttachment = zipOnlyValid + const firstResponse = merge( + getResponse(firstAttachment.fieldId, MOCK_ANSWER), + firstAttachment, + ) + const secondResponse = merge( + getResponse(secondAttachment.fieldId, MOCK_ANSWER), + secondAttachment, + ) + const result = mapAttachmentsFromParsedResponses([ + firstResponse, + secondResponse, + ]) + expect(result.length).toBe(2) + expect(result[0]).toEqual({ + filename: firstAttachment.filename, + content: firstAttachment.content, + }) + expect(result[1]).toEqual({ + filename: secondAttachment.filename, + content: secondAttachment.content, + }) + }) +}) + +const getResponse = (_id: string, answer: string): ISingleAnswerResponse => ({ + _id, + fieldType: BasicField.Attachment, + question: 'mockQuestion', + answer, +}) diff --git a/tests/unit/backend/utils/file-validation.spec.js b/tests/unit/backend/utils/file-validation.spec.js index 69c79f6248..7441dd5587 100644 --- a/tests/unit/backend/utils/file-validation.spec.js +++ b/tests/unit/backend/utils/file-validation.spec.js @@ -97,7 +97,7 @@ describe('getInvalidFileExtensionsInZip on server', () => { }, { name: 'should include invalid extensions in nested zip files', - file: './tests/unit/backend/resources/nested.zip', + file: './tests/unit/backend/resources/nestedInvalid.zip', expected: ['.a', '.oo'], }, ] From 849f80447c8deebbc0ca8a2e91bc524177cb925a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2020 18:50:37 +0800 Subject: [PATCH 18/26] chore(deps-dev): bump @types/helmet from 0.0.47 to 0.0.48 (#232) Bumps [@types/helmet](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/helmet) from 0.0.47 to 0.0.48. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/helmet) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4343e0bec5..c0920a6af0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4799,9 +4799,9 @@ "dev": true }, "@types/helmet": { - "version": "0.0.47", - "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.47.tgz", - "integrity": "sha512-TcHA/djjdUtrMtq/QAayVLrsgjNNZ1Uhtz0KhfH01mrmjH44E54DA1A0HNbwW0H/NBFqV+tGMo85ACuEhMXcdg==", + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz", + "integrity": "sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw==", "dev": true, "requires": { "@types/express": "*" diff --git a/package.json b/package.json index 9b008d9c74..ad05d18be1 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "@types/express": "^4.17.6", "@types/express-session": "^1.17.0", "@types/has-ansi": "^3.0.0", - "@types/helmet": "0.0.47", + "@types/helmet": "0.0.48", "@types/ip": "^1.1.0", "@types/jest": "^26.0.9", "@types/mongoose": "^5.7.36", From c09fef15a7a9077b446581886196be46139bc33a Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Mon, 31 Aug 2020 19:54:09 +0800 Subject: [PATCH 19/26] refactor: use express router for modules (#204) * refactor: rename sns module to bounce * refactor: use router for bounce routes * refactor: use router for vfn routes * refactor: avoid default exports * refactor: use it instead of test * refactor: export on declaration * fix: use message instead of error * refactor: use segments * refactor: change all test wording to should * refactor: add newline after each block * refactor: capitalise imports * refactor: use clearAllMocks * test: add params in mockRequest * test: use expressHandler for req and res * fix: use messages instead of message * refactor: swap joi function order and use message * chore: change it back to test Co-authored-by: Kar Rui Lau * refactor: export on declaration Co-authored-by: Kar Rui Lau --- .../bounce.controller.ts} | 2 +- src/app/modules/bounce/bounce.routes.ts | 16 ++ .../bounce.service.ts} | 0 src/app/modules/sns/sns.routes.ts | 18 -- .../verification/verification.routes.ts | 117 +++++------ src/loaders/express/index.ts | 8 +- .../backend/helpers/{sns.ts => bounce.ts} | 0 tests/unit/backend/helpers/jest-express.ts | 3 + .../models/bounce.server.model.spec.ts | 22 +-- .../models/verification.server.model.spec.ts | 10 +- .../modules/bounce/bounce.controller.spec.ts | 69 +++++++ .../bounce.service.spec.ts} | 59 +++--- .../modules/sns/sns.controller.spec.ts | 53 ----- .../verification.controller.spec.ts | 183 +++++++++--------- .../verification/verification.service.spec.ts | 88 ++++----- 15 files changed, 343 insertions(+), 305 deletions(-) rename src/app/modules/{sns/sns.controller.ts => bounce/bounce.controller.ts} (96%) create mode 100644 src/app/modules/bounce/bounce.routes.ts rename src/app/modules/{sns/sns.service.ts => bounce/bounce.service.ts} (100%) delete mode 100644 src/app/modules/sns/sns.routes.ts rename tests/unit/backend/helpers/{sns.ts => bounce.ts} (100%) create mode 100644 tests/unit/backend/modules/bounce/bounce.controller.spec.ts rename tests/unit/backend/modules/{sns/sns.service.spec.ts => bounce/bounce.service.spec.ts} (89%) delete mode 100644 tests/unit/backend/modules/sns/sns.controller.spec.ts diff --git a/src/app/modules/sns/sns.controller.ts b/src/app/modules/bounce/bounce.controller.ts similarity index 96% rename from src/app/modules/sns/sns.controller.ts rename to src/app/modules/bounce/bounce.controller.ts index 6d67d5e9aa..502bb57f6d 100644 --- a/src/app/modules/sns/sns.controller.ts +++ b/src/app/modules/bounce/bounce.controller.ts @@ -4,7 +4,7 @@ import HttpStatus from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' import { ISnsNotification } from '../../../types' -import * as snsService from './sns.service' +import * as snsService from './bounce.service' const logger = createLoggerWithLabel(module) /** diff --git a/src/app/modules/bounce/bounce.routes.ts b/src/app/modules/bounce/bounce.routes.ts new file mode 100644 index 0000000000..ca74a29eef --- /dev/null +++ b/src/app/modules/bounce/bounce.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express' + +import handleSns from './bounce.controller' + +export const BounceRouter = Router() + +/** + * When email bounces, SNS calls this function to mark the + * submission as having bounced. + * + * Note that if anything errors in between, just return a 200 + * to SNS, as the error code to them doesn't really matter. + * + * @route POST /emailnotifications + */ +BounceRouter.post('/', handleSns) diff --git a/src/app/modules/sns/sns.service.ts b/src/app/modules/bounce/bounce.service.ts similarity index 100% rename from src/app/modules/sns/sns.service.ts rename to src/app/modules/bounce/bounce.service.ts diff --git a/src/app/modules/sns/sns.routes.ts b/src/app/modules/sns/sns.routes.ts deleted file mode 100644 index 34c0072002..0000000000 --- a/src/app/modules/sns/sns.routes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Express } from 'express' - -import handleSns from './sns.controller' - -const mountSnsRoutes = (app: Express) => { - /** - * When email bounces, SNS calls this function to mark the - * submission as having bounced. - * - * Note that if anything errors in between, just return a 200 - * to SNS, as the error code to them doesn't really matter. - * - * @route POST /emailnotifications - */ - app.route('/emailnotifications').post(handleSns) -} - -export default mountSnsRoutes diff --git a/src/app/modules/verification/verification.routes.ts b/src/app/modules/verification/verification.routes.ts index f1925e3f8b..2d591a23f2 100644 --- a/src/app/modules/verification/verification.routes.ts +++ b/src/app/modules/verification/verification.routes.ts @@ -1,65 +1,72 @@ -import { celebrate, Joi } from 'celebrate' -import { Express } from 'express' +import { celebrate, Joi, Segments } from 'celebrate' +import { Router } from 'express' import verifiedFieldsFactory from './verification.factory' -const mountVfnRoutes = (app: Express): void => { - const formatOfId = Joi.string().length(24).hex().required() - app.route('/transaction').post( - celebrate({ - body: Joi.object({ - formId: formatOfId, - }), +export const VfnRouter = Router() + +const formatOfId = Joi.string().length(24).hex().required() + +VfnRouter.post( + '/', + celebrate({ + [Segments.BODY]: Joi.object({ + formId: formatOfId, }), - verifiedFieldsFactory.createTransaction, - ) - app.route('/transaction/:transactionId').get( - celebrate({ - params: Joi.object({ - transactionId: formatOfId, - }), + }), + verifiedFieldsFactory.createTransaction, +) + +VfnRouter.get( + '/:transactionId', + celebrate({ + [Segments.PARAMS]: Joi.object({ + transactionId: formatOfId, }), - verifiedFieldsFactory.getTransactionMetadata, - ) - app.route('/transaction/:transactionId/reset').post( - celebrate({ - params: Joi.object({ - transactionId: formatOfId, - }), - body: Joi.object({ - fieldId: formatOfId, - }), + }), + verifiedFieldsFactory.getTransactionMetadata, +) + +VfnRouter.post( + '/:transactionId/reset', + celebrate({ + [Segments.PARAMS]: Joi.object({ + transactionId: formatOfId, }), - verifiedFieldsFactory.resetFieldInTransaction, - ) - app.route('/transaction/:transactionId/otp').post( - celebrate({ - params: Joi.object({ - transactionId: formatOfId, - }), - body: Joi.object({ - fieldId: formatOfId, - answer: Joi.string().required(), - }), + [Segments.BODY]: Joi.object({ + fieldId: formatOfId, }), - verifiedFieldsFactory.getNewOtp, - ) + }), + verifiedFieldsFactory.resetFieldInTransaction, +) - app.route('/transaction/:transactionId/otp/verify').post( - celebrate({ - params: Joi.object({ - transactionId: formatOfId, - }), - body: Joi.object({ - fieldId: formatOfId, - otp: Joi.string() - .regex(/^\d{6}$/) - .required() - .error(() => 'Please enter a valid otp'), - }), +VfnRouter.post( + '/:transactionId/otp', + celebrate({ + [Segments.PARAMS]: Joi.object({ + transactionId: formatOfId, }), - verifiedFieldsFactory.verifyOtp, - ) -} + [Segments.BODY]: Joi.object({ + fieldId: formatOfId, + answer: Joi.string().required(), + }), + }), + verifiedFieldsFactory.getNewOtp, +) -export default mountVfnRoutes +VfnRouter.post( + '/:transactionId/otp/verify', + celebrate({ + [Segments.PARAMS]: Joi.object({ + transactionId: formatOfId, + }), + [Segments.BODY]: Joi.object({ + fieldId: formatOfId, + otp: Joi.string() + .required() + .regex(/^\d{6}$/) + .message('Please enter a valid OTP'), + }), + }), + verifiedFieldsFactory.verifyOtp, +) diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts index 2a2ff7a16a..96a816e8a1 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -7,9 +7,9 @@ import { Connection } from 'mongoose' import path from 'path' import url from 'url' -import mountSnsRoutes from '../../app/modules/sns/sns.routes' +import { BounceRouter } from '../../app/modules/bounce/bounce.routes' import UserRouter from '../../app/modules/user/user.routes' -import mountVfnRoutes from '../../app/modules/verification/verification.routes' +import { VfnRouter } from '../../app/modules/verification/verification.routes' import apiRoutes from '../../app/routes' import config from '../../config/config' @@ -123,9 +123,9 @@ const loadExpressApp = async (connection: Connection) => { apiRoutes.forEach(function (routeFunction) { routeFunction(app) }) - mountSnsRoutes(app) - mountVfnRoutes(app) app.use('/user', UserRouter) + app.use('/emailnotifications', BounceRouter) + app.use('/transaction', VfnRouter) app.use(sentryMiddlewares()) diff --git a/tests/unit/backend/helpers/sns.ts b/tests/unit/backend/helpers/bounce.ts similarity index 100% rename from tests/unit/backend/helpers/sns.ts rename to tests/unit/backend/helpers/bounce.ts diff --git a/tests/unit/backend/helpers/jest-express.ts b/tests/unit/backend/helpers/jest-express.ts index 1d1a3c141a..96fcc1847b 100644 --- a/tests/unit/backend/helpers/jest-express.ts +++ b/tests/unit/backend/helpers/jest-express.ts @@ -2,13 +2,16 @@ import { Request, Response } from 'express' const mockRequest = ({ body, + params, session, }: { body: Record + params?: Record session?: any }) => { return { body, + params, session, } as Request } diff --git a/tests/unit/backend/models/bounce.server.model.spec.ts b/tests/unit/backend/models/bounce.server.model.spec.ts index 3836698f02..25a81a6f91 100644 --- a/tests/unit/backend/models/bounce.server.model.spec.ts +++ b/tests/unit/backend/models/bounce.server.model.spec.ts @@ -4,12 +4,12 @@ import mongoose from 'mongoose' import getBounceModel from 'src/app/models/bounce.server.model' -import dbHandler from '../helpers/jest-db' import { extractBounceObject, makeBounceNotification, makeDeliveryNotification, -} from '../helpers/sns' +} from '../helpers/bounce' +import dbHandler from '../helpers/jest-db' const Bounce = getBounceModel(mongoose) @@ -21,7 +21,7 @@ describe('Bounce Model', () => { afterAll(async () => await dbHandler.closeDatabase()) describe('schema', () => { - test('should create and save successfully with defaults', async () => { + it('should save with defaults when params are not provided', async () => { const formId = new ObjectId() const bounces = [{ email: MOCK_EMAIL }] const savedBounce = await new Bounce({ formId, bounces }).save() @@ -35,7 +35,7 @@ describe('Bounce Model', () => { }) }) - test('should create and save successfully with non-defaults', async () => { + it('should save with non-defaults when they are provided', async () => { const params = { formId: new ObjectId(), bounces: [{ email: MOCK_EMAIL, hasBounced: true }], @@ -47,7 +47,7 @@ describe('Bounce Model', () => { expect(savedBounceObject).toEqual(params) }) - test('should reject invalid emails', async () => { + it('should reject emails when they are invalid', async () => { const bounce = new Bounce({ formId: new ObjectId(), bounces: [{ email: 'this is an ex-parrot' }], @@ -55,7 +55,7 @@ describe('Bounce Model', () => { await expect(bounce.save()).rejects.toThrow() }) - test('should reject empty form IDs', async () => { + it('should not save when formId is not provided', async () => { const bounce = new Bounce() await expect(bounce.save()).rejects.toThrowError('Form ID is required') }) @@ -63,7 +63,7 @@ describe('Bounce Model', () => { describe('methods', () => { describe('merge', () => { - test('should update old bounce with latest bounce info', async () => { + it('should update old bounce when valid bounce info is given', () => { const formId = new ObjectId() const oldBounce = new Bounce({ formId, @@ -81,7 +81,7 @@ describe('Bounce Model', () => { }) }) - test('should set hasBounced to false if email is delivered later', async () => { + it('should set hasBounced to false when email is delivered later', () => { const formId = new ObjectId() const oldBounce = new Bounce({ formId, @@ -105,7 +105,7 @@ describe('Bounce Model', () => { }) }) - test('should update email list as necessary', async () => { + it('should update email list when it changes', () => { const newEmail = 'newemail@email.com' const formId = new ObjectId() const oldBounce = new Bounce({ @@ -134,7 +134,7 @@ describe('Bounce Model', () => { describe('statics', () => { describe('fromSnsNotification', () => { - test('should create documents from delivery notifications correctly', async () => { + it('should create documents correctly when delivery notification is valid', () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification = JSON.parse( @@ -154,7 +154,7 @@ describe('Bounce Model', () => { expect(actual.expireAt).toBeInstanceOf(Date) }) - test('should create documents from bounce notifications correctly', async () => { + it('should create documents correctly when bounce notification is valid', () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification = JSON.parse( diff --git a/tests/unit/backend/models/verification.server.model.spec.ts b/tests/unit/backend/models/verification.server.model.spec.ts index 419922b481..dd5e4a1592 100644 --- a/tests/unit/backend/models/verification.server.model.spec.ts +++ b/tests/unit/backend/models/verification.server.model.spec.ts @@ -37,7 +37,7 @@ describe('Verification Model', () => { afterAll(async () => await dbHandler.closeDatabase()) describe('Schema', () => { - it('should create and save successfully with defaults', async () => { + it('should save successfully with defaults when params are not given', async () => { const verification = new Verification(VFN_PARAMS) const verificationSaved = await verification.save() expect(verificationSaved._id).toBeDefined() @@ -51,7 +51,7 @@ describe('Verification Model', () => { expect(actualSavedFields).toEqual(expectedSavedFields) }) - it('should create and save successfully with expireAt specified', async () => { + it('should save successfully when expireAt is specified', async () => { const params = merge({}, VFN_PARAMS, { expireAt: new Date() }) const verification = new Verification(params) const verificationSaved = await verification.save() @@ -64,7 +64,7 @@ describe('Verification Model', () => { expect(actualSavedFields).toEqual(expectedSavedFields) }) - it('should create and save successfully with default fields', async () => { + it('should save successfully with defaults when field keys are not specified', async () => { const vfnParams = merge({}, VFN_PARAMS, { fields: [generateFieldParams(), generateFieldParams()], }) @@ -80,7 +80,7 @@ describe('Verification Model', () => { expect(actualSavedFields).toEqual(expectedSavedFields) }) - it('should create and save successfully with field keys specified', async () => { + it('should save successfully when field keys are specified', async () => { const field = { ...generateFieldParams(), signedData: 'signedData', @@ -101,7 +101,7 @@ describe('Verification Model', () => { expect(actualSavedFields).toEqual(expectedSavedFields) }) - it('should reject attempts to save identical field IDs', async () => { + it('should not save when field IDs are identical', async () => { const field = generateFieldParams() const vfnParams = merge({}, VFN_PARAMS, { fields: [field, field], diff --git a/tests/unit/backend/modules/bounce/bounce.controller.spec.ts b/tests/unit/backend/modules/bounce/bounce.controller.spec.ts new file mode 100644 index 0000000000..8ecc959942 --- /dev/null +++ b/tests/unit/backend/modules/bounce/bounce.controller.spec.ts @@ -0,0 +1,69 @@ +import HttpStatus from 'http-status-codes' +import expressHandler from 'tests/unit/backend/helpers/jest-express' +import { mocked } from 'ts-jest/utils' + +import handleSns from 'src/app/modules/bounce/bounce.controller' +import * as BounceService from 'src/app/modules/bounce/bounce.service' + +jest.mock('src/app/modules/bounce/bounce.service') +const MockBounceService = mocked(BounceService, true) + +const MOCK_REQ = expressHandler.mockRequest({ body: { someKey: 'someValue' } }) +const MOCK_RES = expressHandler.mockResponse() + +describe('handleSns', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should not call updateBounces when requests are invalid', async () => { + MockBounceService.isValidSnsRequest.mockReturnValueOnce( + Promise.resolve(false), + ) + await handleSns(MOCK_REQ, MOCK_RES) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.updateBounces).not.toHaveBeenCalled() + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) + }) + + it('should call updateBounces when requests are valid', async () => { + MockBounceService.isValidSnsRequest.mockReturnValueOnce( + Promise.resolve(true), + ) + await handleSns(MOCK_REQ, MOCK_RES) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + }) + + it('should return 400 when errors are thrown in isValidSnsRequest', async () => { + MockBounceService.isValidSnsRequest.mockImplementation(() => { + throw new Error() + }) + await handleSns(MOCK_REQ, MOCK_RES) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.updateBounces).not.toHaveBeenCalled() + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + }) + + it('should return 400 when errors are thrown in updateBounces', async () => { + MockBounceService.isValidSnsRequest.mockReturnValueOnce( + Promise.resolve(true), + ) + MockBounceService.updateBounces.mockImplementation(() => { + throw new Error() + }) + await handleSns(MOCK_REQ, MOCK_RES) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + }) +}) diff --git a/tests/unit/backend/modules/sns/sns.service.spec.ts b/tests/unit/backend/modules/bounce/bounce.service.spec.ts similarity index 89% rename from tests/unit/backend/modules/sns/sns.service.spec.ts rename to tests/unit/backend/modules/bounce/bounce.service.spec.ts index 09b7b58bf1..b6806163f8 100644 --- a/tests/unit/backend/modules/sns/sns.service.spec.ts +++ b/tests/unit/backend/modules/bounce/bounce.service.spec.ts @@ -6,25 +6,25 @@ import { cloneDeep, omit } from 'lodash' import mongoose from 'mongoose' import { mocked } from 'ts-jest/utils' -import * as loggerModule from 'src/config/logger' +import * as LoggerModule from 'src/config/logger' import { ISnsNotification } from 'src/types' -import dbHandler from '../../helpers/jest-db' -import getMockLogger, { resetMockLogger } from '../../helpers/jest-logger' import { extractBounceObject, makeBounceNotification, makeDeliveryNotification, MOCK_SNS_BODY, -} from '../../helpers/sns' +} from '../../helpers/bounce' +import dbHandler from '../../helpers/jest-db' +import getMockLogger, { resetMockLogger } from '../../helpers/jest-logger' jest.mock('axios') const mockAxios = mocked(axios, true) jest.mock('src/config/logger') -const mockLoggerModule = mocked(loggerModule, true) +const MockLoggerModule = mocked(LoggerModule, true) const mockLogger = getMockLogger() -mockLoggerModule.createCloudWatchLogger.mockReturnValue(mockLogger) -mockLoggerModule.createLoggerWithLabel.mockReturnValue(getMockLogger()) +MockLoggerModule.createCloudWatchLogger.mockReturnValue(mockLogger) +MockLoggerModule.createLoggerWithLabel.mockReturnValue(getMockLogger()) // Import modules which depend on config last so that mocks get imported correctly // eslint-disable-next-line import/first @@ -33,12 +33,13 @@ import getBounceModel from 'src/app/models/bounce.server.model' import { isValidSnsRequest, updateBounces, -} from 'src/app/modules/sns/sns.service' +} from 'src/app/modules/bounce/bounce.service' const Bounce = getBounceModel(mongoose) describe('isValidSnsRequest', () => { let keys, body: ISnsNotification + beforeAll(() => { keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, @@ -52,31 +53,38 @@ describe('isValidSnsRequest', () => { }, }) }) + beforeEach(() => { body = cloneDeep(MOCK_SNS_BODY) mockAxios.get.mockResolvedValue({ data: keys.publicKey, }) }) - test('should gracefully reject empty input', () => { + + it('should gracefully reject when input is empty', () => { return expect(isValidSnsRequest(undefined)).resolves.toBe(false) }) - test('should reject requests without valid structure', () => { + + it('should reject requests when their structure is invalid', () => { delete body.Type return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - test('should reject requests with invalid certificate URL', () => { + + it('should reject requests when their certificate URL is invalid', () => { body.SigningCertURL = 'http://www.example.com' return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - test('should reject requests with invalid signature version', () => { + + it('should reject requests when their signature version is invalid', () => { body.SignatureVersion = 'wrongSignatureVersion' return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - test('should reject requests with invalid signature', () => { + + it('should reject requests when their signature is invalid', () => { return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - test('should accept valid requests', () => { + + it('should accept when requests are valid', () => { const signer = crypto.createSign('RSA-SHA1') const baseString = dedent`Message @@ -102,14 +110,17 @@ describe('updateBounces', () => { 'email2@example.com', 'email3@example.com', ] + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => { await dbHandler.clearDatabase() resetMockLogger(mockLogger) }) + afterAll(async () => await dbHandler.closeDatabase()) - test('should save a single delivery notification correctly', async () => { + it('should save correctly when there is a single delivery notification', async () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification = makeDeliveryNotification( @@ -137,7 +148,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save a single non-critical bounce notification correctly', async () => { + it('should save correctly when there is a single non-critical bounce notification', async () => { const bounces = { [recipientList[0]]: true, [recipientList[1]]: false, @@ -170,7 +181,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save a single critical bounce notification correctly', async () => { + it('should save correctly when there is a single critical bounce notification', async () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification = makeBounceNotification( @@ -200,7 +211,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save consecutive delivery notifications correctly', async () => { + it('should save correctly when there are consecutive delivery notifications', async () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification1 = makeDeliveryNotification( @@ -240,7 +251,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save consecutive non-critical bounce notifications correctly', async () => { + it('should save correctly when there are consecutive non-critical bounce notifications', async () => { const bounces = { [recipientList[0]]: true, [recipientList[1]]: true, @@ -285,7 +296,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save consecutive critical bounce notifications correctly', async () => { + it('should save correctly when there are consecutive critical bounce notifications', async () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification1 = makeBounceNotification( @@ -327,7 +338,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save delivery, then bounce notifications correctly', async () => { + it('should save correctly when there are delivery then bounce notifications', async () => { const bounces = { [recipientList[0]]: false, [recipientList[1]]: true, @@ -372,7 +383,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should save bounce, then delivery notifications correctly', async () => { + it('should save correctly when there are bounce then delivery notifications', async () => { const bounces = { [recipientList[0]]: true, [recipientList[1]]: false, @@ -417,7 +428,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should set hasBounced to false on subsequent success', async () => { + it('should set hasBounced to false when a subsequent response is delivered', async () => { const formId = new ObjectId() const submissionId = new ObjectId() const notification1 = makeBounceNotification( @@ -457,7 +468,7 @@ describe('updateBounces', () => { expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - test('should not log critical bounces if hasAlarmed is true', async () => { + it('should not log critical bounces when hasAlarmed is true', async () => { const formId = new ObjectId() const submissionId1 = new ObjectId() const submissionId2 = new ObjectId() diff --git a/tests/unit/backend/modules/sns/sns.controller.spec.ts b/tests/unit/backend/modules/sns/sns.controller.spec.ts deleted file mode 100644 index 50b529b1c2..0000000000 --- a/tests/unit/backend/modules/sns/sns.controller.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import HttpStatus from 'http-status-codes' -import { mocked } from 'ts-jest/utils' - -import handleSns from 'src/app/modules/sns/sns.controller' -import * as snsService from 'src/app/modules/sns/sns.service' - -jest.mock('src/app/modules/sns/sns.service') -const mockSnsService = mocked(snsService, true) - -describe('handleSns', () => { - let req, res - beforeEach(() => { - req = { body: 'somebody' } - res = { sendStatus: jest.fn() } - }) - afterEach(() => { - mockSnsService.updateBounces.mockReset() - mockSnsService.isValidSnsRequest.mockReset() - }) - test('does not call updateBounces for invalid requests', async () => { - mockSnsService.isValidSnsRequest.mockReturnValueOnce(Promise.resolve(false)) - await handleSns(req, res) - expect(mockSnsService.isValidSnsRequest).toHaveBeenCalledWith(req.body) - expect(mockSnsService.updateBounces).not.toHaveBeenCalled() - expect(res.sendStatus).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) - }) - test('calls updateBounces for valid requests', async () => { - mockSnsService.isValidSnsRequest.mockReturnValueOnce(Promise.resolve(true)) - await handleSns(req, res) - expect(mockSnsService.isValidSnsRequest).toHaveBeenCalledWith(req.body) - expect(mockSnsService.updateBounces).toHaveBeenCalledWith(req.body) - expect(res.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) - }) - test('catches errors thrown in isValidSnsRequest', async () => { - mockSnsService.isValidSnsRequest.mockImplementation(() => { - throw new Error() - }) - await handleSns(req, res) - expect(mockSnsService.isValidSnsRequest).toHaveBeenCalledWith(req.body) - expect(mockSnsService.updateBounces).not.toHaveBeenCalled() - expect(res.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) - }) - test('catches errors thrown in updateBounces', async () => { - mockSnsService.isValidSnsRequest.mockReturnValueOnce(Promise.resolve(true)) - mockSnsService.updateBounces.mockImplementation(() => { - throw new Error() - }) - await handleSns(req, res) - expect(mockSnsService.isValidSnsRequest).toHaveBeenCalledWith(req.body) - expect(mockSnsService.updateBounces).toHaveBeenCalledWith(req.body) - expect(res.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) - }) -}) diff --git a/tests/unit/backend/modules/verification/verification.controller.spec.ts b/tests/unit/backend/modules/verification/verification.controller.spec.ts index f0dfc229d3..dd4c9e17b7 100644 --- a/tests/unit/backend/modules/verification/verification.controller.spec.ts +++ b/tests/unit/backend/modules/verification/verification.controller.spec.ts @@ -11,12 +11,12 @@ import { resetFieldInTransaction, verifyOtp, } from 'src/app/modules/verification/verification.controller' -import * as vfnService from 'src/app/modules/verification/verification.service' +import * as VfnService from 'src/app/modules/verification/verification.service' import expressHandler from '../../helpers/jest-express' jest.mock('src/app/modules/verification/verification.service') -const mockVfnService = mocked(vfnService, true) +const MockVfnService = mocked(VfnService, true) const noop = () => {} const MOCK_FORM_ID = 'formId' const MOCK_TRANSACTION_ID = 'transactionId' @@ -24,7 +24,7 @@ const MOCK_FIELD_ID = 'fieldId' const MOCK_ANSWER = 'answer' const MOCK_OTP = 'otp' const MOCK_DATA = 'data' -const mockRes = expressHandler.mockResponse() +const MOCK_RES = expressHandler.mockResponse() const Verification = getVerificationModel(mongoose) const notFoundError = 'TRANSACTION_NOT_FOUND' const waitOtpError = 'WAIT_FOR_OTP' @@ -37,32 +37,32 @@ describe('Verification controller', () => { afterEach(() => jest.clearAllMocks()) beforeAll(() => { - mockReq = { body: { formId: MOCK_FORM_ID } } + mockReq = expressHandler.mockRequest({ body: { formId: MOCK_FORM_ID } }) }) - test('correctly returns transaction', async () => { + it('should correctly return transaction when parameters are valid', async () => { const returnValue = { transactionId: 'Bereft of life, it rests in peace', expireAt: new Date(), } - mockVfnService.createTransaction.mockReturnValueOnce( + MockVfnService.createTransaction.mockReturnValueOnce( Promise.resolve(returnValue), ) - await createTransaction(mockReq, mockRes, noop) - expect(mockVfnService.createTransaction).toHaveBeenCalledWith( + await createTransaction(mockReq, MOCK_RES, noop) + expect(MockVfnService.createTransaction).toHaveBeenCalledWith( MOCK_FORM_ID, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.CREATED) - expect(mockRes.json).toHaveBeenCalledWith(returnValue) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.CREATED) + expect(MOCK_RES.json).toHaveBeenCalledWith(returnValue) }) - test('correctly returns 200 when transaction is not found', async () => { - mockVfnService.createTransaction.mockReturnValueOnce(null) - await createTransaction(mockReq, mockRes, noop) - expect(mockVfnService.createTransaction).toHaveBeenCalledWith( + it('should correctly return 200 when transaction is not found', async () => { + MockVfnService.createTransaction.mockReturnValueOnce(null) + await createTransaction(mockReq, MOCK_RES, noop) + expect(MockVfnService.createTransaction).toHaveBeenCalledWith( MOCK_FORM_ID, ) - expect(mockRes.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) }) }) @@ -71,37 +71,40 @@ describe('Verification controller', () => { afterEach(() => jest.clearAllMocks()) beforeAll(() => { - mockReq = { params: { transactionId: MOCK_TRANSACTION_ID } } + mockReq = expressHandler.mockRequest({ + body: {}, + params: { transactionId: MOCK_TRANSACTION_ID }, + }) }) - test('correctly returns metadata', async () => { + it('should correctly return metadata when parameters are valid', async () => { // Coerce type - const transaction = ('test' as unknown) as ReturnType< - typeof mockVfnService.getTransactionMetadata + const transaction = ('it' as unknown) as ReturnType< + typeof MockVfnService.getTransactionMetadata > - mockVfnService.getTransactionMetadata.mockReturnValueOnce( + MockVfnService.getTransactionMetadata.mockReturnValueOnce( Promise.resolve(transaction), ) - await getTransactionMetadata(mockReq, mockRes, noop) - expect(mockVfnService.getTransactionMetadata).toHaveBeenCalledWith( + await getTransactionMetadata(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransactionMetadata).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.OK) - expect(mockRes.json).toHaveBeenCalledWith(transaction) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.json).toHaveBeenCalledWith(transaction) }) - test('returns 404 on error', async () => { - mockVfnService.getTransactionMetadata.mockImplementationOnce(() => { + it('should return 404 when service throws error', async () => { + MockVfnService.getTransactionMetadata.mockImplementationOnce(() => { const error = new Error(notFoundError) error.name = notFoundError throw error }) - await getTransactionMetadata(mockReq, mockRes, noop) - expect(mockVfnService.getTransactionMetadata).toHaveBeenCalledWith( + await getTransactionMetadata(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransactionMetadata).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) - expect(mockRes.json).toHaveBeenCalledWith(notFoundError) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) }) @@ -110,40 +113,40 @@ describe('Verification controller', () => { afterEach(() => jest.clearAllMocks()) beforeAll(() => { - mockReq = { + mockReq = expressHandler.mockRequest({ body: { fieldId: MOCK_FIELD_ID }, params: { transactionId: MOCK_TRANSACTION_ID }, - } + }) }) - test('correctly calls service', async () => { + it('should correctly call service when params are valid', async () => { const transaction = new Verification({ formId: new ObjectId() }) - mockVfnService.getTransaction.mockReturnValueOnce( + MockVfnService.getTransaction.mockReturnValueOnce( Promise.resolve(transaction), ) - await resetFieldInTransaction(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await resetFieldInTransaction(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.resetFieldInTransaction).toHaveBeenCalledWith( + expect(MockVfnService.resetFieldInTransaction).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, ) - expect(mockRes.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) }) - test('returns 404 on error', async () => { - mockVfnService.getTransaction.mockImplementationOnce(() => { + it('should return 404 when service throws error', async () => { + MockVfnService.getTransaction.mockImplementationOnce(() => { const error = new Error(notFoundError) error.name = notFoundError throw error }) - await resetFieldInTransaction(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await resetFieldInTransaction(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) - expect(mockRes.json).toHaveBeenCalledWith(notFoundError) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) }) @@ -152,84 +155,84 @@ describe('Verification controller', () => { afterEach(() => jest.clearAllMocks()) beforeAll(() => { - mockReq = { + mockReq = expressHandler.mockRequest({ body: { fieldId: MOCK_FIELD_ID, answer: MOCK_ANSWER }, params: { transactionId: MOCK_TRANSACTION_ID }, - } + }) transaction = new Verification({ formId: new ObjectId() }) - mockVfnService.getTransaction.mockReturnValue( + MockVfnService.getTransaction.mockReturnValue( Promise.resolve(transaction), ) }) - test('calls service correctly', async () => { - await getNewOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + it('should call service correctly when params are valid', async () => { + await getNewOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.getNewOtp).toHaveBeenCalledWith( + expect(MockVfnService.getNewOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(mockRes.sendStatus).toHaveBeenCalledWith(HttpStatus.CREATED) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.CREATED) }) - test('returns 404 on not found error', async () => { - mockVfnService.getNewOtp.mockImplementationOnce(() => { + it('should return 404 when service throws not found error', async () => { + MockVfnService.getNewOtp.mockImplementationOnce(() => { const error = new Error(notFoundError) error.name = notFoundError throw error }) - await getNewOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await getNewOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.getNewOtp).toHaveBeenCalledWith( + expect(MockVfnService.getNewOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) - expect(mockRes.json).toHaveBeenCalledWith(notFoundError) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) - test('returns 202 on WaitForOtp error', async () => { - mockVfnService.getNewOtp.mockImplementationOnce(() => { + it('should return 202 when service throws WaitForOtp error', async () => { + MockVfnService.getNewOtp.mockImplementationOnce(() => { const error = new Error(waitOtpError) error.name = waitOtpError throw error }) - await getNewOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await getNewOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.getNewOtp).toHaveBeenCalledWith( + expect(MockVfnService.getNewOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.ACCEPTED) - expect(mockRes.json).toHaveBeenCalledWith(waitOtpError) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.ACCEPTED) + expect(MOCK_RES.json).toHaveBeenCalledWith(waitOtpError) }) - test('returns 400 on OTP failed error', async () => { - mockVfnService.getNewOtp.mockImplementationOnce(() => { + it('should return 400 when services throws OTP failed error', async () => { + MockVfnService.getNewOtp.mockImplementationOnce(() => { const error = new Error(sendOtpError) error.name = sendOtpError throw error }) - await getNewOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await getNewOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.getNewOtp).toHaveBeenCalledWith( + expect(MockVfnService.getNewOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) - expect(mockRes.json).toHaveBeenCalledWith(sendOtpError) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(MOCK_RES.json).toHaveBeenCalledWith(sendOtpError) }) }) @@ -239,50 +242,50 @@ describe('Verification controller', () => { afterEach(() => jest.clearAllMocks()) beforeAll(() => { - mockReq = { + mockReq = expressHandler.mockRequest({ body: { fieldId: MOCK_FIELD_ID, otp: MOCK_OTP }, params: { transactionId: MOCK_TRANSACTION_ID }, - } + }) transaction = new Verification({ formId: new ObjectId() }) - mockVfnService.getTransaction.mockReturnValue( + MockVfnService.getTransaction.mockReturnValue( Promise.resolve(transaction), ) }) - test('calls service correctly', async () => { - mockVfnService.verifyOtp.mockReturnValue(Promise.resolve(MOCK_DATA)) - await verifyOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + it('should call service correctly when params are valid', async () => { + MockVfnService.verifyOtp.mockReturnValue(Promise.resolve(MOCK_DATA)) + await verifyOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.verifyOtp).toHaveBeenCalledWith( + expect(MockVfnService.verifyOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_OTP, ) - expect(mockRes.status).toHaveBeenCalledWith(HttpStatus.OK) - expect(mockRes.json).toHaveBeenCalledWith(MOCK_DATA) + expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.json).toHaveBeenCalledWith(MOCK_DATA) }) - test('returns 422 for invalid OTP', async () => { - mockVfnService.verifyOtp.mockImplementationOnce(() => { + it('should return 422 when OTP is invalid', async () => { + MockVfnService.verifyOtp.mockImplementationOnce(() => { const error = new Error(invalidOtpError) error.name = invalidOtpError throw error }) - await verifyOtp(mockReq, mockRes, noop) - expect(mockVfnService.getTransaction).toHaveBeenCalledWith( + await verifyOtp(mockReq, MOCK_RES, noop) + expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(mockVfnService.verifyOtp).toHaveBeenCalledWith( + expect(MockVfnService.verifyOtp).toHaveBeenCalledWith( transaction, MOCK_FIELD_ID, MOCK_OTP, ) - expect(mockRes.status).toHaveBeenCalledWith( + expect(MOCK_RES.status).toHaveBeenCalledWith( HttpStatus.UNPROCESSABLE_ENTITY, ) - expect(mockRes.json).toHaveBeenCalledWith(invalidOtpError) + expect(MOCK_RES.json).toHaveBeenCalledWith(invalidOtpError) }) }) }) diff --git a/tests/unit/backend/modules/verification/verification.service.spec.ts b/tests/unit/backend/modules/verification/verification.service.spec.ts index e435856193..052c1b6c78 100644 --- a/tests/unit/backend/modules/verification/verification.service.spec.ts +++ b/tests/unit/backend/modules/verification/verification.service.spec.ts @@ -26,15 +26,15 @@ const MOCK_FORM_TITLE = 'Verification service tests' // Set up mocks jest.mock('src/app/utils/otp') -const mockGenerateOtp = mocked(generateOtp, true) +const MockGenerateOtp = mocked(generateOtp, true) jest.mock('src/config/formsg-sdk') -const mockFormsgSdk = mocked(formsgSdk, true) +const MockFormsgSdk = mocked(formsgSdk, true) jest.mock('src/app/factories/sms.factory') -const mockSmsFactory = mocked(smsFactory, true) +const MockSmsFactory = mocked(smsFactory, true) jest.mock('src/app/services/mail.service') -const mockMailService = mocked(MailService, true) +const MockMailService = mocked(MailService, true) jest.mock('bcrypt') -const mockBcrypt = mocked(bcrypt, true) +const MockBcrypt = mocked(bcrypt, true) describe('Verification service', () => { let user: IUserSchema @@ -50,7 +50,7 @@ describe('Verification service', () => { describe('createTransaction', () => { afterEach(async () => await dbHandler.clearDatabase()) - test('should return null when form_fields does not exist', async () => { + it('should return null when form_fields does not exist', async () => { const testForm = new Form({ admin: user, title: MOCK_FORM_TITLE, @@ -63,7 +63,7 @@ describe('Verification service', () => { ).resolves.toBe(null) }) - test('should return null when there are no verifiable fields', async () => { + it('should return null when there are no verifiable fields', async () => { const testForm = new Form({ form_fields: [{ fieldType: BasicField.YesNo }], admin: user, @@ -77,7 +77,7 @@ describe('Verification service', () => { ).resolves.toBe(null) }) - test('should correctly save and return transaction', async () => { + it('should correctly save and return transaction when it is valid', async () => { const testForm = new Form({ form_fields: [{ fieldType: BasicField.Email, isVerifiable: true }], admin: user, @@ -99,13 +99,13 @@ describe('Verification service', () => { describe('getTransactionMetadata', () => { afterEach(async () => await dbHandler.clearDatabase()) - test('should throw error when transaction does not exist', async () => { + it('should throw error when transaction does not exist', async () => { return expect( getTransactionMetadata(String(new ObjectId())), ).rejects.toThrowError('TRANSACTION_NOT_FOUND') }) - test('should correctly return metadata', async () => { + it('should correctly return metadata when request is valid', async () => { const formId = new ObjectId() const expireAt = new Date() const testVerification = new Verification({ formId, expireAt }) @@ -122,7 +122,7 @@ describe('Verification service', () => { describe('resetFieldInTransaction', () => { afterEach(async () => await dbHandler.clearDatabase()) - test('should correctly reset one field', async () => { + it('should reset one field when params are valid', async () => { const testForm = new Form({ admin: user, title: MOCK_FORM_TITLE, @@ -168,7 +168,7 @@ describe('Verification service', () => { }) }) - test('should throw error if field ID does not exist', async () => { + it('should throw error when field ID does not exist', async () => { const transaction = new Verification({ formId: new ObjectId() }) await transaction.save() return expect( @@ -176,7 +176,7 @@ describe('Verification service', () => { ).rejects.toThrowError('Field not found in transaction') }) - test('should throw error if transaction ID does not exist', async () => { + it('should throw error when transaction ID does not exist', async () => { const transaction = new Verification({ formId: new ObjectId() }) return expect( resetFieldInTransaction(transaction, String(new ObjectId())), @@ -219,14 +219,14 @@ describe('Verification service', () => { ], expireAt: new Date(Date.now() + 6e5), // so it won't expire in tests }) - mockGenerateOtp.mockReturnValue(mockOtp) - mockBcrypt.hash.mockReturnValue(Promise.resolve(hashedOtp)) - mockFormsgSdk.verification.generateSignature.mockReturnValue(signedData) + MockGenerateOtp.mockReturnValue(mockOtp) + MockBcrypt.hash.mockReturnValue(Promise.resolve(hashedOtp)) + MockFormsgSdk.verification.generateSignature.mockReturnValue(signedData) }) afterEach(async () => await dbHandler.clearDatabase()) - test('should throw error for expired transaction', async () => { + it('should throw error when transaction is expired', async () => { transaction.expireAt = new Date(1) await transaction.save() return expect( @@ -234,13 +234,13 @@ describe('Verification service', () => { ).rejects.toThrowError('TRANSACTION_NOT_FOUND') }) - test('should throw error for invalid field ID', async () => { + it('should throw error when field ID is invalid', async () => { return expect( getNewOtp(transaction, String(new ObjectId()), mockAnswer), ).rejects.toThrowError('Field not found in transaction') }) - test('should throw error for OTP requested too soon', async () => { + it('should throw error when OTP is requested too soon', async () => { // 1min in the future transaction.fields[0].hashCreatedAt = new Date(Date.now() + 6e4) // Actual error is 'Wait for _ seconds before requesting' @@ -249,25 +249,25 @@ describe('Verification service', () => { ).rejects.toThrowError('seconds before requesting for a new otp') }) - test('should send OTP for email field', async () => { - // Reset field so we can test update later on + it('should send OTP when params are valid for email field', async () => { + // Reset field so we can it update later on transaction.fields[0].hashedOtp = null transaction.fields[0].signedData = null transaction.fields[0].hashCreatedAt = null transaction.fields[0].hashRetries = 1 await transaction.save() await getNewOtp(transaction, transaction.fields[0]._id, mockAnswer) - expect(mockGenerateOtp).toHaveBeenCalled() - expect(mockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) + expect(MockGenerateOtp).toHaveBeenCalled() + expect(MockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) expect( - mockFormsgSdk.verification.generateSignature.mock.calls[0][0], + MockFormsgSdk.verification.generateSignature.mock.calls[0][0], ).toEqual({ transactionId: transaction._id, formId: transaction.formId, fieldId: transaction.fields[0]._id, answer: mockAnswer, }) - expect(mockMailService.sendVerificationOtp.mock.calls[0]).toEqual([ + expect(MockMailService.sendVerificationOtp.mock.calls[0]).toEqual([ mockAnswer, mockOtp, ]) @@ -280,7 +280,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(0) }) - test('should send OTP for mobile field', async () => { + it('should send OTP when params are valid for mobile field', async () => { // Reset field so we can test update later on transaction.fields[1].hashedOtp = null transaction.fields[1].signedData = null @@ -288,17 +288,17 @@ describe('Verification service', () => { transaction.fields[1].hashRetries = 1 await transaction.save() await getNewOtp(transaction, transaction.fields[1]._id, mockAnswer) - expect(mockGenerateOtp).toHaveBeenCalled() - expect(mockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) + expect(MockGenerateOtp).toHaveBeenCalled() + expect(MockBcrypt.hash.mock.calls[0][0]).toBe(mockOtp) expect( - mockFormsgSdk.verification.generateSignature.mock.calls[0][0], + MockFormsgSdk.verification.generateSignature.mock.calls[0][0], ).toEqual({ transactionId: transaction._id, formId: transaction.formId, fieldId: transaction.fields[1]._id, answer: mockAnswer, }) - expect(mockSmsFactory.sendVerificationOtp.mock.calls[0]).toEqual([ + expect(MockSmsFactory.sendVerificationOtp.mock.calls[0]).toEqual([ mockAnswer, mockOtp, transaction.formId, @@ -312,7 +312,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[1].hashRetries).toBe(0) }) - test('should catch and re-throw errors thrown when sending email', async () => { + it('should catch and re-throw errors thrown when sending email', async () => { // So we don't trigger WAIT_FOR_SECONDS error transaction.fields[0].hashCreatedAt = null transaction.fields[0].signedData = null @@ -320,7 +320,7 @@ describe('Verification service', () => { transaction.fields[0].hashRetries = 1 await transaction.save() const myErrorMsg = "I'd like to have an argument please" - mockMailService.sendVerificationOtp.mockImplementationOnce(() => { + MockMailService.sendVerificationOtp.mockImplementationOnce(() => { throw new Error(myErrorMsg) }) return expect( @@ -328,7 +328,7 @@ describe('Verification service', () => { ).rejects.toThrowError(myErrorMsg) }) - test('should catch and re-throw errors thrown when sending sms', async () => { + it('should catch and re-throw errors thrown when sending sms', async () => { // So we don't trigger WAIT_FOR_SECONDS error transaction.fields[1].hashCreatedAt = null transaction.fields[1].signedData = null @@ -336,7 +336,7 @@ describe('Verification service', () => { transaction.fields[1].hashRetries = 1 await transaction.save() const myErrorMsg = 'Tis but a scratch!' - mockSmsFactory.sendVerificationOtp.mockImplementationOnce(() => { + MockSmsFactory.sendVerificationOtp.mockImplementationOnce(() => { throw new Error(myErrorMsg) }) return expect( @@ -380,7 +380,7 @@ describe('Verification service', () => { afterEach(async () => await dbHandler.clearDatabase()) - test('should throw error for expired transaction', async () => { + it('should throw error when transaction is expired', async () => { transaction.expireAt = new Date(1) await transaction.save() await expect( @@ -393,7 +393,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries) }) - test('should throw error for invalid field ID', async () => { + it('should throw error when field ID is invalid', async () => { await transaction.save() await expect( verifyOtp(transaction, String(new ObjectId()), mockOtp), @@ -405,7 +405,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries) }) - test('should throw error for invalid hashed OTP', async () => { + it('should throw error when hashed OTP is invalid', async () => { transaction.fields[0].hashedOtp = null await transaction.save() await expect( @@ -418,7 +418,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries) }) - test('should throw error for invalid hashCreatedAt', async () => { + it('should throw error when hashCreatedAt is invalid', async () => { transaction.fields[0].hashCreatedAt = null await transaction.save() await expect( @@ -431,7 +431,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries) }) - test('should throw error for expired hash', async () => { + it('should throw error when hash is expired', async () => { // 10min 10s ago transaction.fields[0].hashCreatedAt = new Date(Date.now() - 6.1e5) await transaction.save() @@ -445,7 +445,7 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries) }) - test('should throw error if too many retries', async () => { + it('should throw error when retries are maxed out', async () => { const tooManyRetries = 4 transaction.fields[0].hashRetries = tooManyRetries await transaction.save() @@ -459,8 +459,8 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(tooManyRetries) }) - test('should reject invalid OTP', async () => { - mockBcrypt.compare.mockReturnValueOnce(Promise.resolve(false)) + it('should reject when OTP is invalid', async () => { + MockBcrypt.compare.mockReturnValueOnce(Promise.resolve(false)) await transaction.save() await expect( verifyOtp(transaction, transaction.fields[0]._id, mockOtp), @@ -472,8 +472,8 @@ describe('Verification service', () => { expect(foundTransaction.fields[0].hashRetries).toBe(hashRetries + 1) }) - test('should accept valid OTP', async () => { - mockBcrypt.compare.mockReturnValueOnce(Promise.resolve(true)) + it('should resolve when OTP is invalid', async () => { + MockBcrypt.compare.mockReturnValueOnce(Promise.resolve(true)) await transaction.save() await expect( verifyOtp(transaction, transaction.fields[0]._id, mockOtp), From d69e0819275a9dfa3b0a8456ac15f88775ae1b8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2020 20:22:40 +0800 Subject: [PATCH 20/26] fix(deps): bump http-status-codes from 1.4.0 to 2.1.2 (#229) * fix(deps): bump http-status-codes from 1.4.0 to 2.1.2 Bumps [http-status-codes](https://github.com/prettymuchbryce/node-http-status) from 1.4.0 to 2.1.2. - [Release notes](https://github.com/prettymuchbryce/node-http-status/releases) - [Commits](https://github.com/prettymuchbryce/node-http-status/compare/v1.4.0...2.1.2) Signed-off-by: dependabot[bot] * refactor: import StatusCodes from http-status-codes Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Antariksh Mahajan --- package-lock.json | 6 +- package.json | 2 +- .../admin-console.server.controller.js | 24 ++++--- .../admin-forms.server.controller.js | 50 +++++++------- .../authentication.server.controller.js | 40 +++++------ src/app/controllers/core.server.controller.js | 8 +-- .../email-submissions.server.controller.js | 22 +++--- .../encrypt-submissions.server.controller.js | 18 ++--- .../controllers/forms.server.controller.js | 8 +-- .../controllers/frontend.server.controller.js | 8 +-- .../controllers/myinfo.server.controller.js | 8 +-- .../public-forms.server.controller.js | 14 ++-- src/app/controllers/spcp.server.controller.js | 28 ++++---- .../submissions.server.controller.js | 14 ++-- src/app/factories/google-analytics.factory.js | 4 +- src/app/factories/spcp-myinfo.factory.js | 10 +-- src/app/modules/bounce/bounce.controller.ts | 8 +-- .../modules/submission/submission.errors.ts | 4 +- src/app/modules/user/user.controller.ts | 20 +++--- src/app/modules/user/user.errors.ts | 6 +- .../verification/verification.controller.ts | 24 +++---- .../verification/verification.factory.ts | 12 ++-- src/app/modules/webhooks/webhook.errors.ts | 4 +- .../routes/authentication.server.routes.js | 4 +- src/loaders/express/error-handler.ts | 8 +-- .../admin-forms.server.controller.spec.js | 16 +++-- .../authentication.server.controller.spec.js | 20 +++--- ...mail-submissions.server.controller.spec.js | 67 ++++++++++--------- ...rypt-submissions.server.controller.spec.js | 17 +++-- .../forms.server.controller.spec.js | 8 +-- .../public-forms.server.controller.spec.js | 10 +-- .../spcp.server.controller.spec.js | 28 ++++---- .../submissions.server.controller.spec.js | 12 ++-- .../modules/bounce/bounce.controller.spec.ts | 10 +-- .../modules/user/user.controller.spec.ts | 26 +++---- .../verification.controller.spec.ts | 26 +++---- 36 files changed, 306 insertions(+), 288 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0920a6af0..6ed3cf5daf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13591,9 +13591,9 @@ } }, "http-status-codes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-1.4.0.tgz", - "integrity": "sha512-JrT3ua+WgH8zBD3HEJYbeEgnuQaAnUeRRko/YojPAJjGmIfGD3KPU/asLdsLwKjfxOmQe5nXMQ0pt/7MyapVbQ==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.1.2.tgz", + "integrity": "sha512-zpZ1nBcoR0j1FLQ7xbXXBy1z/yUfAi+0a5IZBoZnmOseYkaljdzQ17ZeVXFlK23IbLxMJn6aWI0uU92DQQrG0g==" }, "httpntlm": { "version": "1.6.1", diff --git a/package.json b/package.json index ad05d18be1..8c9f95cf3f 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "glob": "^7.1.2", "has-ansi": "^4.0.0", "helmet": "^3.21.3", - "http-status-codes": "^1.4.0", + "http-status-codes": "^2.1.2", "intl-tel-input": "~12.1.6", "json-stringify-deterministic": "^1.0.1", "json-stringify-safe": "^5.0.1", diff --git a/src/app/controllers/admin-console.server.controller.js b/src/app/controllers/admin-console.server.controller.js index 7bb1dea1d5..8dd781bac6 100644 --- a/src/app/controllers/admin-console.server.controller.js +++ b/src/app/controllers/admin-console.server.controller.js @@ -5,7 +5,7 @@ */ const mongoose = require('mongoose') const moment = require('moment-timezone') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const getLoginModel = require('../models/login.server.model').default const getSubmissionModel = require('../models/submission.server.model').default @@ -534,7 +534,7 @@ const getExampleFormsUsing = ( x.timeText = parseTime(x.lastSubmission) }) const totalNumResults = _.get(totalCount, '[0].count', 0) - return cb(null, HttpStatus.OK, { forms: pageResults, totalNumResults }) + return cb(null, StatusCodes.OK, { forms: pageResults, totalNumResults }) }) } else { mongoQuery = mongoQuery @@ -544,12 +544,12 @@ const getExampleFormsUsing = ( pageResults.forEach((x) => { x.timeText = parseTime(x.lastSubmission) }) - return cb(null, HttpStatus.OK, { forms: pageResults }) + return cb(null, StatusCodes.OK, { forms: pageResults }) }) } mongoQuery.catch((err) => { - return cb(err, HttpStatus.INTERNAL_SERVER_ERROR, { + return cb(err, StatusCodes.INTERNAL_SERVER_ERROR, { message: 'Error in retrieving example forms.', }) }) @@ -632,7 +632,7 @@ const getSingleExampleFormUsing = ( cb, ) => { if (!formId || !mongoose.Types.ObjectId.isValid(formId)) { - return cb(null, HttpStatus.BAD_REQUEST, { + return cb(null, StatusCodes.BAD_REQUEST, { message: 'Form URL is missing/invalid.', }) } @@ -644,18 +644,18 @@ const getSingleExampleFormUsing = ( .exec((err, result) => { // Error if (err) { - return cb(err, HttpStatus.INTERNAL_SERVER_ERROR, { + return cb(err, StatusCodes.INTERNAL_SERVER_ERROR, { message: 'Error in retrieving example forms.', }) } if (!result) { - return cb(err, HttpStatus.NOT_FOUND, { message: 'No results found.' }) + return cb(err, StatusCodes.NOT_FOUND, { message: 'No results found.' }) } let [form] = result if (!form) { // The form data was not retrieved (formId likely invalid) - return cb(err, HttpStatus.NOT_FOUND, { + return cb(err, StatusCodes.NOT_FOUND, { message: 'Error in retrieving template form - form not found.', }) } @@ -677,7 +677,7 @@ const getSingleExampleFormUsing = ( form.avgFeedback = submissionDetails.avgFeedback form.timeText = parseTime(form.lastSubmission) } - return cb(err, HttpStatus.OK, { form }) + return cb(err, StatusCodes.OK, { form }) }) }) } @@ -814,10 +814,12 @@ exports.getLoginStats = function (req, res) { error, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send('Error in retrieving billing records') } else if (!loginStats) { - return res.status(HttpStatus.NOT_FOUND).send('No billing records found') + return res + .status(StatusCodes.NOT_FOUND) + .send('No billing records found') } else { logger.info({ message: `Billing search for ${esrvcId} by ${ diff --git a/src/app/controllers/admin-forms.server.controller.js b/src/app/controllers/admin-forms.server.controller.js index 1b89e1e8dc..8712c907ec 100644 --- a/src/app/controllers/admin-forms.server.controller.js +++ b/src/app/controllers/admin-forms.server.controller.js @@ -7,7 +7,7 @@ const mongoose = require('mongoose') const moment = require('moment-timezone') const _ = require('lodash') const JSONStream = require('JSONStream') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const get = require('lodash/get') const logger = require('../../config/logger').createLoggerWithLabel(module) @@ -78,17 +78,17 @@ function makeModule(connection) { let statusCode if (err.name === 'ValidationError') { - statusCode = HttpStatus.UNPROCESSABLE_ENTITY + statusCode = StatusCodes.UNPROCESSABLE_ENTITY } else if (err.name === 'VersionError') { - statusCode = HttpStatus.CONFLICT + statusCode = StatusCodes.CONFLICT } else if ( err.name === 'FormSizeError' || // FormSG-imposed limit in pre-validate hook err instanceof RangeError || // exception when Mongoose breaches Mongo 16MB size limit (err.name === 'MongoError' && err.code === 10334) // MongoDB Invalid BSON error ) { - statusCode = HttpStatus.REQUEST_TOO_LONG // HTTP 413 Payload Too Large + statusCode = StatusCodes.REQUEST_TOO_LONG // HTTP 413 Payload Too Large } else { - statusCode = HttpStatus.INTERNAL_SERVER_ERROR + statusCode = StatusCodes.INTERNAL_SERVER_ERROR } return res.status(statusCode).send({ @@ -239,7 +239,7 @@ function makeModule(connection) { */ isFormActive: function (req, res, next) { if (req.form.status === 'ARCHIVED') { - return res.status(HttpStatus.NOT_FOUND).send({ + return res.status(StatusCodes.NOT_FOUND).send({ message: 'Form has been archived', }) } else { @@ -254,7 +254,7 @@ function makeModule(connection) { */ isFormEncryptMode: function (req, res, next) { if (req.form.responseMode !== 'encrypt') { - return res.status(HttpStatus.UNPROCESSABLE_ENTITY).send({ + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).send({ message: 'Form is not encrypt mode', }) } @@ -267,7 +267,7 @@ function makeModule(connection) { */ create: function (req, res) { if (!req.body.form) { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Invalid Input', }) } @@ -314,7 +314,7 @@ function makeModule(connection) { }, }) return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid update to form' }) } else { const { error, formFields } = getEditedFormFields( @@ -331,7 +331,7 @@ function makeModule(connection) { }, error, }) - return res.status(HttpStatus.BAD_REQUEST).send({ message: error }) + return res.status(StatusCodes.BAD_REQUEST).send({ message: error }) } form.form_fields = formFields delete updatedForm.editFormField @@ -393,7 +393,7 @@ function makeModule(connection) { return respondOnMongoError(req, res, err) } else if (!form) { return res - .status(HttpStatus.NOT_FOUND) + .status(StatusCodes.NOT_FOUND) .send({ message: 'Form not found for duplication' }) } else { let responseMode = req.body.responseMode || 'email' @@ -470,7 +470,7 @@ function makeModule(connection) { if (err) { return respondOnMongoError(req, res, err) } else if (!forms) { - return res.status(HttpStatus.NOT_FOUND).send({ + return res.status(StatusCodes.NOT_FOUND).send({ message: 'No user-created and collaborated-on forms found', }) } @@ -493,7 +493,7 @@ function makeModule(connection) { return respondOnMongoError(req, res, err) } else if (!feedback) { return res - .status(HttpStatus.NOT_FOUND) + .status(StatusCodes.NOT_FOUND) .send({ message: 'No feedback found' }) } else { let sum = 0 @@ -546,7 +546,7 @@ function makeModule(connection) { }, error: err, }) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -573,7 +573,7 @@ function makeModule(connection) { }, error: err, }) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Error retrieving from database.', }) }) @@ -587,7 +587,7 @@ function makeModule(connection) { }, error: err, }) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Error converting feedback to JSON', }) }) @@ -601,7 +601,7 @@ function makeModule(connection) { }, error: err, }) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Error writing feedback to HTTP stream', }) }) @@ -624,10 +624,10 @@ function makeModule(connection) { !('comment' in req.body) ) { return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send('Form feedback data not passed in') } else { - return res.status(HttpStatus.OK).send('Successfully received feedback') + return res.status(StatusCodes.OK).send('Successfully received feedback') } }, /** @@ -684,7 +684,7 @@ function makeModule(connection) { createPresignedPostForImages: function (req, res) { if (!VALID_UPLOAD_FILE_TYPES.includes(req.body.fileType)) { return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send(`Your file type "${req.body.fileType}" is not supported`) } @@ -712,9 +712,9 @@ function makeModule(connection) { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send(err) + return res.status(StatusCodes.BAD_REQUEST).send(err) } else { - return res.status(HttpStatus.OK).send(presignedPostObject) + return res.status(StatusCodes.OK).send(presignedPostObject) } }, ) @@ -731,7 +731,7 @@ function makeModule(connection) { createPresignedPostForLogos: function (req, res) { if (!VALID_UPLOAD_FILE_TYPES.includes(req.body.fileType)) { return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send(`Your file type "${req.body.fileType}" is not supported`) } @@ -759,9 +759,9 @@ function makeModule(connection) { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send(err) + return res.status(StatusCodes.BAD_REQUEST).send(err) } else { - return res.status(HttpStatus.OK).send(presignedPostObject) + return res.status(StatusCodes.OK).send(presignedPostObject) } }, ) diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js index 4061d3e112..24f6bec63e 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -13,7 +13,7 @@ const Token = getTokenModel(mongoose) const Agency = getAgencyModel(mongoose) const bcrypt = require('bcrypt') const validator = require('validator') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const config = require('../../config/config') const defaults = require('../../config/defaults').default @@ -36,7 +36,7 @@ exports.authenticateUser = function (req, res, next) { return next() } else { return res - .status(HttpStatus.UNAUTHORIZED) + .status(StatusCodes.UNAUTHORIZED) .send({ message: 'User is unauthorized.' }) } } @@ -53,7 +53,7 @@ exports.validateDomain = function (req, res, next) { // Database issues if (err) { return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( `Unable to validate email domain. If this issue persists, please submit a Support Form (${defaults.links.supportFormLink}).`, ) @@ -71,7 +71,7 @@ exports.validateDomain = function (req, res, next) { error: err, }) return res - .status(HttpStatus.UNAUTHORIZED) + .status(StatusCodes.UNAUTHORIZED) .send( 'This is not a whitelisted public service email domain. Please log in with your official government or government-linked email address.', ) @@ -129,7 +129,7 @@ exports.verifyPermission = (requiredPermission) => if (hasSufficientPermission) { return next() } else { - return res.status(HttpStatus.FORBIDDEN).send({ + return res.status(StatusCodes.FORBIDDEN).send({ message: 'User ' + req.session.user.email + @@ -156,7 +156,7 @@ exports.createOtp = function (req, res, next) { bcrypt.hash(otp, 10, function (bcryptErr, hashedOtp) { if (bcryptErr) { return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Error generating OTP. Please try again later and if the problem persists, contact us.', ) @@ -188,7 +188,7 @@ exports.createOtp = function (req, res, next) { error: updateErr, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Error saving OTP. Please try again later and if the problem persists, contact us.', ) @@ -228,7 +228,7 @@ exports.sendOtp = async function (req, res) { email: recipient, }, }) - return res.status(HttpStatus.OK).send(`OTP sent to ${recipient}!`) + return res.status(StatusCodes.OK).send(`OTP sent to ${recipient}!`) } catch (err) { logger.error({ message: 'Mail otp error', @@ -241,7 +241,7 @@ exports.sendOtp = async function (req, res) { error: err, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Error sending OTP. Please try again later and if the problem persists, contact us.', ) @@ -284,7 +284,7 @@ exports.verifyOtp = function (req, res, next) { error: updateErr, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( `Unable to login at this time. Please submit a Support Form (${defaults.links.supportFormLink}).`, ) @@ -299,7 +299,7 @@ exports.verifyOtp = function (req, res, next) { }, }) return res - .status(HttpStatus.UNPROCESSABLE_ENTITY) + .status(StatusCodes.UNPROCESSABLE_ENTITY) .send('OTP has expired. Click Resend to receive a new OTP.') } if (updatedRecord.numOtpAttempts > MAX_OTP_ATTEMPTS) { @@ -312,7 +312,7 @@ exports.verifyOtp = function (req, res, next) { }, }) return res - .status(HttpStatus.UNPROCESSABLE_ENTITY) + .status(StatusCodes.UNPROCESSABLE_ENTITY) .send( 'You have hit the max number of attempts for this OTP. Click Resend to receive a new OTP.', ) @@ -331,7 +331,7 @@ exports.verifyOtp = function (req, res, next) { error: bcryptErr, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Malformed OTP. Please try again later and if the problem persists, contact us.', ) @@ -349,7 +349,7 @@ exports.verifyOtp = function (req, res, next) { error: removeErr, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Failed to validate OTP. Please try again later and if the problem persists, contact us.', ) @@ -367,7 +367,7 @@ exports.verifyOtp = function (req, res, next) { }, }) return res - .status(HttpStatus.UNAUTHORIZED) + .status(StatusCodes.UNAUTHORIZED) .send('OTP is invalid. Please try again.') } }) @@ -410,7 +410,7 @@ exports.signIn = function (req, res) { function (updateErr, user) { if (updateErr || !user) { return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( `User signin failed. Please try again later and if the problem persists, submit our Support Form (${defaults.links.supportFormLink}).`, ) @@ -433,7 +433,7 @@ exports.signIn = function (req, res) { ip: getRequestIp(req), }, }) - return res.status(HttpStatus.OK).send(userObj) + return res.status(StatusCodes.OK).send(userObj) }, ) } @@ -447,11 +447,11 @@ exports.signOut = function (req, res) { req.session.destroy(function (err) { if (err) { return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send('Sign out failed') } else { res.clearCookie('connect.sid') - return res.status(HttpStatus.OK).send('Sign out successful') + return res.status(StatusCodes.OK).send('Sign out successful') } }) } @@ -472,7 +472,7 @@ exports.doesUserBeta = (betaType) => (req, res, next) => { if (user && user.betaFlags && user.betaFlags[betaType]) { return next() } else { - return res.status(HttpStatus.FORBIDDEN).send({ + return res.status(StatusCodes.FORBIDDEN).send({ message: `User is not authorized to access beta feature: ${betaType}`, }) } diff --git a/src/app/controllers/core.server.controller.js b/src/app/controllers/core.server.controller.js index aa03f39b21..aabe21fcb0 100755 --- a/src/app/controllers/core.server.controller.js +++ b/src/app/controllers/core.server.controller.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose') const _ = require('lodash') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const config = require('../../config/config') const { getRequestIp } = require('../utils/request') @@ -58,11 +58,11 @@ exports.formCountUsingAggregateCollection = (req, res) => { }, error: err, }) - res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) + res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE) } else if (result) { res.json(_.get(result, 'numActiveForms', 0)) } else { - res.sendStatus(HttpStatus.INTERNAL_SERVER_ERROR) + res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) } }, ) @@ -102,7 +102,7 @@ exports.formCountUsingSubmissionsCollection = (req, res) => { }, error: err, }) - res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) + res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE) } else { res.json(forms.length) } diff --git a/src/app/controllers/email-submissions.server.controller.js b/src/app/controllers/email-submissions.server.controller.js index 8a7827beea..8c3cd118f2 100644 --- a/src/app/controllers/email-submissions.server.controller.js +++ b/src/app/controllers/email-submissions.server.controller.js @@ -8,7 +8,7 @@ const stringify = require('json-stringify-deterministic') const mongoose = require('mongoose') const { getEmailSubmissionModel } = require('../models/submission.server.model') const emailSubmission = getEmailSubmissionModel(mongoose) -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { getRequestIp } = require('../utils/request') const { ConflictError } = require('../modules/submission/submission.errors') const { MB } = require('../constants/filesize') @@ -61,7 +61,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Required headers are missing', }) } @@ -112,7 +112,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }, error: err, }) - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } }) @@ -129,7 +129,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }, }) return res - .status(HttpStatus.REQUEST_TOO_LONG) + .status(StatusCodes.REQUEST_TOO_LONG) .send({ message: 'Your submission is too large.' }) } @@ -171,13 +171,13 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { formId: req.form._id, }, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Some files were invalid. Try uploading another file.', }) } if (areAttachmentsMoreThan7MB(attachments)) { - return res.status(HttpStatus.UNPROCESSABLE_ENTITY).send({ + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).send({ message: 'Please keep the size of your attachments under 7MB.', }) } @@ -199,7 +199,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { error, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) } }) @@ -215,7 +215,7 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { error: err, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) }) @@ -256,7 +256,7 @@ exports.validateEmailSubmission = function (req, res, next) { 'The form has been updated. Please refresh and submit again.', }) } else { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'There is something wrong with your form submission. Please check your responses and try again. If the problem persists, please refresh the page.', }) @@ -269,7 +269,7 @@ exports.validateEmailSubmission = function (req, res, next) { ) return next() } else { - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } @@ -507,7 +507,7 @@ function onSubmissionEmailFailure(err, req, res, submission) { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.', submissionId: submission._id, diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index 1462540713..d364e21bfe 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -2,7 +2,7 @@ const crypto = require('crypto') const moment = require('moment-timezone') const JSONStream = require('JSONStream') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const errorHandler = require('./errors.server.controller') @@ -49,7 +49,7 @@ exports.validateEncryptSubmission = function (req, res, next) { error, }) return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) } @@ -73,7 +73,7 @@ exports.validateEncryptSubmission = function (req, res, next) { 'The form has been updated. Please refresh and submit again.', }) } else { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'There is something wrong with your form submission. Please check your responses and try again. If the problem persists, please refresh the page.', }) @@ -81,7 +81,7 @@ exports.validateEncryptSubmission = function (req, res, next) { } return next() } else { - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } @@ -116,7 +116,7 @@ function onEncryptSubmissionFailure(err, req, res, submission) { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.', submissionId: submission._id, @@ -283,7 +283,7 @@ exports.getMetadata = function (req, res) { }, error: err, }) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -302,7 +302,7 @@ exports.getMetadata = function (req, res) { number-- return entry }) - return res.status(HttpStatus.OK).send({ metadata, count }) + return res.status(StatusCodes.OK).send({ metadata, count }) } }) } @@ -341,7 +341,7 @@ exports.getEncryptedResponse = function (req, res) { }, error: err, }) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -377,7 +377,7 @@ exports.streamEncryptedResponses = async function (req, res) { isMalformedDate(req.query.startDate) || isMalformedDate(req.query.endDate) ) { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Malformed date parameter', }) } diff --git a/src/app/controllers/forms.server.controller.js b/src/app/controllers/forms.server.controller.js index aefb057ee9..22c87ddf1c 100644 --- a/src/app/controllers/forms.server.controller.js +++ b/src/app/controllers/forms.server.controller.js @@ -5,7 +5,7 @@ */ const mongoose = require('mongoose') const _ = require('lodash') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { getRequestIp } = require('../utils/request') const logger = require('../../config/logger').createLoggerWithLabel(module) @@ -95,20 +95,20 @@ exports.formById = async function (req, res, next) { let id = req.params && req.params.formId if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Form URL is invalid.', }) } try { const form = await Form.getFullFormById(id) if (!form) { - return res.status(HttpStatus.NOT_FOUND).send({ + return res.status(StatusCodes.NOT_FOUND).send({ message: "Oops! We can't find the form you're looking for.", }) } else { // Remove sensitive information from User object if (!form.admin) { - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Server error.', }) } diff --git a/src/app/controllers/frontend.server.controller.js b/src/app/controllers/frontend.server.controller.js index 9b02a4e2bb..4851f6174b 100644 --- a/src/app/controllers/frontend.server.controller.js +++ b/src/app/controllers/frontend.server.controller.js @@ -1,7 +1,7 @@ 'use strict' const ejs = require('ejs') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') /** * Google Tag Manager initialisation Javascript code templated @@ -20,7 +20,7 @@ module.exports.datalayer = function (req, res) { ` res .type('text/javascript') - .status(HttpStatus.OK) + .status(StatusCodes.OK) .send(ejs.render(js, req.app.locals)) } @@ -31,7 +31,7 @@ module.exports.datalayer = function (req, res) { module.exports.environment = function (req, res) { res .type('text/javascript') - .status(HttpStatus.OK) + .status(StatusCodes.OK) .send(req.app.locals.environment) } @@ -48,6 +48,6 @@ module.exports.redirectLayer = function (req, res) { ` res .type('text/javascript') - .status(HttpStatus.OK) + .status(StatusCodes.OK) .send(ejs.render(js, req.query)) } diff --git a/src/app/controllers/myinfo.server.controller.js b/src/app/controllers/myinfo.server.controller.js index 6d33f72f8d..c290a28697 100644 --- a/src/app/controllers/myinfo.server.controller.js +++ b/src/app/controllers/myinfo.server.controller.js @@ -8,7 +8,7 @@ const Promise = require('bluebird') const _ = require('lodash') const mongoose = require('mongoose') const moment = require('moment') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { sessionSecret } = require('../../config/config') const { getRequestIp } = require('../utils/request') @@ -195,7 +195,7 @@ exports.verifyMyInfoVals = function (req, res, next) { }, error: err, }) - return res.status(HttpStatus.SERVICE_UNAVAILABLE).send({ + return res.status(StatusCodes.SERVICE_UNAVAILABLE).send({ message: 'MyInfo verification unavailable, please try again later.', spcpSubmissionFailure: true, }) @@ -210,7 +210,7 @@ exports.verifyMyInfoVals = function (req, res, next) { formId: formObjId, }, }) - return res.status(HttpStatus.GONE).send({ + return res.status(StatusCodes.GONE).send({ message: 'MyInfo verification expired, please refresh and try again.', spcpSubmissionFailure: true, @@ -258,7 +258,7 @@ exports.verifyMyInfoVals = function (req, res, next) { failedFields: hashFailedAttrs, }, }) - return res.status(HttpStatus.UNAUTHORIZED).send({ + return res.status(StatusCodes.UNAUTHORIZED).send({ message: 'MyInfo verification failed.', spcpSubmissionFailure: true, }) diff --git a/src/app/controllers/public-forms.server.controller.js b/src/app/controllers/public-forms.server.controller.js index 73116bf8fc..6f1164b90c 100644 --- a/src/app/controllers/public-forms.server.controller.js +++ b/src/app/controllers/public-forms.server.controller.js @@ -1,7 +1,7 @@ 'use strict' const mongoose = require('mongoose') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { getRequestIp } = require('../utils/request') const logger = require('../../config/logger').createLoggerWithLabel(module) @@ -22,9 +22,9 @@ exports.isFormPublic = function (req, res, next) { case 'PUBLIC': return next() case 'ARCHIVED': - return res.sendStatus(HttpStatus.GONE) + return res.sendStatus(StatusCodes.GONE) default: - return res.status(HttpStatus.NOT_FOUND).send({ + return res.status(StatusCodes.NOT_FOUND).send({ message: req.form.inactiveMessage, isPageFound: true, // Flag to prevent default 404 subtext ("please check link") from showing formTitle: req.form.title, @@ -80,7 +80,7 @@ exports.submitFeedback = function (req, res) { !('comment' in req.body) ) { return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send('Form feedback data not passed in') } @@ -101,10 +101,12 @@ exports.submitFeedback = function (req, res) { error: err, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send('Form feedback could not be created') } else { - return res.status(HttpStatus.OK).send('Successfully submitted feedback') + return res + .status(StatusCodes.OK) + .send('Successfully submitted feedback') } }, ) diff --git a/src/app/controllers/spcp.server.controller.js b/src/app/controllers/spcp.server.controller.js index fdfbfd4f34..b60bfec07f 100644 --- a/src/app/controllers/spcp.server.controller.js +++ b/src/app/controllers/spcp.server.controller.js @@ -6,7 +6,7 @@ const { isEmpty } = require('lodash') const mongoose = require('mongoose') const crypto = require('crypto') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const axios = require('axios') const { getRequestIp } = require('../utils/request') @@ -112,7 +112,7 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { const payloads = String(relayState).split(',') if (payloads.length !== 2) { - return res.status(HttpStatus.BAD_REQUEST).send() + return res.status(StatusCodes.BAD_REQUEST).send() } const destination = payloads[0] @@ -126,7 +126,7 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { authType, ) ) { - res.status(HttpStatus.UNAUTHORIZED).send() + res.status(StatusCodes.UNAUTHORIZED).send() return } @@ -134,11 +134,11 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { samlArt = String(samlArt).replace(/ /g, '+') if (!destinationIsValid(destination)) - return res.status(HttpStatus.BAD_REQUEST).send() + return res.status(StatusCodes.BAD_REQUEST).send() getForm(destination, (err, form) => { if (err || !form || form.authType !== authType) { - res.status(HttpStatus.NOT_FOUND).send() + res.status(StatusCodes.NOT_FOUND).send() return } authClient.getAttributes(samlArt, destination, (err, data) => { @@ -218,13 +218,13 @@ exports.createSpcpRedirectURL = (authClients) => { req.redirectURL = authClient.createRedirectURL(target, esrvcId) return next() } else { - return res.status(HttpStatus.BAD_REQUEST).send('Redirect URL malformed') + return res.status(StatusCodes.BAD_REQUEST).send('Redirect URL malformed') } } } exports.returnSpcpRedirectURL = function (req, res) { - return res.status(HttpStatus.OK).send({ redirectURL: req.redirectURL }) + return res.status(StatusCodes.OK).send({ redirectURL: req.redirectURL }) } const getSubstringBetween = (text, markerStart, markerEnd) => { @@ -249,7 +249,7 @@ exports.validateESrvcId = (req, res) => { }, timeout: 10000, // 10 seconds // Throw error if not status 200. - validateStatus: (status) => status === HttpStatus.OK, + validateStatus: (status) => status === StatusCodes.OK, }) .then(({ data }) => { // The successful login page should have the title 'SingPass Login' @@ -264,12 +264,12 @@ exports.validateESrvcId = (req, res) => { data, }, }) - return res.status(HttpStatus.BAD_GATEWAY).send({ + return res.status(StatusCodes.BAD_GATEWAY).send({ message: 'Singpass returned incomprehensible content', }) } if (title.indexOf('Error') === -1) { - return res.status(HttpStatus.OK).send({ + return res.status(StatusCodes.OK).send({ isValid: true, }) } @@ -280,7 +280,7 @@ exports.validateESrvcId = (req, res) => { 'System Code: ', '', ) - return res.status(HttpStatus.OK).send({ + return res.status(StatusCodes.OK).send({ isValid: false, errorCode, }) @@ -296,7 +296,7 @@ exports.validateESrvcId = (req, res) => { }, error: err, }) - return res.status(HttpStatus.SERVICE_UNAVAILABLE).send({ + return res.status(StatusCodes.SERVICE_UNAVAILABLE).send({ message: 'Failed to contact Singpass', }) }) @@ -419,7 +419,7 @@ exports.encryptedVerifiedFields = (signingSecretKey) => { error, }) return res - .status(HttpStatus.BAD_REQUEST) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) } } @@ -488,7 +488,7 @@ exports.isSpcpAuthenticated = (authClients) => { }, error: err, }) - res.status(HttpStatus.UNAUTHORIZED).send({ + res.status(StatusCodes.UNAUTHORIZED).send({ message: 'User is not SPCP authenticated', spcpSubmissionFailure: true, }) diff --git a/src/app/controllers/submissions.server.controller.js b/src/app/controllers/submissions.server.controller.js index 232d53b047..68dc21ae31 100644 --- a/src/app/controllers/submissions.server.controller.js +++ b/src/app/controllers/submissions.server.controller.js @@ -7,7 +7,7 @@ const errorHandler = require('./errors.server.controller') const getSubmissionModel = require('../models/submission.server.model').default const Submission = getSubmissionModel(mongoose) -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { getRequestIp } = require('../utils/request') const { isMalformedDate, createQueryWithDateParam } = require('../utils/date') @@ -28,7 +28,7 @@ exports.captchaCheck = (captchaPrivateKey) => { return next() } else { if (!captchaPrivateKey) { - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Captcha not set-up', }) } else if (!req.query.captchaResponse) { @@ -40,7 +40,7 @@ exports.captchaCheck = (captchaPrivateKey) => { ip: getRequestIp(req), }, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Captcha was missing. Please refresh and submit again.', }) } else { @@ -62,7 +62,7 @@ exports.captchaCheck = (captchaPrivateKey) => { ip: getRequestIp(req), }, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Captcha was incorrect. Please submit again.', }) } @@ -79,7 +79,7 @@ exports.captchaCheck = (captchaPrivateKey) => { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Could not verify captcha. Please submit again in a few minutes.', }) @@ -204,7 +204,7 @@ exports.count = function (req, res) { isMalformedDate(req.query.startDate) || isMalformedDate(req.query.endDate) ) { - return res.status(HttpStatus.BAD_REQUEST).send({ + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Malformed date parameter', }) } @@ -229,7 +229,7 @@ exports.count = function (req, res) { error: err, }) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { diff --git a/src/app/factories/google-analytics.factory.js b/src/app/factories/google-analytics.factory.js index a4d718a85c..1f83684250 100644 --- a/src/app/factories/google-analytics.factory.js +++ b/src/app/factories/google-analytics.factory.js @@ -1,6 +1,6 @@ const featureManager = require('../../config/feature-manager').default const frontend = require('../controllers/frontend.server.controller') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const googleAnalyticsFactory = ({ isEnabled }) => { if (isEnabled) { @@ -10,7 +10,7 @@ const googleAnalyticsFactory = ({ isEnabled }) => { } else { return { datalayer: (req, res) => { - res.type('text/javascript').status(HttpStatus.OK).send() + res.type('text/javascript').status(StatusCodes.OK).send() }, } } diff --git a/src/app/factories/spcp-myinfo.factory.js b/src/app/factories/spcp-myinfo.factory.js index 4d7979def6..d845c28148 100644 --- a/src/app/factories/spcp-myinfo.factory.js +++ b/src/app/factories/spcp-myinfo.factory.js @@ -2,7 +2,7 @@ const adminConsole = require('../controllers/admin-console.server.controller') const spcp = require('../controllers/spcp.server.controller') const myInfo = require('../controllers/myinfo.server.controller') const admin = require('../controllers/admin-forms.server.controller') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const featureManager = require('../../config/feature-manager').default const config = require('../../config/config') const fs = require('fs') @@ -136,17 +136,17 @@ const spcpFactory = ({ isEnabled, props }) => { }), verifyMyInfoVals: (req, res, next) => next(), returnSpcpRedirectURL: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), singPassLogin: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), corpPassLogin: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), addSpcpSessionInfo: (req, res, next) => next(), isSpcpAuthenticated: (req, res, next) => next(), createSpcpRedirectURL: (req, res, next) => next(), addMyInfo: (req, res, next) => next(), validateESrvcId: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), } } } diff --git a/src/app/modules/bounce/bounce.controller.ts b/src/app/modules/bounce/bounce.controller.ts index 502bb57f6d..f983462f6b 100644 --- a/src/app/modules/bounce/bounce.controller.ts +++ b/src/app/modules/bounce/bounce.controller.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' import { ISnsNotification } from '../../../types' @@ -23,10 +23,10 @@ const handleSns = async ( try { const isValid = await snsService.isValidSnsRequest(req.body) if (!isValid) { - return res.sendStatus(HttpStatus.FORBIDDEN) + return res.sendStatus(StatusCodes.FORBIDDEN) } await snsService.updateBounces(req.body) - return res.sendStatus(HttpStatus.OK) + return res.sendStatus(StatusCodes.OK) } catch (err) { logger.warn({ message: 'Error updating bounces', @@ -35,7 +35,7 @@ const handleSns = async ( }, error: err, }) - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } diff --git a/src/app/modules/submission/submission.errors.ts b/src/app/modules/submission/submission.errors.ts index 49ac049b57..816ba3daf5 100644 --- a/src/app/modules/submission/submission.errors.ts +++ b/src/app/modules/submission/submission.errors.ts @@ -1,4 +1,4 @@ -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { ApplicationError } from '../core/core.errors' @@ -8,6 +8,6 @@ import { ApplicationError } from '../core/core.errors' */ export class ConflictError extends ApplicationError { constructor(message: string, meta?: string) { - super(message, HttpStatus.CONFLICT, meta) + super(message, StatusCodes.CONFLICT, meta) } } diff --git a/src/app/modules/user/user.controller.ts b/src/app/modules/user/user.controller.ts index 4c47c4d1d6..7953a6bf38 100644 --- a/src/app/modules/user/user.controller.ts +++ b/src/app/modules/user/user.controller.ts @@ -1,6 +1,6 @@ import to from 'await-to-js' import { RequestHandler } from 'express' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' import SmsFactory from '../../factories/sms.factory' @@ -33,17 +33,17 @@ export const handleContactSendOtp: RequestHandler< // Guard against user updating for a different user, or if user is not logged // in. if (!sessionUserId || sessionUserId !== userId) { - return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + return res.status(StatusCodes.UNAUTHORIZED).send('User is unauthorized.') } try { const generatedOtp = await createContactOtp(userId, contact) await SmsFactory.sendAdminContactOtp(contact, generatedOtp, userId) - return res.sendStatus(HttpStatus.OK) + return res.sendStatus(StatusCodes.OK) } catch (err) { // TODO(#193): Send different error messages according to error. - return res.status(HttpStatus.BAD_REQUEST).send(err.message) + return res.status(StatusCodes.BAD_REQUEST).send(err.message) } } @@ -71,7 +71,7 @@ export const handleContactVerifyOtp: RequestHandler< // Guard against user updating for a different user, or if user is not logged // in. if (!sessionUserId || sessionUserId !== userId) { - return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + return res.status(StatusCodes.UNAUTHORIZED).send('User is unauthorized.') } try { @@ -81,25 +81,25 @@ export const handleContactVerifyOtp: RequestHandler< if (err instanceof ApplicationError) { return res.status(err.status).send(err.message) } else { - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(err.message) + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(err.message) } } // No error, update user with given contact. try { const updatedUser = await updateUserContact(contact, userId) - return res.status(HttpStatus.OK).send(updatedUser) + return res.status(StatusCodes.OK).send(updatedUser) } catch (updateErr) { // Handle update error. logger.warn(updateErr) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(updateErr.message) + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(updateErr.message) } } export const handleFetchUser: RequestHandler = async (req, res) => { const sessionUserId = getUserIdFromSession(req.session) if (!sessionUserId) { - return res.status(HttpStatus.UNAUTHORIZED).send('User is unauthorized.') + return res.status(StatusCodes.UNAUTHORIZED).send('User is unauthorized.') } // Retrieve user with id in session @@ -114,7 +114,7 @@ export const handleFetchUser: RequestHandler = async (req, res) => { error: dbErr, }) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send('Unable to retrieve user') } diff --git a/src/app/modules/user/user.errors.ts b/src/app/modules/user/user.errors.ts index 764f5c3482..6df7c6d8a8 100644 --- a/src/app/modules/user/user.errors.ts +++ b/src/app/modules/user/user.errors.ts @@ -1,10 +1,10 @@ -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { ApplicationError } from '../core/core.errors' export class InvalidOtpError extends ApplicationError { constructor(message: string, meta?: string) { - super(message, HttpStatus.UNPROCESSABLE_ENTITY, meta) + super(message, StatusCodes.UNPROCESSABLE_ENTITY, meta) } } @@ -13,6 +13,6 @@ export class MalformedOtpError extends ApplicationError { message: string = 'Malformed OTP. Please try again later. If the problem persists, contact us.', meta?: string, ) { - super(message, HttpStatus.INTERNAL_SERVER_ERROR, meta) + super(message, StatusCodes.INTERNAL_SERVER_ERROR, meta) } } diff --git a/src/app/modules/verification/verification.controller.ts b/src/app/modules/verification/verification.controller.ts index 20c0485176..afa51beedd 100644 --- a/src/app/modules/verification/verification.controller.ts +++ b/src/app/modules/verification/verification.controller.ts @@ -1,5 +1,5 @@ import { RequestHandler, Response } from 'express' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' import { VfnErrors } from '../../../shared/util/verification' @@ -24,8 +24,8 @@ export const createTransaction: RequestHandler< const { formId } = req.body const transaction = await verificationService.createTransaction(formId) return transaction - ? res.status(HttpStatus.CREATED).json(transaction) - : res.sendStatus(HttpStatus.OK) + ? res.status(StatusCodes.CREATED).json(transaction) + : res.sendStatus(StatusCodes.OK) } catch (error) { logger.error({ message: 'Error creating transaction', @@ -50,7 +50,7 @@ export const getTransactionMetadata: RequestHandler<{ const transaction = await verificationService.getTransactionMetadata( transactionId, ) - return res.status(HttpStatus.OK).json(transaction) + return res.status(StatusCodes.OK).json(transaction) } catch (error) { logger.error({ message: 'Error retrieving transaction metadata', @@ -78,7 +78,7 @@ export const resetFieldInTransaction: RequestHandler< const { fieldId } = req.body const transaction = await verificationService.getTransaction(transactionId) await verificationService.resetFieldInTransaction(transaction, fieldId) - return res.sendStatus(HttpStatus.OK) + return res.sendStatus(StatusCodes.OK) } catch (error) { logger.error({ message: 'Error resetting field in transaction', @@ -106,7 +106,7 @@ export const getNewOtp: RequestHandler< const { answer, fieldId } = req.body const transaction = await verificationService.getTransaction(transactionId) await verificationService.getNewOtp(transaction, fieldId, answer) - return res.sendStatus(HttpStatus.CREATED) + return res.sendStatus(StatusCodes.CREATED) } catch (error) { logger.error({ message: 'Error retrieving new OTP', @@ -135,7 +135,7 @@ export const verifyOtp: RequestHandler< const { fieldId, otp } = req.body const transaction = await verificationService.getTransaction(transactionId) const data = await verificationService.verifyOtp(transaction, fieldId, otp) - return res.status(HttpStatus.OK).json(data) + return res.status(StatusCodes.OK).json(data) } catch (error) { logger.error({ message: 'Error verifying OTP', @@ -153,22 +153,22 @@ export const verifyOtp: RequestHandler< * @param res */ const handleError = (error: Error, res: Response) => { - let status = HttpStatus.INTERNAL_SERVER_ERROR + let status = StatusCodes.INTERNAL_SERVER_ERROR let message = error.message switch (error.name) { case VfnErrors.SendOtpFailed: - status = HttpStatus.BAD_REQUEST + status = StatusCodes.BAD_REQUEST break case VfnErrors.WaitForOtp: - status = HttpStatus.ACCEPTED + status = StatusCodes.ACCEPTED break case VfnErrors.ResendOtp: case VfnErrors.InvalidOtp: - status = HttpStatus.UNPROCESSABLE_ENTITY + status = StatusCodes.UNPROCESSABLE_ENTITY break case VfnErrors.FieldNotFound: case VfnErrors.TransactionNotFound: - status = HttpStatus.NOT_FOUND + status = StatusCodes.NOT_FOUND break default: message = 'An error occurred' diff --git a/src/app/modules/verification/verification.factory.ts b/src/app/modules/verification/verification.factory.ts index 98f0ba3034..2327aedeb1 100644 --- a/src/app/modules/verification/verification.factory.ts +++ b/src/app/modules/verification/verification.factory.ts @@ -1,5 +1,5 @@ import { RequestHandler } from 'express' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import featureManager from '../../../config/feature-manager' import { FeatureNames } from '../../../config/feature-manager/types' @@ -31,15 +31,15 @@ const verifiedFieldsFactory = ({ const errMsg = 'Verified fields feature is not enabled' return { createTransaction: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), getTransactionMetadata: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), resetFieldInTransaction: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), getNewOtp: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), verifyOtp: (req, res) => - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(errMsg), + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(errMsg), } } } diff --git a/src/app/modules/webhooks/webhook.errors.ts b/src/app/modules/webhooks/webhook.errors.ts index 2ddd84bbee..a06e37ae02 100644 --- a/src/app/modules/webhooks/webhook.errors.ts +++ b/src/app/modules/webhooks/webhook.errors.ts @@ -1,4 +1,4 @@ -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { ApplicationError } from '../core/core.errors' @@ -8,6 +8,6 @@ import { ApplicationError } from '../core/core.errors' */ export class WebhookValidationError extends ApplicationError { constructor(message: string, meta?: string) { - super(message, HttpStatus.UNPROCESSABLE_ENTITY, meta) + super(message, StatusCodes.UNPROCESSABLE_ENTITY, meta) } } diff --git a/src/app/routes/authentication.server.routes.js b/src/app/routes/authentication.server.routes.js index e46273bd1b..e8884701b0 100755 --- a/src/app/routes/authentication.server.routes.js +++ b/src/app/routes/authentication.server.routes.js @@ -3,7 +3,7 @@ /** * Module dependencies. */ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { celebrate, Joi } = require('celebrate') let auth = require('../../app/controllers/authentication.server.controller') @@ -39,7 +39,7 @@ module.exports = function (app) { }), }), auth.validateDomain, - (_, res) => res.sendStatus(HttpStatus.OK), + (_, res) => res.sendStatus(StatusCodes.OK), ) /** diff --git a/src/loaders/express/error-handler.ts b/src/loaders/express/error-handler.ts index b586ed5a46..8ce0a80746 100644 --- a/src/loaders/express/error-handler.ts +++ b/src/loaders/express/error-handler.ts @@ -1,6 +1,6 @@ import { isCelebrate } from 'celebrate' import { ErrorRequestHandler, RequestHandler } from 'express' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import get from 'lodash/get' import { createLoggerWithLabel } from '../../config/logger' @@ -43,11 +43,11 @@ const errorHandlerMiddlewares = () => { }, error: err, }) - return res.status(HttpStatus.BAD_REQUEST).send(errorMessage) + return res.status(StatusCodes.BAD_REQUEST).send(errorMessage) } logger.error(err) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: genericErrorMessage }) } } @@ -57,7 +57,7 @@ const errorHandlerMiddlewares = () => { _req, res, ) { - res.status(HttpStatus.NOT_FOUND).send() + res.status(StatusCodes.NOT_FOUND).send() } return [genericErrorHandlerMiddleware, catchNonExistentRoutesMiddleware] diff --git a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js index fff894ab1f..070e7991b3 100644 --- a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') @@ -62,7 +62,7 @@ describe('Admin-Forms Controller', () => { return this }) Controller.isFormActive(req, res, () => {}) - expect(res.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) }) it('should pass on to the next middleware if not archived', () => { @@ -84,7 +84,7 @@ describe('Admin-Forms Controller', () => { req.form.responseMode = 'email' Controller.isFormEncryptMode(req, res, next) expect(next).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY) + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNPROCESSABLE_ENTITY) }) it('should accept forms that are encrypt mode', () => { req.form.responseMode = 'encrypt' @@ -123,7 +123,7 @@ describe('Admin-Forms Controller', () => { it('should return 400 error when form param not supplied', (done) => { req.body.form = null res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(res.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) done() return res }) @@ -133,7 +133,9 @@ describe('Admin-Forms Controller', () => { it('should return 405 error when saving a Form object with invalid fields', (done) => { req.body.form = { title: 'bad_form', emails: 'wrongemail.com' } res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY) + expect(res.status).toHaveBeenCalledWith( + StatusCodes.UNPROCESSABLE_ENTITY, + ) done() return res }) @@ -177,7 +179,9 @@ describe('Admin-Forms Controller', () => { permissionList: [], } res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY) + expect(res.status).toHaveBeenCalledWith( + StatusCodes.UNPROCESSABLE_ENTITY, + ) done() return res }) diff --git a/tests/unit/backend/controllers/authentication.server.controller.spec.js b/tests/unit/backend/controllers/authentication.server.controller.spec.js index 0b8673429e..4f709b3170 100644 --- a/tests/unit/backend/controllers/authentication.server.controller.spec.js +++ b/tests/unit/backend/controllers/authentication.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') @@ -74,7 +74,7 @@ describe('Authentication Controller', () => { it('should return 401 if not authenticated', (done) => { req.session = null res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED) + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED) done() return res }) @@ -147,7 +147,7 @@ describe('Authentication Controller', () => { }) it('should not authorize if session user is not a collaborator nor admin', () => { res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) + expect(res.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) return res }) // Populate admin with partial user object @@ -259,7 +259,9 @@ describe('Authentication Controller', () => { res.locals.email = req.session.user.email req.body.otp = TEST_OTP res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY) + expect(res.status).toHaveBeenCalledWith( + StatusCodes.UNPROCESSABLE_ENTITY, + ) done() return res }) @@ -276,7 +278,9 @@ describe('Authentication Controller', () => { numOtpAttempts: 10, }) res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY) + expect(res.status).toHaveBeenCalledWith( + StatusCodes.UNPROCESSABLE_ENTITY, + ) done() return res }) @@ -295,7 +299,7 @@ describe('Authentication Controller', () => { numOtpAttempts: 0, }) res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED) + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED) done() return res }) @@ -388,7 +392,7 @@ describe('Authentication Controller', () => { [mockBetaFlag]: false, } res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) + expect(res.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) done() return res }) @@ -398,7 +402,7 @@ describe('Authentication Controller', () => { it('should return 403 when user is undefined', (done) => { req.session.user = undefined res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) + expect(res.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) done() return res }) diff --git a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js b/tests/unit/backend/controllers/email-submissions.server.controller.spec.js index 816733ecc1..139d949553 100644 --- a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js +++ b/tests/unit/backend/controllers/email-submissions.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const { times } = require('lodash') const ejs = require('ejs') const express = require('express') @@ -148,7 +148,7 @@ describe('Email Submissions Controller', () => { request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(() => { const mailOptions = sendSubmissionMailSpy.calls.mostRecent().args[0] expect(mailOptions).toEqual(fixtures) @@ -166,7 +166,7 @@ describe('Email Submissions Controller', () => { delete fixtures.form.emails request(app) .get(endpointPath) - .expect(HttpStatus.BAD_REQUEST) + .expect(StatusCodes.BAD_REQUEST) .then(done) .catch(done) }) @@ -202,7 +202,7 @@ describe('Email Submissions Controller', () => { request(app) .post(endpointPath) .field('body', JSON.stringify(body)) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect({ body }) .end(done) }) @@ -235,7 +235,7 @@ describe('Email Submissions Controller', () => { .post(endpointPath) .field('body', JSON.stringify(body)) .attach('govtech.jpg', Buffer.alloc(1), 'receiveId') - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect(JSON.stringify({ body: parsedBody })) .end(done) }) @@ -246,7 +246,7 @@ describe('Email Submissions Controller', () => { .post(endpointPath) .field('body', JSON.stringify(body)) .attach('invalid.py', Buffer.alloc(1), 'fieldId') - .expect(HttpStatus.BAD_REQUEST) + .expect(StatusCodes.BAD_REQUEST) .end(done) }) @@ -258,7 +258,7 @@ describe('Email Submissions Controller', () => { .attach('valid.jpg', Buffer.alloc(3000000), 'fieldId1') .attach('valid2.jpg', Buffer.alloc(3000000), 'fieldId2') .attach('valid.jpg', Buffer.alloc(3000000), 'fieldId3') - .expect(HttpStatus.UNPROCESSABLE_ENTITY) + .expect(StatusCodes.UNPROCESSABLE_ENTITY) .end(done) }) @@ -305,7 +305,7 @@ describe('Email Submissions Controller', () => { .field('body', JSON.stringify(body)) .attach('attachment.jpg', Buffer.alloc(1), 'attachment1') .attach('attachment.jpg', Buffer.alloc(1), 'attachment2') - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect(JSON.stringify({ body: parsedBody })) .end(done) }) @@ -355,7 +355,7 @@ describe('Email Submissions Controller', () => { it('parses submissions without files', (done) => { request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect( JSON.stringify({ body: { @@ -425,7 +425,7 @@ describe('Email Submissions Controller', () => { request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect( JSON.stringify({ body: { @@ -528,7 +528,7 @@ describe('Email Submissions Controller', () => { testForm = val return request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(({ body: submission }) => { console.info() expect(submission.form).toEqual(testForm._id.toString()) @@ -552,7 +552,7 @@ describe('Email Submissions Controller', () => { it('saves non-MyInfo submissions', (done) => { request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(({ body: submission }) => { expect(submission.form).toEqual(testForm._id.toString()) expect(submission.recipientEmails).toEqual( @@ -584,7 +584,7 @@ describe('Email Submissions Controller', () => { let concatResponse = 'foo bar; checkbox option 1, option 2; number 1; ' request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(({ body: submission }) => { expect(submission.form).toEqual(testForm._id.toString()) expect(submission.recipientEmails).toEqual( @@ -629,7 +629,7 @@ describe('Email Submissions Controller', () => { 'foo bar; [attachment] Text File file.text; \u0000\u0000\u0000\u0000\u0000' request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(({ body: submission }) => { expect(submission.form).toEqual(testForm._id.toString()) expect(submission.recipientEmails).toEqual( @@ -683,7 +683,10 @@ describe('Email Submissions Controller', () => { (req, res) => res.status(200).send(), ) - request(badApp).get(endpointPath).expect(HttpStatus.BAD_REQUEST).end(done) + request(badApp) + .get(endpointPath) + .expect(StatusCodes.BAD_REQUEST) + .end(done) }) }) @@ -744,7 +747,7 @@ describe('Email Submissions Controller', () => { const prepareSubmissionThenCompare = (expected, done) => { request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then( ({ body: { formData, autoReplyData, jsonData, replyToEmails } }) => { expect(formData).withContext('Form Data').toEqual(expected.formData) @@ -1560,7 +1563,7 @@ describe('Email Submissions Controller', () => { ] reqFixtures.form.form_fields = fields reqFixtures.body.responses = responses - request(app).get(endpointPath).expect(HttpStatus.CONFLICT).then(done) + request(app).get(endpointPath).expect(StatusCodes.CONFLICT).then(done) }) describe('Logic', () => { @@ -1667,7 +1670,7 @@ describe('Email Submissions Controller', () => { it('rejects submission prevented by single select value', (done) => { // Fulfills condition, hence submission rejected makeSingleSelectFixtures(preventSubmitLogics, 'Yes', true) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -1771,7 +1774,7 @@ describe('Email Submissions Controller', () => { it('rejects submission prevented by number value', (done) => { makeNumberValueFixtures(preventSubmitLogics, '9', true) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -1876,7 +1879,7 @@ describe('Email Submissions Controller', () => { it('rejects submissions prevented by logic', (done) => { makeMultiSelectFixtures(preventSubmitLogics, 'Option 1', true) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -2033,7 +2036,7 @@ describe('Email Submissions Controller', () => { it('rejects submissions prevented by logic', (done) => { makeMultiAndFixtures(preventSubmitLogics, 'Yes', 'Textfield', true) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -2158,7 +2161,7 @@ describe('Email Submissions Controller', () => { it('rejects submission prevented by logic', (done) => { makeOrFixtures(preventSubmitLogics, 'Yes', 'Radiobutton', true) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -2491,7 +2494,7 @@ describe('Email Submissions Controller', () => { true, true, ) - expectStatusCodeError(HttpStatus.BAD_REQUEST, done) + expectStatusCodeError(StatusCodes.BAD_REQUEST, done) }) }) @@ -2857,7 +2860,7 @@ describe('Email Submissions Controller', () => { answer: 'test@abc.com', } fixtures.body.responses.push(response) - sendAndExpect(HttpStatus.OK, { + sendAndExpect(StatusCodes.OK, { body: { parsedResponses: [Object.assign(response, { isVisible: true })], }, @@ -2903,7 +2906,7 @@ describe('Email Submissions Controller', () => { } fixtures.body.responses.push(response) - sendAndExpect(HttpStatus.BAD_REQUEST).end(done) + sendAndExpect(StatusCodes.BAD_REQUEST).end(done) }) }) @@ -2927,7 +2930,7 @@ describe('Email Submissions Controller', () => { } fixtures.body.responses.push(response) - sendAndExpect(HttpStatus.BAD_REQUEST).end(done) + sendAndExpect(StatusCodes.BAD_REQUEST).end(done) }) }) @@ -2949,7 +2952,7 @@ describe('Email Submissions Controller', () => { } fixtures.body.responses.push(response) - sendAndExpect(HttpStatus.BAD_REQUEST).end(done) + sendAndExpect(StatusCodes.BAD_REQUEST).end(done) }) }) @@ -2989,7 +2992,7 @@ describe('Email Submissions Controller', () => { }, ]).then(() => { fixtures.body.responses.push(response) - sendAndExpect(HttpStatus.OK).end(done) + sendAndExpect(StatusCodes.OK).end(done) }) }) }) @@ -3074,7 +3077,7 @@ describe('Email Submissions Controller', () => { fieldValue: 'test@abc.com', fieldIsRequired: false, fieldIsHidden: false, - expectedStatus: HttpStatus.BAD_REQUEST, + expectedStatus: StatusCodes.BAD_REQUEST, done, }) }) @@ -3083,7 +3086,7 @@ describe('Email Submissions Controller', () => { fieldValue: '', fieldIsRequired: false, fieldIsHidden: false, - expectedStatus: HttpStatus.OK, + expectedStatus: StatusCodes.OK, done, }) }) @@ -3092,7 +3095,7 @@ describe('Email Submissions Controller', () => { fieldValue: 'test@abc.com', fieldIsRequired: true, fieldIsHidden: false, - expectedStatus: HttpStatus.BAD_REQUEST, + expectedStatus: StatusCodes.BAD_REQUEST, done, }) }) @@ -3102,7 +3105,7 @@ describe('Email Submissions Controller', () => { fieldValue: '', fieldIsRequired: true, fieldIsHidden: true, - expectedStatus: HttpStatus.OK, + expectedStatus: StatusCodes.OK, done, }) }) diff --git a/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js b/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js index bf9cc91507..60f54265e7 100644 --- a/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js +++ b/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const express = require('express') const request = require('supertest') @@ -98,7 +98,7 @@ describe('Encrypt Submissions Controller', () => { formData = 'encryptedContent' request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .then(({ body: submission }) => { expect(submission.form).toEqual(testForm._id.toString()) expect(submission.authType).toEqual('NIL') @@ -171,7 +171,7 @@ describe('Encrypt Submissions Controller', () => { request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect( JSON.stringify({ body: { @@ -197,7 +197,10 @@ describe('Encrypt Submissions Controller', () => { form_fields: [], }).toObject(), } - request(app).post(endpointPath).expect(HttpStatus.BAD_REQUEST).end(done) + request(app) + .post(endpointPath) + .expect(StatusCodes.BAD_REQUEST) + .end(done) }) }) @@ -247,7 +250,7 @@ describe('Encrypt Submissions Controller', () => { } request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .expect( JSON.stringify({ body: { encryptedContent: correctlyEncryptedContent }, @@ -280,7 +283,7 @@ describe('Encrypt Submissions Controller', () => { request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .end((err, res) => { if (err) return done(err) expect(res.body.body).toEqual({ @@ -319,7 +322,7 @@ describe('Encrypt Submissions Controller', () => { request(app) .post(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .end((err, res) => { if (err) return done(err) expect(res.body.body).toEqual({ diff --git a/tests/unit/backend/controllers/forms.server.controller.spec.js b/tests/unit/backend/controllers/forms.server.controller.spec.js index 7b7b6816b7..94afd5eb85 100644 --- a/tests/unit/backend/controllers/forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/forms.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const { ObjectId } = require('bson-ext') @@ -110,13 +110,13 @@ describe('Form Controller', () => { return res }) Controller.formById(req, res, null, id) - expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(res.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) }) it('should return a 404 error if form id is not found', (done) => { req.params.formId = mongoose.Types.ObjectId() res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) done() return res }) @@ -132,7 +132,7 @@ describe('Form Controller', () => { req.params.formId = invalidForm._id res.status.and.callFake(() => { expect(res.status).toHaveBeenCalledWith( - HttpStatus.INTERNAL_SERVER_ERROR, + StatusCodes.INTERNAL_SERVER_ERROR, ) done() return res diff --git a/tests/unit/backend/controllers/public-forms.server.controller.spec.js b/tests/unit/backend/controllers/public-forms.server.controller.spec.js index 73d2aff076..da933cb960 100644 --- a/tests/unit/backend/controllers/public-forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/public-forms.server.controller.spec.js @@ -1,4 +1,4 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') @@ -56,7 +56,7 @@ describe('Public-Forms Controller', () => { status: 'PRIVATE', } res.status.and.callFake(() => { - expect(res.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) done() return res }) @@ -135,7 +135,7 @@ describe('Public-Forms Controller', () => { return res }) Controller.submitFeedback(req, res) - expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(res.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) }) }) @@ -150,7 +150,7 @@ describe('Public-Forms Controller', () => { } res.status.and.callFake(() => { expect(res.status).toHaveBeenCalledWith( - HttpStatus.INTERNAL_SERVER_ERROR, + StatusCodes.INTERNAL_SERVER_ERROR, ) done() return res @@ -167,7 +167,7 @@ describe('Public-Forms Controller', () => { formId: mongoose.Types.ObjectId(), } res.status.and.callFake((args) => { - expect(args).toBe(HttpStatus.OK) + expect(args).toBe(StatusCodes.OK) return res }) res.send.and.callFake(() => { diff --git a/tests/unit/backend/controllers/spcp.server.controller.spec.js b/tests/unit/backend/controllers/spcp.server.controller.spec.js index 09086d977c..888c0fd132 100644 --- a/tests/unit/backend/controllers/spcp.server.controller.spec.js +++ b/tests/unit/backend/controllers/spcp.server.controller.spec.js @@ -1,5 +1,5 @@ const crypto = require('crypto') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') @@ -177,22 +177,22 @@ describe('SPCP Controller', () => { it('should return 400 if target not provided', () => { req.query.target = '' Controller.createSpcpRedirectURL(authClients)(req, res, next) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 400 if authType not provided', () => { req.query.authType = '' Controller.createSpcpRedirectURL(authClients)(req, res, next) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 400 if esrvcId not provided', () => { req.query.esrvcId = '' Controller.createSpcpRedirectURL(authClients)(req, res, next) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 400 if authType is NIL', () => { Controller.createSpcpRedirectURL(authClients)(req, res, next) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 200 and redirectUrl if authType is SP', () => { req.query.authType = 'SP' @@ -207,7 +207,7 @@ describe('SPCP Controller', () => { Controller.createSpcpRedirectURL(authClients)(req, res, next) expect(next).toHaveBeenCalled() Controller.returnSpcpRedirectURL(req, res) - expectResponse(HttpStatus.OK, expectedUrl) + expectResponse(StatusCodes.OK, expectedUrl) }) it('should return 200 and redirectUrl if authType is CP', () => { req.query.authType = 'CP' @@ -222,7 +222,7 @@ describe('SPCP Controller', () => { Controller.createSpcpRedirectURL(authClients)(req, res, next) expect(next).toHaveBeenCalled() Controller.returnSpcpRedirectURL(req, res) - expectResponse(HttpStatus.OK, expectedUrl) + expectResponse(StatusCodes.OK, expectedUrl) }) }) @@ -270,7 +270,7 @@ describe('SPCP Controller', () => { replyWith('error', {}) Controller.isSpcpAuthenticated(authClients)(req, res, next) expect(next).not.toHaveBeenCalled() - expectResponse(HttpStatus.UNAUTHORIZED, { + expectResponse(StatusCodes.UNAUTHORIZED, { message: 'User is not SPCP authenticated', spcpSubmissionFailure: true, }) @@ -393,35 +393,35 @@ describe('SPCP Controller', () => { it('should return 401 if artifact not provided', () => { req.query.SAMLart = '' Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(HttpStatus.UNAUTHORIZED) + expectResponse(StatusCodes.UNAUTHORIZED) }) it('should return 400 if relayState not provided', () => { req.query.RelayState = '' Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 401 if artifact is invalid', () => { req.query.SAMLart = '123456789' Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(HttpStatus.UNAUTHORIZED) + expectResponse(StatusCodes.UNAUTHORIZED) }) it('should return 400 if relayState is invalid format', () => { req.query.RelayState = '/invalidRelayState' Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(HttpStatus.BAD_REQUEST) + expectResponse(StatusCodes.BAD_REQUEST) }) it('should return 404 if relayState has valid format but invalid formId', (done) => { req.query.RelayState = '/123,false' - expectBadRequestOnLogin(HttpStatus.NOT_FOUND, done) + expectBadRequestOnLogin(StatusCodes.NOT_FOUND, done) }) it('should return 401 if relayState has valid format but belongs to non SP form', (done) => { req.query.RelayState = `$/{testForm._id},false` - expectBadRequestOnLogin(HttpStatus.UNAUTHORIZED, done) + expectBadRequestOnLogin(StatusCodes.UNAUTHORIZED, done) }) it('should redirect to relayState with login error if getAttributes returns relayState only', (done) => { diff --git a/tests/unit/backend/controllers/submissions.server.controller.spec.js b/tests/unit/backend/controllers/submissions.server.controller.spec.js index 95953a565f..4b266d9d39 100644 --- a/tests/unit/backend/controllers/submissions.server.controller.spec.js +++ b/tests/unit/backend/controllers/submissions.server.controller.spec.js @@ -1,6 +1,6 @@ const MockAdapter = require('axios-mock-adapter') const axios = require('axios') -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') const ejs = require('ejs') const express = require('express') const request = require('supertest') @@ -104,12 +104,12 @@ describe('Submissions Controller', () => { it('passes through on no form captcha', (done) => { answerCaptchaWith(new Error()) hasCaptcha = false - request(app).post(endpointPath).expect(HttpStatus.OK).end(done) + request(app).post(endpointPath).expect(StatusCodes.OK).end(done) }) it('errors with 400 on no captcha response', (done) => { answerCaptchaWith(new Error()) - request(app).post(endpointPath).expect(HttpStatus.BAD_REQUEST).end(done) + request(app).post(endpointPath).expect(StatusCodes.BAD_REQUEST).end(done) }) it('errors with 500 if no captcha private key', (done) => { @@ -206,7 +206,7 @@ describe('Submissions Controller', () => { testFn(mail) }) - request(app).get(endpointPath).expect(HttpStatus.OK).end(done) + request(app).get(endpointPath).expect(StatusCodes.OK).end(done) } it( @@ -259,7 +259,7 @@ describe('Submissions Controller', () => { mockSendNodeMail.calls.reset() request(app) .get(endpointPath) - .expect(HttpStatus.OK) + .expect(StatusCodes.OK) .end(() => { expect(mockSendNodeMail).not.toHaveBeenCalled() done() @@ -314,7 +314,7 @@ describe('Submissions Controller', () => { request(app) .get(endpointPath) - .expect(HttpStatus.INTERNAL_SERVER_ERROR) + .expect(StatusCodes.INTERNAL_SERVER_ERROR) .end((err) => { // Restore Submission.countDocuments before passing on any errors Submission.countDocuments = originalSubmissionCountDoc diff --git a/tests/unit/backend/modules/bounce/bounce.controller.spec.ts b/tests/unit/backend/modules/bounce/bounce.controller.spec.ts index 8ecc959942..e76d0b3700 100644 --- a/tests/unit/backend/modules/bounce/bounce.controller.spec.ts +++ b/tests/unit/backend/modules/bounce/bounce.controller.spec.ts @@ -1,4 +1,4 @@ -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import expressHandler from 'tests/unit/backend/helpers/jest-express' import { mocked } from 'ts-jest/utils' @@ -25,7 +25,7 @@ describe('handleSns', () => { MOCK_REQ.body, ) expect(MockBounceService.updateBounces).not.toHaveBeenCalled() - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.FORBIDDEN) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) }) it('should call updateBounces when requests are valid', async () => { @@ -37,7 +37,7 @@ describe('handleSns', () => { MOCK_REQ.body, ) expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.OK) }) it('should return 400 when errors are thrown in isValidSnsRequest', async () => { @@ -49,7 +49,7 @@ describe('handleSns', () => { MOCK_REQ.body, ) expect(MockBounceService.updateBounces).not.toHaveBeenCalled() - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) }) it('should return 400 when errors are thrown in updateBounces', async () => { @@ -64,6 +64,6 @@ describe('handleSns', () => { MOCK_REQ.body, ) expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) }) }) diff --git a/tests/unit/backend/modules/user/user.controller.spec.ts b/tests/unit/backend/modules/user/user.controller.spec.ts index 17f5c090b8..d057cfdc02 100644 --- a/tests/unit/backend/modules/user/user.controller.spec.ts +++ b/tests/unit/backend/modules/user/user.controller.spec.ts @@ -1,4 +1,4 @@ -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import { mocked } from 'ts-jest/utils' import SmsFactory from 'src/app/factories/sms.factory' @@ -56,7 +56,7 @@ describe('user.controller', () => { expectedOtp, MOCK_REQ.body.userId, ) - expect(mockRes.sendStatus).toBeCalledWith(HttpStatus.OK) + expect(mockRes.sendStatus).toBeCalledWith(StatusCodes.OK) }) it('should return 401 when user id is not in session', async () => { @@ -78,7 +78,7 @@ describe('user.controller', () => { // Assert // Should trigger unauthorized response. - expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.status).toBeCalledWith(StatusCodes.UNAUTHORIZED) expect(mockRes.send).toBeCalledWith('User is unauthorized.') // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -109,7 +109,7 @@ describe('user.controller', () => { // Assert // Should trigger unauthorized response. - expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.status).toBeCalledWith(StatusCodes.UNAUTHORIZED) expect(mockRes.send).toBeCalledWith('User is unauthorized.') // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -129,7 +129,7 @@ describe('user.controller', () => { await UserController.handleContactSendOtp(MOCK_REQ, mockRes, jest.fn()) // Assert - expect(mockRes.status).toBeCalledWith(HttpStatus.BAD_REQUEST) + expect(mockRes.status).toBeCalledWith(StatusCodes.BAD_REQUEST) expect(mockRes.send).toBeCalledWith(expectedError.message) // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -148,7 +148,7 @@ describe('user.controller', () => { await UserController.handleContactSendOtp(MOCK_REQ, mockRes, jest.fn()) // Assert - expect(mockRes.status).toBeCalledWith(HttpStatus.BAD_REQUEST) + expect(mockRes.status).toBeCalledWith(StatusCodes.BAD_REQUEST) expect(mockRes.send).toBeCalledWith(expectedError.message) // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -199,7 +199,7 @@ describe('user.controller', () => { MOCK_REQ.body.contact, MOCK_REQ.body.userId, ) - expect(mockRes.status).toBeCalledWith(HttpStatus.OK) + expect(mockRes.status).toBeCalledWith(StatusCodes.OK) expect(mockRes.send).toBeCalledWith(MOCK_UPDATED_USER) }) @@ -223,7 +223,7 @@ describe('user.controller', () => { // Assert // Should trigger unauthorized response. - expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.status).toBeCalledWith(StatusCodes.UNAUTHORIZED) expect(mockRes.send).toBeCalledWith('User is unauthorized.') // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -255,7 +255,7 @@ describe('user.controller', () => { // Assert // Should trigger unauthorized response. - expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.status).toBeCalledWith(StatusCodes.UNAUTHORIZED) expect(mockRes.send).toBeCalledWith('User is unauthorized.') // Service functions should not be called. expect(MockUserService.verifyContactOtp).not.toHaveBeenCalled() @@ -276,7 +276,7 @@ describe('user.controller', () => { await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) // Assert - expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.status).toBeCalledWith(StatusCodes.INTERNAL_SERVER_ERROR) expect(mockRes.send).toBeCalledWith(expectedError.message) expect(MockUserService.verifyContactOtp).toHaveBeenCalledTimes(1) expect(MockUserService.updateUserContact).toHaveBeenCalledTimes(1) @@ -313,7 +313,7 @@ describe('user.controller', () => { await UserController.handleContactVerifyOtp(MOCK_REQ, mockRes, jest.fn()) // Assert - expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.status).toBeCalledWith(StatusCodes.INTERNAL_SERVER_ERROR) expect(mockRes.send).toBeCalledWith(expectedError.message) expect(MockUserService.verifyContactOtp).toHaveBeenCalledTimes(1) expect(MockUserService.updateUserContact).not.toHaveBeenCalled() @@ -367,7 +367,7 @@ describe('user.controller', () => { // Assert // Should trigger unauthorized response. - expect(mockRes.status).toBeCalledWith(HttpStatus.UNAUTHORIZED) + expect(mockRes.status).toBeCalledWith(StatusCodes.UNAUTHORIZED) expect(mockRes.send).toBeCalledWith('User is unauthorized.') }) @@ -381,7 +381,7 @@ describe('user.controller', () => { await UserController.handleFetchUser(MOCK_REQ, mockRes, jest.fn()) // Assert - expect(mockRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR) + expect(mockRes.status).toBeCalledWith(StatusCodes.INTERNAL_SERVER_ERROR) expect(mockRes.send).toBeCalledWith('Unable to retrieve user') }) }) diff --git a/tests/unit/backend/modules/verification/verification.controller.spec.ts b/tests/unit/backend/modules/verification/verification.controller.spec.ts index dd4c9e17b7..6de8a16cfc 100644 --- a/tests/unit/backend/modules/verification/verification.controller.spec.ts +++ b/tests/unit/backend/modules/verification/verification.controller.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'bson' -import HttpStatus from 'http-status-codes' +import { StatusCodes } from 'http-status-codes' import mongoose from 'mongoose' import { mocked } from 'ts-jest/utils' @@ -52,7 +52,7 @@ describe('Verification controller', () => { expect(MockVfnService.createTransaction).toHaveBeenCalledWith( MOCK_FORM_ID, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.CREATED) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.CREATED) expect(MOCK_RES.json).toHaveBeenCalledWith(returnValue) }) @@ -62,7 +62,7 @@ describe('Verification controller', () => { expect(MockVfnService.createTransaction).toHaveBeenCalledWith( MOCK_FORM_ID, ) - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.OK) }) }) @@ -89,7 +89,7 @@ describe('Verification controller', () => { expect(MockVfnService.getTransactionMetadata).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.OK) expect(MOCK_RES.json).toHaveBeenCalledWith(transaction) }) @@ -103,7 +103,7 @@ describe('Verification controller', () => { expect(MockVfnService.getTransactionMetadata).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) }) @@ -132,7 +132,7 @@ describe('Verification controller', () => { transaction, MOCK_FIELD_ID, ) - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.OK) }) it('should return 404 when service throws error', async () => { @@ -145,7 +145,7 @@ describe('Verification controller', () => { expect(MockVfnService.getTransaction).toHaveBeenCalledWith( MOCK_TRANSACTION_ID, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) }) @@ -175,7 +175,7 @@ describe('Verification controller', () => { MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(HttpStatus.CREATED) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.CREATED) }) it('should return 404 when service throws not found error', async () => { @@ -193,7 +193,7 @@ describe('Verification controller', () => { MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND) expect(MOCK_RES.json).toHaveBeenCalledWith(notFoundError) }) @@ -212,7 +212,7 @@ describe('Verification controller', () => { MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.ACCEPTED) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.ACCEPTED) expect(MOCK_RES.json).toHaveBeenCalledWith(waitOtpError) }) @@ -231,7 +231,7 @@ describe('Verification controller', () => { MOCK_FIELD_ID, MOCK_ANSWER, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) expect(MOCK_RES.json).toHaveBeenCalledWith(sendOtpError) }) }) @@ -263,7 +263,7 @@ describe('Verification controller', () => { MOCK_FIELD_ID, MOCK_OTP, ) - expect(MOCK_RES.status).toHaveBeenCalledWith(HttpStatus.OK) + expect(MOCK_RES.status).toHaveBeenCalledWith(StatusCodes.OK) expect(MOCK_RES.json).toHaveBeenCalledWith(MOCK_DATA) }) @@ -283,7 +283,7 @@ describe('Verification controller', () => { MOCK_OTP, ) expect(MOCK_RES.status).toHaveBeenCalledWith( - HttpStatus.UNPROCESSABLE_ENTITY, + StatusCodes.UNPROCESSABLE_ENTITY, ) expect(MOCK_RES.json).toHaveBeenCalledWith(invalidOtpError) }) From 61d510312affdea6e971147dd547a6f5449b270b Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Mon, 31 Aug 2020 21:09:02 +0800 Subject: [PATCH 21/26] feat: MailService#sendNodeMail invocations to retry on 4xx errors(#227) --- package-lock.json | 34 + package.json | 2 + src/app/services/mail.service.ts | 79 +- .../backend/services/mail.service.spec.ts | 691 ++++++++++++++---- 4 files changed, 639 insertions(+), 167 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ed3cf5daf..b338f13233 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5032,6 +5032,15 @@ "integrity": "sha512-IkVfat549ggtkZUthUzEX49562eGikhSYeVGX97SkMFn+sTZrgRewXjQ4tPKFPCykZHkX1Zfd9OoELGqKU2jJA==", "dev": true }, + "@types/promise-retry": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/promise-retry/-/promise-retry-1.1.3.tgz", + "integrity": "sha512-LxIlEpEX6frE3co3vCO2EUJfHIta1IOmhDlcAsR4GMMv9hev1iTI9VwberVGkePJAuLZs5rMucrV8CziCfuJMw==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, "@types/puppeteer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-3.0.1.tgz", @@ -5091,6 +5100,12 @@ } } }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, "@types/serve-static": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.4.tgz", @@ -10885,6 +10900,11 @@ "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz", "integrity": "sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg==" }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "errno": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", @@ -20897,6 +20917,15 @@ "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", "dev": true }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, "promisify-event": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/promisify-event/-/promisify-event-1.0.0.tgz", @@ -21881,6 +21910,11 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "dev": true }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index 8c9f95cf3f..aaf5c9a2a6 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "nodemailer": "^6.4.11", "nodemailer-direct-transport": "~3.3.2", "opossum": "^5.0.1", + "promise-retry": "^2.0.1", "proxyquire": "^2.1.0", "puppeteer-core": "^5.2.1", "raven-js": "^3.27.0", @@ -181,6 +182,7 @@ "@types/node": "^14.0.13", "@types/nodemailer": "^6.4.0", "@types/nodemailer-direct-transport": "^1.0.31", + "@types/promise-retry": "^1.1.3", "@types/puppeteer-core": "^2.0.0", "@types/triple-beam": "^1.3.2", "@types/uid-generator": "^2.0.2", diff --git a/src/app/services/mail.service.ts b/src/app/services/mail.service.ts index 8c976fcbe4..2f0afaf0a0 100644 --- a/src/app/services/mail.service.ts +++ b/src/app/services/mail.service.ts @@ -1,7 +1,8 @@ -import { String } from 'aws-sdk/clients/cloudhsm' -import { isEmpty } from 'lodash' +import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' import Mail from 'nodemailer/lib/mailer' +import promiseRetry from 'promise-retry' +import { OperationOptions } from 'retry' import validator from 'validator' import config from '../../config/config' @@ -53,6 +54,7 @@ type MailServiceParams = { appUrl?: string transporter?: Mail senderMail?: string + retryParams?: Partial } type AutoReplyMailData = { @@ -72,42 +74,55 @@ export type AutoreplySummaryRenderData = { formUrl: string } -type MailOptions = Omit & { +export type MailOptions = Omit & { to: string | string[] } +const DEFAULT_RETRY_PARAMS: MailServiceParams['retryParams'] = { + retries: 3, + // Exponential backoff. + factor: 2, + minTimeout: 5000, +} + export class MailService { /** * The application name to be shown in some sent emails' fields such as mail * subject or mail body. */ - #appName: string + #appName: Required /** * The application URL to be shown in some sent emails' fields such as mail * subject or mail body. */ - #appUrl: string + #appUrl: Required /** * The transporter to be used to send mail. */ - #transporter: Mail + #transporter: Required /** * The email string to denote the "from" field of the email. */ - #senderMail: string + #senderMail: Required /** * The full string that can be shown in the mail's "from" field created from * the given `appName` and `senderMail` arguments. * * E.g. `FormSG ` */ - #senderFromString: String + #senderFromString: string + + /** + * Sets the retry parameters for sendNodeMail retries. + */ + #retryParams: MailServiceParams['retryParams'] constructor({ appName = config.app.title, appUrl = config.app.appUrl, transporter = config.mail.transporter, senderMail = config.mail.mailFrom, + retryParams = DEFAULT_RETRY_PARAMS, }: MailServiceParams = {}) { // Email validation if (!validator.isEmail(senderMail)) { @@ -128,6 +143,7 @@ export class MailService { this.#senderMail = senderMail this.#senderFromString = `${appName} <${senderMail}>` this.#transporter = transporter + this.#retryParams = retryParams } /** @@ -139,8 +155,8 @@ export class MailService { const logMeta = { action: '#sendNodeMail', mailId: sendOptions?.mailId, - mailFrom: mail?.from, - mailSubject: mail?.subject, + mailFrom: mail.from, + mailSubject: mail.subject, formId: sendOptions?.formId, } @@ -162,27 +178,36 @@ export class MailService { return Promise.reject(new Error('Invalid email error')) } - try { + return promiseRetry(async (retry, attemptNum) => { logger.info({ - message: 'Attempting to send mail', + message: `Attempt ${attemptNum} to send mail`, meta: logMeta, }) - const response = await this.#transporter.sendMail(mail) - logger.info({ - message: 'Mail successfully sent', - meta: logMeta, - }) - return response - } catch (err) { - // Pass errors to the callback - logger.error({ - message: 'Send mail failure', - meta: logMeta, - error: err, - }) - return Promise.reject(err) - } + try { + const response = await this.#transporter.sendMail(mail) + logger.info({ + message: `Mail successfully sent on attempt ${attemptNum}`, + meta: logMeta, + }) + return response + } catch (err) { + // Pass errors to the callback + logger.error({ + message: `Send mail failure on attempt ${attemptNum}`, + meta: logMeta, + error: err, + }) + + // Retry only on 4xx errors. + if (inRange(get(err, 'responseCode', 0), 400, 500)) { + return retry(err) + } + + // Not 4xx error, rethrow error. + throw err + } + }, this.#retryParams) } /** diff --git a/tests/unit/backend/services/mail.service.spec.ts b/tests/unit/backend/services/mail.service.spec.ts index 1b8d7b74af..ddc8a6a462 100644 --- a/tests/unit/backend/services/mail.service.spec.ts +++ b/tests/unit/backend/services/mail.service.spec.ts @@ -5,6 +5,7 @@ import { ImportMock } from 'ts-mock-imports' import { AutoreplySummaryRenderData, + MailOptions, MailService, SendAutoReplyEmailsArgs, } from 'src/app/services/mail.service' @@ -14,12 +15,16 @@ import { IPopulatedForm, ISubmissionSchema } from 'src/types' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' const MOCK_VALID_EMAIL_3 = 'to3@example.com' +const MOCK_VALID_EMAIL_4 = 'to4@example.com' +const MOCK_VALID_EMAIL_5 = 'to5@example.com' const MOCK_SENDER_EMAIL = 'from@example.com' const MOCK_APP_NAME = 'mockApp' const MOCK_APP_URL = 'mockApp.example.com' const MOCK_SENDER_STRING = `${MOCK_APP_NAME} <${MOCK_SENDER_EMAIL}>` const MOCK_PDF = 'fake pdf' +const MOCK_RETRY_COUNT = 10 + describe('mail.service', () => { const sendMailSpy = jest.fn() const mockTransporter = ({ @@ -38,6 +43,8 @@ describe('mail.service', () => { senderMail: MOCK_SENDER_EMAIL, appName: MOCK_APP_NAME, appUrl: MOCK_APP_URL, + // Set for instant timeouts during testing. + retryParams: { retries: MOCK_RETRY_COUNT, minTimeout: 0, factor: 2 }, }) describe('Constructor', () => { @@ -61,13 +68,8 @@ describe('mail.service', () => { describe('sendVerificationOtp', () => { const MOCK_OTP = '123456' - it('should send verification otp successfully', async () => { - // Arrange - // sendMail should return mocked success response - const mockedResponse = 'mockedSuccessResponse' - sendMailSpy.mockResolvedValueOnce(mockedResponse) - - const expectedArgument = { + const generateExpectedArg = async () => { + return { to: MOCK_VALID_EMAIL, from: MOCK_SENDER_STRING, subject: `Your OTP for submitting a form on ${MOCK_APP_NAME}`, @@ -81,11 +83,24 @@ describe('mail.service', () => { 'X-Formsg-Email-Type': 'Verification OTP', }, } + } - // Act + Assert - await expect( - mailService.sendVerificationOtp(MOCK_VALID_EMAIL, MOCK_OTP), - ).resolves.toEqual(mockedResponse) + it('should send verification otp successfully', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy.mockResolvedValueOnce(mockedResponse) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSend = mailService.sendVerificationOtp( + MOCK_VALID_EMAIL, + MOCK_OTP, + ) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) @@ -94,23 +109,105 @@ describe('mail.service', () => { it('should reject with error when email is invalid', async () => { // Arrange const invalidEmail = 'notAnEmail' - // Act + Assert - await expect( - mailService.sendVerificationOtp(invalidEmail, MOCK_OTP), - ).rejects.toThrowError('Invalid email error') + + // Act + const pendingSend = mailService.sendVerificationOtp( + invalidEmail, + MOCK_OTP, + ) + + // Assert + await expect(pendingSend).rejects.toThrowError('Invalid email error') + }) + + it('should autoretry when 4xx error is thrown by sendNodeMail and pass if second try passes', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockReturnValueOnce(mockedResponse) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendVerification = mailService.sendVerificationOtp( + MOCK_VALID_EMAIL, + MOCK_OTP, + ) + + // Assert + await expect(pendingSendVerification).resolves.toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + // Should have been called two times since it rejected the first one and + // resolved + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => { + // Arrange + const mock4xxReject = { + responseCode: 413, + message: 'oh no something went wrong', + } + sendMailSpy.mockRejectedValue(mock4xxReject) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendVerification = mailService.sendVerificationOtp( + MOCK_VALID_EMAIL, + MOCK_OTP, + ) + + // Assert + await expect(pendingSendVerification).rejects.toEqual(mock4xxReject) + // Check arguments passed to sendNodeMail + // Should have been called MOCK_RETRY_COUNT + 1 times + expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should stop autoretrying when the returned error is not a 4xx error', async () => { + // Arrange + const mockError = new Error('this should be returned at the end') + const mock4xxReject = { + responseCode: 413, + message: 'oh no something went wrong', + } + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockRejectedValueOnce(mockError) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendVerification = mailService.sendVerificationOtp( + MOCK_VALID_EMAIL, + MOCK_OTP, + ) + + // Assert + await expect(pendingSendVerification).rejects.toEqual(mockError) + // Check arguments passed to sendNodeMail + // Should retry two times and stop since the second rejected value is + // non-4xx error. + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) }) }) describe('sendLoginOtp', () => { const MOCK_OTP = '123456' const MOCK_IP = 'mock:5000' - it('should send login otp successfully', async () => { - // Arrange - // sendMail should return mocked success response - const mockedResponse = 'mockedSuccessResponse' - sendMailSpy.mockResolvedValueOnce(mockedResponse) - const expectedArgument = { + const generateExpectedArg = async () => { + return { to: MOCK_VALID_EMAIL, from: MOCK_SENDER_STRING, subject: `One-Time Password (OTP) for ${MOCK_APP_NAME}`, @@ -125,15 +222,25 @@ describe('mail.service', () => { 'X-Formsg-Email-Type': 'Login OTP', }, } + } - // Act + Assert - await expect( - mailService.sendLoginOtp({ - recipient: MOCK_VALID_EMAIL, - otp: MOCK_OTP, - ipAddress: MOCK_IP, - }), - ).resolves.toEqual(mockedResponse) + it('should send login otp successfully', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy.mockResolvedValueOnce(mockedResponse) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSend = mailService.sendLoginOtp({ + recipient: MOCK_VALID_EMAIL, + otp: MOCK_OTP, + ipAddress: MOCK_IP, + }) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) @@ -142,10 +249,99 @@ describe('mail.service', () => { it('should reject with error when email is invalid', async () => { // Arrange const invalidEmail = 'notAnEmail' - // Act + Assert - await expect( - mailService.sendVerificationOtp(invalidEmail, MOCK_OTP), - ).rejects.toThrowError('Invalid email error') + + // Act + const pendingSend = mailService.sendLoginOtp({ + recipient: invalidEmail, + otp: MOCK_OTP, + ipAddress: MOCK_IP, + }) + // Assert + await expect(pendingSend).rejects.toThrowError('Invalid email error') + }) + + it('should autoretry when 4xx error is thrown by sendNodeMail and pass if second try passes', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockReturnValueOnce(mockedResponse) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendLoginOtp = mailService.sendLoginOtp({ + recipient: MOCK_VALID_EMAIL, + otp: MOCK_OTP, + ipAddress: MOCK_IP, + }) + + // Assert + await expect(pendingSendLoginOtp).resolves.toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + // Should have been called two times since it rejected the first one and + // resolved + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => { + // Arrange + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + sendMailSpy.mockRejectedValue(mock4xxReject) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendLoginOtp = mailService.sendLoginOtp({ + recipient: MOCK_VALID_EMAIL, + otp: MOCK_OTP, + ipAddress: MOCK_IP, + }) + + // Assert + await expect(pendingSendLoginOtp).rejects.toEqual(mock4xxReject) + // Check arguments passed to sendNodeMail + // Should have been called MOCK_RETRY_COUNT + 1 times + expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should stop autoretrying when the returned error is not a 4xx error', async () => { + // Arrange + const mockError = new Error('this should be returned at the end') + const mock4xxReject = { + responseCode: 413, + message: 'oh no something went wrong', + } + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockRejectedValueOnce(mockError) + + const expectedArgument = await generateExpectedArg() + + // Act + const pendingSendLoginOtp = mailService.sendLoginOtp({ + recipient: MOCK_VALID_EMAIL, + otp: MOCK_OTP, + ipAddress: MOCK_IP, + }) + + // Assert + await expect(pendingSendLoginOtp).rejects.toEqual(mockError) + // Check arguments passed to sendNodeMail + // Should only invoke two times and stop since the second rejected value + // is non-4xx error. + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) }) }) @@ -192,6 +388,23 @@ describe('mail.service', () => { ...MOCK_VALID_SUBMISSION_PARAMS.jsonData, ] + const generateExpectedArgWithToField = (toField: string[]) => { + return { + to: toField, + from: MOCK_SENDER_STRING, + subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (Ref: ${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, + html: expectedHtml, + attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, + headers: { + // Hardcode in tests in case something changes this. + 'X-Formsg-Email-Type': 'Admin (response)', + 'X-Formsg-Form-ID': MOCK_VALID_SUBMISSION_PARAMS.form._id, + 'X-Formsg-Submission-ID': MOCK_VALID_SUBMISSION_PARAMS.submission.id, + }, + replyTo: MOCK_VALID_SUBMISSION_PARAMS.replyToEmails.join(', '), + } + } + beforeAll(async () => { const htmlData = { appName: MOCK_APP_NAME, @@ -205,35 +418,29 @@ describe('mail.service', () => { }) it('should send submission mail to admin successfully if form.emails is an array with a single string', async () => { + // Arrange // sendMail should return mocked success response const mockedResponse = 'mockedSuccessResponse' sendMailSpy.mockResolvedValueOnce(mockedResponse) - const expectedArgument = { - to: MOCK_VALID_SUBMISSION_PARAMS.form.emails, - from: MOCK_SENDER_STRING, - subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (Ref: ${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, - html: expectedHtml, - attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, - headers: { - // Hardcode in tests in case something changes this. - 'X-Formsg-Email-Type': 'Admin (response)', - 'X-Formsg-Form-ID': MOCK_VALID_SUBMISSION_PARAMS.form._id, - 'X-Formsg-Submission-ID': MOCK_VALID_SUBMISSION_PARAMS.submission.id, - }, - replyTo: MOCK_VALID_SUBMISSION_PARAMS.replyToEmails.join(', '), - } + const expectedArgument = generateExpectedArgWithToField( + MOCK_VALID_SUBMISSION_PARAMS.form.emails, + ) - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(MOCK_VALID_SUBMISSION_PARAMS), - ).resolves.toEqual(mockedResponse) + // Act + const pendingSend = mailService.sendSubmissionToAdmin( + MOCK_VALID_SUBMISSION_PARAMS, + ) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) }) it('should send submission mail to admin successfully if form.emails is an array with a single comma separated string', async () => { + // Arrange // sendMail should return mocked success response const mockedResponse = 'mockedSuccessResponse' sendMailSpy.mockResolvedValueOnce(mockedResponse) @@ -244,25 +451,15 @@ describe('mail.service', () => { const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) modifiedParams.form.emails = formEmailsCommaSeparated - const expectedArgument = { - to: formEmailsCommaSeparated, - from: MOCK_SENDER_STRING, - subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (Ref: ${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, - html: expectedHtml, - attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, - headers: { - // Hardcode in tests in case something changes this. - 'X-Formsg-Email-Type': 'Admin (response)', - 'X-Formsg-Form-ID': MOCK_VALID_SUBMISSION_PARAMS.form._id, - 'X-Formsg-Submission-ID': MOCK_VALID_SUBMISSION_PARAMS.submission.id, - }, - replyTo: MOCK_VALID_SUBMISSION_PARAMS.replyToEmails.join(', '), - } + const expectedArgument = generateExpectedArgWithToField( + formEmailsCommaSeparated, + ) - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(modifiedParams), - ).resolves.toEqual(mockedResponse) + // Act + const pendingSend = mailService.sendSubmissionToAdmin(modifiedParams) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) @@ -277,24 +474,15 @@ describe('mail.service', () => { const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) modifiedParams.form.emails = formMultipleEmailsArray - const expectedArgument = { - to: formMultipleEmailsArray, - from: MOCK_SENDER_STRING, - subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (Ref: ${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, - html: expectedHtml, - attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, - headers: { - // Hardcode in tests in case something changes this. - 'X-Formsg-Email-Type': 'Admin (response)', - 'X-Formsg-Form-ID': MOCK_VALID_SUBMISSION_PARAMS.form._id, - 'X-Formsg-Submission-ID': MOCK_VALID_SUBMISSION_PARAMS.submission.id, - }, - replyTo: MOCK_VALID_SUBMISSION_PARAMS.replyToEmails.join(', '), - } - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(modifiedParams), - ).resolves.toEqual(mockedResponse) + const expectedArgument = generateExpectedArgWithToField( + formMultipleEmailsArray, + ) + + // Act + const pendingSend = mailService.sendSubmissionToAdmin(modifiedParams) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) @@ -312,24 +500,37 @@ describe('mail.service', () => { const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) modifiedParams.form.emails = formEmailsMixture - const expectedArgument = { - to: formEmailsMixture, - from: MOCK_SENDER_STRING, - subject: `formsg-auto: ${MOCK_VALID_SUBMISSION_PARAMS.form.title} (Ref: ${MOCK_VALID_SUBMISSION_PARAMS.submission.id})`, - html: expectedHtml, - attachments: MOCK_VALID_SUBMISSION_PARAMS.attachments, - headers: { - // Hardcode in tests in case something changes this. - 'X-Formsg-Email-Type': 'Admin (response)', - 'X-Formsg-Form-ID': MOCK_VALID_SUBMISSION_PARAMS.form._id, - 'X-Formsg-Submission-ID': MOCK_VALID_SUBMISSION_PARAMS.submission.id, - }, - replyTo: MOCK_VALID_SUBMISSION_PARAMS.replyToEmails.join(', '), - } - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(modifiedParams), - ).resolves.toEqual(mockedResponse) + const expectedArgument = generateExpectedArgWithToField(formEmailsMixture) + + // Act + const pendingSend = mailService.sendSubmissionToAdmin(modifiedParams) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should send submission mail to admin successfully if form.emails is an array with a mixture of emails, semi-colon, and comma separated emails strings', async () => { + // Arrange + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy.mockResolvedValueOnce(mockedResponse) + + const formEmailsMixture = [ + `${MOCK_VALID_EMAIL}, ${MOCK_VALID_EMAIL_2}`, + `${MOCK_VALID_EMAIL_4};${MOCK_VALID_EMAIL_5}, ${MOCK_VALID_EMAIL_3}`, + ] + const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) + modifiedParams.form.emails = formEmailsMixture + + const expectedArgument = generateExpectedArgWithToField(formEmailsMixture) + + // Act + const pendingSend = mailService.sendSubmissionToAdmin(modifiedParams) + + // Assert + await expect(pendingSend).resolves.toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) @@ -340,26 +541,130 @@ describe('mail.service', () => { const invalidParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) invalidParams.form.emails = ['notAnEmail', MOCK_VALID_EMAIL] - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(invalidParams), - ).rejects.toThrowError('Invalid email error') + // Act + const pendingSend = mailService.sendSubmissionToAdmin(invalidParams) + + // Assert + await expect(pendingSend).rejects.toThrowError('Invalid email error') }) it('should reject with error when form.emails param is an empty array', async () => { // Arrange const invalidParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) invalidParams.form.emails = [] - // Act + Assert - await expect( - mailService.sendSubmissionToAdmin(invalidParams), - ).rejects.toThrowError('Mail undefined error') + + // Act + const pendingSend = mailService.sendSubmissionToAdmin(invalidParams) + + // Assert + await expect(pendingSend).rejects.toThrowError('Mail undefined error') + }) + + it('should autoretry when 4xx error is thrown by sendNodeMail and pass if second try passes', async () => { + // Arrange + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockReturnValueOnce(mockedResponse) + + const formEmailsMixture = [ + `${MOCK_VALID_EMAIL}, ${MOCK_VALID_EMAIL_2}`, + MOCK_VALID_EMAIL_3, + ] + const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) + modifiedParams.form.emails = formEmailsMixture + + const expectedArgument = generateExpectedArgWithToField(formEmailsMixture) + + // Act + const pendingSendSubmission = mailService.sendSubmissionToAdmin( + modifiedParams, + ) + + // Assert + await expect(pendingSendSubmission).resolves.toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + // Should have been called two times since it rejected the first one and + // resolved + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => { + // Arrange + const mock4xxReject = { + responseCode: 400, + message: 'oh no something went wrong', + } + sendMailSpy.mockRejectedValue(mock4xxReject) + + const formEmailsCommaSeparated = [ + `${MOCK_VALID_EMAIL}, ${MOCK_VALID_EMAIL_2}`, + ] + const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) + modifiedParams.form.emails = formEmailsCommaSeparated + + const expectedArgument = generateExpectedArgWithToField( + formEmailsCommaSeparated, + ) + + // Act + const pendingSendSubmission = mailService.sendSubmissionToAdmin( + modifiedParams, + ) + + // Assert + await expect(pendingSendSubmission).rejects.toEqual(mock4xxReject) + // Check arguments passed to sendNodeMail + // Should have been called MOCK_RETRY_COUNT + 1 times + expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) + }) + + it('should stop autoretrying when the returned error is not a 4xx error', async () => { + // Arrange + const mockError = new Error('this should be returned at the end') + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + + // Mock 4xx error then non 4xx error. + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockRejectedValueOnce(mockError) + + const formEmailsMixture = [ + `${MOCK_VALID_EMAIL}, ${MOCK_VALID_EMAIL_2}`, + MOCK_VALID_EMAIL_3, + ] + const modifiedParams = cloneDeep(MOCK_VALID_SUBMISSION_PARAMS) + modifiedParams.form.emails = formEmailsMixture + + const expectedArgument = generateExpectedArgWithToField(formEmailsMixture) + + // Act + const pendingSendSubmission = mailService.sendSubmissionToAdmin( + modifiedParams, + ) + + // Assert + await expect(pendingSendSubmission).rejects.toEqual(mockError) + // Check arguments passed to sendNodeMail + // Should retry two times and stop since the second rejected value is + // non-4xx error. + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) }) }) describe('sendAutoReplyEmails', () => { let defaultHtml: string - let defaultExpectedArg + let defaultExpectedArg: MailOptions const MOCK_SENDER_NAME = 'John Doe' const MOCK_AUTOREPLY_PARAMS: SendAutoReplyEmailsArgs = { @@ -419,19 +724,22 @@ describe('mail.service', () => { const mockedResponse = 'mockedSuccessResponse' sendMailSpy.mockResolvedValueOnce(mockedResponse) - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.resolve(mockedResponse), ]) - await expect( - mailService.sendAutoReplyEmails(MOCK_AUTOREPLY_PARAMS), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(MOCK_AUTOREPLY_PARAMS) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(defaultExpectedArg) }) it('should send array of multiple autoreply mails successfully with defaults', async () => { + // Arrange const firstMockedResponse = 'mockedSuccessResponse1' const secondMockedResponse = 'mockedSuccessResponse1' sendMailSpy @@ -446,14 +754,16 @@ describe('mail.service', () => { const secondExpectedArg = cloneDeep(defaultExpectedArg) secondExpectedArg.to = MOCK_VALID_EMAIL_3 - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.resolve(firstMockedResponse), Promise.resolve(secondMockedResponse), ]) - await expect( - mailService.sendAutoReplyEmails(multipleEmailParams), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(multipleEmailParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(2) expect(sendMailSpy).toHaveBeenNthCalledWith(1, defaultExpectedArg) @@ -472,19 +782,22 @@ describe('mail.service', () => { const expectedArg = { ...defaultExpectedArg, subject: customSubject } - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.resolve(mockedResponse), ]) - await expect( - mailService.sendAutoReplyEmails(customDataParams), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) it('should send single autoreply mail successfully with custom autoreply sender', async () => { + // Arrange const mockedResponse = 'mockedSuccessResponse' sendMailSpy.mockResolvedValueOnce(mockedResponse) @@ -496,14 +809,15 @@ describe('mail.service', () => { ...defaultExpectedArg, from: `${customSender} <${MOCK_SENDER_EMAIL}>`, } - - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.resolve(mockedResponse), ]) - await expect( - mailService.sendAutoReplyEmails(customDataParams), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) @@ -543,14 +857,15 @@ describe('mail.service', () => { }, ], } - - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.resolve(mockedResponse), ]) - await expect( - mailService.sendAutoReplyEmails(customDataParams), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) @@ -561,13 +876,109 @@ describe('mail.service', () => { const invalidDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) invalidDataParams.autoReplyMailDatas[0].email = 'notAnEmail' - // Act + Assert const expectedResponse = await Promise.allSettled([ Promise.reject(Error('Invalid email error')), ]) - await expect( - mailService.sendAutoReplyEmails(invalidDataParams), - ).resolves.toEqual(expectedResponse) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(invalidDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) + }) + + it('should autoretry when 4xx error is thrown by sendNodeMail and pass if second try passes', async () => { + // Arrange + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockReturnValueOnce(mockedResponse) + + const customSubject = 'customSubject' + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.autoReplyMailDatas[0].subject = customSubject + + const expectedArg = { ...defaultExpectedArg, subject: customSubject } + const expectedResponse = await Promise.allSettled([ + Promise.resolve(mockedResponse), + ]) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) + // Check arguments passed to sendNodeMail + // Should have been called two times since it rejected the first one and + // resolved + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) + }) + + it('should autoretry MOCK_RETRY_COUNT times and return error when all retries fail with 4xx errors', async () => { + // Arrange + const mock4xxReject = { + responseCode: 454, + message: 'oh no something went wrong', + } + sendMailSpy.mockRejectedValue(mock4xxReject) + + const customSubject = 'customSubject' + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.autoReplyMailDatas[0].subject = customSubject + + const expectedArg = { ...defaultExpectedArg, subject: customSubject } + const expectedResponse = await Promise.allSettled([ + Promise.reject(mock4xxReject), + ]) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) + // Check arguments passed to sendNodeMail + // Should have been called MOCK_RETRY_COUNT + 1 times + expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) + }) + + it('should stop autoretrying when the returned error is not a 4xx error', async () => { + // Arrange + const mockError = new Error('this should be returned at the end') + const mock4xxReject = { + responseCode: 413, + message: 'oh no something went wrong', + } + sendMailSpy + .mockRejectedValueOnce(mock4xxReject) + .mockRejectedValueOnce(mockError) + + const customSubject = 'customSubject' + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.autoReplyMailDatas[0].subject = customSubject + + const expectedArg = { ...defaultExpectedArg, subject: customSubject } + + // Should be the error and not the 4xx rejection. + const expectedResponse = await Promise.allSettled([ + Promise.reject(mockError), + ]) + + // Act + const pendingSend = mailService.sendAutoReplyEmails(customDataParams) + + // Assert + await expect(pendingSend).resolves.toEqual(expectedResponse) + // Check arguments passed to sendNodeMail + // Should only invoke two times and stop since the second rejected value + // is non-4xx error. + expect(sendMailSpy).toHaveBeenCalledTimes(2) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) }) }) From 4b89d9c3f17625d13120bb3f5dadaef5d769b24a Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Tue, 1 Sep 2020 09:16:44 +0800 Subject: [PATCH 22/26] fix: remove filetype from permission levels imports (#236) --- src/app/controllers/authentication.server.controller.js | 2 +- src/app/routes/admin-forms.server.routes.js | 2 +- .../controllers/authentication.server.controller.spec.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js index 24f6bec63e..1902b1ca8d 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -17,7 +17,7 @@ const { StatusCodes } = require('http-status-codes') const config = require('../../config/config') const defaults = require('../../config/defaults').default -const PERMISSIONS = require('../utils/permission-levels.js') +const PERMISSIONS = require('../utils/permission-levels').default const { getRequestIp } = require('../utils/request') const logger = require('../../config/logger').createLoggerWithLabel(module) const { generateOtp } = require('../utils/otp') diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js index 72f5182a80..a6528cdfe9 100644 --- a/src/app/routes/admin-forms.server.routes.js +++ b/src/app/routes/admin-forms.server.routes.js @@ -12,7 +12,7 @@ let auth = require('../../app/controllers/authentication.server.controller') let submissions = require('../../app/controllers/submissions.server.controller') const emailSubmissions = require('../../app/controllers/email-submissions.server.controller') let encryptSubmissions = require('../../app/controllers/encrypt-submissions.server.controller') -let PERMISSIONS = require('../utils/permission-levels.js') +let PERMISSIONS = require('../utils/permission-levels').default const spcpFactory = require('../factories/spcp-myinfo.factory') const webhookVerifiedContentFactory = require('../factories/webhook-verified-content.factory') diff --git a/tests/unit/backend/controllers/authentication.server.controller.spec.js b/tests/unit/backend/controllers/authentication.server.controller.spec.js index 4f709b3170..3b980fc620 100644 --- a/tests/unit/backend/controllers/authentication.server.controller.spec.js +++ b/tests/unit/backend/controllers/authentication.server.controller.spec.js @@ -4,6 +4,7 @@ const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') let roles = require('../helpers/roles') let permissionLevels = require('../../../../dist/backend/app/utils/permission-levels') + .default const User = dbHandler.makeModel('user.server.model', 'User') const Token = dbHandler.makeModel('token.server.model', 'Token') From f03dd1bce780e3e1b4c5ef244625d120c17d4e48 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Tue, 1 Sep 2020 09:32:42 +0800 Subject: [PATCH 23/26] feat: log more info about critical bounces (#237) * refactor: rename bounceInfo to bounceDoc * feat: log bounce info in critical bounce notification --- src/app/modules/bounce/bounce.service.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/app/modules/bounce/bounce.service.ts b/src/app/modules/bounce/bounce.service.ts index 976988d048..082b3238e7 100644 --- a/src/app/modules/bounce/bounce.service.ts +++ b/src/app/modules/bounce/bounce.service.ts @@ -5,6 +5,7 @@ import mongoose from 'mongoose' import { createCloudWatchLogger } from '../../../config/logger' import { + IBounceNotification, IBounceSchema, IEmailNotification, ISnsNotification, @@ -98,19 +99,26 @@ export const isValidSnsRequest = async ( } // Writes a log message if all recipients have bounced -const logCriticalBounce = (bounceInfo: IBounceSchema, formId: string): void => { +const logCriticalBounce = ( + bounceDoc: IBounceSchema, + formId: string, + notification: IEmailNotification, +): void => { if ( - !bounceInfo.hasAlarmed && - bounceInfo.bounces.every((emailInfo) => emailInfo.hasBounced) + !bounceDoc.hasAlarmed && + bounceDoc.bounces.every((emailInfo) => emailInfo.hasBounced) ) { logger.warn({ type: 'CRITICAL BOUNCE', formId, - recipients: bounceInfo.bounces.map((emailInfo) => emailInfo.email), + recipients: bounceDoc.bounces.map((emailInfo) => emailInfo.email), + // We know for sure that critical bounces can only happen because of bounce + // notifications, so this casting is okay + bounceInfo: (notification as IBounceNotification).bounce, }) // We don't want a flood of logs and alarms, so we use this to limit the rate of // critical bounce logs for each form ID - bounceInfo.hasAlarmed = true + bounceDoc.hasAlarmed = true } } @@ -129,10 +137,10 @@ export const updateBounces = async (body: ISnsNotification): Promise => { const oldBounces = await Bounce.findOne({ formId }) if (oldBounces) { oldBounces.merge(latestBounces, notification) - logCriticalBounce(oldBounces, formId) + logCriticalBounce(oldBounces, formId, notification) await oldBounces.save() } else { - logCriticalBounce(latestBounces, formId) + logCriticalBounce(latestBounces, formId, notification) await latestBounces.save() } } From 6c0951e877e498751c94a539ce93b03eb0ff9d53 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Tue, 1 Sep 2020 09:36:04 +0800 Subject: [PATCH 24/26] build: bump version to 4.33.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b338f13233..e28c2df037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "FormSG", - "version": "4.32.1", + "version": "4.33.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index aaf5c9a2a6..3a22ffd27d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "4.32.1", + "version": "4.33.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " From 6facdae7a934610ed38b873ae965745fff4b7358 Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Tue, 1 Sep 2020 10:39:27 +0800 Subject: [PATCH 25/26] fix: correct left margin in acknowledgment error when activating storage mode form (#240) * style: fix margins of warning messagebox when typing in storage ack * feat(VerifySecretKeyModal): remove unneeded class --- src/public/modules/forms/admin/css/view-responses.css | 7 ------- .../verify-secret-key-activation.client.view.html | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/public/modules/forms/admin/css/view-responses.css b/src/public/modules/forms/admin/css/view-responses.css index 8ff6086fbb..662eebac1a 100644 --- a/src/public/modules/forms/admin/css/view-responses.css +++ b/src/public/modules/forms/admin/css/view-responses.css @@ -418,13 +418,6 @@ text-align: left; } -#secret-key .warning-acknowledgement { - display: flex; - padding: 8px 10px; - margin: 0 10px 0; - width: 100%; -} - #secret-key .instructions-container { margin: 0 auto 30px; } diff --git a/src/public/modules/forms/admin/directiveViews/verify-secret-key-activation.client.view.html b/src/public/modules/forms/admin/directiveViews/verify-secret-key-activation.client.view.html index ceb7f470da..da9a2ecfe6 100644 --- a/src/public/modules/forms/admin/directiveViews/verify-secret-key-activation.client.view.html +++ b/src/public/modules/forms/admin/directiveViews/verify-secret-key-activation.client.view.html @@ -79,7 +79,7 @@
Date: Tue, 1 Sep 2020 11:15:04 +0800 Subject: [PATCH 26/26] fix: use original questionCount (#242) --- .../forms/admin/controllers/view-responses.client.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js index 0c46d4c9a6..eceab09d5d 100644 --- a/src/public/modules/forms/admin/controllers/view-responses.client.controller.js +++ b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js @@ -204,7 +204,7 @@ function ViewResponsesController( } // Populate S3 presigned URL for attachments if (attachmentMetadata[field._id]) { - vm.attachmentDownloadUrls.set(questionCount - 1, { + vm.attachmentDownloadUrls.set(questionCount, { url: attachmentMetadata[field._id], filename: field.answer, })