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/.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/.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/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/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/jest.config.js b/jest.config.js index 60c92074d8..9e0ce3ef05 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,8 +4,16 @@ 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, }, + 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 9a7d28612a..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": { @@ -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", @@ -4618,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", @@ -4638,6 +4631,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", @@ -4800,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": "*" @@ -4908,9 +4907,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": "*", @@ -4918,9 +4917,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": "*", @@ -5033,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", @@ -5067,6 +5075,37 @@ "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/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", @@ -5100,6 +5139,18 @@ "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/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", @@ -5810,12 +5861,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", @@ -6083,11 +6128,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": { @@ -7471,6 +7523,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", @@ -7813,6 +7875,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", @@ -8116,6 +8184,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", @@ -8156,12 +8233,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", @@ -8396,12 +8467,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", @@ -8883,6 +8948,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": { @@ -9138,6 +9210,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", @@ -9997,6 +10082,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", @@ -10293,9 +10434,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": { @@ -10759,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", @@ -10883,22 +11029,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", @@ -10907,30 +11054,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", @@ -10940,11 +11085,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", @@ -10954,14 +11145,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", @@ -10972,38 +11160,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" + } } } }, @@ -11302,14 +11568,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": { @@ -11327,9 +11607,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 } } @@ -11729,17 +12009,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", @@ -13342,9 +13611,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", @@ -13720,111 +13989,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", @@ -16924,6 +17088,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 +17525,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", @@ -17535,12 +17711,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", @@ -18158,6 +18328,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", @@ -18794,12 +18970,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", @@ -18904,6 +19074,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", @@ -18911,15 +19109,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": { @@ -18929,15 +19127,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", @@ -19496,9 +19685,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", @@ -20728,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", @@ -21712,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", @@ -21759,12 +21962,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", @@ -22250,20 +22447,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": { @@ -24727,6 +24939,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", @@ -24745,6 +24977,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", @@ -25021,6 +25259,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", @@ -25547,9 +25793,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 3c29fa2eb0..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 " @@ -19,19 +19,20 @@ "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", + "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", - "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\"", + "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", @@ -39,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": { @@ -67,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", @@ -87,7 +87,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", @@ -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", @@ -139,7 +139,8 @@ "node-jose": "^1.0.0", "nodemailer": "^6.4.11", "nodemailer-direct-transport": "~3.3.2", - "opossum": "^5.0.0", + "opossum": "^5.0.1", + "promise-retry": "^2.0.1", "proxyquire": "^2.1.0", "puppeteer-core": "^5.2.1", "raven-js": "^3.27.0", @@ -149,12 +150,13 @@ "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", "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", @@ -165,6 +167,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", @@ -172,14 +175,16 @@ "@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.24", + "@types/mongoose": "^5.7.36", "@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", "@types/uuid": "^8.0.0", "@types/validator": "^13.0.0", @@ -190,9 +195,10 @@ "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", + "eslint": "^7.7.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-html": "^6.0.2", @@ -213,10 +219,11 @@ "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", - "sinon": "^6.0.0", + "sinon": "^9.0.3", "stylelint": "^13.3.3", "stylelint-config-prettier": "^8.0.1", "stylelint-config-standard": "^20.0.0", diff --git a/src/app/controllers/admin-console.server.controller.js b/src/app/controllers/admin-console.server.controller.js index 06531c9f11..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 @@ -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 @@ -536,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 @@ -546,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.', }) }) @@ -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) }, ) @@ -613,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.', }) } @@ -625,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.', }) } @@ -658,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 }) }) }) } @@ -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,35 @@ 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) + .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( - '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..8712c907ec 100644 --- a/src/app/controllers/admin-forms.server.controller.js +++ b/src/app/controllers/admin-forms.server.controller.js @@ -7,12 +7,10 @@ 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( - '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,20 +65,30 @@ 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 + 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 - logger.error(getRequestIp(req), req.url, req.headers, err) + statusCode = StatusCodes.INTERNAL_SERVER_ERROR } 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', + }, + }) } } } @@ -225,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 { @@ -240,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', }) } @@ -253,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', }) } @@ -291,13 +305,16 @@ 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) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid update to form' }) } else { const { error, formFields } = getEditedFormFields( @@ -305,12 +322,16 @@ function makeModule(connection) { updatedForm.editFormField, ) if (error) { - logger.error( - `formId="${form._id}", ip=${getRequestIp( - req, - )}, message="${error}"`, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ message: error }) + logger.error({ + message: 'Error getting edited form fields', + meta: { + action: 'makeModule.update', + ip: getRequestIp(req), + formId: form._id, + }, + error, + }) + return res.status(StatusCodes.BAD_REQUEST).send({ message: error }) } form.form_fields = formFields delete updatedForm.editFormField @@ -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) @@ -367,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' @@ -444,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', }) } @@ -467,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 @@ -510,8 +536,17 @@ function makeModule(connection) { count, ) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + 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(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -530,31 +565,43 @@ 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, - ) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + logger.error({ + message: 'Error streaming feedback from MongoDB', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) + res.status(StatusCodes.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, - ) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + logger.error({ + message: 'Error converting feedback to JSON', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) + res.status(StatusCodes.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, - ) - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + logger.error({ + message: 'Error writing feedback to HTTP stream', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: 'Error writing feedback to HTTP stream', }) }) @@ -577,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') } }, /** @@ -637,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`) } @@ -657,15 +704,17 @@ function makeModule(connection) { }, function (err, presignedPostObject) { if (err) { - logger.error( - `Presigning post data encountered an error, ip=${getRequestIp( - req, - )}`, - err, - ) - return res.status(HttpStatus.BAD_REQUEST).send(err) + logger.error({ + message: 'Presigning post data encountered an error', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: err, + }) + return res.status(StatusCodes.BAD_REQUEST).send(err) } else { - return res.status(HttpStatus.OK).send(presignedPostObject) + return res.status(StatusCodes.OK).send(presignedPostObject) } }, ) @@ -682,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`) } @@ -702,15 +751,17 @@ function makeModule(connection) { }, function (err, presignedPostObject) { if (err) { - logger.error( - `Presigning post data encountered an error:\tip=${getRequestIp( - req, - )}`, - err, - ) - return res.status(HttpStatus.BAD_REQUEST).send(err) + logger.error({ + message: 'Presigning post data encountered an error', + meta: { + action: 'makeModule.streamFeedback', + ip: getRequestIp(req), + }, + error: 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 725a575bdc..1902b1ca8d 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -13,15 +13,14 @@ 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 -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( - 'authentication', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) +const { generateOtp } = require('../utils/otp') const MailService = require('../services/mail.service').default const MAX_OTP_ATTEMPTS = 10 @@ -37,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.' }) } } @@ -54,20 +53,25 @@ 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}).`, ) } // 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) + .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.', ) @@ -125,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 + @@ -148,11 +152,11 @@ 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 - .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.', ) @@ -173,9 +177,18 @@ 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Error saving OTP. Please try again later and if the problem persists, contact us.', ) @@ -207,12 +220,28 @@ exports.sendOtp = async function (req, res) { otp, ipAddress: getRequestIp(req), }) - logger.info(`Login OTP sent:\temail=${recipient} ip=${getRequestIp(req)}`) - return res.status(HttpStatus.OK).send(`OTP sent to ${recipient}!`) + logger.info({ + message: 'Login OTP sent', + meta: { + action: 'sendOtp', + ip: getRequestIp(req), + email: recipient, + }, + }) + return res.status(StatusCodes.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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Error sending OTP. Please try again later and if the problem persists, contact us.', ) @@ -245,29 +274,45 @@ 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( `Unable to login at this time. Please submit a Support Form (${defaults.links.supportFormLink}).`, ) } 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) + .status(StatusCodes.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) + .status(StatusCodes.UNPROCESSABLE_ENTITY) .send( 'You have hit the max number of attempts for this OTP. Click Resend to receive a new OTP.', ) @@ -277,9 +322,16 @@ 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Malformed OTP. Please try again later and if the problem persists, contact us.', ) @@ -287,13 +339,17 @@ 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send( 'Failed to validate OTP. Please try again later and if the problem persists, contact us.', ) @@ -302,9 +358,16 @@ 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) + .status(StatusCodes.UNAUTHORIZED) .send('OTP is invalid. Please try again.') } }) @@ -347,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}).`, ) @@ -355,16 +418,22 @@ exports.signIn = function (req, res) { let userObj = { agency: agency, email: user.email, + contact: user.contact, _id: user._id, betaFlags: user.betaFlags, } // Add user info to session req.session.user = userObj - logger.info( - `Successful Login:\temail=${user.email} ip=${getRequestIp(req)}`, - ) - return res.status(HttpStatus.OK).send(userObj) + logger.info({ + message: 'Successful login', + meta: { + action: 'signIn', + email, + ip: getRequestIp(req), + }, + }) + return res.status(StatusCodes.OK).send(userObj) }, ) } @@ -378,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') } }) } @@ -403,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 520acaa0a9..aabe21fcb0 100755 --- a/src/app/controllers/core.server.controller.js +++ b/src/app/controllers/core.server.controller.js @@ -1,10 +1,10 @@ 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') -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,12 +48,21 @@ exports.formCountUsingAggregateCollection = (req, res) => { ], function (err, [result]) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) - res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) + logger.error({ + message: 'Mongo form statistics aggregate error', + meta: { + action: 'formCountUsingAggregateCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + 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) } }, ) @@ -83,8 +92,17 @@ exports.formCountUsingSubmissionsCollection = (req, res) => { ], function (err, forms) { if (err) { - logger.error(req.ip, req.url, req.headers, err) - res.sendStatus(HttpStatus.SERVICE_UNAVAILABLE) + logger.error({ + message: 'Mongo submission aggregate error', + meta: { + action: 'formCountUsingSubmissionsCollection', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + res.sendStatus(StatusCodes.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..8c3cd118f2 100644 --- a/src/app/controllers/email-submissions.server.controller.js +++ b/src/app/controllers/email-submissions.server.controller.js @@ -8,9 +8,9 @@ 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('../utils/custom-errors') +const { ConflictError } = require('../modules/submission/submission.errors') const { MB } = require('../constants/filesize') const { attachmentsAreValid, @@ -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,12 +52,16 @@ exports.receiveEmailSubmissionUsingBusBoy = function (req, res, next) { }, }) } catch (err) { - logger.error( - `formId=${_.get(req, 'form._id')} ip="${getRequestIp( - req, - )}" busboy error=${err}`, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Busboy error', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: _.get(req, 'form._id'), + }, + error: err, + }) + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Required headers are missing', }) } @@ -101,12 +103,16 @@ 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}'`, - ) - return res.sendStatus(HttpStatus.BAD_REQUEST) + logger.error({ + message: 'Invalid form data', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + error: err, + }) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } }) @@ -114,13 +120,16 @@ 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) + .status(StatusCodes.REQUEST_TOO_LONG) .send({ message: 'Your submission is too large.' }) } @@ -139,58 +148,74 @@ 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`, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Invalid attachments', + meta: { + action: 'receiveEmailSubmissionUsingBusBoy', + ip: getRequestIp(req), + formId: req.form._id, + }, + }) + 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.', }) } handleDuplicatesInAttachments(attachments) - addAttachmentToResponses(req, attachments) + addAttachmentToResponses(req.body.responses, attachments) 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) } }) 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) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: 'Unable to process submission.' }) }) @@ -213,14 +238,25 @@ 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({ + return res.status(err.status).send({ message: '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.', }) @@ -233,7 +269,7 @@ exports.validateEmailSubmission = function (req, res, next) { ) return next() } else { - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } @@ -461,8 +497,17 @@ const getVerifiedPrefix = (isUserVerified) => { * of the submission */ function onSubmissionEmailFailure(err, req, res, submission) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Error submitting email form', + meta: { + action: 'onSubmissionEmailFailure', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + 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, @@ -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..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') @@ -14,12 +14,10 @@ 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( - 'encrypt-submissions', -) +const logger = require('../../config/logger').createLoggerWithLabel(module) const { aws: { attachmentS3Bucket, s3 }, } = require('../../config/config') @@ -41,13 +39,17 @@ 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) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) } @@ -56,14 +58,22 @@ 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({ + return res.status(err.status).send({ message: '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.', }) @@ -71,7 +81,7 @@ exports.validateEncryptSubmission = function (req, res, next) { } return next() } else { - return res.sendStatus(HttpStatus.BAD_REQUEST) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } @@ -96,8 +106,17 @@ exports.prepareEncryptSubmission = (req, res, next) => { * of the submission */ function onEncryptSubmissionFailure(err, req, res, submission) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Encrypt submission error', + meta: { + action: 'onEncryptSubmissionFailure', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: err, + }) + 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, @@ -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,8 +273,17 @@ 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) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + 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(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -268,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 }) } }) } @@ -297,8 +331,17 @@ exports.getEncryptedResponse = function (req, res) { }, ).exec(async (err, response) => { if (err || !response) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + 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(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -334,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', }) } @@ -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..22c87ddf1c 100644 --- a/src/app/controllers/forms.server.controller.js +++ b/src/app/controllers/forms.server.controller.js @@ -5,10 +5,10 @@ */ 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('forms') +const logger = require('../../config/logger').createLoggerWithLabel(module) const getFormModel = require('../models/form.server.model').default const Form = getFormModel(mongoose) @@ -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.', }) } @@ -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/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 4683e5c393..c290a28697 100644 --- a/src/app/controllers/myinfo.server.controller.js +++ b/src/app/controllers/myinfo.server.controller.js @@ -8,11 +8,11 @@ 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') -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,24 +187,30 @@ 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}`, - ) - return res.status(HttpStatus.SERVICE_UNAVAILABLE).send({ + logger.error({ + message: 'Error retrieving MyInfo hash from database', + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + }, + error: err, + }) + return res.status(StatusCodes.SERVICE_UNAVAILABLE).send({ message: 'MyInfo verification unavailable, please try again later.', spcpSubmissionFailure: true, }) } if (!hashedObj) { - logger.error( - `[MyInfo] Unable to find hashes for form: ${formObjId} ip=${getRequestIp( - req, - )}`, - ) - return res.status(HttpStatus.GONE).send({ + logger.error({ + message: `Unable to find MyInfo hashes for ${formObjId}`, + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + formId: formObjId, + }, + }) + return res.status(StatusCodes.GONE).send({ message: 'MyInfo verification expired, please refresh and try again.', spcpSubmissionFailure: true, @@ -233,12 +250,15 @@ 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, - )}`, - ) - return res.status(HttpStatus.UNAUTHORIZED).send({ + logger.error({ + message: `Hash did not match for form ${formObjId}`, + meta: { + action: 'verifyMyInfoVals', + ip: getRequestIp(req), + failedFields: hashFailedAttrs, + }, + }) + 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 0ffd76c4a9..6f1164b90c 100644 --- a/src/app/controllers/public-forms.server.controller.js +++ b/src/app/controllers/public-forms.server.controller.js @@ -1,12 +1,10 @@ '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( - '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 @@ -24,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, @@ -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) } @@ -76,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') } @@ -88,12 +92,21 @@ 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) + .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 91b8ece05e..b60bfec07f 100644 --- a/src/app/controllers/spcp.server.controller.js +++ b/src/app/controllers/spcp.server.controller.js @@ -6,11 +6,11 @@ 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') -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, + }) }) } @@ -105,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] @@ -119,7 +126,7 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { authType, ) ) { - res.status(HttpStatus.UNAUTHORIZED).send() + res.status(StatusCodes.UNAUTHORIZED).send() return } @@ -127,16 +134,25 @@ 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) => { 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) @@ -202,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) => { @@ -233,20 +249,27 @@ 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' // 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 }) - return res.status(HttpStatus.BAD_GATEWAY).send({ + logger.error({ + message: 'Could not find title', + meta: { + action: 'validateESrvcId', + redirectUrl: redirectURL, + data, + }, + }) + 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, }) } @@ -257,7 +280,7 @@ exports.validateESrvcId = (req, res) => { 'System Code: ', '', ) - return res.status(HttpStatus.OK).send({ + return res.status(StatusCodes.OK).send({ isValid: false, errorCode, }) @@ -265,12 +288,15 @@ 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({ + return res.status(StatusCodes.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,13 +409,17 @@ 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) + .status(StatusCodes.BAD_REQUEST) .send({ message: 'Invalid data was found. Please submit again.' }) } } @@ -438,8 +478,17 @@ exports.isSpcpAuthenticated = (authClients) => { let jwt = req.cookies[jwtName] authClient.verifyJWT(jwt, (err, payload) => { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) - res.status(HttpStatus.UNAUTHORIZED).send({ + 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(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 0de3f8ac7d..68dc21ae31 100644 --- a/src/app/controllers/submissions.server.controller.js +++ b/src/app/controllers/submissions.server.controller.js @@ -7,13 +7,11 @@ 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') -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' @@ -30,16 +28,19 @@ 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) { - logger.error( - `[${ - req.form._id - }] Error 400: Missing captchaResponse ip=${getRequestIp(req)}`, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Missing captchaResponse param', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + }) + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Captcha was missing. Please refresh and submit again.', }) } else { @@ -53,12 +54,15 @@ exports.captchaCheck = (captchaPrivateKey) => { }) .then(({ data }) => { if (!data.success) { - logger.error( - `Error 400 - Incorrect captchaResponse: formId=${ - req.form._id - } ip=${getRequestIp(req)}`, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Incorrect captcha response', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + }) + return res.status(StatusCodes.BAD_REQUEST).send({ message: 'Captcha was incorrect. Please submit again.', }) } @@ -66,13 +70,16 @@ 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, - ) - return res.status(HttpStatus.BAD_REQUEST).send({ + logger.error({ + message: 'Error verifying captcha', + meta: { + action: 'captchaCheck', + formId: req.form._id, + ip: getRequestIp(req), + }, + error: err, + }) + return res.status(StatusCodes.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() } @@ -192,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', }) } @@ -206,8 +218,18 @@ exports.count = function (req, res) { Submission.countDocuments(query, function (err, count) { if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + 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(StatusCodes.INTERNAL_SERVER_ERROR).send({ message: errorHandler.getMongoErrorMessage(err), }) } else { @@ -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/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/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/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/factories/spcp-myinfo.factory.js b/src/app/factories/spcp-myinfo.factory.js index 8444884b75..d845c28148 100644 --- a/src/app/factories/spcp-myinfo.factory.js +++ b/src/app/factories/spcp-myinfo.factory.js @@ -2,20 +2,23 @@ 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') 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', @@ -120,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/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/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/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/sns/sns.controller.ts b/src/app/modules/bounce/bounce.controller.ts similarity index 65% rename from src/app/modules/sns/sns.controller.ts rename to src/app/modules/bounce/bounce.controller.ts index 10d0b55a35..f983462f6b 100644 --- a/src/app/modules/sns/sns.controller.ts +++ b/src/app/modules/bounce/bounce.controller.ts @@ -1,12 +1,12 @@ 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' -import * as snsService from './sns.service' +import * as snsService from './bounce.service' -const logger = createLoggerWithLabel('sns-controller') +const logger = createLoggerWithLabel(module) /** * Validates that a request came from Amazon SNS, then updates the Bounce * collection. @@ -23,13 +23,19 @@ 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(err) - return res.sendStatus(HttpStatus.BAD_REQUEST) + logger.warn({ + message: 'Error updating bounces', + meta: { + action: 'handleSns', + }, + error: err, + }) + return res.sendStatus(StatusCodes.BAD_REQUEST) } } 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 86% rename from src/app/modules/sns/sns.service.ts rename to src/app/modules/bounce/bounce.service.ts index 976988d048..082b3238e7 100644 --- a/src/app/modules/sns/sns.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() } } 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/submission/submission.errors.ts b/src/app/modules/submission/submission.errors.ts new file mode 100644 index 0000000000..816ba3daf5 --- /dev/null +++ b/src/app/modules/submission/submission.errors.ts @@ -0,0 +1,13 @@ +import { StatusCodes } 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, StatusCodes.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/user/user.controller.ts b/src/app/modules/user/user.controller.ts new file mode 100644 index 0000000000..7953a6bf38 --- /dev/null +++ b/src/app/modules/user/user.controller.ts @@ -0,0 +1,127 @@ +import to from 'await-to-js' +import { RequestHandler } from 'express' +import { StatusCodes } 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(module) + +/** + * 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(StatusCodes.UNAUTHORIZED).send('User is unauthorized.') + } + + try { + const generatedOtp = await createContactOtp(userId, contact) + await SmsFactory.sendAdminContactOtp(contact, generatedOtp, userId) + + return res.sendStatus(StatusCodes.OK) + } catch (err) { + // TODO(#193): Send different error messages according to error. + return res.status(StatusCodes.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(StatusCodes.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(StatusCodes.INTERNAL_SERVER_ERROR).send(err.message) + } + } + + // No error, update user with given contact. + try { + const updatedUser = await updateUserContact(contact, userId) + return res.status(StatusCodes.OK).send(updatedUser) + } catch (updateErr) { + // Handle update error. + logger.warn(updateErr) + 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(StatusCodes.UNAUTHORIZED).send('User is unauthorized.') + } + + // Retrieve user with id in session + const [dbErr, retrievedUser] = await to(getPopulatedUserById(sessionUserId)) + + if (dbErr || !retrievedUser) { + logger.warn({ + message: `Unable to retrieve user ${sessionUserId}`, + meta: { + action: 'handleFetchUser', + }, + error: dbErr, + }) + return res + .status(StatusCodes.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..6df7c6d8a8 --- /dev/null +++ b/src/app/modules/user/user.errors.ts @@ -0,0 +1,18 @@ +import { StatusCodes } from 'http-status-codes' + +import { ApplicationError } from '../core/core.errors' + +export class InvalidOtpError extends ApplicationError { + constructor(message: string, meta?: string) { + super(message, StatusCodes.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, StatusCodes.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.controller.ts b/src/app/modules/verification/verification.controller.ts index 2eb989c717..afa51beedd 100644 --- a/src/app/modules/verification/verification.controller.ts +++ b/src/app/modules/verification/verification.controller.ts @@ -1,12 +1,12 @@ 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' 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. @@ -24,10 +24,16 @@ 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(`createTransaction: ${error}`) + logger.error({ + message: 'Error creating transaction', + meta: { + action: 'createTransaction', + }, + error, + }) return handleError(error, res) } } @@ -44,9 +50,15 @@ 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(`getTransaction: ${error}`) + logger.error({ + message: 'Error retrieving transaction metadata', + meta: { + action: 'getTransactionMetadata', + }, + error, + }) return handleError(error, res) } } @@ -66,9 +78,15 @@ 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(`resetFieldInTransaction: ${error}`) + logger.error({ + message: 'Error resetting field in transaction', + meta: { + action: 'resetFieldInTransaction', + }, + error, + }) return handleError(error, res) } } @@ -88,9 +106,15 @@ 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(`getNewOtp: ${error}`) + logger.error({ + message: 'Error retrieving new OTP', + meta: { + action: 'getNewOtp', + }, + error, + }) return handleError(error, res) } } @@ -111,9 +135,15 @@ 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(`verifyOtp: ${error}`) + logger.error({ + message: 'Error verifying OTP', + meta: { + action: 'verifyOtp', + }, + error, + }) return handleError(error, res) } } @@ -123,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/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/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/modules/webhooks/webhook.errors.ts b/src/app/modules/webhooks/webhook.errors.ts new file mode 100644 index 0000000000..a06e37ae02 --- /dev/null +++ b/src/app/modules/webhooks/webhook.errors.ts @@ -0,0 +1,13 @@ +import { StatusCodes } 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, StatusCodes.UNPROCESSABLE_ENTITY, meta) + } +} 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/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/app/services/mail.service.ts b/src/app/services/mail.service.ts index bb1b782438..2f0afaf0a0 100644 --- a/src/app/services/mail.service.ts +++ b/src/app/services/mail.service.ts @@ -1,8 +1,9 @@ -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 { Logger } from 'winston' import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' @@ -24,7 +25,7 @@ import { isToFieldValid, } from '../utils/mail' -const mailLogger = createLoggerWithLabel('mail') +const logger = createLoggerWithLabel(module) type SendMailOptions = { mailId?: string @@ -53,7 +54,7 @@ type MailServiceParams = { appUrl?: string transporter?: Mail senderMail?: string - logger?: Logger + retryParams?: Partial } type AutoReplyMailData = { @@ -73,29 +74,36 @@ 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. @@ -103,26 +111,30 @@ export class MailService { * E.g. `FormSG ` */ #senderFromString: string + /** - * Logger to log any errors encounted while sending mail. + * Sets the retry parameters for sendNodeMail retries. */ - #logger: Logger + #retryParams: MailServiceParams['retryParams'] constructor({ appName = config.app.title, appUrl = config.app.appUrl, transporter = config.mail.transporter, senderMail = config.mail.mailFrom, - logger = mailLogger, + retryParams = DEFAULT_RETRY_PARAMS, }: 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 } @@ -131,6 +143,7 @@ export class MailService { this.#senderMail = senderMail this.#senderFromString = `${appName} <${senderMail}>` this.#transporter = transporter + this.#retryParams = retryParams } /** @@ -139,37 +152,62 @@ 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 { - const response = await this.#transporter.sendMail(mail) - this.#logger.info(`mailSuccess:\t${emailLogString}`) - return response - } catch (err) { - // Pass errors to the callback - this.#logger.error( - `mailError ${err.responseCode}:\t${emailLogString}`, - err, - ) - return Promise.reject(err) - } + return promiseRetry(async (retry, attemptNum) => { + logger.info({ + message: `Attempt ${attemptNum} to send mail`, + meta: logMeta, + }) + + 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/src/app/services/sms.service.ts b/src/app/services/sms.service.ts index 4bc53c958b..ff2bc93d2b 100644 --- a/src/app/services/sms.service.ts +++ b/src/app/services/sms.service.ts @@ -7,11 +7,17 @@ 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' -const logger = createLoggerWithLabel('sms') +const logger = createLoggerWithLabel(module) const SmsCount = getSmsCountModel(mongoose) const Form = getFormModel(mongoose) const secretsManager = new SecretsManager({ region: config.aws.region }) @@ -53,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 } @@ -100,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 @@ -134,7 +157,7 @@ const getOtpDataFromForm = async (formId: string) => { */ const send = async ( twilioConfig: TwilioConfig, - otpData: OtpData, + otpData: FormOtpData | AdminContactOtpData, recipient: string, message: string, smsType: SmsType, @@ -167,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 = { @@ -185,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 @@ -214,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) @@ -229,6 +282,29 @@ const sendVerificationOtp = async ( return send(twilioData, otpData, recipient, message, SmsType.verification) } +const sendAdminContactOtp = async ( + recipient: string, + otp: string, + userId: string, + defaultConfig: TwilioConfig, +) => { + 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.` + + const otpData: AdminContactOtpData = { + admin: userId, + } + + return send(defaultConfig, otpData, recipient, message, SmsType.adminContact) +} + class TwilioError extends Error { code: number status: string @@ -247,4 +323,5 @@ class TwilioError extends Error { module.exports = { sendVerificationOtp, + sendAdminContactOtp, } diff --git a/src/app/services/webhooks.service.js b/src/app/services/webhooks.service.js deleted file mode 100644 index 908ca06072..0000000000 --- a/src/app/services/webhooks.service.js +++ /dev/null @@ -1,190 +0,0 @@ -const axios = require('axios') -const _ = require('lodash') - -const { WebhookValidationError } = require('../utils/custom-errors') -const mongoose = require('mongoose') -const { - getEncryptSubmissionModel, -} = require('../models/submission.server.model') -const EncryptSubmission = getEncryptSubmissionModel(mongoose) -// 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') - -/** - * Logs webhook failure in console and database. - * @param {error} error Error object returned by axios - * @param {Object} webhookParams Parameters which fully specify webhook - * @param {string} webhookParams.webhookUrl URL to POST to - * @param {Object} webhookParams.submissionWebhookView POST body - * @param {string} webhookParams.submissionId - * @param {string} webhookParams.formId - * @param {string} webhookParams.now Epoch for POST header - * @param {string} webhookParams.signature Signature generated by FormSG SDK - */ -const handleWebhookFailure = (error, webhookParams) => { - logWebhookFailure(error, webhookParams) - updateSubmissionsDb( - webhookParams.formId, - webhookParams.submissionId, - getFailureDbUpdate(error, webhookParams), - ) -} - -/** - * Logs webhook success in console and database. - * @param {response} response Response object returned by axios - * @param {Object} webhookParams Parameters which fully specify webhook - * @param {string} webhookParams.webhookUrl URL to POST to - * @param {Object} webhookParams.submissionWebhookView POST body - * @param {string} webhookParams.submissionId - * @param {string} webhookParams.formId - * @param {string} webhookParams.now Epoch for POST header - * @param {string} webhookParams.signature Signature generated by FormSG SDK - */ -const handleWebhookSuccess = (response, webhookParams) => { - logWebhookSuccess(response, webhookParams) - updateSubmissionsDb( - webhookParams.formId, - webhookParams.submissionId, - getSuccessDbUpdate(response, webhookParams), - ) -} - -/** - * Sends webhook POST. - * Note that the arguments are the same as those in webhookParams - * for handleWebhookSuccess and handleWebhookFailure, just destructured. - * @param {string} webhookUrl URL to POST to - * @param {Object} submissionWebhookView POST body - * @param {string} submissionId - * @param {string} formId - * @param {string} now Epoch for POST header - * @param {string} signature Signature generated by FormSG SDK - */ -const postWebhook = ({ - webhookUrl, - submissionWebhookView, - submissionId, - formId, - now, - signature, -}) => { - return axios.post(webhookUrl, submissionWebhookView, { - headers: { - 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({ - epoch: now, - submissionId, - formId, - signature, - }), - }, - maxRedirects: 0, - }) -} - -// Logging for webhook success -const logWebhookSuccess = ( - response, - { webhookUrl, submissionId, formId, now, signature }, -) => { - const status = _.get(response, 'status') - const loggingParams = { - status, - submissionId, - formId, - now, - webhookUrl, - signature, - } - logger.info(getConsoleMessage('Webhook POST succeeded', loggingParams)) -} - -// Logging for webhook failure -const logWebhookFailure = ( - error, - { webhookUrl, submissionId, formId, now, signature }, -) => { - const errorMessage = _.get(error, 'message') - const loggingParams = { - submissionId, - formId, - now, - webhookUrl, - signature, - errorMessage, - } - if (error instanceof WebhookValidationError) { - logger.error(getConsoleMessage('Webhook not attempted', loggingParams)) - } else { - 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'), - }), - ) - }) -} - -// 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) => { - 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, { webhookUrl, signature }) => { - return { webhookUrl, signature, response: 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 } - if (!(error instanceof WebhookValidationError)) { - update.response = getFormattedResponse(_.get(error, 'response')) - } - return update -} - -// Formats a response object for update in the Submissions collection -const getFormattedResponse = (response = {}) => { - return { - status: response.status, - statusText: response.statusText, - headers: stringifySafe(response.headers), - data: stringifySafe(response.data), - } -} - -module.exports = { - postWebhook, - handleWebhookSuccess, - handleWebhookFailure, - logWebhookFailure, -} diff --git a/src/app/services/webhooks.service.ts b/src/app/services/webhooks.service.ts new file mode 100644 index 0000000000..53e2f978eb --- /dev/null +++ b/src/app/services/webhooks.service.ts @@ -0,0 +1,219 @@ +import axios, { AxiosError, AxiosResponse } from 'axios' +import { get } from 'lodash' +import mongoose from 'mongoose' + +import formsgSdk from '../../config/formsg-sdk' +import { createLoggerWithLabel } from '../../config/logger' +// Prevents JSON.stringify error for circular JSONs and BigInts +import { stringifySafe } from '../../shared/util/stringify-safe' +import { + IFormSchema, + ISubmissionSchema, + IWebhookResponse, + WebhookParams, +} from '../../types' +import { getEncryptSubmissionModel } from '../models/submission.server.model' +import { WebhookValidationError } from '../modules/webhooks/webhook.errors' + +const logger = createLoggerWithLabel(module) +const EncryptSubmission = getEncryptSubmissionModel(mongoose) + +/** + * Logs webhook failure in console and database. + * @param {error} error Error object returned by axios + * @param {Object} webhookParams Parameters which fully specify webhook + * @param {string} webhookParams.webhookUrl URL to POST to + * @param {Object} webhookParams.submissionWebhookView POST body + * @param {string} webhookParams.submissionId + * @param {string} webhookParams.formId + * @param {string} webhookParams.now Epoch for POST header + * @param {string} webhookParams.signature Signature generated by FormSG SDK + */ +export const handleWebhookFailure = async ( + error: Error | AxiosError, + webhookParams: WebhookParams, +): Promise => { + logWebhookFailure(error, webhookParams) + await updateSubmissionsDb( + webhookParams.formId, + webhookParams.submissionId, + getFailureDbUpdate(error, webhookParams), + ) +} + +/** + * Logs webhook success in console and database. + * @param {response} response Response object returned by axios + * @param {Object} webhookParams Parameters which fully specify webhook + * @param {string} webhookParams.webhookUrl URL to POST to + * @param {Object} webhookParams.submissionWebhookView POST body + * @param {string} webhookParams.submissionId + * @param {string} webhookParams.formId + * @param {string} webhookParams.now Epoch for POST header + * @param {string} webhookParams.signature Signature generated by FormSG SDK + */ +export const handleWebhookSuccess = async ( + response: AxiosResponse, + webhookParams: WebhookParams, +): Promise => { + logWebhookSuccess(response, webhookParams) + await updateSubmissionsDb( + webhookParams.formId, + webhookParams.submissionId, + getSuccessDbUpdate(response, webhookParams), + ) +} + +/** + * Sends webhook POST. + * Note that the arguments are the same as those in webhookParams + * for handleWebhookSuccess and handleWebhookFailure, just destructured. + * @param {string} webhookUrl URL to POST to + * @param {Object} submissionWebhookView POST body + * @param {string} submissionId + * @param {string} formId + * @param {string} now Epoch for POST header + * @param {string} signature Signature generated by FormSG SDK + */ +export const postWebhook = ({ + webhookUrl, + submissionWebhookView, + submissionId, + formId, + now, + signature, +}: WebhookParams): Promise => { + return axios.post(webhookUrl, submissionWebhookView, { + headers: { + 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({ + epoch: now, + submissionId, + formId, + signature, + }), + }, + maxRedirects: 0, + }) +} + +// Logging for webhook success +const logWebhookSuccess = ( + response: AxiosResponse, + { webhookUrl, submissionId, formId, now, signature }: WebhookParams, +): void => { + const status = get(response, 'status') + + logger.info({ + message: 'Webhook POST succeeded', + meta: { + action: 'logWebhookSuccess', + status, + submissionId, + formId, + now, + webhookUrl, + signature, + }, + }) +} + +// Logging for webhook failure +export const logWebhookFailure = ( + error: Error | AxiosError, + { webhookUrl, submissionId, formId, now, signature }: WebhookParams, +): void => { + let logMeta = { + action: 'logWebhookFailure', + submissionId, + formId, + now, + webhookUrl, + signature, + } + + if (error instanceof WebhookValidationError) { + logger.error({ + message: 'Webhook not attempted', + meta: logMeta, + error, + }) + } else { + logger.error({ + message: 'Webhook POST failed', + meta: { + ...logMeta, + status: get(error, 'response.status'), + }, + error, + }) + } +} + +// Updates the submission in the database with the webhook response +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({ + message: 'Database update for webhook status failed', + meta: { + action: 'updateSubmissionsDb', + formId, + submissionId, + updateObj: stringifySafe(updateObj), + }, + error, + }) + } +} + +// Formats webhook success info into an object to update Submissions collection +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: Error | AxiosError, + { webhookUrl, signature }: WebhookParams, +): IWebhookResponse => { + const errorMessage = get(error, 'message') + let update: IWebhookResponse = { + webhookUrl, + signature, + errorMessage, + } + if (!(error instanceof WebhookValidationError)) { + const { response } = getFormattedResponse(get(error, 'response')) + update.response = response + } + return update +} + +// Formats a response object for update in the Submissions collection +const getFormattedResponse = ( + response: AxiosResponse, +): Pick => { + return { + response: { + status: get(response, 'status'), + statusText: get(response, 'statusText'), + headers: stringifySafe(get(response, 'headers')), + data: stringifySafe(get(response, 'data')), + }, + } +} 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/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/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/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/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/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 cc4ef03a74..aa09b3c805 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' @@ -11,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 = { @@ -74,7 +73,6 @@ type Config = { // Functions configureAws: () => Promise - otpGenerator: () => string } // Enums @@ -117,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', + }, + }) } /** @@ -125,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) } /** @@ -143,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 @@ -256,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({})) } @@ -383,24 +401,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 +423,6 @@ const config: Config = { siteBannerContent, adminBannerContent, configureAws, - otpGenerator, } export = config 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..8ce0a80746 100644 --- a/src/loaders/express/error-handler.ts +++ b/src/loaders/express/error-handler.ts @@ -1,11 +1,11 @@ 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' -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,14 +35,19 @@ 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}`, - ) - return res.status(HttpStatus.BAD_REQUEST).send(errorMessage) + logger.error({ + message: 'Joi validation error', + meta: { + action: 'genericErrorHandlerMiddleware', + formId, + }, + error: err, + }) + return res.status(StatusCodes.BAD_REQUEST).send(errorMessage) } - expressLogger.error(err) + logger.error(err) return res - .status(HttpStatus.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .send({ message: genericErrorMessage }) } } @@ -52,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/src/loaders/express/index.ts b/src/loaders/express/index.ts index fa45b85c56..96a816e8a1 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -7,8 +7,9 @@ import { Connection } from 'mongoose' import path from 'path' import url from 'url' -import mountSnsRoutes from '../../app/modules/sns/sns.routes' -import mountVfnRoutes from '../../app/modules/verification/verification.routes' +import { BounceRouter } from '../../app/modules/bounce/bounce.routes' +import UserRouter from '../../app/modules/user/user.routes' +import { VfnRouter } from '../../app/modules/verification/verification.routes' import apiRoutes from '../../app/routes' import config from '../../config/config' @@ -122,8 +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/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/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/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..69d0f14e4f 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') @@ -270,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 @@ -322,10 +326,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 +341,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..51a0bb4543 --- /dev/null +++ b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js @@ -0,0 +1,164 @@ +angular + .module('core') + .controller('EditContactNumberModalController', [ + '$state', + '$interval', + '$http', + '$timeout', + '$scope', + '$uibModalInstance', + '$window', + 'Auth', + 'Toastr', + EditContactNumberModalController, + ]) + +function EditContactNumberModalController( + $state, + $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/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/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/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/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/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/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/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index 68a5dd1405..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 @@ -102,20 +101,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/create-form-modal.client.controller.js b/src/public/modules/forms/admin/controllers/create-form-modal.client.controller.js index d2a088d632..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,26 +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/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/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/controllers/view-responses.client.controller.js b/src/public/modules/forms/admin/controllers/view-responses.client.controller.js index fd2235ffe6..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 @@ -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 @@ -47,6 +50,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: '', @@ -133,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 { @@ -162,6 +204,10 @@ function ViewResponsesController( } // Populate S3 presigned URL for attachments if (attachmentMetadata[field._id]) { + vm.attachmentDownloadUrls.set(questionCount, { + url: attachmentMetadata[field._id], + filename: field.answer, + }) field.downloadUrl = attachmentMetadata[field._id] } }) @@ -177,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 @@ -266,6 +349,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 ***/ diff --git a/src/public/modules/forms/admin/css/view-responses.css b/src/public/modules/forms/admin/css/view-responses.css index 46978472b5..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; } @@ -455,10 +448,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/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 @@
{ + 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/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/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/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/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/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/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/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/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 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), ) 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 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/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/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/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/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 088c8d6436..ba8cab0532 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,3 +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/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/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/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/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/src/types/webhook.ts b/src/types/webhook.ts new file mode 100644 index 0000000000..0fe4aa8299 --- /dev/null +++ b/src/types/webhook.ts @@ -0,0 +1,11 @@ +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 +} 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/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 88523f2c8d..c514006bbd 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 @@ -195,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/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..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'), @@ -93,6 +94,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/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/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/.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..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') @@ -45,6 +45,7 @@ describe('Admin-Forms Controller', () => { }, headers: {}, ip: '127.0.0.1', + get: () => {}, } }) @@ -61,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', () => { @@ -83,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' @@ -122,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 }) @@ -132,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 }) @@ -176,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 edcce4a5ca..3b980fc620 100644 --- a/tests/unit/backend/controllers/authentication.server.controller.spec.js +++ b/tests/unit/backend/controllers/authentication.server.controller.spec.js @@ -1,9 +1,10 @@ -const HttpStatus = require('http-status-codes') +const { StatusCodes } = require('http-status-codes') 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') @@ -17,8 +18,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, @@ -74,7 +75,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 +148,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 +260,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 +279,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 +300,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 +393,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 +403,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/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-db.ts b/tests/unit/backend/helpers/jest-db.ts index c3db14d9c0..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 @@ -57,15 +98,9 @@ const insertFormCollectionReqs = async ({ 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..96fcc1847b 100644 --- a/tests/unit/backend/helpers/jest-express.ts +++ b/tests/unit/backend/helpers/jest-express.ts @@ -1,16 +1,33 @@ -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, + params, + session, +}: { + body: Record + params?: Record + session?: any +}) => { + return { + body, + params, + 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/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/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/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/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/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..e76d0b3700 --- /dev/null +++ b/tests/unit/backend/modules/bounce/bounce.controller.spec.ts @@ -0,0 +1,69 @@ +import { StatusCodes } 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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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/user/user.controller.spec.ts b/tests/unit/backend/modules/user/user.controller.spec.ts new file mode 100644 index 0000000000..d057cfdc02 --- /dev/null +++ b/tests/unit/backend/modules/user/user.controller.spec.ts @@ -0,0 +1,388 @@ +import { StatusCodes } 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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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..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' @@ -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 { mockResponse } from '../../helpers/jest-express' +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 = 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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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(StatusCodes.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( - HttpStatus.UNPROCESSABLE_ENTITY, + expect(MOCK_RES.status).toHaveBeenCalledWith( + StatusCodes.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 ae39e714bc..052c1b6c78 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,16 +25,16 @@ 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) +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 }) - mockOtpGenerator.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(mockOtpGenerator).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(mockOtpGenerator).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), diff --git a/tests/unit/backend/resources/govtech.jpg b/tests/unit/backend/resources/govtech.jpg new file mode 100644 index 0000000000..dd9c1ee968 Binary files /dev/null and b/tests/unit/backend/resources/govtech.jpg differ 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 062c37ab79..0000000000 Binary files a/tests/unit/backend/resources/nested.zip and /dev/null differ diff --git a/tests/unit/backend/resources/nestedInvalid.zip b/tests/unit/backend/resources/nestedInvalid.zip new file mode 100644 index 0000000000..623e102d22 Binary files /dev/null and b/tests/unit/backend/resources/nestedInvalid.zip differ diff --git a/tests/unit/backend/resources/nestedValid.zip b/tests/unit/backend/resources/nestedValid.zip new file mode 100644 index 0000000000..97602491c5 Binary files /dev/null and b/tests/unit/backend/resources/nestedValid.zip differ diff --git a/tests/unit/backend/services/mail.service.spec.ts b/tests/unit/backend/services/mail.service.spec.ts index 8c118f61af..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 = { @@ -372,9 +677,10 @@ describe('mail.service', () => { }, }, } as IPopulatedForm, - submission: ({ + submission: { id: 'mockSubmissionId', - } as unknown) as ISubmissionSchema, + created: new Date(), + } as ISubmissionSchema, responsesData: [ { question: 'some question', @@ -418,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 @@ -445,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) @@ -471,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) @@ -495,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) @@ -542,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) @@ -560,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) }) }) }) 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..e15db7db57 --- /dev/null +++ b/tests/unit/backend/services/webhook.service.spec.ts @@ -0,0 +1,182 @@ +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' +import { WebhookValidationError } from 'src/app/modules/webhooks/webhook.errors' +import { + handleWebhookFailure, + handleWebhookSuccess, + postWebhook, +} from 'src/app/services/webhooks.service' +import { ResponseMode, SubmissionType } from 'src/types' + +jest.mock('axios') +const mockAxios = mocked(axios, true) + +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 + mockAxios.post.mockImplementationOnce((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return Promise.resolve(MOCK_SUCCESS_RESPONSE) + }) + + // Act + const response = await postWebhook(testWebhookParam) + + // Assert + expect(response).toEqual(MOCK_SUCCESS_RESPONSE) + }) + }) +}) diff --git a/tests/unit/backend/utils/attachment.spec.ts b/tests/unit/backend/utils/attachment.spec.ts new file mode 100644 index 0000000000..b651e115fa --- /dev/null +++ b/tests/unit/backend/utils/attachment.spec.ts @@ -0,0 +1,282 @@ +import { ObjectId } from 'bson' +import { readFileSync } from 'fs' +import { cloneDeep, merge } from 'lodash' + +import { + addAttachmentToResponses, + areAttachmentsMoreThan7MB, + attachmentsAreValid, + handleDuplicatesInAttachments, + mapAttachmentsFromParsedResponses, +} from 'src/app/utils/attachment' +import { + BasicField, + IAttachmentResponse, + ISingleAnswerResponse, +} from 'src/types' + +const validSingleFile = { + filename: 'govtech.jpg', + content: readFileSync('./tests/unit/backend/resources/govtech.jpg'), + fieldId: String(new ObjectId()), +} + +const invalidSingleFile = { + filename: 'invalid.py', + content: readFileSync('./tests/unit/backend/resources/invalid.py'), + fieldId: String(new ObjectId()), +} + +const zipWithValidAndInvalid = { + filename: 'invalidandvalid.zip', + content: readFileSync('./tests/unit/backend/resources/invalidandvalid.zip'), + fieldId: String(new ObjectId()), +} + +const zipNestedInvalid = { + filename: 'nested.zip', + content: readFileSync('./tests/unit/backend/resources/nestedInvalid.zip'), + fieldId: String(new ObjectId()), +} + +const zipNestedValid = { + filename: 'nestedValid.zip', + content: readFileSync('./tests/unit/backend/resources/nestedValid.zip'), + fieldId: String(new ObjectId()), +} + +const zipOnlyInvalid = { + filename: 'onlyinvalid.zip', + content: readFileSync('./tests/unit/backend/resources/onlyinvalid.zip'), + fieldId: String(new ObjectId()), +} + +const zipOnlyValid = { + filename: 'onlyvalid.zip', + content: readFileSync('./tests/unit/backend/resources/onlyvalid.zip'), + fieldId: String(new ObjectId()), +} + +const MOCK_ANSWER = 'mockAnswer' + +describe('attachmentsAreValid', () => { + 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'], }, ]