From aa057f9065f2b440e9fb35fc60694355e8d10b0b Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Wed, 16 Sep 2020 10:00:34 +0800 Subject: [PATCH 01/29] fix(deps): fix npm audit issues (#322) --- package-lock.json | 471 +++++++++++++++++----------------------------- package.json | 2 +- 2 files changed, 170 insertions(+), 303 deletions(-) diff --git a/package-lock.json b/package-lock.json index d218b2405a..d2d1202be6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7320,9 +7320,9 @@ } }, "bl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz", - "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", "requires": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -7603,22 +7603,6 @@ "ieee754": "^1.1.4" } }, - "buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "requires": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true - }, "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -7629,12 +7613,6 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" }, - "buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "dev": true - }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -9707,153 +9685,6 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, - "decompress": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", - "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", - "dev": true, - "requires": { - "decompress-tar": "^4.0.0", - "decompress-tarbz2": "^4.0.0", - "decompress-targz": "^4.0.0", - "decompress-unzip": "^4.0.1", - "graceful-fs": "^4.1.10", - "make-dir": "^1.0.0", - "pify": "^2.3.0", - "strip-dirs": "^2.0.0" - }, - "dependencies": { - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "requires": { - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true - } - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true - } - } - }, - "decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "requires": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "dependencies": { - "bl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", - "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", - "dev": true, - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "tar-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", - "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", - "dev": true, - "requires": { - "bl": "^1.0.0", - "buffer-alloc": "^1.2.0", - "end-of-stream": "^1.0.0", - "fs-constants": "^1.0.0", - "readable-stream": "^2.3.0", - "to-buffer": "^1.1.1", - "xtend": "^4.0.0" - } - } - } - }, - "decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "requires": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "dependencies": { - "file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true - } - } - }, - "decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", - "dev": true, - "requires": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - } - }, - "decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=", - "dev": true, - "requires": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "dependencies": { - "file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", - "dev": true - }, - "get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", - "dev": true, - "requires": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true - } - } - }, "decompress-zip": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/decompress-zip/-/decompress-zip-0.3.2.tgz", @@ -11954,12 +11785,6 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.2.tgz", "integrity": "sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw==" }, - "file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", - "dev": true - }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -12470,23 +12295,6 @@ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", "dev": true }, - "getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "requires": { - "async": "^3.2.0" - }, - "dependencies": { - "async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==", - "dev": true - } - } - }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -12781,12 +12589,6 @@ "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", "dev": true }, - "graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "dev": true - }, "graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", @@ -14032,12 +13834,6 @@ "integrity": "sha512-18toSebUVF7y717dgw/Dzn6djOCqrkiDp3MhB8P6TdKyCVkbD1ZwE7Uz8Hwx6hUPTvKjbyYH9ncXT4ts4qLaSA==", "dev": true }, - "is-natural-number": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", - "integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=", - "dev": true - }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -18064,9 +17860,9 @@ "dev": true }, "md5-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-4.0.0.tgz", - "integrity": "sha512-UC0qFwyAjn4YdPpKaDNw6gNxRf7Mcx7jC1UGCY4boCzgvU2Aoc1mOGzTtrjjLKhM5ivsnhoKpQVxKPp+1j1qwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", + "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==", "dev": true }, "md5.js": { @@ -18758,36 +18554,72 @@ } }, "mongodb-memory-server-core": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-5.2.11.tgz", - "integrity": "sha512-KYU+bg5p0Mjo9yS9mMs5TnaTe82ATOhM6p2C7hrMk4v1RpawzoMTKcp/A7ynHavnNOeNLh6ESP1ScabL1fGqkw==", + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-6.7.5.tgz", + "integrity": "sha512-G4Wv1MAQg5B/aXpRwD5jy6Ubspcd7w4LsFl+rYJHhUgj8VMZGa4hX1pcEW26ghaw7gm24YsyXwDezHZPtxijKA==", "dev": true, "requires": { - "camelcase": "^5.3.1", - "cross-spawn": "^6.0.5", + "@types/tmp": "^0.2.0", + "camelcase": "^6.0.0", + "cross-spawn": "^7.0.3", "debug": "^4.1.1", - "decompress": "^4.2.0", - "dedent": "^0.7.0", - "find-cache-dir": "^3.0.0", + "find-cache-dir": "^3.3.1", "find-package-json": "^1.2.0", - "get-port": "^5.0.0", - "getos": "^3.1.1", - "https-proxy-agent": "^3.0.0", + "get-port": "^5.1.1", + "https-proxy-agent": "^5.0.0", "lockfile": "^1.0.4", - "md5-file": "^4.0.0", - "mkdirp": "^0.5.1", - "mongodb": "^3.2.7", - "tmp": "^0.1.0", - "uuid": "^3.3.3" + "md5-file": "^5.0.0", + "mkdirp": "^1.0.4", + "mongodb": "3.6.2", + "semver": "^7.3.2", + "tar-stream": "^2.1.4", + "tmp": "^0.2.1", + "uuid": "8.3.0", + "yauzl": "^2.10.0" }, "dependencies": { "agent-base": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", - "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.1.tgz", + "integrity": "sha512-01q25QQDwLSsyfhrKbn8yuur+JNw0H+0Y4JiGIKd3z9aYk/w/2kxD/Upc+t2ZBBSUNff50VjPsSW2YxM8QYKVg==", "dev": true, "requires": { - "es6-promisify": "^5.0.0" + "debug": "4" + } + }, + "bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "dev": true, + "optional": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "bson": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", + "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==", + "dev": true, + "optional": true + }, + "camelcase": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz", + "integrity": "sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==", + "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": { @@ -18821,24 +18653,13 @@ } }, "https-proxy-agent": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", - "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", "dev": true, "requires": { - "agent-base": "^4.3.0", - "debug": "^3.1.0" - }, - "dependencies": { - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } + "agent-base": "6", + "debug": "4" } }, "locate-path": { @@ -18857,6 +18678,35 @@ "dev": true, "requires": { "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "mongodb": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.2.tgz", + "integrity": "sha512-sSZOb04w3HcnrrXC82NEh/YGCmBuRgR+C1hZgmmv4L6dBz4BkRse6Y8/q/neXer9i95fKUBbFi4KgeceXmbsOA==", + "dev": true, + "optional": true, + "requires": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "require_optional": "^1.0.1", + "safe-buffer": "^5.1.2", + "saslprep": "^1.0.0" } }, "p-limit": { @@ -18889,6 +18739,12 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true }, + "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 + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -18898,35 +18754,81 @@ "find-up": "^4.0.0" } }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "semver": { + "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": { - "glob": "^7.1.3" + "shebang-regex": "^3.0.0" } }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "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 }, + "tar-stream": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz", + "integrity": "sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "bl": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "tmp": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz", - "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", "dev": true, "requires": { - "rimraf": "^2.6.3" + "rimraf": "^3.0.0" } }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "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" + } } } }, @@ -22352,26 +22254,6 @@ "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==" }, - "seek-bzip": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.5.tgz", - "integrity": "sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w=", - "dev": true, - "requires": { - "commander": "~2.8.1" - }, - "dependencies": { - "commander": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", - "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", - "dev": true, - "requires": { - "graceful-readlink": ">= 1.0.0" - } - } - } - }, "select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -23331,15 +23213,6 @@ "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", "dev": true }, - "strip-dirs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", - "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", - "dev": true, - "requires": { - "is-natural-number": "^4.0.1" - } - }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -23814,9 +23687,9 @@ }, "dependencies": { "bl": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz", - "integrity": "sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", "requires": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -25001,12 +24874,6 @@ "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", "dev": true }, - "to-buffer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true - }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/package.json b/package.json index d6c4289a65..22e7231eed 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,7 @@ "lint-staged": "^10.2.2", "maildev": "^1.1.0", "mini-css-extract-plugin": "^0.5.0", - "mongodb-memory-server-core": "^5.1.5", + "mongodb-memory-server-core": "^6.7.5", "ngrok": "^3.2.7", "optimize-css-assets-webpack-plugin": "^5.0.1", "prettier": "^2.1.1", From acd825e9294f25c8d804453c0b151e3b65e1c9ba Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Wed, 16 Sep 2020 21:53:34 +0800 Subject: [PATCH 02/29] fix(dev): fix hot reloading and Localstack port (#324) * fix(dev): fix server hot reloading * fix(dev): switch to localstack edge port * fix(dev): use default init method for localstack * docs: add explanation for AWS entrypoint --- Dockerfile.development | 2 +- docker-compose.yml | 12 +++++++++--- docker-entrypoint-initaws.d/init-localstack.sh | 6 ++++++ init-localstack.sh | 10 ---------- package.json | 2 +- tests/.test-basic-env | 2 +- tests/.test-full-env | 2 +- 7 files changed, 19 insertions(+), 17 deletions(-) create mode 100644 docker-entrypoint-initaws.d/init-localstack.sh delete mode 100644 init-localstack.sh diff --git a/Dockerfile.development b/Dockerfile.development index 722fc41e7a..bf52cc744f 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -38,4 +38,4 @@ EXPOSE 5000 # e.g. chromium when launched to create a new PDF ENTRYPOINT [ "tini", "--" ] # Create local S3 buckets before building the app -CMD sh init-localstack.sh && npm run docker-dev +CMD npm run docker-dev diff --git a/docker-compose.yml b/docker-compose.yml index 8ac35513e8..0fa14d2436 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - /opt/formsg/node_modules ports: - '5000:5000' - - '4572:4572' # localstack ports + - '4566:4566' # localstack ports - '5156:5156' # mockpass ports - '9229:9229' # Node debugger port environment: @@ -29,6 +29,7 @@ services: - AWS_ACCESS_KEY_ID=fakeKey - AWS_SECRET_ACCESS_KEY=fakeSecret - SESSION_SECRET=thisisasecret + - AWS_ENDPOINT=http://localhost:4566 - GA_TRACKING_ID - SENTRY_CONFIG_URL - TWILIO_ACCOUNT_SID @@ -64,7 +65,6 @@ services: - IS_SP_MAINTENANCE - IS_CP_MAINTENANCE - AGGREGATE_COLLECTION - - AWS_ENDPOINT=http://localhost:4572 mockpass: build: https://github.com/opengovsg/mockpass.git @@ -99,10 +99,16 @@ services: environment: - SERVICES=s3 - DATA_DIR=/tmp/localstack/data + - ATTACHMENT_S3_BUCKET=local-attachment-bucket + - IMAGE_S3_BUCKET=local-image-bucket + - LOGO_S3_BUCKET=local-logo-bucket volumes: - './.localstack:/tmp/localstack' - '/var/run/docker.sock:/var/run/docker.sock' - network_mode: 'service:formsg' # reuse formsg service's network stack so that it can resolve localhost:4572 to localstack:4572 + # This is where we add scripts to initialise AWS resources. + # Docs: https://github.com/localstack/localstack#initializing-a-fresh-instance + - './docker-entrypoint-initaws.d:/docker-entrypoint-initaws.d' + network_mode: 'service:formsg' # reuse formsg service's network stack so that it can resolve localhost:4566 to localstack:4566 volumes: mongodata: diff --git a/docker-entrypoint-initaws.d/init-localstack.sh b/docker-entrypoint-initaws.d/init-localstack.sh new file mode 100644 index 0000000000..db04846f86 --- /dev/null +++ b/docker-entrypoint-initaws.d/init-localstack.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -x +awslocal s3 mb s3://$IMAGE_S3_BUCKET +awslocal s3 mb s3://$LOGO_S3_BUCKET +awslocal s3 mb s3://$ATTACHMENT_S3_BUCKET +set +x \ No newline at end of file diff --git a/init-localstack.sh b/init-localstack.sh deleted file mode 100644 index c0d162faaf..0000000000 --- a/init-localstack.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -x -until $(curl --output /dev/null --silent --head --fail $AWS_ENDPOINT); do - printf 'Waiting for Localstack to be ready...' - sleep 5 -done -awslocal --endpoint-url=$AWS_ENDPOINT s3 mb s3://$IMAGE_S3_BUCKET -awslocal --endpoint-url=$AWS_ENDPOINT s3 mb s3://$LOGO_S3_BUCKET -awslocal --endpoint-url=$AWS_ENDPOINT s3 mb s3://$ATTACHMENT_S3_BUCKET -set +x \ No newline at end of file diff --git a/package.json b/package.json index 22e7231eed..a683de66f3 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "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:watch & 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 --exit-child -- src/server.ts", "test-backend": "npm run test-backend-jasmine && npm run test-backend-jest", "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage --maxWorkers=2", "test-backend-jest:watch": "env-cmd -f tests/.test-full-env jest --watch", diff --git a/tests/.test-basic-env b/tests/.test-basic-env index 706e8eb986..793b16d2dc 100644 --- a/tests/.test-basic-env +++ b/tests/.test-basic-env @@ -16,4 +16,4 @@ AWS_ACCESS_KEY_ID=fakeAccessKeyId AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey SESSION_SECRET=sandcrawler-138577 -AWS_ENDPOINT=http://localhost:4572 \ No newline at end of file +AWS_ENDPOINT=http://localhost:4566 \ No newline at end of file diff --git a/tests/.test-full-env b/tests/.test-full-env index cd2935666c..1a4d805922 100644 --- a/tests/.test-full-env +++ b/tests/.test-full-env @@ -64,4 +64,4 @@ MOCK_WEBHOOK_PORT=4000 AWS_ACCESS_KEY_ID=fakeAccessKeyId AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey -AWS_ENDPOINT=http://localhost:4572 \ No newline at end of file +AWS_ENDPOINT=http://localhost:4566 \ No newline at end of file From 789897b61e05bfe4344ef4b986b013894e056935 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Wed, 16 Sep 2020 22:18:39 +0800 Subject: [PATCH 03/29] refactor: remove unused key (#325) --- src/types/field/dateField.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types/field/dateField.ts b/src/types/field/dateField.ts index f82461912f..54aa9ea456 100644 --- a/src/types/field/dateField.ts +++ b/src/types/field/dateField.ts @@ -13,7 +13,6 @@ export type DateValidationOptions = { } export interface IDateField extends IField { - isFutureOnly: boolean dateValidation: DateValidationOptions } From 0ec85ac76583dabfaea5f4323825d87db02ee28b Mon Sep 17 00:00:00 2001 From: shuli-ogp <63710093+shuli-ogp@users.noreply.github.com> Date: Wed, 16 Sep 2020 22:36:50 +0800 Subject: [PATCH 04/29] fix(deps): bump helmet from 3.23.1 to 4.1.0 (#233) * fix(deps): bump helmet from 3.23.1 to 4.1.0 Bumps [helmet](https://github.com/helmetjs/helmet) from 3.23.1 to 4.1.0. - [Release notes](https://github.com/helmetjs/helmet/releases) - [Changelog](https://github.com/helmetjs/helmet/blob/master/CHANGELOG.md) - [Commits](https://github.com/helmetjs/helmet/compare/v3.23.1...v4.1.0) Signed-off-by: dependabot[bot] (cherry picked from commit 2437e8dd442a3ef071617b2fe396b667145c5dd9) * chore: import nocache * fix: merge conflicts * fix: declare type of cspDirective * test: check that helmet sets the correct headers * fix: replace helmet.noCache with nocache * docs: explain why if statement is necessary for optional directives * chore: improve tests * chore: update styleSrc in tests * docs: link to helmetjs issue on falsey values * chore: shift mock implementations to beforeEach * refactor: use mockReturnValue instead of mockImplementation * chore: remove unnecessary mock function implementation which returns undefined * chore: simplify csp tests and make them stricter * chore: use beforeAll and clearAllMocks * refactor: mockReturnValue instead of mockImplementation * fix: set correct mockFeatureManager.props for !cspReportUri * refactor: shift cspCoreDirective constant up * chore: add tests for hsts * fix: use ContentSecurityPolicyOptions for type * refactor: filter for hsts middleware instead of using array index * chore: include param for expect mockHelmet.hsts to have been called Co-authored-by: Antariksh Mahajan * refactor: use find() instead of filter() * fix: set correct default value for secure * chore: rename test folder * chore: check that next() is called correctly by hstsMiddleware * chore: remove unnecessary mockReturnValue for mockHelmet.hsts Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Antariksh Mahajan --- package-lock.json | 123 +------------- package.json | 5 +- src/loaders/express/__tests__/helmet.spec.ts | 170 +++++++++++++++++++ src/loaders/express/helmet.ts | 17 +- src/loaders/express/index.ts | 6 +- tests/integration/helpers/express-setup.ts | 6 +- tests/unit/backend/helpers/jest-express.ts | 3 + 7 files changed, 201 insertions(+), 129 deletions(-) create mode 100644 src/loaders/express/__tests__/helmet.spec.ts diff --git a/package-lock.json b/package-lock.json index d2d1202be6..de5ca4f013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7404,7 +7404,8 @@ "bowser": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.9.0.tgz", - "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA==" + "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA==", + "dev": true }, "boxicons": { "version": "1.8.0", @@ -7857,11 +7858,6 @@ "quick-lru": "^4.0.1" } }, - "camelize": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", - "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" - }, "caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -8656,11 +8652,6 @@ } } }, - "content-security-policy-builder": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz", - "integrity": "sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ==" - }, "content-type": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", @@ -9417,11 +9408,6 @@ "assert-plus": "^1.0.0" } }, - "dasherize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz", - "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" - }, "data-uri-to-buffer": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz", @@ -10084,11 +10070,6 @@ } } }, - "dns-prefetch-control": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz", - "integrity": "sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q==" - }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -10157,11 +10138,6 @@ "domhandler": "^3.0.0" } }, - "dont-sniff-mimetype": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz", - "integrity": "sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug==" - }, "dot-prop": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", @@ -11404,11 +11380,6 @@ } } }, - "expect-ct": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/expect-ct/-/expect-ct-0.2.0.tgz", - "integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g==" - }, "expiry-map": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/expiry-map/-/expiry-map-1.1.0.tgz", @@ -11732,11 +11703,6 @@ "pend": "~1.2.0" } }, - "feature-policy": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz", - "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ==" - }, "fecha": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", @@ -12095,11 +12061,6 @@ "map-cache": "^0.2.2" } }, - "frameguard": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/frameguard/-/frameguard-3.1.0.tgz", - "integrity": "sha512-TxgSKM+7LTA6sidjOiSZK9wxY0ffMPY3Wta//MqwmX0nZuEHc8QrkV8Fh3ZhMJeiH+Uyh/tcaarImRy8u77O7g==" - }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -12826,48 +12787,9 @@ "dev": true }, "helmet": { - "version": "3.23.1", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.23.1.tgz", - "integrity": "sha512-e034HHfRK4065BFjYbffn5jXaTWWrhTNgmLIppsGEOjpdDB1MBQkWlAFW/auULXAu6uKk2X76n7a7gvz5sSjkg==", - "requires": { - "depd": "2.0.0", - "dns-prefetch-control": "0.2.0", - "dont-sniff-mimetype": "1.1.0", - "expect-ct": "0.2.0", - "feature-policy": "0.3.0", - "frameguard": "3.1.0", - "helmet-crossdomain": "0.4.0", - "helmet-csp": "2.10.0", - "hide-powered-by": "1.1.0", - "hpkp": "2.0.0", - "hsts": "2.2.0", - "nocache": "2.1.0", - "referrer-policy": "1.2.0", - "x-xss-protection": "1.3.0" - }, - "dependencies": { - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - } - } - }, - "helmet-crossdomain": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz", - "integrity": "sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA==" - }, - "helmet-csp": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.10.0.tgz", - "integrity": "sha512-Rz953ZNEFk8sT2XvewXkYN0Ho4GEZdjAZy4stjiEQV3eN7GDxg1QKmYggH7otDyIA7uGA6XnUMVSgeJwbR5X+w==", - "requires": { - "bowser": "2.9.0", - "camelize": "1.0.0", - "content-security-policy-builder": "2.1.0", - "dasherize": "2.0.0" - } + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.1.0.tgz", + "integrity": "sha512-KWy75fYN8hOG2Rhl8e5B3WhOzb0by1boQum85TiddIE9iu6gV+TXbUjVC17wfej0o/ZUpqB9kxM0NFCZRMzf+Q==" }, "hex-color-regex": { "version": "1.1.0", @@ -12875,11 +12797,6 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "dev": true }, - "hide-powered-by": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz", - "integrity": "sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg==" - }, "highlight-es": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.3.tgz", @@ -12935,11 +12852,6 @@ "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "dev": true }, - "hpkp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz", - "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI=" - }, "hsl-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", @@ -12952,21 +12864,6 @@ "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", "dev": true }, - "hsts": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.2.0.tgz", - "integrity": "sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ==", - "requires": { - "depd": "2.0.0" - }, - "dependencies": { - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - } - } - }, "html-comment-regex": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", @@ -21517,11 +21414,6 @@ "esprima": "~3.0.0" } }, - "referrer-policy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz", - "integrity": "sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA==" - }, "regenerate": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz", @@ -26922,11 +26814,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz", "integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==" }, - "x-xss-protection": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.3.0.tgz", - "integrity": "sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg==" - }, "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index a683de66f3..98263a9b85 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "font-awesome": "4.7.0", "glob": "^7.1.2", "has-ansi": "^4.0.0", - "helmet": "^3.21.3", + "helmet": "^4.1.0", "http-status-codes": "^2.1.2", "intl-tel-input": "~12.1.6", "json-stringify-deterministic": "^1.0.1", @@ -139,6 +139,7 @@ "ng-infinite-scroll": "^1.3.0", "ng-table": "^3.0.1", "ngclipboard": "^2.0.0", + "nocache": "^2.1.0", "node-cache": "^5.1.2", "node-jose": "^1.0.0", "nodemailer": "^6.4.11", @@ -250,4 +251,4 @@ "webpack-merge": "^4.1.3", "worker-loader": "^2.0.0" } -} +} \ No newline at end of file diff --git a/src/loaders/express/__tests__/helmet.spec.ts b/src/loaders/express/__tests__/helmet.spec.ts new file mode 100644 index 0000000000..0f8b1cadc4 --- /dev/null +++ b/src/loaders/express/__tests__/helmet.spec.ts @@ -0,0 +1,170 @@ +import helmet from 'helmet' +import expressHandler from 'tests/unit/backend/helpers/jest-express' +import { mocked } from 'ts-jest/utils' + +import config from 'src/config/config' +import featureManager from 'src/config/feature-manager' + +import helmetMiddlewares from '../helmet' + +describe('helmetMiddlewares', () => { + jest.mock('helmet') + const mockHelmet = mocked(helmet, true) + jest.mock('src/config/feature-manager') + const mockFeatureManager = mocked(featureManager, true) + jest.mock('src/config/config') + const mockConfig = mocked(config, true) + + const cspCoreDirectives = { + defaultSrc: ["'self'"], + imgSrc: [ + "'self'", + 'data:', + 'https://www.googletagmanager.com/', + 'https://www.google-analytics.com/', + `https://s3-${config.aws.region}.amazonaws.com/agency.form.sg/`, + config.aws.imageBucketUrl, + config.aws.logoBucketUrl, + '*', + ], + fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com/'], + scriptSrc: [ + "'self'", + 'https://www.googletagmanager.com/', + 'https://ssl.google-analytics.com/', + 'https://www.google-analytics.com/', + 'https://www.tagmanager.google.com/', + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + 'https://www.gstatic.com/recaptcha/', + 'https://www.gstatic.cn/', + 'https://www.google-analytics.com/', + ], + connectSrc: [ + "'self'", + 'https://www.google-analytics.com/', + 'https://ssl.google-analytics.com/', + 'https://sentry.io/api/', + config.aws.attachmentBucketUrl, + config.aws.imageBucketUrl, + config.aws.logoBucketUrl, + ], + frameSrc: [ + "'self'", + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + ], + objectSrc: ["'none'"], + styleSrc: [ + "'self'", + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + 'https://www.gstatic.com/recaptcha/', + 'https://www.gstatic.cn/', + // For inline styles in angular-sanitize.js + "'sha256-b3IrgBVvuKx/Q3tmAi79fnf6AFClibrz/0S5x1ghdGU='", + ], + formAction: ["'self'"], + } + + beforeAll(() => { + mockHelmet.xssFilter = jest.fn().mockReturnValue('xssFilter') + mockHelmet.noSniff = jest.fn().mockReturnValue('noSniff') + mockHelmet.ieNoOpen = jest.fn().mockReturnValue('ieNoOpen') + mockHelmet.dnsPrefetchControl = jest + .fn() + .mockReturnValue('dnsPrefetchControl') + mockHelmet.hidePoweredBy = jest.fn().mockReturnValue('hidePoweredBy') + mockHelmet.referrerPolicy = jest.fn().mockReturnValue('referrerPolicy') + mockHelmet.contentSecurityPolicy = jest + .fn() + .mockReturnValue('contentSecurityPolicy') + mockHelmet.hsts = jest.fn().mockReturnValue(jest.fn()) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should call the correct helmet functions', () => { + helmetMiddlewares() + expect(mockHelmet.xssFilter).toHaveBeenCalled() + expect(mockHelmet.noSniff).toHaveBeenCalled() + expect(mockHelmet.ieNoOpen).toHaveBeenCalled() + expect(mockHelmet.dnsPrefetchControl).toHaveBeenCalled() + expect(mockHelmet.hidePoweredBy).toHaveBeenCalled() + expect(mockHelmet.referrerPolicy).toHaveBeenCalled() + expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalled() + }) + + it('should call helmet.hsts() if req.secure', () => { + const mockReq = expressHandler.mockRequest({ secure: true }) + const mockRes = expressHandler.mockResponse() + const mockNext = jest.fn() + + // Find works for helmet.hsts() because the other functions are mocked to return a string + const hstsFn = helmetMiddlewares().find( + (result) => typeof result === 'function', + ) + // Necessary to check for hstsFn because find() returns undefined by default, otherwise + // will throw TypeError + if (hstsFn) { + hstsFn(mockReq, mockRes, mockNext) + } + expect(mockHelmet.hsts).toHaveBeenCalledWith({ maxAge: 5184000 }) + expect(mockNext).not.toHaveBeenCalled() + }) + + it('should not call helmet.hsts() if !req.secure', () => { + const mockReq = expressHandler.mockRequest({ secure: false }) + const mockRes = expressHandler.mockResponse() + const mockNext = jest.fn() + + const hstsFn = helmetMiddlewares().find( + (result) => typeof result === 'function', + ) + if (hstsFn) { + hstsFn(mockReq, mockRes, mockNext) + } + + expect(mockHelmet.hsts).not.toHaveBeenCalled() + expect(mockNext).toHaveBeenCalled() + }) + + it('should call helmet.contentSecurityPolicy() with the correct directives if cspReportUri and !isDev', () => { + mockFeatureManager.props = jest + .fn() + .mockReturnValue({ cspReportUri: 'value' }) + mockConfig.isDev = false + helmetMiddlewares() + expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalledWith({ + directives: { + ...cspCoreDirectives, + reportUri: ['value'], + upgradeInsecureRequests: [], + }, + }) + }) + + it('should call helmet.contentSecurityPolicy() with the correct directives if !cspReportUri and isDev', () => { + mockFeatureManager.props = jest.fn() + mockConfig.isDev = true + helmetMiddlewares() + expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalledWith({ + directives: { + ...cspCoreDirectives, + }, + }) + }) + + it('should return the correct values from helmet', () => { + const result = helmetMiddlewares() + expect(result).toContain('xssFilter') + expect(result).toContain('noSniff') + expect(result).toContain('ieNoOpen') + expect(result).toContain('dnsPrefetchControl') + expect(result).toContain('hidePoweredBy') + expect(result).toContain('referrerPolicy') + expect(result).toContain('contentSecurityPolicy') + }) +}) diff --git a/src/loaders/express/helmet.ts b/src/loaders/express/helmet.ts index 46a0122584..008c0b909a 100644 --- a/src/loaders/express/helmet.ts +++ b/src/loaders/express/helmet.ts @@ -1,5 +1,6 @@ import { RequestHandler } from 'express' import helmet from 'helmet' +import { ContentSecurityPolicyOptions } from 'helmet/dist/middlewares/content-security-policy' import { get } from 'lodash' import config from '../../config/config' @@ -27,7 +28,7 @@ const helmetMiddlewares = () => { policy: 'strict-origin-when-cross-origin', }) - const cspCoreDirectives = { + const cspCoreDirectives: ContentSecurityPolicyOptions['directives'] = { defaultSrc: ["'self'"], imgSrc: [ "'self'", @@ -77,7 +78,6 @@ const helmetMiddlewares = () => { "'sha256-b3IrgBVvuKx/Q3tmAi79fnf6AFClibrz/0S5x1ghdGU='", ], formAction: ["'self'"], - upgradeInsecureRequests: !config.isDev, } const reportUri = get( @@ -85,7 +85,18 @@ const helmetMiddlewares = () => { 'cspReportUri', undefined, ) - const cspOptionalDirectives = reportUri ? { reportUri } : {} + + const cspOptionalDirectives: ContentSecurityPolicyOptions['directives'] = {} + + // Add on reportUri CSP header if ReportUri exists + // It is necessary to have the if statement for optional directives because falsey values + // do not work - e.g. cspOptionalDirectives.reportUri = [false] will still set the reportUri header + // See https://github.com/helmetjs/csp/issues/36 and + // https://github.com/helmetjs/helmet/blob/cb170160e7c1ccac314cc19d3b979cfc771f1349/middlewares/content-security-policy/index.ts#L135 + if (reportUri) cspOptionalDirectives.reportUri = [reportUri] + + // Add on upgradeInsecureRequest CSP header if !config.isDev + if (!config.isDev) cspOptionalDirectives.upgradeInsecureRequests = [] const contentSecurityPolicyMiddleware = helmet.contentSecurityPolicy({ directives: { diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts index 8a2ffe5238..92cf6be6f6 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -1,9 +1,9 @@ import compression from 'compression' import express, { Express } from 'express' import device from 'express-device' -import helmet from 'helmet' import http from 'http' import { Connection } from 'mongoose' +import nocache from 'nocache' import path from 'path' import url from 'url' @@ -93,7 +93,7 @@ const loadExpressApp = async (connection: Connection) => { // !!!!! DO NOT CHANGE THE ORDER OF THE NEXT 3 LINES !!!!! // The first line redirects requests to /public/fonts to - // ./dist/frontend/fonts. After that, helmet.noCache() ensures that + // ./dist/frontend/fonts. After that, nocache() ensures that // cache headers are not set on requests for fonts, which ensures that // fonts are shown correctly on IE11. // The last line redirects requests to /public to ./dist/frontend, @@ -103,7 +103,7 @@ const loadExpressApp = async (connection: Connection) => { express.static(path.resolve('./dist/frontend/fonts')), ) - app.use(helmet.noCache()) // Add headers to prevent browser caching front-end code + app.use(nocache()) // Add headers to prevent browser caching front-end code // Setting the app static folder app.use('/public', express.static(path.resolve('./dist/frontend'))) diff --git a/tests/integration/helpers/express-setup.ts b/tests/integration/helpers/express-setup.ts index 994c51853f..03bd044831 100644 --- a/tests/integration/helpers/express-setup.ts +++ b/tests/integration/helpers/express-setup.ts @@ -1,7 +1,7 @@ import compression from 'compression' import express, { Router } from 'express' -import helmet from 'helmet' import mongoose from 'mongoose' +import nocache from 'nocache' import { Response } from 'supertest' import errorHandlerMiddlewares from 'src/loaders/express/error-handler' @@ -20,7 +20,7 @@ export const setupApp = ( app.use(compression()) app.use(parserMiddlewares()) app.use(helmetMiddlewares()) - app.use(helmet.noCache()) + app.use(nocache()) app.use(sessionMiddlewares(mongoose.connection)) @@ -43,7 +43,7 @@ export const setupApp = ( .set('cookie', cookieStore.get()); */ export class CookieStore { - #currentCookie: string = '' + #currentCookie = '' handleCookie(res: Response) { this.set(res.header['set-cookie'][0]) diff --git a/tests/unit/backend/helpers/jest-express.ts b/tests/unit/backend/helpers/jest-express.ts index 46672e5324..ed91e6fed2 100644 --- a/tests/unit/backend/helpers/jest-express.ts +++ b/tests/unit/backend/helpers/jest-express.ts @@ -4,15 +4,18 @@ const mockRequest =

, B>({ params, body, session, + secure, }: { params?: P body?: B session?: any + secure?: boolean } = {}) => { return { body: body ?? {}, params: params ?? {}, session: session ?? {}, + secure: secure ?? true, get(name: string) { if (name === 'cf-connecting-ip') return 'MOCK_IP' return undefined From e1be172783ef5b167342b04fcf4bdc835c2ab252 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2020 09:27:52 +0800 Subject: [PATCH 05/29] chore(deps-dev): bump concurrently from 3.6.1 to 5.3.0 (#308) Bumps [concurrently](https://github.com/kimmobrunfeldt/concurrently) from 3.6.1 to 5.3.0. - [Release notes](https://github.com/kimmobrunfeldt/concurrently/releases) - [Commits](https://github.com/kimmobrunfeldt/concurrently/compare/3.6.1...v5.3.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 213 ++++++++++++++++++++++++++++++++++------------ package.json | 2 +- 2 files changed, 161 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index de5ca4f013..afdf0bafa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8544,35 +8544,160 @@ } }, "concurrently": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-3.6.1.tgz", - "integrity": "sha512-/+ugz+gwFSEfTGUxn0KHkY+19XPRTXR8+7oUK/HxgiN1n7FjeJmkrbSiXAJfyQ0zORgJYPaenmymwon51YXH9Q==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-5.3.0.tgz", + "integrity": "sha512-8MhqOB6PWlBfA2vJ8a0bSFKATOdWlHiQlk11IfmQBPaHVP8oP2gsh2MObE6UR3hqDHqvaIvLTyceNW6obVuFHQ==", "dev": true, "requires": { - "chalk": "^2.4.1", - "commander": "2.6.0", - "date-fns": "^1.23.0", - "lodash": "^4.5.1", - "read-pkg": "^3.0.0", - "rx": "2.3.24", + "chalk": "^2.4.2", + "date-fns": "^2.0.1", + "lodash": "^4.17.15", + "read-pkg": "^4.0.1", + "rxjs": "^6.5.2", "spawn-command": "^0.0.2-1", - "supports-color": "^3.2.3", - "tree-kill": "^1.1.0" + "supports-color": "^6.1.0", + "tree-kill": "^1.2.2", + "yargs": "^13.3.0" }, "dependencies": { - "has-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, "supports-color": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", - "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", "dev": true, "requires": { - "has-flag": "^1.0.0" + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" } } } @@ -9425,9 +9550,9 @@ } }, "date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==", "dev": true }, "date-now": { @@ -17190,26 +17315,6 @@ } } }, - "load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true - } - } - }, "loader-runner": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", @@ -21242,14 +21347,22 @@ } }, "read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-4.0.1.tgz", + "integrity": "sha1-ljYlN48+HE1IyFhytabsfV0JMjc=", "dev": true, "requires": { - "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" + "parse-json": "^4.0.0", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } } }, "read-pkg-up": { @@ -21905,12 +22018,6 @@ "aproba": "^1.1.1" } }, - "rx": { - "version": "2.3.24", - "resolved": "https://registry.npmjs.org/rx/-/rx-2.3.24.tgz", - "integrity": "sha1-FPlQpCF9fjXapxu8vljv9o6ksrc=", - "dev": true - }, "rxjs": { "version": "6.5.5", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", diff --git a/package.json b/package.json index 98263a9b85..b0bbcd391f 100644 --- a/package.json +++ b/package.json @@ -200,7 +200,7 @@ "auto-changelog": "^2.2.0", "axios-mock-adapter": "^1.18.1", "babel-loader": "^8.0.5", - "concurrently": "^3.6.1", + "concurrently": "^5.3.0", "copy-webpack-plugin": "^6.0.2", "core-js": "^3.6.4", "coveralls": "^3.1.0", From b5e90e9f17055a66196229bd51dde3b21e80e8ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2020 09:33:45 +0800 Subject: [PATCH 06/29] fix(deps): bump whatwg-fetch from 3.0.0 to 3.4.1 (#300) Bumps [whatwg-fetch](https://github.com/github/fetch) from 3.0.0 to 3.4.1. - [Release notes](https://github.com/github/fetch/releases) - [Commits](https://github.com/github/fetch/compare/v3.0.0...v3.4.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index afdf0bafa0..0080626b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26565,9 +26565,9 @@ } }, "whatwg-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", - "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz", + "integrity": "sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==" }, "whatwg-mimetype": { "version": "2.3.0", diff --git a/package.json b/package.json index b0bbcd391f..ff5fad4878 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "uuid": "^8.3.0", "validator": "^13.1.1", "web-streams-polyfill": "^2.1.1", - "whatwg-fetch": "^3.0.0", + "whatwg-fetch": "^3.4.1", "winston": "^3.2.1", "winston-cloudwatch": "^2.3.2" }, From d2ad27d8afb93a3708dfb20df2509715562b1eaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2020 10:15:45 +0800 Subject: [PATCH 07/29] fix(deps): bump winston from 3.2.1 to 3.3.3 (#278) Bumps [winston](https://github.com/winstonjs/winston) from 3.2.1 to 3.3.3. - [Release notes](https://github.com/winstonjs/winston/releases) - [Changelog](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md) - [Commits](https://github.com/winstonjs/winston/compare/3.2.1...v3.3.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 121 ++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 59 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0080626b7f..7d40ffa9f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2814,6 +2814,16 @@ "minimist": "^1.2.0" } }, + "@dabh/diagnostics": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", + "integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==", + "requires": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "@eslint/eslintrc": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz", @@ -8378,11 +8388,6 @@ "simple-swizzle": "^0.2.2" } }, - "colornames": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", - "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=" - }, "colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -10129,16 +10134,6 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.781568.tgz", "integrity": "sha512-9Uqnzy6m6zEStluH9iyJ3iHyaQziFnMnLeC8vK0eN6smiJmIx7+yB64d67C2lH/LZra+5cGscJAJsNXO+MdPMg==" }, - "diagnostics": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", - "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", - "requires": { - "colorspace": "1.1.x", - "enabled": "1.0.x", - "kuler": "1.0.x" - } - }, "dicer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", @@ -10390,12 +10385,9 @@ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, "enabled": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", - "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", - "requires": { - "env-variable": "0.0.x" - } + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, "encodeurl": { "version": "1.0.2", @@ -10602,11 +10594,6 @@ } } }, - "env-variable": { - "version": "0.0.6", - "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", @@ -11829,9 +11816,9 @@ } }, "fecha": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", - "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.0.tgz", + "integrity": "sha512-aN3pcx/DSmtyoovUudctc8+6Hl4T+hI9GBBHLjA76jdZl7+b1sgh5g4k+u/GL3dTy1/pnYzKp69FpJ0OicE3Wg==" }, "fetch-readablestream": { "version": "0.2.0", @@ -12132,6 +12119,11 @@ "readable-stream": "^2.3.6" } }, + "fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "follow-redirects": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", @@ -13942,7 +13934,8 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true }, "is-string": { "version": "1.0.5", @@ -17005,12 +16998,9 @@ "dev": true }, "kuler": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", - "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", - "requires": { - "colornames": "^1.1.1" - } + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "last-call-webpack-plugin": { "version": "3.0.0", @@ -17638,13 +17628,13 @@ } }, "logform": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.1.2.tgz", - "integrity": "sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz", + "integrity": "sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==", "requires": { "colors": "^1.2.1", "fast-safe-stringify": "^2.0.4", - "fecha": "^2.3.3", + "fecha": "^4.2.0", "ms": "^2.1.1", "triple-beam": "^1.3.0" } @@ -19685,9 +19675,12 @@ } }, "one-time": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", - "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "requires": { + "fn.name": "1.x.x" + } }, "onetime": { "version": "5.1.0", @@ -26656,28 +26649,30 @@ "dev": true }, "winston": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.2.1.tgz", - "integrity": "sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz", + "integrity": "sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==", "requires": { - "async": "^2.6.1", - "diagnostics": "^1.1.1", - "is-stream": "^1.1.0", - "logform": "^2.1.1", - "one-time": "0.0.4", - "readable-stream": "^3.1.1", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.1.0", + "is-stream": "^2.0.0", + "logform": "^2.2.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.3.0" + "winston-transport": "^4.4.0" }, "dependencies": { "async": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", - "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", - "requires": { - "lodash": "^4.17.14" - } + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" }, "readable-stream": { "version": "3.6.0", @@ -26759,11 +26754,11 @@ } }, "winston-transport": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.3.0.tgz", - "integrity": "sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.4.0.tgz", + "integrity": "sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==", "requires": { - "readable-stream": "^2.3.6", + "readable-stream": "^2.3.7", "triple-beam": "^1.2.0" } }, diff --git a/package.json b/package.json index ff5fad4878..6d5943e61f 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,7 @@ "validator": "^13.1.1", "web-streams-polyfill": "^2.1.1", "whatwg-fetch": "^3.4.1", - "winston": "^3.2.1", + "winston": "^3.3.3", "winston-cloudwatch": "^2.3.2" }, "devDependencies": { From 523909b98118bdb80ecdbba0c570c0dbf2686dad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2020 10:17:23 +0800 Subject: [PATCH 08/29] fix(deps): uninstall async (#309) * fix(deps): bump async from 1.5.2 to 3.2.0 Bumps [async](https://github.com/caolan/async) from 1.5.2 to 3.2.0. - [Release notes](https://github.com/caolan/async/releases) - [Changelog](https://github.com/caolan/async/blob/master/CHANGELOG.md) - [Commits](https://github.com/caolan/async/compare/v1.5.2...v3.2.0) Signed-off-by: dependabot[bot] * fix(deps): uninstall async Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Antariksh Mahajan --- package-lock.json | 5 ----- package.json | 1 - 2 files changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d40ffa9f1..569d1afb3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5761,11 +5761,6 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" - }, "async-each": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", diff --git a/package.json b/package.json index 6d5943e61f..956a785151 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "angular-translate-loader-partial": "^2.18.3", "angular-ui-bootstrap": "~2.5.6", "angular-ui-router": "~1.0.22", - "async": "~1.5.2", "await-to-js": "^2.1.1", "aws-info": "^1.1.0", "aws-sdk": "^2.734.0", From ddba47d0dcc001fc46e65be32c08d027d2d1ee70 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Fri, 18 Sep 2020 18:31:49 +0800 Subject: [PATCH 09/29] test: remove storage mode attachment tests (#336) --- package.json | 4 ++-- tests/end-to-end/encrypt-submission.e2e.js | 24 ++++++-------------- tests/end-to-end/helpers/all-fields.js | 7 +++++- tests/end-to-end/helpers/all-hidden-form.js | 25 ++++++++++++++++++++- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 956a785151..0fa33c8b0b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test-ci": "npm run test-backend && npm run test-frontend && coveralls < coverage/lcov.info", "test": "npm run build-backend && npm run test-backend && npm run test-frontend", "test-e2e-build": "npm run build-backend && npm run build-frontend-dev", - "test-run": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/server.js\" \"localstack start --host\" \"node ./tests/mock-webhook-server.js\"", + "test-run": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/server.js\" \"node ./tests/mock-webhook-server.js\"", "testcafe-full-env": "testcafe --skip-js-errors -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 --skip-js-errors -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", @@ -250,4 +250,4 @@ "webpack-merge": "^4.1.3", "worker-loader": "^2.0.0" } -} \ No newline at end of file +} diff --git a/tests/end-to-end/encrypt-submission.e2e.js b/tests/end-to-end/encrypt-submission.e2e.js index 46f71abeef..8ef89bd79d 100644 --- a/tests/end-to-end/encrypt-submission.e2e.js +++ b/tests/end-to-end/encrypt-submission.e2e.js @@ -16,17 +16,16 @@ const { createWebhookConfig, removeWebhookConfig, } = require('./helpers/encrypt-mode') -const { allFields } = require('./helpers/all-fields') +const { allFieldsEncrypt } = require('./helpers/all-fields') const { verifiableEmailField } = require('./helpers/verifiable-email-field') const { - hiddenFieldsData, - hiddenFieldsLogicData, + hiddenFieldsDataEncrypt, + hiddenFieldsLogicDataEncrypt, } = require('./helpers/all-hidden-form') const chainDisabled = require('./helpers/disabled-form-chained') const { cloneDeep } = require('lodash') -const aws = require('aws-sdk') let User let Form @@ -49,15 +48,6 @@ fixture('Storage mode submissions') govTech = await Agency.findOne({ shortName: 'govtech' }).exec() // Check whether captcha is enabled in environment captchaEnabled = await getFeatureState('captcha') - - // Create s3 bucket for attachments - const s3 = new aws.S3({ - endpoint: process.env.AWS_ENDPOINT, - s3ForcePathStyle: true, - }) - await s3 - .createBucket({ Bucket: process.env.ATTACHMENT_S3_BUCKET }) - .promise() }) .after(async () => { // Delete models defined by mongoose and close connection @@ -77,7 +67,7 @@ fixture('Storage mode submissions') // Form with all field types available in storage mode test.meta('basic-env', 'true').before(async (t) => { const formData = await getDefaultFormOptions() - formData.formFields = cloneDeep(allFields) + formData.formFields = cloneDeep(allFieldsEncrypt) t.ctx.formData = formData })('Create and submit form with all field types', async (t) => { t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) @@ -87,8 +77,8 @@ test.meta('basic-env', 'true').before(async (t) => { // Form where all basic field types are hidden by logic test.meta('basic-env', 'true').before(async (t) => { const formData = await getDefaultFormOptions() - formData.formFields = cloneDeep(hiddenFieldsData) - formData.logicData = cloneDeep(hiddenFieldsLogicData) + formData.formFields = cloneDeep(hiddenFieldsDataEncrypt) + formData.logicData = cloneDeep(hiddenFieldsLogicDataEncrypt) t.ctx.formData = formData })('Create and submit form with all field types hidden', async (t) => { t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) @@ -98,7 +88,7 @@ test.meta('basic-env', 'true').before(async (t) => { // Form where all fields are optional and no field is answered test.meta('basic-env', 'true').before(async (t) => { const formData = await getDefaultFormOptions() - formData.formFields = allFields.map((field) => { + formData.formFields = allFieldsEncrypt.map((field) => { return getBlankVersion(getOptionalVersion(field)) }) t.ctx.formData = formData diff --git a/tests/end-to-end/helpers/all-fields.js b/tests/end-to-end/helpers/all-fields.js index 661eb5a1b7..7dacad9338 100644 --- a/tests/end-to-end/helpers/all-fields.js +++ b/tests/end-to-end/helpers/all-fields.js @@ -117,4 +117,9 @@ const allFieldInfo = [ }, ] const allFields = allFieldInfo.map(makeField) -module.exports = { allFields } +module.exports = { + allFields, + allFieldsEncrypt: allFields.filter( + (field) => field.fieldType !== 'attachment', + ), +} diff --git a/tests/end-to-end/helpers/all-hidden-form.js b/tests/end-to-end/helpers/all-hidden-form.js index 3d326d34cf..9c4c79cbce 100644 --- a/tests/end-to-end/helpers/all-hidden-form.js +++ b/tests/end-to-end/helpers/all-hidden-form.js @@ -1,7 +1,7 @@ // Exports data for a form containing all basic field types, where all the fields are // hidden due to logic depending on the value of the first field. -const { allFields } = require('./all-fields') +const { allFields, allFieldsEncrypt } = require('./all-fields') const { getBlankVersion, getHiddenVersion, @@ -18,6 +18,9 @@ const shownFields = [ const hiddenFields = allFields.map((field) => getHiddenVersion(getBlankVersion(field)), ) +const hiddenFieldsEncrypt = allFieldsEncrypt.map((field) => + getHiddenVersion(getBlankVersion(field)), +) const hiddenFieldsLogicData = [ { @@ -36,9 +39,29 @@ const hiddenFieldsLogicData = [ logicType: 'showFields', }, ] +const hiddenFieldsLogicDataEncrypt = [ + { + showFieldIndices: listIntsInclusive( + shownFields.length, + shownFields.length + hiddenFieldsEncrypt.length - 1, + ), + conditions: [ + { + fieldIndex: 0, + state: 'is equals to', + value: 'Yes', + ifValueType: 'single-select', + }, + ], + logicType: 'showFields', + }, +] const hiddenFieldsData = [...shownFields, ...hiddenFields] +const hiddenFieldsDataEncrypt = [...shownFields, ...hiddenFieldsEncrypt] module.exports = { hiddenFieldsData, hiddenFieldsLogicData, + hiddenFieldsDataEncrypt, + hiddenFieldsLogicDataEncrypt, } From d9b7d5d9971dff4e16d4bd481bc40d01ad8faecc Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Fri, 18 Sep 2020 18:51:11 +0800 Subject: [PATCH 10/29] fix: set nodeEnv to assigned variable if in dev environment (#335) Needed for test environment to remove console log spam during tests --- src/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.ts b/src/config/config.ts index 0158ebf57e..8d91c6034b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -34,7 +34,7 @@ const basicVars = merge(optionalVars, compulsoryVars) const isDev = basicVars.core.nodeEnv === Environment.Dev || basicVars.core.nodeEnv === Environment.Test -const nodeEnv = isDev ? Environment.Dev : Environment.Prod +const nodeEnv = isDev ? basicVars.core.nodeEnv : Environment.Prod // Load and validate configuration values which are compulsory only in production // If environment variables are not present, an error will be thrown From f134a4187550d68dcccc274e405ab615e1433dd0 Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Mon, 21 Sep 2020 09:27:34 +0800 Subject: [PATCH 11/29] chore: update travis.yml and pin localstack in docker-compose (#337) * chore: update travis.yml It will now install a pinned version of localstack and use the chrome addon instead of downloading from apt * chore: pin localstack version * chore: remove code related to localstack from travis.yml * build: re-enable e2e tests Co-authored-by: Antariksh Mahajan --- .travis.yml | 15 +++------------ docker-compose.yml | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 36623c1df1..b0f53d9b54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,12 +9,9 @@ language: node_js node_js: '12' cache: - npm - - pip -# Setup browsers needed for end-to-end tests -before_install: - - sudo apt-get clean && sudo apt-get update - - sudo apt-get install -y dpkg google-chrome-stable fluxbox chromium-browser +addons: + chrome: stable notifications: email: @@ -25,23 +22,17 @@ notifications: on_success: always on_failure: always -before_script: - - fluxbox >/dev/null 2>&1 & - - pyenv global 3.7.1 - - pip3 install --user localstack[full] - script: - set -e - npm run lint-ci - npm run build - npm run test-ci - # - npm run test-e2e-ci + - npm run test-e2e-ci before_deploy: # Workaround to run before_deploy only once - > if ! [ "$TAG" ]; then - pip install --user awscli # Put AWS in path export PATH=$PATH:$HOME/.local/bin # Login to AWS ECR, credentials defined in $AWS_ACCESS_KEY_ID and $AWS_SECRET_ACCESS_KEY diff --git a/docker-compose.yml b/docker-compose.yml index 0fa14d2436..1bc0601dd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,7 +92,7 @@ services: - '27017:27017' localstack: - image: localstack/localstack:latest + image: localstack/localstack:0.11.5 container_name: formsg-localstack depends_on: - formsg From f089d4f7d693423c99b19da3bb84188d3358ce41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 09:28:13 +0800 Subject: [PATCH 12/29] chore(deps-dev): bump lint-staged from 10.2.11 to 10.4.0 (#328) Bumps [lint-staged](https://github.com/okonet/lint-staged) from 10.2.11 to 10.4.0. - [Release notes](https://github.com/okonet/lint-staged/releases) - [Commits](https://github.com/okonet/lint-staged/compare/v10.2.11...v10.4.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 115 +++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49a018ea7a..3951821609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16861,6 +16861,12 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", @@ -17061,20 +17067,20 @@ "dev": true }, "lint-staged": { - "version": "10.2.11", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.2.11.tgz", - "integrity": "sha512-LRRrSogzbixYaZItE2APaS4l2eJMjjf5MbclRZpLJtcQJShcvUzKXsNeZgsLIZ0H0+fg2tL4B59fU9wHIHtFIA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.0.tgz", + "integrity": "sha512-uaiX4U5yERUSiIEQc329vhCTDDwUcSvKdRLsNomkYLRzijk3v8V9GWm2Nz0RMVB87VcuzLvtgy6OsjoH++QHIg==", "dev": true, "requires": { - "chalk": "^4.0.0", - "cli-truncate": "2.1.0", - "commander": "^5.1.0", - "cosmiconfig": "^6.0.0", + "chalk": "^4.1.0", + "cli-truncate": "^2.1.0", + "commander": "^6.0.0", + "cosmiconfig": "^7.0.0", "debug": "^4.1.1", "dedent": "^0.7.0", - "enquirer": "^2.3.5", - "execa": "^4.0.1", - "listr2": "^2.1.0", + "enquirer": "^2.3.6", + "execa": "^4.0.3", + "listr2": "^2.6.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.2", "normalize-path": "^3.0.0", @@ -17083,6 +17089,12 @@ "stringify-object": "^3.3.0" }, "dependencies": { + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, "ansi-styles": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", @@ -17119,11 +17131,24 @@ "dev": true }, "commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.1.0.tgz", + "integrity": "sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==", "dev": true }, + "cosmiconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", + "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -17144,10 +17169,19 @@ "ms": "^2.1.1" } }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, "execa": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.2.tgz", - "integrity": "sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.3.tgz", + "integrity": "sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==", "dev": true, "requires": { "cross-spawn": "^7.0.0", @@ -17182,12 +17216,30 @@ "path-key": "^3.0.0" } }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, "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 }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -17204,9 +17256,9 @@ "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==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "requires": { "has-flag": "^4.0.0" @@ -17233,18 +17285,18 @@ } }, "listr2": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-2.1.8.tgz", - "integrity": "sha512-Op+hheiChfAphkJ5qUxZtHgyjlX9iNnAeFS/S134xw7mVSg0YVrQo1IY4/K+ElY6XgOPg2Ij4z07urUXR+YEew==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-2.6.2.tgz", + "integrity": "sha512-6x6pKEMs8DSIpA/tixiYY2m/GcbgMplMVmhQAaLFxEtNSKLeWTGjtmU57xvv6QCm2XcqzyNXL/cTSVf4IChCRA==", "dev": true, "requires": { - "chalk": "^4.0.0", + "chalk": "^4.1.0", "cli-truncate": "^2.1.0", "figures": "^3.2.0", "indent-string": "^4.0.0", "log-update": "^4.0.0", "p-map": "^4.0.0", - "rxjs": "^6.5.5", + "rxjs": "^6.6.2", "through": "^2.3.8" }, "dependencies": { @@ -17289,10 +17341,19 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "rxjs": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", + "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", + "dev": true, + "requires": { + "tslib": "^1.9.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==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "requires": { "has-flag": "^4.0.0" diff --git a/package.json b/package.json index 9e0a34d472..01b3236ebc 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "jasmine-sinon": "^0.4.0", "jasmine-spec-reporter": "^5.0.2", "jest": "^26.4.2", - "lint-staged": "^10.2.2", + "lint-staged": "^10.4.0", "maildev": "^1.1.0", "mini-css-extract-plugin": "^0.5.0", "mongodb-memory-server-core": "^6.7.5", From 41acbf21f8310d0f8198254bd46ae6b3227dcfea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 09:29:24 +0800 Subject: [PATCH 13/29] chore(deps-dev): bump ts-node from 8.10.2 to 9.0.0 (#329) Bumps [ts-node](https://github.com/TypeStrong/ts-node) from 8.10.2 to 9.0.0. - [Release notes](https://github.com/TypeStrong/ts-node/releases) - [Commits](https://github.com/TypeStrong/ts-node/compare/v8.10.2...v9.0.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 27 ++++++++++++++++----------- package.json | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3951821609..bead2b295b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25152,9 +25152,9 @@ "dev": true }, "ts-node": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", - "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.0.0.tgz", + "integrity": "sha512-/TqB4SnererCDR/vb4S/QvSZvzQMJN8daAslg7MeaiHvD8rDZsSfXmNeNumyZZzMned72Xoq/isQljYSt8Ynfg==", "dev": true, "requires": { "arg": "^4.1.0", @@ -25162,14 +25162,6 @@ "make-error": "^1.1.1", "source-map-support": "^0.5.17", "yn": "3.1.1" - }, - "dependencies": { - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true - } } }, "ts-node-dev": { @@ -25225,6 +25217,19 @@ "requires": { "glob": "^7.1.3" } + }, + "ts-node": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", + "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", + "dev": true, + "requires": { + "arg": "^4.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.17", + "yn": "3.1.1" + } } } }, diff --git a/package.json b/package.json index 01b3236ebc..d7088a439c 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "ts-jest": "^26.3.0", "ts-loader": "^7.0.5", "ts-mock-imports": "^1.3.0", - "ts-node": "^8.10.2", + "ts-node": "^9.0.0", "ts-node-dev": "^1.0.0-pre.44", "typescript": "^4.0.2", "url-loader": "^1.1.2", From 2c7b7be61070fa139e0435c25810526e0ffa6b25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 09:29:57 +0800 Subject: [PATCH 14/29] chore(deps-dev): bump eslint-plugin-import from 2.21.2 to 2.22.0 (#330) Bumps [eslint-plugin-import](https://github.com/benmosher/eslint-plugin-import) from 2.21.2 to 2.22.0. - [Release notes](https://github.com/benmosher/eslint-plugin-import/releases) - [Changelog](https://github.com/benmosher/eslint-plugin-import/blob/master/CHANGELOG.md) - [Commits](https://github.com/benmosher/eslint-plugin-import/compare/v2.21.2...v2.22.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 56 +++++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index bead2b295b..be8cf8fbe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11039,9 +11039,9 @@ } }, "eslint-plugin-import": { - "version": "2.21.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.21.2.tgz", - "integrity": "sha512-FEmxeGI6yaz+SnEB6YgNHlQK1Bs2DKLM+YF+vuTk5H8J9CLbJLtlPvRFgZZ2+sXiKAlN5dpdlrWOjK8ZoZJpQA==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz", + "integrity": "sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg==", "dev": true, "requires": { "array-includes": "^3.1.1", @@ -11078,33 +11078,12 @@ "isarray": "^1.0.0" } }, - "load-json-file": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", - "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "strip-bom": "^3.0.0" - } - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, - "parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", - "dev": true, - "requires": { - "error-ex": "^1.2.0" - } - }, "path-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", @@ -17361,6 +17340,35 @@ } } }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, "loader-runner": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", diff --git a/package.json b/package.json index d7088a439c..ada918ba39 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "eslint-config-prettier": "^6.11.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-html": "^6.0.2", - "eslint-plugin-import": "^2.21.2", + "eslint-plugin-import": "^2.22.0", "eslint-plugin-jest": "^24.0.0", "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-simple-import-sort": "^5.0.3", From bb3172c891d77c13d987558c5b24d69c91f99b2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 09:30:23 +0800 Subject: [PATCH 15/29] chore(deps-dev): bump eslint from 7.8.1 to 7.9.0 (#331) Bumps [eslint](https://github.com/eslint/eslint) from 7.8.1 to 7.9.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/master/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v7.8.1...v7.9.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 24 +++++++++--------------- package.json | 2 +- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index be8cf8fbe2..be6535317d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5318,9 +5318,9 @@ } }, "acorn-jsx": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz", - "integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", "dev": true }, "acorn-walk": { @@ -5357,9 +5357,9 @@ } }, "ajv": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz", - "integrity": "sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==", + "version": "6.12.5", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.5.tgz", + "integrity": "sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -10718,9 +10718,9 @@ } }, "eslint": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.8.1.tgz", - "integrity": "sha512-/2rX2pfhyUG0y+A123d0ccXtMm7DV7sH1m3lk9nk2DZ2LReq39FXHueR9xZwshE5MdfSf0xunSaMWRqyIA6M1w==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.9.0.tgz", + "integrity": "sha512-V6QyhX21+uXp4T+3nrNfI3hQNBDa/P8ga7LoQOenwrlEFXrEnUEE+ok1dMtaS3b6rmLXhT1TkTIsG75HMLbknA==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -11191,12 +11191,6 @@ "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", diff --git a/package.json b/package.json index ada918ba39..5b68697354 100644 --- a/package.json +++ b/package.json @@ -205,7 +205,7 @@ "coveralls": "^3.1.0", "css-loader": "^2.1.1", "env-cmd": "^10.1.0", - "eslint": "^7.8.1", + "eslint": "^7.9.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-html": "^6.0.2", From 8bf7fa2df4c63b9e512aa96ec8c43f4ac29ed3ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 12:46:13 +0800 Subject: [PATCH 16/29] fix(deps): bump celebrate from 12.2.0 to 13.0.3 (#338) * fix(deps): bump celebrate from 12.2.0 to 13.0.3 Bumps [celebrate](https://github.com/arb/celebrate) from 12.2.0 to 13.0.3. - [Release notes](https://github.com/arb/celebrate/releases) - [Commits](https://github.com/arb/celebrate/compare/v12.2.0...v13.0.3) Signed-off-by: dependabot[bot] * fix: update how error message is extracted from celebrate errors * feat: add return typing for errorHandlerMiddlewares * feat: remove unused error message interpolation from rebase Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Rui Lau --- package-lock.json | 52 ++++++++++++---------------- package.json | 2 +- src/loaders/express/error-handler.ts | 14 ++++---- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index be6535317d..0e82d4bf49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2888,21 +2888,9 @@ "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==" }, "@hapi/hoek": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz", - "integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==" - }, - "@hapi/joi": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz", - "integrity": "sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==", - "requires": { - "@hapi/address": "^4.0.1", - "@hapi/formula": "^2.0.0", - "@hapi/hoek": "^9.0.0", - "@hapi/pinpoint": "^2.0.0", - "@hapi/topo": "^5.0.0" - } + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz", + "integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==" }, "@hapi/pinpoint": { "version": "2.0.0", @@ -4486,11 +4474,6 @@ "@types/node": "*" } }, - "@types/hapi__joi": { - "version": "17.1.4", - "resolved": "https://registry.npmjs.org/@types/hapi__joi/-/hapi__joi-17.1.4.tgz", - "integrity": "sha512-gqY3TeTyZvnyNhM02HgyCIoGIWsTFMnuzMfnD8evTsr1KIfueGJaz+QC77j+dFvhZ5cJArUNjDRHUjPxNohzGA==" - }, "@types/has-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/has-ansi/-/has-ansi-3.0.0.tgz", @@ -7911,13 +7894,12 @@ "dev": true }, "celebrate": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/celebrate/-/celebrate-12.2.0.tgz", - "integrity": "sha512-dkcQaUL4zrPOua/NwTM74jf/NY3wv9Fyb1mkC2ru75KRHowSIDe/tJtIG9yRyPyFCfkr1odif8zNQq23eTwEYg==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/celebrate/-/celebrate-13.0.3.tgz", + "integrity": "sha512-RJc48EMR0Z3Anp8nXK241NvpXExQaqwf49aQYkzsR+z+X7nBSTORBPZWLBARjiJP1jnfxLJ4XnXDknHDMt8rRA==", "requires": { - "@hapi/joi": "17.x.x", - "@types/hapi__joi": "17.x.x", "escape-html": "1.0.3", + "joi": "17.x.x", "lodash": "4.17.x" } }, @@ -16732,6 +16714,18 @@ "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" }, + "joi": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.2.1.tgz", + "integrity": "sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA==", + "requires": { + "@hapi/address": "^4.1.0", + "@hapi/formula": "^2.0.0", + "@hapi/hoek": "^9.0.0", + "@hapi/pinpoint": "^2.0.0", + "@hapi/topo": "^5.0.0" + } + }, "jose": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/jose/-/jose-0.3.2.tgz", @@ -17134,12 +17128,12 @@ } }, "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", "dev": true, "requires": { - "ms": "^2.1.1" + "ms": "2.1.2" } }, "enquirer": { diff --git a/package.json b/package.json index 5b68697354..764007821c 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "boxicons": "1.8.0", "bson-ext": "^2.0.3", "busboy": "^0.3.1", - "celebrate": "^12.2.0", + "celebrate": "^13.0.3", "compression": "~1.7.2", "connect": "^3.6.6", "connect-mongo": "^3.2.0", diff --git a/src/loaders/express/error-handler.ts b/src/loaders/express/error-handler.ts index ef3fd38a9f..27f532203b 100644 --- a/src/loaders/express/error-handler.ts +++ b/src/loaders/express/error-handler.ts @@ -1,4 +1,4 @@ -import { isCelebrate } from 'celebrate' +import { isCelebrateError, Segments } from 'celebrate' import { ErrorRequestHandler, RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' import get from 'lodash/get' @@ -7,7 +7,10 @@ import { createLoggerWithLabel } from '../../config/logger' const logger = createLoggerWithLabel(module) -const errorHandlerMiddlewares = () => { +const errorHandlerMiddlewares = (): ( + | ErrorRequestHandler + | RequestHandler +)[] => { // 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. const genericErrorHandlerMiddleware: ErrorRequestHandler = function ( err, @@ -27,14 +30,13 @@ const errorHandlerMiddlewares = () => { const genericErrorMessage = 'Apologies, something odd happened. Please try again later!' // Error page - if (isCelebrate(err)) { - // formId is only present for Joi validated routes that require it - const formId = get(req, 'form._id', null) + if (isCelebrateError(err)) { logger.error({ message: 'Joi validation error', meta: { action: 'genericErrorHandlerMiddleware', - formId, + // formId is only present for Joi validated routes that require it + formId: get(req, 'form._id', null), }, error: err, }) From 2b515df0e3fd79620322b2d4e4c50d51a64fa9e8 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Mon, 21 Sep 2020 09:18:41 +0300 Subject: [PATCH 17/29] fix(deps): uninstall node-jose (#339) * fix: package.json & package-lock.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-NODEFORGE-598677 * fix: uninstall node-jose Co-authored-by: Antariksh Mahajan --- package-lock.json | 96 +++++++++++++++++++++++++++++++---------------- package.json | 1 - 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e82d4bf49..74ef07b6a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4035,6 +4035,38 @@ "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==", "dev": true }, + "node-jose": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.1.4.tgz", + "integrity": "sha512-L31IFwL3pWWcMHxxidCY51ezqrDXMkvlT/5pLTfNw5sXmmOLJuN6ug7txzF/iuZN55cRpyOmoJrotwBQIoo5Lw==", + "dev": true, + "requires": { + "base64url": "^3.0.1", + "browserify-zlib": "^0.2.0", + "buffer": "^5.5.0", + "es6-promise": "^4.2.8", + "lodash": "^4.17.15", + "long": "^4.0.0", + "node-forge": "^0.8.5", + "process": "^0.11.10", + "react-zlib-js": "^1.0.4", + "uuid": "^3.3.3" + }, + "dependencies": { + "node-forge": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", + "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==", + "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 + } + } + }, "xml-encryption": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.0.tgz", @@ -4066,6 +4098,35 @@ "node-jose": "^1.1.0", "path": "^0.12.7", "request": "^2.88.0" + }, + "dependencies": { + "node-forge": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", + "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" + }, + "node-jose": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.1.4.tgz", + "integrity": "sha512-L31IFwL3pWWcMHxxidCY51ezqrDXMkvlT/5pLTfNw5sXmmOLJuN6ug7txzF/iuZN55cRpyOmoJrotwBQIoo5Lw==", + "requires": { + "base64url": "^3.0.1", + "browserify-zlib": "^0.2.0", + "buffer": "^5.5.0", + "es6-promise": "^4.2.8", + "lodash": "^4.17.15", + "long": "^4.0.0", + "node-forge": "^0.8.5", + "process": "^0.11.10", + "react-zlib-js": "^1.0.4", + "uuid": "^3.3.3" + } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "@opengovsg/ng-file-upload": { @@ -19244,41 +19305,12 @@ "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", "dev": true }, - "node-forge": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", - "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" - }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", "dev": true }, - "node-jose": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.1.4.tgz", - "integrity": "sha512-L31IFwL3pWWcMHxxidCY51ezqrDXMkvlT/5pLTfNw5sXmmOLJuN6ug7txzF/iuZN55cRpyOmoJrotwBQIoo5Lw==", - "requires": { - "base64url": "^3.0.1", - "browserify-zlib": "^0.2.0", - "buffer": "^5.5.0", - "es6-promise": "^4.2.8", - "lodash": "^4.17.15", - "long": "^4.0.0", - "node-forge": "^0.8.5", - "process": "^0.11.10", - "react-zlib-js": "^1.0.4", - "uuid": "^3.3.3" - }, - "dependencies": { - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - } - } - }, "node-libs-browser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", @@ -21378,9 +21410,9 @@ "dev": true }, "react-zlib-js": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/react-zlib-js/-/react-zlib-js-1.0.4.tgz", - "integrity": "sha512-ynXD9DFxpE7vtGoa3ZwBtPmZrkZYw2plzHGbanUjBOSN4RtuXdektSfABykHtTiWEHMh7WdYj45LHtp228ZF1A==" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/react-zlib-js/-/react-zlib-js-1.0.5.tgz", + "integrity": "sha512-TLcPdmqhIl+ylwOwlfm1WUuI7NVvhAv3L74d1AabhjyaAbmLOROTA/Q4EQ/UMCFCOjIkVim9fT3UZOQSFk/mlA==" }, "read-file-relative": { "version": "1.2.0", diff --git a/package.json b/package.json index 764007821c..c67d5b8bca 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,6 @@ "ngclipboard": "^2.0.0", "nocache": "^2.1.0", "node-cache": "^5.1.2", - "node-jose": "^1.0.0", "nodemailer": "^6.4.11", "nodemailer-direct-transport": "~3.3.2", "opossum": "^5.0.1", From 40be73cb8ec29dcb168bab2509e3ea7a2388242b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 15:02:45 +0800 Subject: [PATCH 18/29] chore(deps-dev): bump eslint-plugin-jest from 24.0.0 to 24.0.2 (#341) Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 24.0.0 to 24.0.2. - [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases) - [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/master/CHANGELOG.md) - [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v24.0.0...v24.0.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74ef07b6a1..d448c4077d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11166,9 +11166,9 @@ } }, "eslint-plugin-jest": { - "version": "24.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.0.0.tgz", - "integrity": "sha512-a0G7hSDbuBCW4PNT6MVpAyfnGbUDOqxzOyhR6wT2BIBnR7MhvfAqd6KKfsTjX+Z3gxzIHiEsihzdClU4cSc6qQ==", + "version": "24.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.0.2.tgz", + "integrity": "sha512-DSBLNpkKDOpUJQkTGSs5sVJWsu0nDyQ2rYxkr0Eh7nrkc5bMUr/dlDbtTj3l8y6UaCVsem6rryF1OZrKnz1S5g==", "dev": true, "requires": { "@typescript-eslint/experimental-utils": "^4.0.1" diff --git a/package.json b/package.json index c67d5b8bca..777e89da52 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "eslint-plugin-angular": "^4.0.1", "eslint-plugin-html": "^6.0.2", "eslint-plugin-import": "^2.22.0", - "eslint-plugin-jest": "^24.0.0", + "eslint-plugin-jest": "^24.0.2", "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-simple-import-sort": "^5.0.3", "google-fonts-plugin": "4.1.0", From bcd2c616a7ec8df8c706fc0b69a0088bcab75512 Mon Sep 17 00:00:00 2001 From: Frank Chen Date: Mon, 21 Sep 2020 00:42:09 -0700 Subject: [PATCH 19/29] feat: Filter Storage Mode Responses by Submission Id (#174) * feat: Filter Storage Mode Responses by Submission Id (updated 2020-09-13) * Update page design so that it is compatible with tablets and everything is on one row * Update CSS for iPad --- .../encrypt-submissions.server.controller.js | 44 +++++++++++++-- .../view-responses.client.controller.js | 29 +++++++++- .../modules/forms/admin/css/export-button.css | 1 - .../forms/admin/css/view-responses.css | 22 +++++++- .../views/view-responses.client.view.html | 56 +++++++++++++++++-- .../services/submissions.client.factory.js | 6 +- 6 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index d364e21bfe..413867c43d 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -25,6 +25,8 @@ const { getProcessedResponses, } = require('../modules/submission/submission.service') +const HttpStatus = require('http-status-codes') + /** * Extracts relevant fields, injects questions, verifies visibility of field and validates answers * to produce req.body.parsedResponses @@ -223,15 +225,47 @@ exports.saveResponseToDb = function (req, res, next) { */ exports.getMetadata = function (req, res) { let pageSize = 10 - let { page } = req.query || {} + let { page, submissionId } = req.query || {} let numToSkip = parseInt(page - 1 || 0) * pageSize + let matchClause = { + form: req.form._id, + submissionType: 'encryptSubmission', + } + + if (submissionId) { + if (mongoose.Types.ObjectId.isValid(submissionId)) { + matchClause._id = mongoose.Types.ObjectId(submissionId) + // Reading from primary to avoid any contention issues with bulk queries on secondary servers + Submission.findOne(matchClause, { created: 1 }) + .read('primary') + .exec((err, result) => { + if (err) { + logger.error(getRequestIp(req), req.url, req.headers, err) + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + message: errorHandler.getMongoErrorMessage(err), + }) + } + if (!result) { + return res.status(HttpStatus.OK).send({ metadata: [], count: 0 }) + } + let entry = { + number: 1, + refNo: result._id, + submissionTime: moment(result.created) + .tz('Asia/Singapore') + .format('Do MMM YYYY, h:mm:ss a'), + } + return res.status(HttpStatus.OK).send({ metadata: [entry], count: 1 }) + }) + } else { + return res.status(HttpStatus.OK).send({ metadata: [], count: 0 }) + } + } + Submission.aggregate([ { - $match: { - form: req.form._id, - submissionType: 'encryptSubmission', - }, + $match: matchClause, }, { $sort: { created: -1 }, 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 eceab09d5d..ab0d81f37c 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 @@ -40,7 +40,12 @@ 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() + vm.filterBySubmissionRefId = '' // whether to filter submissions by a specific ID + vm.filterBySubmissionRefIdTextbox = '' + vm.filterBySubmissionRefIdMatcher = /^[0-9A-Fa-f]{24}$/ + vm.filterBySubmissionShowFilterBox = false // Three views: // 1 - Unlock view for verifying form password @@ -318,12 +323,29 @@ function ViewResponsesController( } }) + vm.filterBySubmissionChanged = function () { + // We only reload the table if the text box has changed. This prevents excessive + // requests being sent by users clicking on the "Filter" button repeatedly. + if ( + vm.filterBySubmissionRefIdTextbox !== '' && + vm.filterBySubmissionRefId !== vm.filterBySubmissionRefIdTextbox + ) { + vm.filterBySubmissionRefId = vm.filterBySubmissionRefIdTextbox + vm.tableParams.reload() + } + } + + vm.filterBySubmissionReset = function () { + vm.filterBySubmissionShowFilterBox = false + vm.filterBySubmissionRefId = '' + vm.filterBySubmissionRefIdTextbox = '' + vm.tableParams.reload() + } + // Called by child directive unlockResponsesForm after key is verified to get responses - vm.loadResponses = function (formPassword) { - vm.formPassword = formPassword + vm.loadResponses = function () { vm.currentView = 2 vm.loading = true - vm.tableParams = new NgTableParams( { page: 1, // show first page @@ -334,6 +356,7 @@ function ViewResponsesController( let { page } = params.url() return Submissions.getMetadata({ formId: vm.myform._id, + filterBySubmissionRefId: vm.filterBySubmissionRefId, page, }) .then((data) => { diff --git a/src/public/modules/forms/admin/css/export-button.css b/src/public/modules/forms/admin/css/export-button.css index 602f23432c..942afd499f 100644 --- a/src/public/modules/forms/admin/css/export-button.css +++ b/src/public/modules/forms/admin/css/export-button.css @@ -7,7 +7,6 @@ display: flex; justify-content: center; align-items: center; - min-width: 164px; margin-left: auto; } diff --git a/src/public/modules/forms/admin/css/view-responses.css b/src/public/modules/forms/admin/css/view-responses.css index 662eebac1a..20836838fe 100644 --- a/src/public/modules/forms/admin/css/view-responses.css +++ b/src/public/modules/forms/admin/css/view-responses.css @@ -101,7 +101,8 @@ } #responses-tab .response-stats { - width: 100%; + white-space: nowrap; + min-width: 240px; } #responses-tab .datepicker-export-container { @@ -715,3 +716,22 @@ padding-left: 16px !important; border: solid 1px #ccc; } + +#filter-by-submission-reset-link { + padding-top: 13px; + padding-left: 13px; + white-space: nowrap; + cursor: pointer; +} + +#filter-by-submission-filter-link { + padding-top: 13px; + padding-left: 5px; + white-space: nowrap; + cursor: pointer; +} + +#filter-by-submission-filter-textbox-container { + display: flex; + min-width: 240px; +} 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 5e9671e07c..f6f3081780 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 @@ -31,7 +31,9 @@

-
+
-
+
-
+
{{vm.responsesCount}} response(s) to date + + + + +
+
+ + + View All
-
+
+
+
+
-
+
+
+
+ +
No results found
+
+ Did you enter the right reference number? We can't seem to find + the response. +
+
+
Date: Mon, 21 Sep 2020 16:44:46 +0800 Subject: [PATCH 20/29] fix(deps): bump csv-parse from 4.10.1 to 4.12.0 (#340) * fix(deps): bump csv-parse from 4.10.1 to 4.12.0 Bumps [csv-parse](https://github.com/wdavidw/node-csv-parse) from 4.10.1 to 4.12.0. - [Release notes](https://github.com/wdavidw/node-csv-parse/releases) - [Changelog](https://github.com/adaltas/node-csv-parse/blob/master/CHANGELOG.md) - [Commits](https://github.com/wdavidw/node-csv-parse/compare/v4.10.1...v4.12.0) Signed-off-by: dependabot[bot] * fix(deps-dev): move csv-parse to dev deps Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Antariksh Mahajan --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d448c4077d..bfc531521e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9538,9 +9538,9 @@ } }, "csv-parse": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.10.1.tgz", - "integrity": "sha512-gdDJVchi0oSLIcYXz1H/VSgLE6htHDqJyFsRU/vTkQgmVOZ3S0IR2LXnNbWUYG7VD76dYVwdfBLyx8AX9+An8A==" + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.12.0.tgz", + "integrity": "sha512-wPQl3H79vWLPI8cgKFcQXl0NBgYYEqVnT1i6/So7OjMpsI540oD7p93r3w6fDSyPvwkTepG05F69/7AViX2lXg==" }, "csv-string": { "version": "3.2.0", diff --git a/package.json b/package.json index 777e89da52..dd3ef7cbca 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ "cookie-parser": "~1.4.0", "crypto-js": "^4.0.0", "css-toggle-switch": "^4.1.0", - "csv-parse": "^4.10.1", "csv-string": "^3.1.5", "dedent-js": "^1.0.1", "deep-diff": "^1.0.1", @@ -203,6 +202,7 @@ "core-js": "^3.6.4", "coveralls": "^3.1.0", "css-loader": "^2.1.1", + "csv-parse": "^4.12.0", "env-cmd": "^10.1.0", "eslint": "^7.9.0", "eslint-config-prettier": "^6.11.0", From ce4a82d612703c4585b073961b8aa65021f2cdf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2020 19:28:57 +0800 Subject: [PATCH 21/29] fix(deps): bump csv-string from 3.2.0 to 4.0.1 (#344) * fix(deps): bump csv-string from 3.2.0 to 4.0.1 Bumps [csv-string](https://github.com/Inist-CNRS/node-csv-string) from 3.2.0 to 4.0.1. - [Release notes](https://github.com/Inist-CNRS/node-csv-string/releases) - [Commits](https://github.com/Inist-CNRS/node-csv-string/compare/v3.2.0...v4.0.1) Signed-off-by: dependabot[bot] * fix: use correctly imported stringify function Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kar Rui Lau --- package-lock.json | 6 ++-- package.json | 2 +- .../modules/forms/helpers/CsvGenerator.js | 8 ++--- .../helpers/CsvMergedHeadersGenerator.test.js | 32 +++++++++---------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfc531521e..673d48081f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9543,9 +9543,9 @@ "integrity": "sha512-wPQl3H79vWLPI8cgKFcQXl0NBgYYEqVnT1i6/So7OjMpsI540oD7p93r3w6fDSyPvwkTepG05F69/7AViX2lXg==" }, "csv-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-3.2.0.tgz", - "integrity": "sha512-JN3iAuFJ+r7+CwF6UtP3U8ryorRkQp8NT+9VufeiRV+Xyv+Q8HPPBHGm4LAq7YihTQYmUnIeYy5CPQ8Y2GhMkg==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-4.0.1.tgz", + "integrity": "sha512-nCdK+EWDbqLvZ2MmVQhHTmidMEsHbK3ncgTJb4oguNRpkmH5OOr+KkDRB4nqsVrJ7oK0AdO1QEsBp0+z7KBtGQ==" }, "cuint": { "version": "0.2.2", diff --git a/package.json b/package.json index dd3ef7cbca..84f27eaa98 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "cookie-parser": "~1.4.0", "crypto-js": "^4.0.0", "css-toggle-switch": "^4.1.0", - "csv-string": "^3.1.5", + "csv-string": "^4.0.1", "dedent-js": "^1.0.1", "deep-diff": "^1.0.1", "ejs": "^3.1.5", diff --git a/src/public/modules/forms/helpers/CsvGenerator.js b/src/public/modules/forms/helpers/CsvGenerator.js index 62ef3f8ad6..568604e252 100644 --- a/src/public/modules/forms/helpers/CsvGenerator.js +++ b/src/public/modules/forms/helpers/CsvGenerator.js @@ -1,4 +1,4 @@ -const CSV = require('csv-string') +const { stringify } = require('csv-string') const { triggerFileDownload } = require('./util') // Used to denote to Excel that the CSV is UTF8-encoded. See @@ -30,7 +30,7 @@ module.exports = class CsvGenerator { * @param {Array} rowData array of data to be inserted */ addLine(rowData) { - this.records[this.idx] = CSV.stringify(rowData) + this.records[this.idx] = stringify(rowData) this.idx++ } @@ -39,7 +39,7 @@ module.exports = class CsvGenerator { * @param {Array} headerLabels array of labels for header row */ setHeader(headerLabels) { - this.records[this.startIdx - 1] = CSV.stringify(headerLabels) + this.records[this.startIdx - 1] = stringify(headerLabels) } /** @@ -47,7 +47,7 @@ module.exports = class CsvGenerator { * @param {Array} metaDataRows array of arrays, metaDataRows[i][j] holds the data for row i, col j of the metaData table */ addMetaData(metaDataRows) { - const metaData = metaDataRows.map((data) => CSV.stringify(data)) + const metaData = metaDataRows.map((data) => stringify(data)) // Start splicing at index 1 because BOM is at index 0. this.records.splice(1, this.numOfMetaDataRows, ...metaData) } diff --git a/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js b/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js index f31b5d5346..ab72ebb532 100644 --- a/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js +++ b/tests/unit/frontend/forms/helpers/CsvMergedHeadersGenerator.test.js @@ -1,4 +1,4 @@ -import CSV from 'csv-string' +import { stringify } from 'csv-string' import moment from 'moment-timezone' import CsvMergedHeadersGenerator from '../../../../../src/public/modules/forms/helpers/CsvMergedHeadersGenerator' @@ -273,7 +273,7 @@ describe('CsvMergedHeadersGenerator', () => { // Assert // Should have 1 header row and 1 submission row expect(generator.records.length).toEqual(2 + BOM_LENGTH) - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockDecryptedRecord[0].question, @@ -281,7 +281,7 @@ describe('CsvMergedHeadersGenerator', () => { mockDecryptedRecord[2].question, mockDecryptedRecord[3].question, ]) - const expectedSubmissionRow = CSV.stringify([ + const expectedSubmissionRow = stringify([ mockRecord.submissionId, moment(mockRecord.created) .tz('Asia/Singapore') @@ -335,7 +335,7 @@ describe('CsvMergedHeadersGenerator', () => { // Assert // Should have 1 header row and 2 submission row expect(generator.records.length).toEqual(3 + BOM_LENGTH) - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockFirstDecryptedRecord[0].question, @@ -343,7 +343,7 @@ describe('CsvMergedHeadersGenerator', () => { mockFirstDecryptedRecord[2].question, mockFirstDecryptedRecord[3].question, ]) - const expectedSubmissionRow1 = CSV.stringify([ + const expectedSubmissionRow1 = stringify([ mockFirstRecord.submissionId, moment(mockFirstRecord.created) .tz('Asia/Singapore') @@ -355,7 +355,7 @@ describe('CsvMergedHeadersGenerator', () => { ]) // Second processed row should be mockReversedRecord's answers in reversed // order since the fieldIds are reversed - const expectedSubmissionRow2 = CSV.stringify([ + const expectedSubmissionRow2 = stringify([ mockReversedRecord.submissionId, moment(mockReversedRecord.created) .tz('Asia/Singapore') @@ -408,7 +408,7 @@ describe('CsvMergedHeadersGenerator', () => { // Assert // Should have 1 header row and 2 submission row expect(generator.records.length).toEqual(3 + BOM_LENGTH) - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockDecryptedRecord[0].question, @@ -421,7 +421,7 @@ describe('CsvMergedHeadersGenerator', () => { ]) // First row should be the first submission - const expectedSubmissionRow1 = CSV.stringify([ + const expectedSubmissionRow1 = stringify([ mockRecord.submissionId, moment(mockRecord.created) .tz('Asia/Singapore') @@ -436,7 +436,7 @@ describe('CsvMergedHeadersGenerator', () => { // Second processed row should be second submission, with fields it does // not have blank. // Should only have questionId of `intersectFieldId` and `new` filled. - const expectedSubmissionRow2 = CSV.stringify([ + const expectedSubmissionRow2 = stringify([ mockNewRecord.submissionId, moment(mockNewRecord.created) .tz('Asia/Singapore') @@ -476,13 +476,13 @@ describe('CsvMergedHeadersGenerator', () => { // Assert // Should have 1 header row and 1 submission row expect(generator.records.length).toEqual(2 + BOM_LENGTH) - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockDecryptedRecord[0].question, ]) - const expectedSubmissionRow = CSV.stringify([ + const expectedSubmissionRow = stringify([ mockRecord.submissionId, moment(mockRecord.created) .tz('Asia/Singapore') @@ -524,14 +524,14 @@ describe('CsvMergedHeadersGenerator', () => { // Should have 1 header row and 1 submission row expect(generator.records.length).toEqual(2 + BOM_LENGTH) // Question is repeated for two columns - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockDecryptedRecord[0].question, mockDecryptedRecord[0].question, ]) - const expectedSubmissionRow = CSV.stringify([ + const expectedSubmissionRow = stringify([ mockRecord.submissionId, moment(mockRecord.created) .tz('Asia/Singapore') @@ -571,14 +571,14 @@ describe('CsvMergedHeadersGenerator', () => { // Assert // Should have 1 header row and 2 submission rows expect(generator.records.length).toEqual(3 + BOM_LENGTH) - const expectedHeaderRow = CSV.stringify([ + const expectedHeaderRow = stringify([ 'Reference number', 'Timestamp', mockAnswerArray[0].question, ]) // First row is answer array - const expectedSubmissionRow1 = CSV.stringify([ + const expectedSubmissionRow1 = stringify([ mockAnswerArrayRecord.submissionId, moment(mockAnswerArrayRecord.created) .tz('Asia/Singapore') @@ -587,7 +587,7 @@ describe('CsvMergedHeadersGenerator', () => { mockAnswerArray[0].answerArray.join(';'), ]) // Second row is answer, but same field Id, so same number of headers - const expectedSubmissionRow2 = CSV.stringify([ + const expectedSubmissionRow2 = stringify([ mockAnswerRecord.submissionId, moment(mockAnswerRecord.created) .tz('Asia/Singapore') From c64927e1317d2c6c8a69c523871408baff588d5e Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Tue, 22 Sep 2020 09:37:05 +0800 Subject: [PATCH 22/29] feat: Using `neverthrow` to explicitly handle errors in AuthController (#332) * chore: add neverthrow package for explicit error handling * feat: add core DatabaseError * refactor(AuthService): rename and convert getAgencyWithEmail to Result * test(Auth): update tests to use new validateEmailDomain function * refactor(Auth): inline validate middleware into /checkuser endpoint * refactor(Auth): inline validate middleware into /sendotp endpoint * refactor(Auth): inline validate middleware into /verifyotp endpoint * refactor(Auth): remove unused auth.middlewares * refactor(Auth): remove unused imports/types after middleware removal * refactor(AuthService): use validateEmailDomain with ResultAsync return * refactor(AuthService): update createLoginOtp with ResultAsync return * refactor(AuthService): update verifyLoginOtp with ResultAsync return * fix: fix typo in logger action and remove unused imports * refactor(MailUtils): update generateLoginOtpHtml to return ResultAsync * refactor: update sendLoginOtp to return ResultAsync * refactor: update handleLoginSendOtp to chain ResultAsync calls * refactor(AuthController): update handleLoginVerifyOtp to chain * fix: remove unused imports and remove null assertion * feat: update comments and tests names to return error instead of throw * chore: update travis.yml It will now install a pinned version of localstack and use the chrome addon instead of downloading from apt * feat(AuthController): add explicit undefined check for req.session * refactor(UserService): update upsertUser function to ResultAsync * refactor(AuthController): use updated UserService#retrieveUser fn * feat(AuthController): add logging for unknown errors observed * refactor(Auth): extract mapRouteError into auth.utils file --- package-lock.json | 5 + package.json | 1 + src/app/models/user.server.model.ts | 5 +- .../auth/__tests__/auth.controller.spec.ts | 162 ++++++---- .../auth/__tests__/auth.middlewares.spec.ts | 71 ----- .../auth/__tests__/auth.routes.spec.ts | 48 +-- .../auth/__tests__/auth.service.spec.ts | 73 +++-- src/app/modules/auth/auth.controller.ts | 268 +++++++++-------- src/app/modules/auth/auth.middlewares.ts | 58 ---- src/app/modules/auth/auth.routes.ts | 4 - src/app/modules/auth/auth.service.ts | 279 +++++++++++++++--- src/app/modules/auth/auth.types.ts | 12 +- src/app/modules/auth/auth.utils.ts | 58 ++++ src/app/modules/core/core.errors.ts | 8 +- src/app/modules/core/core.types.ts | 3 - src/app/modules/mail/mail.errors.ts | 9 + src/app/modules/user/user.service.ts | 47 ++- src/app/services/mail.service.ts | 55 ++-- src/app/utils/mail.ts | 22 +- src/config/logger.ts | 2 +- src/types/token.ts | 4 +- src/types/user.ts | 9 +- tests/unit/backend/helpers/jest-express.ts | 2 +- .../backend/modules/user/user.service.spec.ts | 86 +++++- .../backend/services/mail.service.spec.ts | 46 +-- 25 files changed, 845 insertions(+), 492 deletions(-) delete mode 100644 src/app/modules/auth/__tests__/auth.middlewares.spec.ts delete mode 100644 src/app/modules/auth/auth.middlewares.ts create mode 100644 src/app/modules/auth/auth.utils.ts delete mode 100644 src/app/modules/core/core.types.ts create mode 100644 src/app/modules/mail/mail.errors.ts diff --git a/package-lock.json b/package-lock.json index 673d48081f..692b8d1640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19187,6 +19187,11 @@ "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=" }, + "neverthrow": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-2.7.1.tgz", + "integrity": "sha512-tk7vBuxlBcVU6GIXrAOpez2ZpvJxVW1xl2OjVufdVd87g61SjvekY0VBOuTh1EdUf6/gbgQcQujpxXlap0bQ0A==" + }, "ng-infinite-scroll": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ng-infinite-scroll/-/ng-infinite-scroll-1.3.0.tgz", diff --git a/package.json b/package.json index 84f27eaa98..25ada01da2 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "mongodb-uri": "^0.9.7", "mongoose": "^5.10.0", "multiparty": ">=4.1.3", + "neverthrow": "^2.7.1", "ng-infinite-scroll": "^1.3.0", "ng-table": "^3.0.1", "ngclipboard": "^2.0.0", diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index 32aaad4d37..7b98277af4 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -98,7 +98,10 @@ const compileUserModel = (db: Mongoose) => { runValidators: true, setDefaultsOnInsert: true, }, - ) + ).populate({ + path: 'agency', + model: AGENCY_SCHEMA_ID, + }) } return db.model(USER_SCHEMA_ID, UserSchema) diff --git a/src/app/modules/auth/__tests__/auth.controller.spec.ts b/src/app/modules/auth/__tests__/auth.controller.spec.ts index 21ba412ef6..cde65ca3fc 100644 --- a/src/app/modules/auth/__tests__/auth.controller.spec.ts +++ b/src/app/modules/auth/__tests__/auth.controller.spec.ts @@ -1,12 +1,15 @@ +import { errAsync, okAsync } from 'neverthrow' import expressHandler from 'tests/unit/backend/helpers/jest-express' import { mocked } from 'ts-jest/utils' import MailService from 'src/app/services/mail.service' import { IAgencySchema, IUserSchema } from 'src/types' +import { ApplicationError, DatabaseError } from '../../core/core.errors' +import { MailSendError } from '../../mail/mail.errors' import * as UserService from '../../user/user.service' import * as AuthController from '../auth.controller' -import { InvalidOtpError } from '../auth.errors' +import { InvalidDomainError, InvalidOtpError } from '../auth.errors' import * as AuthService from '../auth.service' const VALID_EMAIL = 'test@example.com' @@ -25,20 +28,39 @@ describe('auth.controller', () => { }) describe('handleCheckUser', () => { - it('should return 200', async () => { + const MOCK_REQ = expressHandler.mockRequest({ + body: { email: 'test@example.com' }, + }) + + it('should return 200 when domain is valid', async () => { // Arrange const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync({}), + ) // Act - await AuthController.handleCheckUser( - expressHandler.mockRequest(), - mockRes, - jest.fn(), - ) + await AuthController.handleCheckUser(MOCK_REQ, mockRes, jest.fn()) // Assert expect(mockRes.sendStatus).toBeCalledWith(200) }) + + it('should return with ApplicationError status and message when retrieving agency returns an ApplicationError', async () => { + // Arrange + const expectedError = new InvalidDomainError() + const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + errAsync(expectedError), + ) + + // Act + await AuthController.handleCheckUser(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(expectedError.status) + expect(mockRes.send).toBeCalledWith(expectedError.message) + }) }) describe('handleLoginSendOtp', () => { @@ -51,8 +73,11 @@ describe('auth.controller', () => { // Arrange const mockRes = expressHandler.mockResponse() // Mock AuthService and MailService to return without errors - MockAuthService.createLoginOtp.mockResolvedValueOnce(MOCK_OTP) - MockMailService.sendLoginOtp.mockResolvedValueOnce(true) + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync({}), + ) + MockAuthService.createLoginOtp.mockReturnValueOnce(okAsync(MOCK_OTP)) + MockMailService.sendLoginOtp.mockReturnValueOnce(okAsync(true)) // Act await AuthController.handleLoginSendOtp(MOCK_REQ, mockRes, jest.fn()) @@ -65,12 +90,31 @@ describe('auth.controller', () => { expect(MockMailService.sendLoginOtp).toHaveBeenCalledTimes(1) }) + it('should return with ApplicationError status and message when retrieving agency returns an ApplicationError', async () => { + // Arrange + const expectedError = new InvalidDomainError() + const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + errAsync(expectedError), + ) + + // Act + await AuthController.handleLoginSendOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(expectedError.status) + expect(mockRes.send).toBeCalledWith(expectedError.message) + }) + it('should return 500 when there is an error generating login OTP', async () => { // Arrange const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync({}), + ) // Mock createLoginOtp failure - MockAuthService.createLoginOtp.mockRejectedValueOnce( - new Error('otp creation error'), + MockAuthService.createLoginOtp.mockReturnValueOnce( + errAsync(new DatabaseError('otp creation error')), ) // Act @@ -89,10 +133,13 @@ describe('auth.controller', () => { it('should return 500 when there is an error sending login OTP', async () => { // Arrange const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync({}), + ) // Mock createLoginOtp success but sendLoginOtp failure. - MockAuthService.createLoginOtp.mockResolvedValueOnce(MOCK_OTP) - MockMailService.sendLoginOtp.mockRejectedValueOnce( - new Error('send error'), + MockAuthService.createLoginOtp.mockReturnValueOnce(okAsync(MOCK_OTP)) + MockMailService.sendLoginOtp.mockReturnValueOnce( + errAsync(new MailSendError('send error')), ) // Act @@ -101,7 +148,7 @@ describe('auth.controller', () => { // Assert expect(mockRes.status).toBeCalledWith(500) expect(mockRes.send).toBeCalledWith( - 'Error sending OTP. Please try again later and if the problem persists, contact us.', + 'Failed to send login OTP. Please try again later and if the problem persists, contact us.', ) // Services should have been invoked. expect(MockAuthService.createLoginOtp).toHaveBeenCalledTimes(1) @@ -122,36 +169,49 @@ describe('auth.controller', () => { const mockUser = { toObject: () => ({ id: 'imagine this is a user document from the db' }), } as IUserSchema - // Add agency into locals due to precondition. - const mockRes = expressHandler.mockResponse({ - locals: { agency: MOCK_AGENCY }, - }) + const mockRes = expressHandler.mockResponse() // Mock all service success. - MockAuthService.verifyLoginOtp.mockResolvedValueOnce(true) - MockUserService.retrieveUser.mockResolvedValueOnce(mockUser) + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync(MOCK_AGENCY), + ) + MockAuthService.verifyLoginOtp.mockReturnValueOnce(okAsync(true)) + MockUserService.retrieveUser.mockReturnValueOnce(okAsync(mockUser)) // Act await AuthController.handleLoginVerifyOtp(MOCK_REQ, mockRes, jest.fn()) // Assert expect(mockRes.status).toBeCalledWith(200) - expect(mockRes.send).toBeCalledWith({ - ...mockUser.toObject(), - agency: MOCK_AGENCY, - }) + expect(mockRes.send).toBeCalledWith(mockUser.toObject()) }) - it('should return 422 when verifying login OTP throws an InvalidOtpError', async () => { + it('should return with ApplicationError status and message when retrieving agency returns an ApplicationError', async () => { // Arrange - // Add agency into locals due to precondition. - const mockRes = expressHandler.mockResponse({ - locals: { agency: MOCK_AGENCY }, - }) + const expectedError = new InvalidDomainError() + const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + errAsync(expectedError), + ) + + // Act + await AuthController.handleLoginVerifyOtp(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toBeCalledWith(expectedError.status) + expect(mockRes.send).toBeCalledWith(expectedError.message) + }) + + it('should return 422 when verifying login OTP returns an InvalidOtpError', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() const expectedInvalidOtpError = new InvalidOtpError() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync(MOCK_AGENCY), + ) // Mock error from verifyLoginOtp. - MockAuthService.verifyLoginOtp.mockRejectedValueOnce( - expectedInvalidOtpError, + MockAuthService.verifyLoginOtp.mockReturnValueOnce( + errAsync(expectedInvalidOtpError), ) // Act @@ -165,15 +225,15 @@ describe('auth.controller', () => { expect(MockUserService.retrieveUser).not.toHaveBeenCalled() }) - it('should return 500 when verifying login OTP throws a non-InvalidOtpError', async () => { + it('should return 500 when verifying login OTP returns a non-InvalidOtpError', async () => { // Arrange - // Add agency into locals due to precondition. - const mockRes = expressHandler.mockResponse({ - locals: { agency: MOCK_AGENCY }, - }) + const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync(MOCK_AGENCY), + ) // Mock generic error from verifyLoginOtp. - MockAuthService.verifyLoginOtp.mockRejectedValueOnce( - new Error('generic error'), + MockAuthService.verifyLoginOtp.mockReturnValueOnce( + errAsync(new ApplicationError('generic error')), ) // Act @@ -182,22 +242,22 @@ describe('auth.controller', () => { // Assert expect(mockRes.status).toBeCalledWith(500) expect(mockRes.send).toBeCalledWith( - 'Failed to validate OTP. Please try again later and if the problem persists, contact us.', + expect.stringContaining('Failed to process OTP.'), ) // Check that the correct services have been called or not called. expect(MockAuthService.verifyLoginOtp).toHaveBeenCalledTimes(1) expect(MockUserService.retrieveUser).not.toHaveBeenCalled() }) - it('should return 500 when an error is thrown while upserting user', async () => { + it('should return 500 when an error is returned while upserting user', async () => { // Arrange - // Add agency into locals due to precondition. - const mockRes = expressHandler.mockResponse({ - locals: { agency: MOCK_AGENCY }, - }) - MockAuthService.verifyLoginOtp.mockResolvedValueOnce(true) - MockUserService.retrieveUser.mockRejectedValueOnce( - new Error('upsert error'), + const mockRes = expressHandler.mockResponse() + MockAuthService.validateEmailDomain.mockReturnValueOnce( + okAsync(MOCK_AGENCY), + ) + MockAuthService.verifyLoginOtp.mockReturnValueOnce(okAsync(true)) + MockUserService.retrieveUser.mockReturnValueOnce( + errAsync(new DatabaseError()), ) // Act @@ -207,9 +267,7 @@ describe('auth.controller', () => { expect(mockRes.status).toBeCalledWith(500) expect(mockRes.send).toBeCalledWith( // Use stringContaining here due to dynamic text and out of test scope. - expect.stringContaining( - 'User signin failed. Please try again later and if the problem persists', - ), + expect.stringContaining('Failed to process OTP.'), ) // Check that the correct services have been called or not called. expect(MockAuthService.verifyLoginOtp).toHaveBeenCalledTimes(1) @@ -257,7 +315,7 @@ describe('auth.controller', () => { expect(mockRes.sendStatus).toBeCalledWith(400) }) - it('should return 500 when error is thrown when destroying session', async () => { + it('should return 500 when error is returned when destroying session', async () => { // Arrange const mockDestroyWithErr = jest .fn() diff --git a/src/app/modules/auth/__tests__/auth.middlewares.spec.ts b/src/app/modules/auth/__tests__/auth.middlewares.spec.ts deleted file mode 100644 index 4c3bb824ba..0000000000 --- a/src/app/modules/auth/__tests__/auth.middlewares.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import expressHandler from 'tests/unit/backend/helpers/jest-express' -import { mocked } from 'ts-jest/utils' - -import { IAgencySchema } from 'src/types' - -import { InvalidDomainError } from '../auth.errors' -import * as AuthMiddleware from '../auth.middlewares' -import * as AuthService from '../auth.service' - -jest.mock('../auth.service') -const MockAuthService = mocked(AuthService) - -describe('auth.middleware', () => { - describe('validateDomain', () => { - const MOCK_REQ = expressHandler.mockRequest({ - body: { email: 'test@example.com' }, - }) - - it('should continue without error when domain is valid', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - const mockNext = jest.fn() - MockAuthService.getAgencyWithEmail.mockResolvedValueOnce( - {} as IAgencySchema, - ) - - // Act - await AuthMiddleware.validateDomain(MOCK_REQ, mockRes, mockNext) - - // Assert - expect(mockNext).toBeCalled() - }) - - it('should return 500 when retrieving agency throws non ApplicationError', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - const mockNext = jest.fn() - MockAuthService.getAgencyWithEmail.mockRejectedValueOnce( - new Error('some error'), - ) - - // Act - await AuthMiddleware.validateDomain(MOCK_REQ, mockRes, mockNext) - - // Assert - expect(mockRes.status).toBeCalledWith(500) - expect(mockRes.send).toBeCalledWith( - expect.stringContaining( - 'Unable to validate email domain. If this issue persists, please submit a Support Form', - ), - ) - expect(mockNext).not.toBeCalled() - }) - - it('should return with ApplicationError status and message when retrieving agency throws ApplicationError', async () => { - // Arrange - const expectedError = new InvalidDomainError() - const mockRes = expressHandler.mockResponse() - const mockNext = jest.fn() - MockAuthService.getAgencyWithEmail.mockRejectedValueOnce(expectedError) - - // Act - await AuthMiddleware.validateDomain(MOCK_REQ, mockRes, mockNext) - - // Assert - expect(mockRes.status).toBeCalledWith(expectedError.status) - expect(mockRes.send).toBeCalledWith(expectedError.message) - expect(mockNext).not.toBeCalled() - }) - }) -}) diff --git a/src/app/modules/auth/__tests__/auth.routes.spec.ts b/src/app/modules/auth/__tests__/auth.routes.spec.ts index fa3e69b9f6..5c01e21040 100644 --- a/src/app/modules/auth/__tests__/auth.routes.spec.ts +++ b/src/app/modules/auth/__tests__/auth.routes.spec.ts @@ -1,4 +1,5 @@ import { pick } from 'lodash' +import { errAsync, okAsync } from 'neverthrow' import supertest from 'supertest' import { CookieStore, setupApp } from 'tests/integration/helpers/express-setup' import dbHandler from 'tests/unit/backend/helpers/jest-db' @@ -8,6 +9,8 @@ import MailService from 'src/app/services/mail.service' import * as OtpUtils from 'src/app/utils/otp' import { IAgencySchema } from 'src/types' +import { ApplicationError, DatabaseError } from '../../core/core.errors' +import { MailSendError } from '../../mail/mail.errors' import * as UserService from '../../user/user.service' import { AuthRouter } from '../auth.routes' import * as AuthService from '../auth.service' @@ -82,16 +85,17 @@ describe('auth.routes', () => { expect(response.text).toEqual('OK') }) - it('should return 500 when validating domain throws an unknown error', async () => { + it('should return 500 when validating domain returns a database error', async () => { // Arrange // Insert agency const validDomain = 'example.com' const validEmail = `test@${validDomain}` + const mockErrorString = 'Unable to validate email domain.' await dbHandler.insertDefaultAgency({ mailDomain: validDomain }) const getAgencySpy = jest - .spyOn(AuthService, 'getAgencyWithEmail') - .mockRejectedValueOnce(new Error('some error occured')) + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString))) // Act const response = await request @@ -101,9 +105,7 @@ describe('auth.routes', () => { // Assert expect(getAgencySpy).toBeCalled() expect(response.status).toEqual(500) - expect(response.text).toEqual( - expect.stringContaining('Unable to validate email domain.'), - ) + expect(response.text).toEqual(mockErrorString) }) }) @@ -160,7 +162,7 @@ describe('auth.routes', () => { // Arrange const createLoginOtpSpy = jest .spyOn(AuthService, 'createLoginOtp') - .mockRejectedValueOnce(new Error('some error')) + .mockReturnValueOnce(errAsync(new ApplicationError())) // Act const response = await request @@ -179,7 +181,7 @@ describe('auth.routes', () => { // Arrange const sendLoginOtpSpy = jest .spyOn(MailService, 'sendLoginOtp') - .mockRejectedValueOnce(new Error('some error')) + .mockReturnValueOnce(errAsync(new MailSendError('some error'))) // Act const response = await request @@ -190,15 +192,15 @@ describe('auth.routes', () => { expect(sendLoginOtpSpy).toHaveBeenCalled() expect(response.status).toEqual(500) expect(response.text).toEqual( - 'Error sending OTP. Please try again later and if the problem persists, contact us.', + 'Failed to send login OTP. Please try again later and if the problem persists, contact us.', ) }) - it('should return 500 when validating domain throws an unknown error', async () => { + it('should return 500 when validating domain returns a database error', async () => { // Arrange const getAgencySpy = jest - .spyOn(AuthService, 'getAgencyWithEmail') - .mockRejectedValueOnce(new Error('some error occured')) + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValueOnce(errAsync(new DatabaseError())) // Act const response = await request @@ -209,7 +211,7 @@ describe('auth.routes', () => { expect(getAgencySpy).toBeCalled() expect(response.status).toEqual(500) expect(response.text).toEqual( - expect.stringContaining('Unable to validate email domain.'), + 'Failed to send login OTP. Please try again later and if the problem persists, contact us.', ) }) @@ -217,7 +219,7 @@ describe('auth.routes', () => { // Arrange const sendLoginOtpSpy = jest .spyOn(MailService, 'sendLoginOtp') - .mockResolvedValueOnce(true) + .mockReturnValueOnce(okAsync(true)) // Act const response = await request @@ -323,11 +325,11 @@ describe('auth.routes', () => { ) }) - it('should return 500 when validating domain throws an unknown error', async () => { + it('should return 500 when validating domain returns a database error', async () => { // Arrange const getAgencySpy = jest - .spyOn(AuthService, 'getAgencyWithEmail') - .mockRejectedValueOnce(new Error('some error occured')) + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValueOnce(errAsync(new DatabaseError())) // Act const response = await request @@ -337,9 +339,7 @@ describe('auth.routes', () => { // Assert expect(getAgencySpy).toBeCalled() expect(response.status).toEqual(500) - expect(response.text).toEqual( - expect.stringContaining('Unable to validate email domain.'), - ) + expect(response.text).toEqual('Something went wrong. Please try again.') }) it('should return 422 when hash does not exist for body.otp', async () => { @@ -443,10 +443,10 @@ describe('auth.routes', () => { // Request for OTP so the hash exists. await requestForOtp(VALID_EMAIL) - // Mock error thrown when creating user + // Mock error returned when creating user const upsertSpy = jest .spyOn(UserService, 'retrieveUser') - .mockRejectedValueOnce(new Error('some error')) + .mockReturnValueOnce(errAsync(new DatabaseError('some error'))) // Act const response = await request @@ -458,7 +458,7 @@ describe('auth.routes', () => { expect(upsertSpy).toBeCalled() expect(response.status).toEqual(500) expect(response.text).toEqual( - expect.stringContaining('User signin failed. Please try again later'), + expect.stringContaining('Failed to process OTP.'), ) }) }) @@ -512,7 +512,7 @@ describe('auth.routes', () => { // Helper functions const requestForOtp = async (email: string) => { // Set that so no real mail is sent. - jest.spyOn(MailService, 'sendLoginOtp').mockResolvedValue(true) + jest.spyOn(MailService, 'sendLoginOtp').mockReturnValue(okAsync(true)) const response = await request.post('/auth/sendotp').send({ email }) expect(response.text).toEqual(`OTP sent to ${email}!`) diff --git a/src/app/modules/auth/__tests__/auth.service.spec.ts b/src/app/modules/auth/__tests__/auth.service.spec.ts index 540e0ed17d..77d9c8c3c4 100644 --- a/src/app/modules/auth/__tests__/auth.service.spec.ts +++ b/src/app/modules/auth/__tests__/auth.service.spec.ts @@ -36,35 +36,40 @@ describe('auth.service', () => { afterAll(async () => await dbHandler.closeDatabase()) - describe('getAgencyWithEmail', () => { + describe('validateEmailDomain', () => { it('should retrieve agency successfully when email is valid and domain is in Agency collection', async () => { // Act - const actual = await AuthService.getAgencyWithEmail(VALID_EMAIL) + const actual = await AuthService.validateEmailDomain(VALID_EMAIL) // Assert - expect(actual.toObject()).toEqual(defaultAgency.toObject()) + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap().toObject()).toEqual( + defaultAgency.toObject(), + ) }) - it('should throw InvalidDomainError when email is invalid', async () => { + it('should return InvalidDomainError error result when email is invalid', async () => { // Arrange const notAnEmail = 'not an email' // Act - const actualPromise = AuthService.getAgencyWithEmail(notAnEmail) + const actual = await AuthService.validateEmailDomain(notAnEmail) // Assert - await expect(actualPromise).rejects.toThrowError(InvalidDomainError) + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(new InvalidDomainError()) }) - it('should throw InvalidDomainError when valid email domain is not in Agency collection', async () => { + it('should return InvalidDomainError error result when valid email domain is not in Agency collection', async () => { // Arrange const invalidEmail = 'invalid@example.com' // Act - const actualPromise = AuthService.getAgencyWithEmail(invalidEmail) + const actual = await AuthService.validateEmailDomain(invalidEmail) // Assert - await expect(actualPromise).rejects.toThrowError(InvalidDomainError) + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(new InvalidDomainError()) }) }) @@ -75,23 +80,25 @@ describe('auth.service', () => { await expect(TokenModel.countDocuments()).resolves.toEqual(0) // Act - const actualOtp = await AuthService.createLoginOtp(VALID_EMAIL) + const actualResult = await AuthService.createLoginOtp(VALID_EMAIL) // Assert - expect(actualOtp).toEqual(MOCK_OTP) + expect(actualResult.isOk()).toBe(true) + expect(actualResult._unsafeUnwrap()).toEqual(MOCK_OTP) // Should have new token document inserted. await expect(TokenModel.countDocuments()).resolves.toEqual(1) }) - it('should throw InvalidDomainError when email is invalid', async () => { + it('should return with InvalidDomainError when email is invalid', async () => { // Arrange const notAnEmail = 'not an email' // Act - const actualPromise = AuthService.createLoginOtp(notAnEmail) + const actualResult = await AuthService.createLoginOtp(notAnEmail) // Assert - await expect(actualPromise).rejects.toThrowError(InvalidDomainError) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(InvalidDomainError) }) }) @@ -103,31 +110,39 @@ describe('auth.service', () => { await expect(TokenModel.countDocuments()).resolves.toEqual(1) // Act - const actual = await AuthService.verifyLoginOtp(MOCK_OTP, VALID_EMAIL) + const actualResult = await AuthService.verifyLoginOtp( + MOCK_OTP, + VALID_EMAIL, + ) // Assert // Resolves successfully. - expect(actual).toEqual(true) + expect(actualResult.isOk()).toBe(true) + expect(actualResult._unsafeUnwrap()).toEqual(true) // Token document should be removed. await expect(TokenModel.countDocuments()).resolves.toEqual(0) }) - it('should throw InvalidOtpError when Token document cannot be retrieved', async () => { + it('should return with InvalidOtpError when Token document cannot be retrieved', async () => { // Arrange // No OTP requested; should have no documents prior to acting. await expect(TokenModel.countDocuments()).resolves.toEqual(0) // Act - const verifyPromise = AuthService.verifyLoginOtp(MOCK_OTP, VALID_EMAIL) + const actualResult = await AuthService.verifyLoginOtp( + MOCK_OTP, + VALID_EMAIL, + ) // Assert const expectedError = new InvalidOtpError( 'OTP has expired. Please request for a new OTP.', ) - await expect(verifyPromise).rejects.toThrowError(expectedError) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(expectedError) }) - it('should throw InvalidOtpError when verification has been attempted too many times', async () => { + it('should return with InvalidOtpError when verification has been attempted too many times', async () => { // Arrange // Add a Token document to verify against. await AuthService.createLoginOtp(VALID_EMAIL) @@ -138,29 +153,37 @@ describe('auth.service', () => { ) // Act - const verifyPromise = AuthService.verifyLoginOtp(MOCK_OTP, VALID_EMAIL) + const actualResult = await AuthService.verifyLoginOtp( + MOCK_OTP, + VALID_EMAIL, + ) // Assert const expectedError = new InvalidOtpError( 'You have hit the max number of attempts. Please request for a new OTP.', ) - await expect(verifyPromise).rejects.toThrowError(expectedError) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(expectedError) }) - it('should throw InvalidOtpError when the OTP hash does not match', async () => { + it('should return with InvalidOtpError when the OTP hash does not match', async () => { // Arrange // Add a Token document to verify against. await AuthService.createLoginOtp(VALID_EMAIL) const invalidOtp = '654321' // Act - const verifyPromise = AuthService.verifyLoginOtp(invalidOtp, VALID_EMAIL) + const actualResult = await AuthService.verifyLoginOtp( + invalidOtp, + VALID_EMAIL, + ) // Assert const expectedError = new InvalidOtpError( 'OTP is invalid. Please try again.', ) - await expect(verifyPromise).rejects.toThrowError(expectedError) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(expectedError) }) }) }) diff --git a/src/app/modules/auth/auth.controller.ts b/src/app/modules/auth/auth.controller.ts index 4fa51633a4..c5907ff358 100644 --- a/src/app/modules/auth/auth.controller.ts +++ b/src/app/modules/auth/auth.controller.ts @@ -1,5 +1,4 @@ -import to from 'await-to-js' -import { Request, RequestHandler } from 'express' +import { RequestHandler } from 'express' import { ParamsDictionary } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import { isEmpty } from 'lodash' @@ -8,171 +7,188 @@ import { createLoggerWithLabel } from '../../../config/logger' import { LINKS } from '../../../shared/constants' import MailService from '../../services/mail.service' import { getRequestIp } from '../../utils/request' -import { ApplicationError } from '../core/core.errors' import * as UserService from '../user/user.service' import * as AuthService from './auth.service' -import { ResponseAfter, SessionUser } from './auth.types' +import { SessionUser } from './auth.types' +import { mapRouteError } from './auth.utils' const logger = createLoggerWithLabel(module) /** - * Precondition: AuthMiddlewares.validateDomain must precede this handler. - * @returns 200 regardless, assumed to have passed domain validation. + * Handler for GET /auth/checkuser endpoint. + * @returns 500 when there was an error validating body.email + * @returns 401 when domain of body.email is invalid + * @returns 200 if domain of body.email is valid */ -export const handleCheckUser: RequestHandler = async ( - _req: Request, - res: ResponseAfter['validateDomain'], -) => { - return res.sendStatus(StatusCodes.OK) +export const handleCheckUser: RequestHandler< + ParamsDictionary, + string, + { email: string } +> = async (req, res) => { + // Joi validation ensures existence. + const { email } = req.body + + return AuthService.validateEmailDomain(email) + .map(() => res.sendStatus(StatusCodes.OK)) + .mapErr((error) => { + logger.error({ + message: 'Domain validation error', + meta: { + action: 'handleCheckUser', + ip: getRequestIp(req), + email, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).send(errorMessage) + }) } /** - * Precondition: AuthMiddlewares.validateDomain must precede this handler. + * Handler for POST /auth/sendotp endpoint. + * @return 200 when OTP has been been successfully sent + * @return 401 when email domain is invalid + * @return 500 when unknown errors occurs during generate OTP, or create/send the email that delivers the OTP to the user's email address */ -export const handleLoginSendOtp: RequestHandler = async ( - req: Request, - res: ResponseAfter['validateDomain'], -) => { +export const handleLoginSendOtp: RequestHandler< + ParamsDictionary, + string, + { email: string } +> = async (req, res) => { // Joi validation ensures existence. const { email } = req.body const requestIp = getRequestIp(req) const logMeta = { - action: 'handleSendLoginOtp', + action: 'handleLoginSendOtp', email, ip: requestIp, } - // Create OTP. - const [otpErr, otp] = await to(AuthService.createLoginOtp(email)) - - if (otpErr || !otp) { - logger.error({ - message: 'Error generating OTP', - meta: logMeta, - error: otpErr ?? undefined, - }) - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .send( - 'Failed to send login OTP. Please try again later and if the problem persists, contact us.', + return ( + // Step 1: Validate email domain. + AuthService.validateEmailDomain(email) + // Step 2: Create login OTP. + .andThen(() => AuthService.createLoginOtp(email)) + // Step 3: Send login OTP to email address. + .andThen((otp) => + MailService.sendLoginOtp({ + recipient: email, + otp, + ipAddress: requestIp, + }), ) - } - - // Send OTP. - const [sendErr] = await to( - MailService.sendLoginOtp({ - recipient: email, - otp, - ipAddress: requestIp, - }), + // Step 4a: Successfully sent login otp. + .map(() => { + logger.info({ + message: 'Login OTP sent successfully', + meta: logMeta, + }) + + return res.status(StatusCodes.OK).send(`OTP sent to ${email}!`) + }) + // Step 4b: Error occurred whilst sending otp. + .mapErr((error) => { + logger.error({ + message: 'Error sending login OTP', + meta: logMeta, + error, + }) + const { errorMessage, statusCode } = mapRouteError( + error, + /* coreErrorMessage=*/ 'Failed to send login OTP. Please try again later and if the problem persists, contact us.', + ) + return res.status(statusCode).send(errorMessage) + }) ) - if (sendErr) { - logger.error({ - message: 'Error mailing OTP', - meta: logMeta, - error: sendErr, - }) - - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .send( - 'Error sending OTP. Please try again later and if the problem persists, contact us.', - ) - } - - // Successfully sent login otp. - logger.info({ - message: 'Login OTP sent successfully', - meta: logMeta, - }) - - return res.status(StatusCodes.OK).send(`OTP sent to ${email}!`) } /** - * Precondition: AuthMiddlewares.validateDomain must precede this handler. + * Handler for POST /auth/verifyotp endpoint. + * @returns 200 when user has successfully logged in, with session cookie set + * @returns 401 when the email domain is invalid + * @returns 422 when the OTP is invalid + * @returns 500 when error occurred whilst verifying the OTP */ -export const handleLoginVerifyOtp: RequestHandler = async ( - req: Request< - ParamsDictionary, - string | SessionUser, - { email: string; otp: string } - >, - res: ResponseAfter['validateDomain'], -) => { +export const handleLoginVerifyOtp: RequestHandler< + ParamsDictionary, + string | SessionUser, + { email: string; otp: string } +> = async (req, res) => { // Joi validation ensures existence. const { email, otp } = req.body - // validateDomain middleware will populate agency. - const { agency } = res.locals const logMeta = { action: 'handleLoginVerifyOtp', email, ip: getRequestIp(req), } + const coreErrorMessage = `Failed to process OTP. Please try again later and if the problem persists, submit our Support Form (${LINKS.supportFormLink}).` - const [verifyErr] = await to(AuthService.verifyLoginOtp(otp, email)) - - if (verifyErr) { - logger.warn({ - message: - verifyErr instanceof ApplicationError - ? 'Login OTP is invalid' - : 'Error occurred when trying to validate login OTP', - meta: logMeta, - error: verifyErr, - }) - - if (verifyErr instanceof ApplicationError) { - return res.status(verifyErr.status).send(verifyErr.message) - } - - // Unknown error, return generic error response. - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .send( - 'Failed to validate OTP. Please try again later and if the problem persists, contact us.', - ) - } - - // OTP is valid, proceed to login user. - try { - // TODO (#317): remove usage of non-null assertion - const user = await UserService.retrieveUser(email, agency!) - // Create user object to return to frontend. - const userObj = { ...user.toObject(), agency } - - if (!req.session) { - throw new Error('req.session not found') - } - - // TODO(#212): Should store only userId in session. - // Add user info to session. - req.session.user = userObj as SessionUser - logger.info({ - message: `Successfully logged in user ${user.email}`, - meta: logMeta, - }) - - return res.status(StatusCodes.OK).send(userObj) - } catch (err) { + const validateResult = await AuthService.validateEmailDomain(email) + if (validateResult.isErr()) { + const { error } = validateResult logger.error({ - message: 'Error logging in user', + message: 'Domain validation error', meta: logMeta, - error: err, + error, }) - - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .send( - `User signin failed. Please try again later and if the problem persists, submit our Support Form (${LINKS.supportFormLink}).`, - ) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).send(errorMessage) } + + // Since there is no error, agency is retrieved from validation. + const agency = validateResult.value + + // Step 1: Verify login OTP. + return ( + AuthService.verifyLoginOtp(otp, email) + // Step 2: OTP is valid, retrieve associated user. + .andThen(() => UserService.retrieveUser(email, agency._id)) + // Step 3a: Set session and return user in response. + .map((user) => { + if (!req.session) { + logger.error({ + message: 'Error logging in user; req.session is undefined', + meta: logMeta, + }) + + return res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .send(coreErrorMessage) + } + + // TODO(#212): Should store only userId in session. + // Add user info to session. + const userObj = user.toObject() as SessionUser + req.session.user = userObj + logger.info({ + message: `Successfully logged in user ${user.email}`, + meta: logMeta, + }) + + return res.status(StatusCodes.OK).send(userObj) + }) + // Step 3b: Error occured in one of the steps. + .mapErr((error) => { + logger.warn({ + message: 'Error occurred when trying to validate login OTP', + meta: logMeta, + error, + }) + + const { errorMessage, statusCode } = mapRouteError( + error, + coreErrorMessage, + ) + return res.status(statusCode).send(errorMessage) + }) + ) } export const handleSignout: RequestHandler = async (req, res) => { - if (isEmpty(req.session)) { + if (!req.session || isEmpty(req.session)) { logger.error({ message: 'Attempted to sign out without a session', meta: { @@ -182,7 +198,7 @@ export const handleSignout: RequestHandler = async (req, res) => { return res.sendStatus(StatusCodes.BAD_REQUEST) } - req.session!.destroy((error) => { + req.session.destroy((error) => { if (error) { logger.error({ message: 'Failed to destroy session', diff --git a/src/app/modules/auth/auth.middlewares.ts b/src/app/modules/auth/auth.middlewares.ts deleted file mode 100644 index 7a19f652e8..0000000000 --- a/src/app/modules/auth/auth.middlewares.ts +++ /dev/null @@ -1,58 +0,0 @@ -import to from 'await-to-js' -import { RequestHandler } from 'express' -import { ParamsDictionary } from 'express-serve-static-core' -import { StatusCodes } from 'http-status-codes' - -import { createLoggerWithLabel } from '../../../config/logger' -import { LINKS } from '../../../shared/constants' -import { getRequestIp } from '../../utils/request' -import { ApplicationError } from '../core/core.errors' - -import * as AuthService from './auth.service' - -const logger = createLoggerWithLabel(module) - -/** - * Middleware to check if domain of email in the body is from a whitelisted - * agency. - * @returns 500 when there was an error validating email - * @returns 401 when email domain is invalid - * @returns sets retrieved agency in `res.locals.agency` and calls next when domain is valid - */ -export const validateDomain: RequestHandler< - ParamsDictionary, - string, - { email: string } -> = async (req, res, next) => { - // Joi validation ensures existence. - const { email } = req.body - - const [validationError, agency] = await to( - AuthService.getAgencyWithEmail(email), - ) - - if (validationError) { - logger.error({ - message: 'Domain validation error', - meta: { - action: 'validateDomain', - ip: getRequestIp(req), - email, - }, - error: validationError, - }) - if (validationError instanceof ApplicationError) { - return res.status(validationError.status).send(validationError.message) - } - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .send( - `Unable to validate email domain. If this issue persists, please submit a Support Form at (${LINKS.supportFormLink}).`, - ) - } - - // Pass down agency to next handler. - res.locals.agency = agency - - return next() -} diff --git a/src/app/modules/auth/auth.routes.ts b/src/app/modules/auth/auth.routes.ts index 8584040a04..de7babc84e 100644 --- a/src/app/modules/auth/auth.routes.ts +++ b/src/app/modules/auth/auth.routes.ts @@ -2,7 +2,6 @@ import { celebrate, Joi, Segments } from 'celebrate' import { Router } from 'express' import * as AuthController from './auth.controller' -import * as AuthMiddlewares from './auth.middlewares' export const AuthRouter = Router() @@ -24,7 +23,6 @@ AuthRouter.post( .message('Please enter a valid email'), }), }), - AuthMiddlewares.validateDomain, AuthController.handleCheckUser, ) @@ -50,7 +48,6 @@ AuthRouter.post( .message('Please enter a valid email'), }), }), - AuthMiddlewares.validateDomain, AuthController.handleLoginSendOtp, ) @@ -81,7 +78,6 @@ AuthRouter.post( .message('Please enter a valid otp'), }), }), - AuthMiddlewares.validateDomain, AuthController.handleLoginVerifyOtp, ) diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index ac6d34f4b6..9478d5fbaa 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -1,14 +1,21 @@ import bcrypt from 'bcrypt' import mongoose from 'mongoose' +import { errAsync, okAsync, ResultAsync } from 'neverthrow' import validator from 'validator' +import { IAgencySchema, ITokenSchema } from 'src/types' + import config from '../../../config/config' +import { createLoggerWithLabel } from '../../../config/logger' +import { LINKS } from '../../../shared/constants' import getAgencyModel from '../../models/agency.server.model' import getTokenModel from '../../models/token.server.model' import { generateOtp } from '../../utils/otp' +import { ApplicationError, DatabaseError } from '../core/core.errors' import { InvalidDomainError, InvalidOtpError } from './auth.errors' +const logger = createLoggerWithLabel(module) const TokenModel = getTokenModel(mongoose) const AgencyModel = getAgencyModel(mongoose) @@ -19,46 +26,80 @@ export const MAX_OTP_ATTEMPTS = 10 * Validates the domain of the given email. A domain is valid if it exists in * the Agency collection in the database. * @param email the email to validate the domain for - * @returns the agency document with the domain of the email only if it is valid. - * @throws error if database query fails or if agency cannot be found. + * @returns ok(the agency document) with the domain of the email if the domain is valid + * @returns err(InvalidDomainError) if the agency document cannot be found + * @returns err(DatabaseError) if database query fails */ -export const getAgencyWithEmail = async (email: string) => { +export const validateEmailDomain = ( + email: string, +): ResultAsync => { // Extra guard even if Joi validation has already checked. if (!validator.isEmail(email)) { - throw new InvalidDomainError() + return errAsync(new InvalidDomainError()) } const emailDomain = email.split('@').pop() - const agency = await AgencyModel.findOne({ emailDomain }) - if (!agency) { - throw new InvalidDomainError() - } + return ResultAsync.fromPromise( + AgencyModel.findOne({ emailDomain }).exec(), + (error) => { + logger.error({ + message: 'Database error when retrieving agency', + meta: { + action: 'validateEmailDomain', + emailDomain, + }, + error, + }) - return agency + return new DatabaseError( + `Unable to validate email domain. If this issue persists, please submit a Support Form at (${LINKS.supportFormLink})`, + ) + }, + ).andThen((agency) => { + if (!agency) { + const noAgencyError = new InvalidDomainError() + logger.warn({ + message: 'Agency not found', + meta: { + action: 'retrieveAgency', + emailDomain, + }, + error: noAgencyError, + }) + return errAsync(noAgencyError) + } + return okAsync(agency) + }) } /** * Creates a login OTP and saves its hash into the Token collection. * @param email the email to link the generated otp to - * @returns the generated OTP if saving into DB is successful - * @throws {InvalidDomainError} the given email is invalid - * @throws {Error} if any error occur whilst creating the OTP or insertion of OTP into the database. + * @returns ok(the generated OTP) if saving into DB is successful + * @returns err(InvalidDomainError) if the given email is invalid + * @returns err(ApplicationError) if any error occur whilst hashing the OTP + * @returns err(DatabaseError) if error occurs during upsertion of hashed OTP into the database. */ -export const createLoginOtp = async (email: string) => { +export const createLoginOtp = ( + email: string, +): ResultAsync< + string, + ApplicationError | DatabaseError | InvalidDomainError +> => { if (!validator.isEmail(email)) { - throw new InvalidDomainError() + return errAsync(new InvalidDomainError()) } const otp = generateOtp() - const hashedOtp = await bcrypt.hash(otp, DEFAULT_SALT_ROUNDS) - - await TokenModel.upsertOtp({ - email, - hashedOtp, - expireAt: new Date(Date.now() + config.otpLifeSpan), - }) - return otp + return ( + // Step 1: Hash OTP. + hashOtp(otp, { email }) + // Step 2: Upsert otp hash into database. + .andThen((hashedOtp) => upsertOtp(email, hashedOtp)) + // Step 3: Return generated OTP. + .map(() => otp) + ) } /** @@ -69,37 +110,179 @@ export const createLoginOtp = async (email: string) => { * 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 email the email used to retrieve the document from the database - * @returns true on success - * @throws {InvalidOtpError} if the OTP is invalid or expired. - * @throws {Error} if any errors occur whilst retrieving from database or comparing hashes. + * @returns ok(true) on success + * @returns err(InvalidOtpError) if the OTP is invalid or expired + * @returns err(ApplicationError) if any error occurs whilst comparing hashes + * @returns err(DatabaseError) if any errors occur whilst querying the database */ -export const verifyLoginOtp = async (otpToVerify: string, email: string) => { - const updatedDocument = await TokenModel.incrementAttemptsByEmail(email) +export const verifyLoginOtp = ( + otpToVerify: string, + email: string, +): ResultAsync => { + return ( + // Step 1: Increment login attempts. + incrementLoginAttempts(email) + // Step 2: Compare otp with saved hash. + .andThen(({ hashedOtp }) => compareOtpHash(otpToVerify, hashedOtp)) + // Step 3: Remove token document from collection since hash matches. + .andThen(() => removeTokenOnSuccess(email)) + // Step 4: Return true (as success). + .map(() => true) + ) +} - // Does not exist, return expired error message. - if (!updatedDocument) { - throw new InvalidOtpError('OTP has expired. Please request for a new OTP.') - } +// Private helper functions +/** + * Hashes the given otp. + * @param otpToHash the otp to hash + * @param logMeta additional metadata for logging, if available + * @returns ok(hashed otp) if the hashing was successful + * @returns err(ApplicationError) if hashing error occurs + */ +const hashOtp = (otpToHash: string, logMeta: Record = {}) => { + return ResultAsync.fromPromise( + bcrypt.hash(otpToHash, DEFAULT_SALT_ROUNDS), + (error) => { + logger.error({ + message: 'bcrypt hash otp error', + meta: { + action: 'hashOtp', + ...logMeta, + }, + error, + }) - // 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.', - ) - } + return new ApplicationError() + }, + ) +} + +/** + * Compares otp with a given hash. + * @param otpToVerify The unhashed OTP to check match for + * @param hashedOtp The hashed OTP to check match for + * @param logMeta additional metadata for logging, if available + * @returns ok(true) if the hash matches + * @returns err(ApplicationError) if error occurs whilst comparing hashes + * @returns err(InvalidOtpError) if OTP hashes do not match + */ +const compareOtpHash = ( + otpToVerify: string, + hashedOtp: string, + logMeta: Record = {}, +): ResultAsync => { + return ResultAsync.fromPromise( + bcrypt.compare(otpToVerify, hashedOtp), + (error) => { + logger.error({ + message: 'bcrypt compare otp error', + meta: { + action: 'compareHash', + ...logMeta, + }, + error, + }) - // Compare otp with saved hash. - const isOtpMatch = await bcrypt.compare( - otpToVerify, - updatedDocument.hashedOtp, + return new ApplicationError() + }, + ).andThen((isOtpMatch) => { + if (!isOtpMatch) { + return errAsync(new InvalidOtpError('OTP is invalid. Please try again.')) + } + + return okAsync(isOtpMatch) + }) +} + +/** + * Upserts the given otp hash into the document keyed by the given email + * @param email the email to retrieve the current Token document for + * @param hashedOtp the otp hash to upsert + * @returns ok(upserted Token document) if upsert is successful + * @returns err(DatabaseError) if upsert fails + */ +const upsertOtp = ( + email: string, + hashedOtp: string, +): ResultAsync => { + return ResultAsync.fromPromise( + TokenModel.upsertOtp({ + email, + hashedOtp, + expireAt: new Date(Date.now() + config.otpLifeSpan), + }), + (error) => { + logger.error({ + message: 'Database upsert OTP error', + meta: { + action: 'upsertOtp', + email, + }, + error, + }) + + return new DatabaseError() + }, ) +} - if (!isOtpMatch) { - throw new InvalidOtpError('OTP is invalid. Please try again.') - } +/** + * Increment login attempts for the given email + * @param email the email to increment login attempts for + * @returns ok(updated document) if increment succeeds + * @returns err(DatabaseError) if any database error occurs whilst updating attempts + * @returns err(InvalidOtpError) if login has expired or if max number of attempts are reached + */ +const incrementLoginAttempts = ( + email: string, +): ResultAsync => { + return ResultAsync.fromPromise( + TokenModel.incrementAttemptsByEmail(email), + (error) => { + logger.error({ + message: 'Database increment OTP error', + meta: { + action: 'incrementAttempts', + email, + }, + error, + }) + + return new DatabaseError() + }, + ).andThen((upsertedDoc) => { + // Document does not exist, return expired error message. + if (!upsertedDoc || !upsertedDoc.numOtpAttempts) { + return errAsync( + new InvalidOtpError('OTP has expired. Please request for a new OTP.'), + ) + } + if (upsertedDoc.numOtpAttempts > MAX_OTP_ATTEMPTS) { + return errAsync( + new InvalidOtpError( + 'You have hit the max number of attempts. Please request for a new OTP.', + ), + ) + } + + return okAsync(upsertedDoc) + }) +} - // Hashed OTP matches, remove from collection. - await TokenModel.findOneAndRemove({ email }) - // Finally return true (as success). - return true +const removeTokenOnSuccess = (email: string) => { + return ResultAsync.fromPromise( + TokenModel.findOneAndRemove({ email }).exec(), + (error) => { + logger.error({ + message: 'Database remove Token document error', + meta: { + action: 'removeTokenOnSuccess', + email, + }, + error, + }) + + return new DatabaseError() + }, + ) } diff --git a/src/app/modules/auth/auth.types.ts b/src/app/modules/auth/auth.types.ts index 861c0e7a98..ba7e1a50ee 100644 --- a/src/app/modules/auth/auth.types.ts +++ b/src/app/modules/auth/auth.types.ts @@ -1,13 +1,3 @@ -import { IAgencySchema, IPopulatedUser } from 'src/types' - -import { ResponseWithLocals } from '../core/core.types' - -/** - * Meta typing for the shape of the Express.Response object after various - * middlewares for /auth. - */ -export type ResponseAfter = { - validateDomain: ResponseWithLocals<{ agency?: IAgencySchema }> -} +import { IPopulatedUser } from 'src/types' export type SessionUser = IPopulatedUser diff --git a/src/app/modules/auth/auth.utils.ts b/src/app/modules/auth/auth.utils.ts new file mode 100644 index 0000000000..366717de21 --- /dev/null +++ b/src/app/modules/auth/auth.utils.ts @@ -0,0 +1,58 @@ +import { StatusCodes } from 'http-status-codes' + +import { createLoggerWithLabel } from '../../../config/logger' +import { ApplicationError, DatabaseError } from '../core/core.errors' +import { MailSendError } from '../mail/mail.errors' + +import { InvalidDomainError, InvalidOtpError } from './auth.errors' + +const logger = createLoggerWithLabel(module) + +type ErrorResponseData = { + statusCode: StatusCodes + errorMessage: string +} + +/** + * Handler to map ApplicationErrors to their correct status code and error + * messages. + * @param error The error to retrieve the status codes and error messages + * @param coreErrorMessage Any error message to return instead of the default core error message, if any + */ +export const mapRouteError = ( + error: ApplicationError, + coreErrorMessage?: string, +): ErrorResponseData => { + switch (error.constructor) { + case InvalidDomainError: + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorMessage: error.message, + } + case InvalidOtpError: + return { + statusCode: StatusCodes.UNPROCESSABLE_ENTITY, + errorMessage: error.message, + } + case MailSendError: + case ApplicationError: + case DatabaseError: + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: coreErrorMessage ?? error.message, + } + default: + logger.error({ + message: 'Unknown route error observed', + meta: { + action: 'mapRouteError', + }, + error, + }) + + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: 'Something went wrong. Please try again.', + } + } +} diff --git a/src/app/modules/core/core.errors.ts b/src/app/modules/core/core.errors.ts index 5c66fdfacb..d6f665ff1f 100644 --- a/src/app/modules/core/core.errors.ts +++ b/src/app/modules/core/core.errors.ts @@ -2,7 +2,7 @@ * A custom base error class that encapsulates the name, message, status code, * and logging meta string (if any) for the error. */ -export abstract class ApplicationError extends Error { +export class ApplicationError extends Error { /** * Http status code for the error to be returned in the response. */ @@ -26,3 +26,9 @@ export abstract class ApplicationError extends Error { this.meta = meta } } + +export class DatabaseError extends ApplicationError { + constructor(message?: string) { + super(message) + } +} diff --git a/src/app/modules/core/core.types.ts b/src/app/modules/core/core.types.ts deleted file mode 100644 index 9c1011c90c..0000000000 --- a/src/app/modules/core/core.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Response } from 'express' - -export type ResponseWithLocals = Omit & { locals: T } diff --git a/src/app/modules/mail/mail.errors.ts b/src/app/modules/mail/mail.errors.ts new file mode 100644 index 0000000000..a9726ead37 --- /dev/null +++ b/src/app/modules/mail/mail.errors.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from '../core/core.errors' + +export class MailSendError extends ApplicationError { + constructor( + message = 'Error sending OTP. Please try again later and if the problem persists, contact us.', + ) { + super(message) + } +} diff --git a/src/app/modules/user/user.service.ts b/src/app/modules/user/user.service.ts index 358953227a..799e24a902 100644 --- a/src/app/modules/user/user.service.ts +++ b/src/app/modules/user/user.service.ts @@ -1,6 +1,7 @@ import to from 'await-to-js' import bcrypt from 'bcrypt' import mongoose from 'mongoose' +import { errAsync, ResultAsync } from 'neverthrow' import validator from 'validator' import getAdminVerificationModel from '../../../app/models/admin_verification.server.model' @@ -8,11 +9,15 @@ 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 { createLoggerWithLabel } from '../../../config/logger' import { IAgency, IPopulatedUser, IUserSchema } from '../../../types' import { InvalidDomainError } from '../auth/auth.errors' +import { DatabaseError } from '../core/core.errors' import { InvalidOtpError, MalformedOtpError } from './user.errors' +const logger = createLoggerWithLabel(module) + const AdminVerificationModel = getAdminVerificationModel(mongoose) const UserModel = getUserModel(mongoose) @@ -161,23 +166,35 @@ export const getPopulatedUserById = async ( * new user document is created and returned. * @param email the email of the user to retrieve * @param agency the agency document to associate with the user - * @returns the upserted user document - * @throws {InvalidDomainError} on invalid email - * @throws {Error} on upsert failure + * @returns ok(upserted user document) populated with agency details + * @returns err(InvalidDomainError) when given email is invalid + * @returns err(DatabaseError) on upsert failure */ -export const retrieveUser = async (email: string, agency: IAgency) => { +export const retrieveUser = ( + email: string, + agencyId: IAgency['_id'], +): ResultAsync => { if (!validator.isEmail(email)) { - throw new InvalidDomainError() + return errAsync(new InvalidDomainError()) } - const admin = await UserModel.upsertUser({ - email, - agency: agency._id, - }) - - if (!admin) { - throw new Error('Failed to upsert user') - } - - return admin + return ResultAsync.fromPromise( + UserModel.upsertUser({ + email, + agency: agencyId, + }), + (error) => { + logger.error({ + message: 'Database error when upserting user', + meta: { + action: 'retrieveUser', + email, + agencyId, + }, + error, + }) + + return new DatabaseError() + }, + ) } diff --git a/src/app/services/mail.service.ts b/src/app/services/mail.service.ts index 4803b4853b..f325d2f536 100644 --- a/src/app/services/mail.service.ts +++ b/src/app/services/mail.service.ts @@ -1,5 +1,6 @@ import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' +import { ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import { OperationOptions } from 'retry' @@ -16,6 +17,7 @@ import { ISubmissionSchema, } from '../../types' import { EMAIL_HEADERS, EmailType } from '../constants/mail' +import { MailSendError } from '../modules/mail/mail.errors' import { generateAutoreplyHtml, generateAutoreplyPdf, @@ -302,9 +304,10 @@ export class MailService { * Sends a login otp email to a valid email * @param recipient the recipient email address * @param otp the OTP to send - * @throws error if mail fails, to be handled by the caller + * @returns ok(never) if sending of mail succeeds + * @returns err(MailSendError) if sending of mail fails, to be handled by the caller */ - sendLoginOtp = async ({ + sendLoginOtp = ({ recipient, otp, ipAddress, @@ -312,23 +315,39 @@ export class MailService { recipient: string otp: string ipAddress: string - }) => { - const mail: MailOptions = { - to: recipient, - from: this.#senderFromString, - subject: `One-Time Password (OTP) for ${this.#appName}`, - html: await generateLoginOtpHtml({ - appName: this.#appName, - appUrl: this.#appUrl, - ipAddress: ipAddress, - otp, - }), - headers: { - [EMAIL_HEADERS.emailType]: EmailType.LoginOtp, - }, - } + }): ResultAsync => { + return generateLoginOtpHtml({ + appName: this.#appName, + appUrl: this.#appUrl, + ipAddress: ipAddress, + otp, + }).andThen((loginHtml) => { + const mail: MailOptions = { + to: recipient, + from: this.#senderFromString, + subject: `One-Time Password (OTP) for ${this.#appName}`, + html: loginHtml, + headers: { + [EMAIL_HEADERS.emailType]: EmailType.LoginOtp, + }, + } - return this.#sendNodeMail(mail, { mailId: 'OTP' }) + return ResultAsync.fromPromise( + this.#sendNodeMail(mail, { mailId: 'OTP' }), + (error) => { + logger.error({ + message: 'Error sending login OTP to email', + meta: { + action: 'sendLoginOtp', + recipient, + }, + error, + }) + + return new MailSendError() + }, + ) + }) } /** diff --git a/src/app/utils/mail.ts b/src/app/utils/mail.ts index 87e25f420d..f8179ed539 100644 --- a/src/app/utils/mail.ts +++ b/src/app/utils/mail.ts @@ -1,21 +1,39 @@ import dedent from 'dedent-js' import ejs from 'ejs' import { flattenDeep } from 'lodash' +import { ResultAsync } from 'neverthrow' import puppeteer from 'puppeteer-core' import validator from 'validator' import { IFormSchema, ISubmissionSchema } from 'src/types' import config from '../../config/config' +import { createLoggerWithLabel } from '../../config/logger' +import { MailSendError } from '../modules/mail/mail.errors' + +const logger = createLoggerWithLabel(module) export const generateLoginOtpHtml = (htmlData: { otp: string appName: string appUrl: string ipAddress: string -}): Promise => { +}): ResultAsync => { const pathToTemplate = `${process.cwd()}/src/app/views/templates/otp-email.server.view.html` - return ejs.renderFile(pathToTemplate, htmlData) + return ResultAsync.fromPromise( + ejs.renderFile(pathToTemplate, htmlData), + (error) => { + logger.error({ + message: 'Error occurred whilst rendering login otp ejs', + meta: { + action: 'generateLoginOtpHtml', + }, + error, + }) + + return new MailSendError() + }, + ) } export const generateVerificationOtpHtml = ({ diff --git a/src/config/logger.ts b/src/config/logger.ts index bf4ef0e4b3..2254a39f3e 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -20,7 +20,7 @@ type CustomLoggerParams = { action: string [other: string]: any } - error?: Error + error?: unknown } // A variety of helper functions to make winston logging like console logging, diff --git a/src/types/token.ts b/src/types/token.ts index f808a833d7..55d4332d71 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -18,5 +18,7 @@ export interface ITokenModel extends Model { params: Omit, ) => Promise - incrementAttemptsByEmail: (email: IToken['email']) => Promise + incrementAttemptsByEmail: ( + email: IToken['email'], + ) => Promise } diff --git a/src/types/user.ts b/src/types/user.ts index 01ab753ab1..ed8a7f8762 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -20,9 +20,16 @@ export interface IUserSchema extends IUser, Document { } export interface IUserModel extends Model { + /** + * Upsert into User collection with given email and agencyId. + * If user with given email does not exist, a new document will be created. + * If user with given email already exists, the user's agency will be updated + * and populated before being returned. + * @returns the user document after upsert with populated agency details + */ upsertUser: ( upsertParams: Pick, - ) => Promise + ) => Promise } export interface IPopulatedUser extends IUserSchema { diff --git a/tests/unit/backend/helpers/jest-express.ts b/tests/unit/backend/helpers/jest-express.ts index ed91e6fed2..051dc86f88 100644 --- a/tests/unit/backend/helpers/jest-express.ts +++ b/tests/unit/backend/helpers/jest-express.ts @@ -10,7 +10,7 @@ const mockRequest =

, B>({ body?: B session?: any secure?: boolean -} = {}) => { +} = {}): Request => { return { body: body ?? {}, params: params ?? {}, diff --git a/tests/unit/backend/modules/user/user.service.spec.ts b/tests/unit/backend/modules/user/user.service.spec.ts index d14e0a8447..3f3ac51c01 100644 --- a/tests/unit/backend/modules/user/user.service.spec.ts +++ b/tests/unit/backend/modules/user/user.service.spec.ts @@ -3,13 +3,16 @@ import mongoose from 'mongoose' import { ImportMock } from 'ts-mock-imports' import getAdminVerificationModel from 'src/app/models/admin_verification.server.model' +import getUserModel from 'src/app/models/user.server.model' +import { InvalidDomainError } from 'src/app/modules/auth/auth.errors' 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 { IAgencySchema, IPopulatedUser, IUserSchema } from 'src/types' import dbHandler from '../../helpers/jest-db' const AdminVerification = getAdminVerificationModel(mongoose) +const UserModel = getUserModel(mongoose) describe('user.service', () => { // Obtained from Twilio's @@ -17,27 +20,23 @@ describe('user.service', () => { const MOCK_CONTACT = '+15005550006' const MOCK_OTP = '123456' const USER_ID = new ObjectID() + const ALLOWED_DOMAIN = 'test.gov.sg' let defaultAgency: IAgencySchema let defaultUser: IUserSchema - beforeAll(async () => { - await dbHandler.connect() - + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { // Insert user into collections. const { agency, user } = await dbHandler.insertFormCollectionReqs({ userId: USER_ID, + mailDomain: ALLOWED_DOMAIN, }) defaultAgency = agency.toObject() defaultUser = user.toObject() }) - beforeEach( - async () => - await dbHandler.clearCollection( - AdminVerification.collection.collectionName, - ), - ) + afterEach(async () => await dbHandler.clearDatabase()) afterAll(async () => await dbHandler.closeDatabase()) describe('createContactOtp', () => { @@ -232,4 +231,71 @@ describe('user.service', () => { await expect(userPromise).resolves.toBeNull() }) }) + + describe('retrieveUser', () => { + it('should return InvalidDomainError on invalid email', async () => { + // Arrange + const notAnEmail = 'not an email' + + // Act + const actualResult = await UserService.retrieveUser( + notAnEmail, + defaultAgency._id, + ) + + // Assert + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(InvalidDomainError) + }) + + it('should return new User document when user does not yet exist in the collection', async () => { + // Arrange + const newUserEmail = `newUser@${ALLOWED_DOMAIN}` + // Should have the default user document in collection. + await expect(UserModel.countDocuments()).resolves.toEqual(1) + + // Act + const actualResult = await UserService.retrieveUser( + newUserEmail, + defaultAgency._id, + ) + + // Assert + const expectedUser: Partial = { + agency: defaultAgency, + email: newUserEmail, + } + expect(actualResult.isOk()).toBe(true) + // Should now have 2 user documents + await expect(UserModel.countDocuments()).resolves.toEqual(2) + expect(actualResult._unsafeUnwrap().toObject()).toEqual( + expect.objectContaining(expectedUser), + ) + }) + + it('should return existing User document when user already exists', async () => { + // Arrange + // Should have the default user document in collection. + await expect(UserModel.countDocuments()).resolves.toEqual(1) + const userEmail = defaultUser.email + + // Act + const actualResult = await UserService.retrieveUser( + userEmail, + defaultAgency._id, + ) + + // Assert + const expectedUser: Partial = { + ...defaultUser, + agency: defaultAgency, + } + expect(actualResult.isOk()).toBe(true) + // Should still only have 1 user document. + await expect(UserModel.countDocuments()).resolves.toEqual(1) + expect(actualResult._unsafeUnwrap().toObject()).toEqual( + expect.objectContaining(expectedUser), + ) + }) + }) }) diff --git a/tests/unit/backend/services/mail.service.spec.ts b/tests/unit/backend/services/mail.service.spec.ts index 5193414b88..798022b4ea 100644 --- a/tests/unit/backend/services/mail.service.spec.ts +++ b/tests/unit/backend/services/mail.service.spec.ts @@ -3,6 +3,7 @@ import moment from 'moment-timezone' import Mail, { Attachment } from 'nodemailer/lib/mailer' import { ImportMock } from 'ts-mock-imports' +import { MailSendError } from 'src/app/modules/mail/mail.errors' import { AutoreplySummaryRenderData, MailOptions, @@ -48,7 +49,7 @@ describe('mail.service', () => { }) describe('Constructor', () => { - it('should throw error when invalid senderMail param is passed ', () => { + it('should throw error when invalid senderMail param is passed', () => { // Arrange const invalidParams = { transporter: mockTransporter, @@ -211,12 +212,14 @@ describe('mail.service', () => { to: MOCK_VALID_EMAIL, from: MOCK_SENDER_STRING, subject: `One-Time Password (OTP) for ${MOCK_APP_NAME}`, - html: await MailUtils.generateLoginOtpHtml({ - otp: MOCK_OTP, - appName: MOCK_APP_NAME, - appUrl: MOCK_APP_URL, - ipAddress: MOCK_IP, - }), + html: ( + await MailUtils.generateLoginOtpHtml({ + otp: MOCK_OTP, + appName: MOCK_APP_NAME, + appUrl: MOCK_APP_URL, + ipAddress: MOCK_IP, + }) + )._unsafeUnwrap(), headers: { // Hardcode in tests in case something changes this. 'X-Formsg-Email-Type': 'Login OTP', @@ -233,31 +236,33 @@ describe('mail.service', () => { const expectedArgument = await generateExpectedArg() // Act - const pendingSend = mailService.sendLoginOtp({ + const actualResult = await mailService.sendLoginOtp({ recipient: MOCK_VALID_EMAIL, otp: MOCK_OTP, ipAddress: MOCK_IP, }) // Assert - await expect(pendingSend).resolves.toEqual(mockedResponse) + expect(actualResult.isOk()).toBe(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockedResponse) // Check arguments passed to sendNodeMail expect(sendMailSpy).toHaveBeenCalledTimes(1) expect(sendMailSpy).toHaveBeenCalledWith(expectedArgument) }) - it('should reject with error when email is invalid', async () => { + it('should return a MailSendError when email is invalid', async () => { // Arrange const invalidEmail = 'notAnEmail' // Act - const pendingSend = mailService.sendLoginOtp({ + const actualResult = await mailService.sendLoginOtp({ recipient: invalidEmail, otp: MOCK_OTP, ipAddress: MOCK_IP, }) // Assert - await expect(pendingSend).rejects.toThrowError('Invalid email error') + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(MailSendError) }) it('should autoretry when 4xx error is thrown by sendNodeMail and pass if second try passes', async () => { @@ -275,14 +280,15 @@ describe('mail.service', () => { const expectedArgument = await generateExpectedArg() // Act - const pendingSendLoginOtp = mailService.sendLoginOtp({ + const actualResult = await mailService.sendLoginOtp({ recipient: MOCK_VALID_EMAIL, otp: MOCK_OTP, ipAddress: MOCK_IP, }) // Assert - await expect(pendingSendLoginOtp).resolves.toEqual(mockedResponse) + expect(actualResult.isOk()).toBe(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockedResponse) // Check arguments passed to sendNodeMail // Should have been called two times since it rejected the first one and // resolved @@ -301,14 +307,15 @@ describe('mail.service', () => { const expectedArgument = await generateExpectedArg() // Act - const pendingSendLoginOtp = mailService.sendLoginOtp({ + const actualResult = await mailService.sendLoginOtp({ recipient: MOCK_VALID_EMAIL, otp: MOCK_OTP, ipAddress: MOCK_IP, }) // Assert - await expect(pendingSendLoginOtp).rejects.toEqual(mock4xxReject) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(MailSendError) // Check arguments passed to sendNodeMail // Should have been called MOCK_RETRY_COUNT + 1 times expect(sendMailSpy).toHaveBeenCalledTimes(MOCK_RETRY_COUNT + 1) @@ -329,14 +336,15 @@ describe('mail.service', () => { const expectedArgument = await generateExpectedArg() // Act - const pendingSendLoginOtp = mailService.sendLoginOtp({ + const actualResult = await mailService.sendLoginOtp({ recipient: MOCK_VALID_EMAIL, otp: MOCK_OTP, ipAddress: MOCK_IP, }) // Assert - await expect(pendingSendLoginOtp).rejects.toEqual(mockError) + expect(actualResult.isErr()).toBe(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(MailSendError) // Check arguments passed to sendNodeMail // Should only invoke two times and stop since the second rejected value // is non-4xx error. @@ -850,7 +858,7 @@ describe('mail.service', () => { html: expectedMailBody, // Attachments should be concatted with mock pdf response attachments: [ - ...MOCK_AUTOREPLY_PARAMS.attachments!, + ...(MOCK_AUTOREPLY_PARAMS.attachments ?? []), { content: MOCK_PDF, filename: 'response.pdf', From 0973938298b571509427b96a21c10de829cb7e90 Mon Sep 17 00:00:00 2001 From: "desmond.ang" <5405953+awhdesmond@users.noreply.github.com> Date: Tue, 22 Sep 2020 10:23:11 +0800 Subject: [PATCH 23/29] feat: form ownership transfer feature (#111) * transfer code from old repo * some frontend changes * refactor authActiveForm to add admin permission * fix dropdown hidden in scroll * add tests * do not save form in schema transferOwner * add logging when failed verifyPersmission * fix logging; use Joi to validate email field * update test * update mobile css * remove PERMISSION.ADMIN and use PERMISSION.DELETE * fix lint * fix lint and rebase * update logging format * remove duplicated code * fix mobile view close button * refactor frontend * shift collaborator related code to collaborator-modal client controller * cannot add ownself as owner * refresh admin-form myform when collab myform changes * shift success message to the top * reset messages when saveCollabEmail * set spacing between email and dropdown to 10px * fix rebase * remove letter spacing; fix mobile add collab button * fix spelling; add typescript return type * Update error message * Fix positioning of cancel button * Fix sizing of avatar Co-authored-by: Arshad Ali --- .../admin-forms.server.controller.js | 33 ++ .../authentication.server.controller.js | 73 +++- src/app/models/form.server.model.ts | 26 +- src/app/routes/admin-forms.server.routes.js | 29 ++ src/public/main.js | 5 + .../modules/core/css/admin-form-header.css | 148 ++++---- .../modules/core/css/avatar-dropdown.css | 8 + .../admin-form.client.controller.js | 348 ++++-------------- .../collaborator-modal.client.controller.js | 344 +++++++++++++++++ .../modules/forms/admin/css/pop-up-modal.css | 2 + .../admin/views/admin-form.client.view.html | 258 +------------ .../views/collaborator.client.modal.html | 321 ++++++++++++++++ .../forms/services/form-api.client.factory.js | 5 + src/types/form.ts | 1 + .../admin-forms.server.controller.spec.js | 46 +++ tests/unit/backend/jasmine.json | 2 +- 16 files changed, 1029 insertions(+), 620 deletions(-) create mode 100644 src/public/modules/forms/admin/controllers/collaborator-modal.client.controller.js create mode 100644 src/public/modules/forms/admin/views/collaborator.client.modal.html diff --git a/src/app/controllers/admin-forms.server.controller.js b/src/app/controllers/admin-forms.server.controller.js index 14d7c8088c..5f6d76bbc1 100644 --- a/src/app/controllers/admin-forms.server.controller.js +++ b/src/app/controllers/admin-forms.server.controller.js @@ -746,5 +746,38 @@ function makeModule(connection) { }, ) }, + + /** + * Transfer a form to another user + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ + transferOwner: async function (req, res) { + const newOwnerEmail = req.body.email + + // Transfer owner and Save the form + try { + await req.form.transferOwner(req.session.user, newOwnerEmail) + } catch (err) { + logger.error({ + message: err.message, + meta: { + action: 'makeModule.transferOwner', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + err, + }) + return res.status(StatusCodes.CONFLICT).send({ message: err.message }) + } + req.form.save(function (err, savedForm) { + if (err) return respondOnMongoError(req, res, err) + savedForm.populate('admin', (err) => { + if (err) return respondOnMongoError(req, res, err) + return res.json({ form: savedForm }) + }) + }) + }, } } diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js index 5787ddc46c..35cce1b62c 100755 --- a/src/app/controllers/authentication.server.controller.js +++ b/src/app/controllers/authentication.server.controller.js @@ -4,8 +4,9 @@ * Module dependencies. */ const { StatusCodes } = require('http-status-codes') - const PERMISSIONS = require('../utils/permission-levels').default +const { getRequestIp } = require('../utils/request') +const logger = require('../../config/logger').createLoggerWithLabel(module) /** * Middleware that authenticates admin-user @@ -23,10 +24,42 @@ exports.authenticateUser = function (req, res, next) { } } +/** + * Returns the error message when a user cannot perform an action on a form + * @param {String} user - user email + * @param {String} title - form title + * @returns {String} - the error message + */ +const makeUnauthorizedMessage = (user, title) => { + return `User ${user} is not authorized to perform this operation on Form: ${title}.` +} + +/** + * Logs an error message when a user cannot perform an action on a form + * @param {String} user - user email + * @param {String} requiredPermission - level of permission required + * @param {String} form - form + * @returns {String} - the error message + */ +const logUnauthorizedAccess = (req, action, requiredPermission) => { + const user = req.session.user + const form = req.form + const msg = `User ${user.email} not authorized to perform ${requiredPermission} operation on Form ${form._id} with title: ${form.title}.` + logger.error({ + message: msg, + meta: { + action: action, + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + }, + error: Error(msg), + }) +} + /** * Returns a middleware function that ensures that only users with the requiredPermission will pass. - * @param {String} requiredPermission - Either 'write' or 'delete', indicating what level of authorization - * the user needs + * @param {String} requiredPermission - one of PERMISSION_LEVELS, indicating the level of authorization required * @returns {function({Object}, {Object}, {Object})} - A middleware function that takes req, the express * request object, and res, the express response object. */ @@ -40,9 +73,23 @@ exports.verifyPermission = (requiredPermission) => * @param {function} next - Next middleware function */ (req, res, next) => { - // Admins always have sufficient permission - let hasSufficientPermission = + const isFormAdmin = String(req.form.admin.id) === String(req.session.user._id) + + // Forbidden if requiredPersmission is admin but user is not + if (!isFormAdmin && requiredPermission === PERMISSIONS.DELETE) { + logUnauthorizedAccess(req, 'verifyPermission', requiredPermission) + return res.status(StatusCodes.FORBIDDEN).send({ + message: makeUnauthorizedMessage( + req.session.user.email, + req.form.title, + ), + }) + } + + // Admins always have sufficient permission + let hasSufficientPermission = isFormAdmin + // Write users can access forms that require write/read if ( requiredPermission === PERMISSIONS.WRITE || @@ -64,16 +111,14 @@ exports.verifyPermission = (requiredPermission) => ) } - if (hasSufficientPermission) { - return next() - } else { + if (!hasSufficientPermission) { + logUnauthorizedAccess(req, 'verifyPermission', requiredPermission) return res.status(StatusCodes.FORBIDDEN).send({ - message: - 'User ' + - req.session.user.email + - ' is not authorized to perform this operation on Form: ' + - req.form.title + - '.', + message: makeUnauthorizedMessage( + req.session.user.email, + req.form.title, + ), }) } + return next() } diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index b16f42f966..dd3a211a88 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -1,5 +1,5 @@ import BSON from 'bson-ext' -import { compact, pick, uniq } from 'lodash' +import { compact, filter, pick, uniq } from 'lodash' import { Model, Mongoose, Schema, SchemaOptions } from 'mongoose' import validator from 'validator' @@ -20,6 +20,7 @@ import { ResponseMode, Status, } from '../../types' +import { IUserSchema } from '../../types/user' import { MB } from '../constants/filesize' import { validateWebhookUrl } from '../modules/webhook/webhook.utils' @@ -405,6 +406,29 @@ const compileFormModel = (db: Mongoose): IFormModel => { return newForm } + // Transfer ownership of the form to another user + FormSchema.methods.transferOwner = async function ( + currentOwner: IUserSchema, + newOwnerEmail: string, + ) { + // Verify that the new owner exists + const newOwner = await User.findOne({ email: newOwnerEmail }) + if (newOwner == null) { + throw new Error( + `${newOwnerEmail} must have logged in once before being added as Owner`, + ) + } + + // Update form's admin to new owner's id + this.admin = newOwner._id + + // Remove new owner from perm list and include previous owner as an editor + this.permissionList = filter(this.permissionList, (item) => { + return item.email !== newOwnerEmail + }) + this.permissionList.push({ email: currentOwner.email, write: true }) + } + // Statics // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function ( diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js index a6528cdfe9..2475d3bc1c 100644 --- a/src/app/routes/admin-forms.server.routes.js +++ b/src/app/routes/admin-forms.server.routes.js @@ -16,6 +16,12 @@ let PERMISSIONS = require('../utils/permission-levels').default const spcpFactory = require('../factories/spcp-myinfo.factory') const webhookVerifiedContentFactory = require('../factories/webhook-verified-content.factory') +const emailValOpts = { + minDomainSegments: 2, // Number of segments required for the domain + tlds: true, // TLD (top level domain) validation + multiple: false, // Disallow multiple emails +} + /** * Authenticates logged in user, before retrieving non-archived form * and verifying read/write permissions. @@ -282,6 +288,29 @@ module.exports = function (app) { .route('/:formId([a-fA-F0-9]{24})/adminform/feedback/download') .get(authActiveForm(PERMISSIONS.READ), adminForms.streamFeedback) + /** + * Transfer form ownership to another user + * @route POST /{formId}/adminform/transfer-owner + * @group forms - endpoints to manage forms + * @param {string} formId.path.required - the form id + * @param {string} request.body.email.required - the new owner's email address + * @produces application/json + * @returns {ErrorMessage.model} 500 - Errors while querying for response + * @returns {Object} 200 - Response document + */ + app.route('/:formId([a-fA-F0-9]{24})/adminform/transfer-owner').post( + authActiveForm(PERMISSIONS.DELETE), + celebrate({ + body: Joi.object().keys({ + email: Joi.string() + .required() + .email(emailValOpts) + .error(() => 'Please enter a valid email'), + }), + }), + adminForms.transferOwner, + ) + /** * On preview, submit a form response, processing it as an email to be sent to * the public servant who created the form. Optionally, email a PDF diff --git a/src/public/main.js b/src/public/main.js index a647a19cdc..d5fc80ce9a 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -195,6 +195,7 @@ require('./modules/forms/admin/directives/daterangepicker.client.directive.js') require('./modules/forms/admin/directives/scroll-to-id.client.directive.js') require('./modules/forms/admin/directives/edit-captcha.client.directive.js') require('./modules/forms/admin/controllers/create-form-template-modal.client.controller.js') +require('./modules/forms/admin/controllers/collaborator-modal.client.controller.js') // forms admin components require('./modules/forms/admin/components/share-form.client.component.js') @@ -507,6 +508,10 @@ app.run([ 'modules/forms/admin/views/create-form-template.client.modal.html', require('./modules/forms/admin/views/create-form-template.client.modal.html'), ) + $templateCache.put( + 'modules/forms/admin/views/collaborator.client.modal.html', + require('./modules/forms/admin/views/collaborator.client.modal.html'), + ) // Forms base componentViews $templateCache.put( diff --git a/src/public/modules/core/css/admin-form-header.css b/src/public/modules/core/css/admin-form-header.css index 6695bbfb6d..5155cc5edd 100644 --- a/src/public/modules/core/css/admin-form-header.css +++ b/src/public/modules/core/css/admin-form-header.css @@ -141,7 +141,19 @@ /* Form collaborator modal */ -#admin-form #collaborator-modal #collab-overlay { +@media all and (max-width: 768px) { + #collaborator-modal-body { + padding-right: 3em; + padding-left: 3em; + } + + #collaborator-modal-body .top-section-label { + display: flex; + justify-content: space-between; + } +} + +#collaborator-modal #collab-overlay { position: fixed; z-index: 450; width: 100%; @@ -151,34 +163,32 @@ background-color: rgba(50, 50, 50, 0.5); } -#admin-form #collab-role #role-dropdown-menu { +#collab-role #role-dropdown-menu { padding-top: 15px; padding-bottom: 15px; border-radius: 0; margin-top: 0; } -#admin-form #collaborator-modal-body .inputs-container { +#collaborator-modal-body .inputs-container { margin-bottom: 10px; } @media not all and (min-width: 768px) { - #admin-form #collaborator-modal-body { - position: fixed; + #collaborator-modal-body { width: 100%; - height: 100vh; top: 0; overflow-y: scroll; } - #admin-form #collab-list .role-column { + #collab-list .role-column { margin-bottom: 15px; margin-left: 0; padding-left: 0; padding-right: 0; } - #admin-form #collab-role #role-dropdown-menu { + #collab-role #role-dropdown-menu { width: inherit; } @@ -190,52 +200,55 @@ } @media all and (min-width: 768px) { - #admin-form #collaborator-modal-body { + #collaborator-modal-body { position: absolute; width: 700px; top: 125px; left: 50%; transform: translate(-50%); - margin-bottom: 105px; + } + + #collab-email { + padding-right: 10px; + } + + #collab-role { + padding-left: 0; } #admin-form #top-section .role-column { padding-left: 0; } - #admin-form #collab-list .role-column { + #collab-list .role-column { padding-left: 25px; padding-right: 25px; margin-bottom: 10px; } - #admin-form #collab-role #role-dropdown-menu { - width: 287px; - } - #admin-form .non-mobile-hide { display: none !important; } } -#admin-form #collaborator-modal .role-dropdown-btn { +#collaborator-modal .role-dropdown-btn { height: 48px; background-color: #fff; } -#admin-form #collaborator-modal-body .remove-left-padding { +#collaborator-modal-body .remove-left-padding { padding-left: 0; } -#admin-form #collaborator-modal-body .remove-right-padding { +#collaborator-modal-body .remove-right-padding { padding-right: 0; } -#admin-form #role-dropdown-menu .role-explanation { +#role-dropdown-menu .role-explanation { color: #888; } -#admin-form #collab-role .caret { +#collab-role .caret { margin-top: 12px; border-left: 6px solid transparent; border-right: 6px solid transparent; @@ -246,101 +259,103 @@ height: 15px; } -#admin-form #collaborator-modal-body { - z-index: 500; - box-shadow: 0 6px 8px 0 rgba(0, 0, 0, 0.24), 0 0 8px 0 rgba(0, 0, 0, 0.12); -} - -#admin-form #collaborator-modal-body #top-section { - padding: 30px 39px 40px 39px; - background-color: #f0f4f6; -} - -#admin-form #collaborator-modal-body #bottom-section { - background-color: #fff; +#collaborator-modal-body { + z-index: 750; } @media all and (min-width: 768px) { - #admin-form #collaborator-modal-body #bottom-section { + #collaborator-modal-body #bottom-section { padding-bottom: 30px; } } @media not all and (min-width: 768px) { - #admin-form #collaborator-modal-body #bottom-section { + #collaborator-modal-body #bottom-section { /* This padding is to prevent the last dropdown from getting cut off */ padding-bottom: 130px; } } -#admin-form #collab-role .dropdown-disabled { +#collab-role .dropdown-disabled { background-color: #f0f0f0; border: solid 1px #ccc; cursor: default; color: #484848; } -#admin-form #collab-role .dropdown-disabled > span { +#collab-role .dropdown-disabled > span { opacity: 0.5; } -#admin-form #collaborator-modal-body #invite-title { +#collaborator-modal-body #invite-title { margin-bottom: 20px; font-size: 22px; font-weight: bold; } -#admin-form #collaborator-modal-body button#collab-btn { +#collaborator-modal-body button#collab-btn { width: 100%; } -#admin-form #collaborator-modal-body #collab-title { - padding-top: 30px; - padding-left: 39px; +#collaborator-modal-body #collab-title { + font-size: 16px; + font-weight: normal; + font-style: normal; + font-stretch: normal; + line-height: 1.38; + letter-spacing: 0; + color: #4a4a4a; padding-bottom: 25px; } -#admin-form #collaborator-modal-body .collab-hr { +#collaborator-modal-body #transfer-owner-cancel-btn { + font-size: 18px; + color: #2f60ce; + background-color: white; + border: 0; + letter-spacing: 1.4px; + text-transform: uppercase; + cursor: pointer; + text-decoration: underline; +} + +#collaborator-modal-body .collab-hr { height: 1px; - border: solid 0.5px #dcdcdc; + border: solid 1px #dcdcdc; margin-bottom: 10px; } -#admin-form #collaborator-modal-body #collab-list { +#collaborator-modal-body #collab-list { /* If they squeeze the screen too small, white screen will appear at the bottom. TODO: Fix if possible. */ min-height: 60px; - padding-left: 39px; - padding-right: 39px; } -#admin-form #collaborator-modal-body .secret-key-warning { +#collaborator-modal-body .secret-key-warning { margin-bottom: 30px; } @media all and (min-width: 768px) { - #admin-form #collaborator-modal-body #collab-list { + #collaborator-modal-body #collab-list { /* Max height should avoid extending the screen, because if the modal + dropdown menu is taller than the current screen's height, the bottom of the page will just appear white and not load. */ max-height: calc(100vh - 608px); - - /* Always show the scrollbar, because opening the collaborator dropdowns will introduce a compensating margin - (.lock-scroll) to make up for a lost scrollbar to prevent the elements from jumping too much */ - overflow-y: scroll; + overflow-y: auto; + padding-right: 39px; } - #admin-form #collaborator-modal-body .secret-key-warning { + #collaborator-modal-body .secret-key-warning { font-size: 17px; } } @media not all and (min-width: 768px) { - #admin-form #collaborator-modal-body #collab-list { + #collaborator-modal-body #collab-list { /* For mobile, modal must be full screen, so enforce this minimum height to cover the screen. */ min-height: calc(100vh - 452px); } - #admin-form #collaborator-modal-body .secret-key-warning { + #collaborator-modal-body .secret-key-warning { font-size: 14px; } } @@ -358,7 +373,7 @@ } } -#admin-form #collaborator-modal-body #collab-list .collab-email { +#collaborator-modal-body #collab-list .collab-email { line-height: 1.67; color: #484848; margin-bottom: 10px; @@ -367,17 +382,17 @@ text-overflow: ellipsis; } -#admin-form #collaborator-modal-body .collaborator-list-entry { +#collaborator-modal-body .collaborator-list-entry { margin-left: auto; margin-right: auto; } -#admin-form #collaborator-modal-body #collab-list .btn-delete { +#collaborator-modal-body #collab-list .btn-delete { float: right; padding-right: 5px; } -#admin-form #collaborator-modal-body #collaborators-close-button { +#collaborator-modal-body #collaborators-close-button { cursor: pointer; height: 20px; width: 20px; @@ -392,11 +407,11 @@ top: 25px; } -#admin-form #collaborator-modal-body #collaborators-close-button:hover { +#collaborator-modal-body #collaborators-close-button:hover { color: #333; } -#admin-form #collab-list .spacer { +#collab-list .spacer { height: 5px; } @@ -405,28 +420,29 @@ padding-top: 5px; padding-bottom: 5px; border-radius: 0; + z-index: 1050; } -#admin-form .existing-role-button { +.existing-role-button { cursor: pointer; } -#admin-form #collab-list .caret { +#collab-list .caret { margin-top: 11px; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px dashed; } -#admin-form #collab-list .role-dropdown-text { +#collab-list .role-dropdown-text { font-size: 16px; } -#admin-form #existing-collab-role .dropdown-menu > li > a { +#existing-collab-role .dropdown-menu > li > a { padding-left: 15px; padding-right: 15px; } -#admin-form #collab-list .current-role-text { +#collab-list .current-role-text { line-height: 1.67; } diff --git a/src/public/modules/core/css/avatar-dropdown.css b/src/public/modules/core/css/avatar-dropdown.css index 124646e7bc..e1bb64c6e1 100644 --- a/src/public/modules/core/css/avatar-dropdown.css +++ b/src/public/modules/core/css/avatar-dropdown.css @@ -28,6 +28,14 @@ position: relative; } +@media not all and (min-width: 768px) { + .navbar__avatar__avatar { + height: 36px; + width: 36px; + font-size: 14px; + } +} + .navbar__avatar__avatar--alert { position: absolute; bottom: 12px; 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 353796f527..c98c862997 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 @@ -3,18 +3,6 @@ const HttpStatus = require('http-status-codes') const { LogicType } = require('../../../../../types') -/** - * @typedef {string} Role - */ -/** - * @enum {Role} - */ -const ROLES = { - ADMIN: 'Owner', - EDITOR: 'Editor', - VIEWER: 'Read only', -} - // All viewable tabs. readOnly is true if that tab cannot be used to edit form. const VIEW_TABS = [ { title: 'Build', route: 'build', readOnly: false }, @@ -46,12 +34,11 @@ angular .controller('AdminFormController', [ '$scope', '$translate', + '$uibModal', 'FormData', 'Auth', 'moment', - 'FormFields', 'Toastr', - '$timeout', '$state', '$window', 'FormApi', @@ -61,21 +48,17 @@ angular function AdminFormController( $scope, $translate, + $uibModal, FormData, Auth, moment, - FormFields, Toastr, - $timeout, $state, $window, FormApi, ) { // Banner message on form builder routes $scope.bannerContent = $window.siteBannerContent || $window.adminBannerContent - - $scope.ROLES = ROLES - $scope.myform = FormData.form // Redirect to signin if unable to get user @@ -121,23 +104,8 @@ function AdminFormController( ) /* Collaborator */ - - // Stores the state of the collab form UI - $scope.collab = { - isModalShown: false, - hasEmailError: false, - isAlreadyCollabError: false, - // Three possible button states - // 1 - unpressed, - // 2 - pressed; loading, - // 3 - pressed; saved - btnStatus: 1, - // Stores the open/close state of every collaborator dropdown - this is an array of booleans, where each index - // is bound to the is-open attribute of the corresponding edit collaborator dropdown menu. - collaboratorDropdownsOpen: [], - // This is used to prevent scrolling when an edit collaborator dropdown is opened, because the dropdown is appended - // to the html body, and will not follow the dropdown button if the user scrolls the page while open. - lockCollaboratorScroll: false, + $scope.refreshFormDataFromCollab = (form) => { + $scope.myform = form } /** @@ -146,209 +114,75 @@ function AdminFormController( * the page while edit collaborator dropdowns (which are appended to the body) are open. */ $scope.toggleCollabModal = () => { - // Lock the scrolling on mobile when the collab modal is open. - if (!$scope.collab.isModalShown) { - angular.element('body').addClass('mobile-scroll-lock') - } else { - angular.element('body').removeClass('mobile-scroll-lock') - } - $scope.collab.isModalShown = !$scope.collab.isModalShown - } - - /** - * Update the form by adding the email that was filled in the input, with the permissions according to - * the selected role (edit rights by default), into the permissionList. - * Admins, the user themselves, and users already in the permissionList cannot be added to the form. - */ - $scope.saveCollabEmail = () => { - let email = $scope.collab.form.email.toLowerCase() - let permissions = roleToPermissions($scope.collab.form.role) - - // Not allowed to add oneself or existing collaborator/admin as collaborator - if ( - email === $scope.user.email.toLowerCase() || - $scope.myform.permissionList.find( - (user) => user.email.toLowerCase() === email, - ) || - email === $scope.myform.admin.email - ) { - $scope.collab.isAlreadyCollabError = true - $timeout(() => { - $scope.collab.isAlreadyCollabError = false - }, 3000) - } else { - // Allowed to add collaborator - - // Deep copy form to use - let permissionList = [ - { - email, - ...permissions, - }, - ].concat(_.cloneDeep($scope.myform.permissionList)) - - $scope.collab.btnStatus = 2 // pressed; loading - $scope.collab.hasEmailError = false - $scope.collab.isAlreadyCollabError = false - $timeout(() => { - $scope.updateForm({ permissionList }).then((err) => { - if (err) { - // If error, second timeout to make error disappear - $scope.collab.hasEmailError = true - - // Make the alert message correspond to the error code - if (err.status === HttpStatus.BAD_REQUEST) { - $scope.alertMessage = 'Outdated admin page, please refresh' - } else if (err.status === HttpStatus.UNPROCESSABLE_ENTITY) { - $scope.alertMessage = `${email} is not part of a whitelisted agency` - } else { - $scope.alertMessage = 'Error adding collaborator' - } - - resetCollabForm() - $timeout(() => { - $scope.collab.hasEmailError = false - $scope.alertMessage = '' - }, 3000) - } else { - // If no error, clear email input - $scope.collab.btnStatus = 3 // pressed; saved - $scope.collab.hasEmailError = false - $scope.closeEditCollaboratorDropdowns() - $timeout(() => { - resetCollabForm() - }, 1000) - } - }) - }, 1000) - } - /** - * Clear the input, set the button to its unpressed state, and set the to-be-added collaborator's role to editor. - */ - function resetCollabForm() { - $scope.collab.btnStatus = 1 // unpressed - $scope.collab.form.email = '' - $scope.collab.form.role = $scope.ROLES.EDITOR - } - } - - /** - * Remove the user object that has the email provided from the permissionList. - * @param {String} email - The email of the user to remove. - */ - $scope.deleteCollabEmail = (email) => { - let permissionList = _.cloneDeep( - $scope.myform.permissionList.filter( - (user) => user.email.toLowerCase() !== email.toLowerCase(), - ), - ) - $scope.updateForm({ permissionList }) - } - - /** - * Determine role of user based on permission level. This is used to display the role of the user on - * the collaborators list. Admins will not be rendered using this function, they are hardcoded into the view - * instead of determined by this function. - * @param {Object} userObj - The user object to check - * @param {Boolean} userObj.write - The write permissions of the user object - */ - $scope.permissionsToRole = (userObj) => { - if (userObj.write) { - return ROLES.EDITOR - } else { - return ROLES.VIEWER - } - } - - /** - * Determine permission level of a user based on role. This is used to create new user objects to be - * added into the permission list. - * @param {Role} role - The role to determine permissions for - */ - function roleToPermissions(role) { - switch (role) { - case ROLES.ADMIN: - case ROLES.EDITOR: - return { write: true } - case ROLES.VIEWER: - return { write: false } - default: - throw new Error('Invalid role!') - } - } - - /** - * Set the collab form's role. Called via the dropdown menu. - * @param {Role} role - The selected role - */ - $scope.selectRole = function (role) { - $scope.collab.form.role = role - } - - /** - * Update the role of an existing user in the permissionList. Called via an edit collaborator dropdown menu. - * @param {Number} index - The index of the user object in the permissionList (the ng-repeat index) - * @param {Role} newRole - The selected role - */ - $scope.updateRole = function (index, newRole) { - if ( - $scope.myform.permissionList && - index > -1 && - index < $scope.myform.permissionList.length - ) { - let { write } = roleToPermissions(newRole) - let permissionList = _.cloneDeep($scope.myform.permissionList) - permissionList[index].write = write - $scope.updateForm({ permissionList }) - } - } - - /* State management for the edit collaborator dropdowns */ - - // collaboratorDropdownsOpen, toggleScrollLock and closeEditCollaboratorDropdowns is used due to a bug with uib-dropdown's toggle-open with - // multiple dropdowns: https://github.com/angular-ui/bootstrap/issues/6316 . If our dropdown library - // was not buggy, the following code would not be necessary. - - /** - * Activates scroll-lock when there is any dropdown open. The function is used as a dropdown event listener; - * when an edit collaborator dropdown is opened, it calls this function with isOpen === true, and when one is closed, - * it is called with isOpen === false. - * @param {Boolean} isOpen - True if the dropdown is being opened, false if it is being closed - * @param {Number} index - The index of the dropdown being opened/closed - */ - $scope.toggleScrollLock = (isOpen, index) => { - // First, handle the dropdown event by updating the state accordingly. - $scope.collab.collaboratorDropdownsOpen = $scope.collab.collaboratorDropdownsOpen.map( - (state, idx) => { - // When opening a dropdown, close everything else - if (isOpen) { - return index === idx - // When closing a dropdown, close only the dropdown that is actually being closed, the rest should be left as they are. - // This is because if you click a new dropdown while a previous dropdown is opened, you will call the "onOpen" of the new one, - // and immediately call the "onClose" of the previous dropdown, and we don't want to automatically close the new one. - } else { - return index === idx ? false : state - } + angular.element('body').addClass('mobile-scroll-lock') + $uibModal.open({ + animation: false, + templateUrl: 'modules/forms/admin/views/collaborator.client.modal.html', + windowClass: 'full-screen-modal-window', + controller: 'CollaboratorModalController', + resolve: { + externalScope: () => ({ + form: $scope.myform, + user: $scope.user, + userCanEdit: $scope.userCanEdit, + refreshFormDataFromCollab: $scope.refreshFormDataFromCollab, + }), }, - ) - // Lock the scroll if any of the edit collaborator dropdowns are open - $scope.collab.lockCollaboratorScroll = $scope.collab.collaboratorDropdownsOpen.find( - (state) => state === true, - ) + }) } /** - * Sets the state of all collaborator dropdowns to closed, and unlocks the collaborator modal scroll locks. + * Calls the update form api + * formId is the id of the form to update + * updatedForm is a subset of fields from scope.myform which have been edited + * + * @param {Object} {formId: String, updatedForm: Object} + * @returns Promise */ - $scope.closeEditCollaboratorDropdowns = () => { - $scope.collab.collaboratorDropdownsOpen = $scope.collab.collaboratorDropdownsOpen.map( - (_state) => false, - ) - $scope.collab.lockCollaboratorScroll = false + $scope.updateForm = (update) => { + return FormApi.update({ formId: $scope.myform._id }, { form: update }) + .$promise.then((savedForm) => { + // Updating this form updates lastModified + // and also updates myform if a formToUse is passed in + $scope.myform = savedForm + }) + .catch((error) => { + if (!error) { + return + } + let errorMessage + switch (error.status) { + case HttpStatus.BAD_REQUEST: + errorMessage = + 'This page seems outdated, and your changes could not be saved. Please refresh.' + break + case HttpStatus.UNAUTHORIZED: + errorMessage = + 'Your changes could not be saved as your account lacks the requisite privileges.' + break + case HttpStatus.UNPROCESSABLE_ENTITY: + // Validation can fail for many reasons, so return more specific message + errorMessage = _.get( + error, + 'data.message', + 'Your changes contain invalid input.', + ) + break + case HttpStatus.REQUEST_TOO_LONG: // HTTP Payload Too Large + errorMessage = ` + Your form is too large. Reduce the number of fields, or submit a + Support Form. + ` + break + default: + errorMessage = 'An error occurred while saving your changes.' + } + Toastr.error(errorMessage) + return error + }) } /* Logic stuff with checking across Build and Logic tabs */ - $scope.$watch( '[myform.form_fields, myform.form_logics]', () => { @@ -391,56 +225,4 @@ function AdminFormController( return field._id === fieldId }) } - - /* Server update of form */ - - /** - * Calls the update form api - * formId is the id of the form to update - * updatedForm is a subset of fields from scope.myform which have been edited - * - * @param {Object} {formId: String, updatedForm: Object} - * @returns Promise - */ - $scope.updateForm = (update) => { - return FormApi.update({ formId: $scope.myform._id }, { form: update }) - .$promise.then((savedForm) => { - // Updating this form updates lastModified - // and also updates myform if a formToUse is passed in - $scope.myform = savedForm - }) - .catch((error) => { - if (error) { - let errorMessage - switch (error.status) { - case HttpStatus.BAD_REQUEST: - errorMessage = - 'This page seems outdated, and your changes could not be saved. Please refresh.' - break - case HttpStatus.UNAUTHORIZED: - errorMessage = - 'Your changes could not be saved as your account lacks the requisite privileges.' - break - case HttpStatus.UNPROCESSABLE_ENTITY: - // Validation can fail for many reasons, so return more specific message - errorMessage = _.get( - error, - 'data.message', - 'Your changes contain invalid input.', - ) - break - case HttpStatus.REQUEST_TOO_LONG: // HTTP Payload Too Large - errorMessage = ` - Your form is too large. Reduce the number of fields, or submit a - Support Form. - ` - break - default: - errorMessage = 'An error occurred while saving your changes.' - } - Toastr.error(errorMessage) - } - return error - }) - } } diff --git a/src/public/modules/forms/admin/controllers/collaborator-modal.client.controller.js b/src/public/modules/forms/admin/controllers/collaborator-modal.client.controller.js new file mode 100644 index 0000000000..8ac932fed2 --- /dev/null +++ b/src/public/modules/forms/admin/controllers/collaborator-modal.client.controller.js @@ -0,0 +1,344 @@ +'use strict' + +const HttpStatus = require('http-status-codes') + +angular + .module('forms') + .controller('CollaboratorModalController', [ + '$scope', + '$timeout', + '$uibModalInstance', + 'externalScope', + 'Toastr', + 'FormApi', + CollaboratorModalController, + ]) + +/** + * @typedef {string} Role + */ +/** + * @enum {Role} + */ +const ROLES = { + ADMIN: 'Owner', + EDITOR: 'Editor', + VIEWER: 'Read only', +} + +function CollaboratorModalController( + $scope, + $timeout, + $uibModalInstance, + externalScope, + Toastr, + FormApi, +) { + $scope.ROLES = ROLES + $scope.myform = externalScope.form + $scope.user = externalScope.user + $scope.userCanEdit = externalScope.userCanEdit + $scope.collab = {} + + // Three possible button states + // 1 - unpressed, + // 2 - pressed; loading, + // 3 - pressed; saved + $scope.btnStatus = 1 + + // Stores the open/close state of every collaborator dropdown - this is an array of booleans, + // where each index is bound to the is-open attribute of the corresponding edit collaborator + // dropdown menu. + $scope.collaboratorDropdownsOpen = [] + + // This is used to prevent scrolling when an edit collaborator dropdown is opened, because + // the dropdown is appended to the html body, and will not follow the dropdown button if + // the user scrolls the page while open. + $scope.lockCollaboratorScroll = false + + $scope.isDisplayTransferOwnerModal = false + $scope.isDisplayTransferSuccessMessage = false + $scope.isDisplayAlertMessage = false + $scope.transferOwnerEmail = undefined + $scope.successMessage = undefined + $scope.alertMessage = undefined + + /** + * Transfers ownership of the form to the selected user, reset UI messages + */ + $scope.transferOwner = () => { + $scope.resetMessages() + + if ($scope.transferOwnerEmail === $scope.myform.admin.email) { + $scope.isDisplayAlertMessage = true + $scope.alertMessage = 'You are already the owner of this form' + $scope.isDisplayTransferOwnerModal = false + return + } + + FormApi.transferOwner( + { formId: $scope.myform._id }, + { email: $scope.transferOwnerEmail }, + ) + .$promise.then((res) => { + $scope.myform = res.form + externalScope.refreshFormDataFromCollab($scope.myform) + $scope.isDisplayTransferSuccessMessage = true + }) + .catch((err) => { + $scope.alertMessage = err.data.message + $scope.isDisplayAlertMessage = true + }) + .finally(() => { + $scope.isDisplayTransferOwnerModal = false + }) + } + + /** + * Calls FormAPI update to update the permission list of a form + * @param {Array} permissionList - New permission list for the form + */ + $scope.updatePermissionList = (permissionList) => { + return FormApi.update( + { formId: $scope.myform._id }, + { form: { permissionList } }, + ) + .$promise.then((savedForm) => { + $scope.myform = savedForm + externalScope.refreshFormDataFromCollab($scope.myform) + }) + .catch((err) => { + $scope.alertMessage = err.data.message + $scope.isDisplayAlertMessage = true + return err + }) + } + + /** + * Update the role of an existing user in the permissionList. + * Called via an edit collaborator dropdown menu. + * @param {Number} index - The index of the user object in the permissionList (the ng-repeat index) + * @param {Role} newRole - The selected role + */ + $scope.updateRole = function (index, newRole) { + $scope.resetMessages() + + if ( + $scope.myform.permissionList && + index > -1 && + index < $scope.myform.permissionList.length + ) { + let { write } = $scope.roleToPermissions(newRole) + let permissionList = _.cloneDeep($scope.myform.permissionList) + permissionList[index].write = write + $scope.updatePermissionList(permissionList) + } + } + + /** + * Remove the user object that has the email provided from the permissionList. + * @param {String} email - The email of the user to remove. + */ + $scope.deleteCollabEmail = (email) => { + let permissionList = _.cloneDeep( + $scope.myform.permissionList.filter( + (user) => user.email.toLowerCase() !== email.toLowerCase(), + ), + ) + $scope.updatePermissionList(permissionList) + } + + /** + * Set the collab form's role. Called via the dropdown menu. + * @param {Role} role - The selected role + */ + $scope.selectRole = function (role) { + $scope.collab.form.role = role + } + + /** + * Determine role of user based on permission level. This is used to display the role of the user on + * the collaborators list. Admins will not be rendered using this function, + * they are hardcoded into the view instead of determined by this function. + * @param {Object} userObj - The user object to check + * @param {Boolean} userObj.write - The write permissions of the user object + */ + $scope.permissionsToRole = (userObj) => { + if (userObj.write) { + return ROLES.EDITOR + } else { + return ROLES.VIEWER + } + } + + /** + * Determine permission level of a user based on role. This is used to create new user objects to be + * added into the permission list. + * @param {Role} role - The role to determine permissions for + */ + $scope.roleToPermissions = (role) => { + switch (role) { + case ROLES.ADMIN: + case ROLES.EDITOR: + return { write: true } + case ROLES.VIEWER: + return { write: false } + default: + throw new Error('Invalid role!') + } + } + + /** + * Update the form by adding the email that was filled in the input, with the permissions according to + * the selected role (edit rights by default), into the permissionList. + * Admins, the user themselves, and users already in the permissionList cannot be added to the form. + */ + $scope.saveCollabEmail = () => { + $scope.resetMessages() + + let email = $scope.collab.form.email.toLowerCase() + let permissions = $scope.roleToPermissions($scope.collab.form.role) + + // Not allowed to add oneself or existing collaborator/admin as collaborator + if ( + email === $scope.user.email.toLowerCase() || + $scope.myform.permissionList.find( + (user) => user.email.toLowerCase() === email, + ) || + email === $scope.myform.admin.email + ) { + $scope.isDisplayAlertMessage = true + $scope.alertMessage = + 'This user is an existing collaborator. Edit role below.' + return + } + + /** + * Clear the input, set the button to its unpressed state, + * and set the to-be-added collaborator's role to editor. + */ + function resetCollabForm() { + $scope.btnStatus = 1 // unpressed + $scope.collab.form.email = '' + $scope.collab.form.role = ROLES.EDITOR + } + // Allowed to add collaborator + // Deep copy form to use + let permissionList = [{ email, ...permissions }].concat( + _.cloneDeep($scope.myform.permissionList), + ) + + $scope.btnStatus = 2 // pressed; loading + $scope.updatePermissionList(permissionList).then((err) => { + if (err) { + // Make the alert message correspond to the error code + if (err.status === HttpStatus.BAD_REQUEST) { + $scope.alertMessage = 'Outdated admin page, please refresh' + } else if (err.status === HttpStatus.UNPROCESSABLE_ENTITY) { + $scope.alertMessage = `${email} is not part of a whitelisted agency` + } else { + $scope.alertMessage = 'Error adding collaborator' + } + resetCollabForm() + $scope.resetMessages() + return + } + // If no error, clear email input + $scope.btnStatus = 3 // pressed; saved + $scope.closeEditCollaboratorDropdowns() + $scope.resetMessages() + $timeout(() => { + resetCollabForm() + }, 1000) + }) + } + + /** + * Handle modal close + */ + $scope.closeModal = () => { + angular.element('body').removeClass('mobile-scroll-lock') + $uibModalInstance.close('cancel') + } + + /** + * Toggle transfer ownership confirmation view + */ + $scope.toggleTransferOwnerModal = () => { + $scope.isDisplayTransferOwnerModal = !$scope.isDisplayTransferOwnerModal + } + + /** + * Sets the state of all collaborator dropdowns to closed, + * and unlocks the collaborator modal scroll locks. + */ + $scope.closeEditCollaboratorDropdowns = () => { + $scope.collaboratorDropdownsOpen = $scope.collaboratorDropdownsOpen.map( + (_state) => false, + ) + $scope.lockCollaboratorScroll = false + } + + /** + * Clear all error and success messages + */ + $scope.resetMessages = () => { + $scope.isDisplayTransferSuccessMessage = false + $scope.isDisplayAlertMessage = false + } + + /** + * Display transfer ownership verification view + */ + $scope.handleTransferOwnerButtonClick = () => { + const email = $scope.collab.form.email.toLowerCase() + $scope.transferOwnerEmail = email + $scope.toggleTransferOwnerModal() + } + + /** + * Display transfer ownership verification view + */ + $scope.handleUpdateRoleToOwner = (index) => { + const email = $scope.myform.permissionList[index].email + $scope.transferOwnerEmail = email + $scope.toggleTransferOwnerModal() + $scope.collaboratorDropdownsOpen[index] = false + } + + /* State management for the edit collaborator dropdowns */ + + // collaboratorDropdownsOpen, toggleScrollLock and closeEditCollaboratorDropdowns are used due + // to a bug with uib-dropdown's toggle-open with multiple dropdowns: + // https://github.com/angular-ui/bootstrap/issues/6316 . + + /** + * Activates scroll-lock when there is any dropdown open. The function is used as a dropdown + * event listener; when an edit collaborator dropdown is opened, it calls this function with + * isOpen === true, and when one is closed, it is called with isOpen === false. + * @param {Boolean} isOpen - True if the dropdown is being opened, false if it is being closed + * @param {Number} index - The index of the dropdown being opened/closed + */ + $scope.toggleScrollLock = (isOpen, index) => { + // First, handle the dropdown event by updating the state accordingly. + $scope.collaboratorDropdownsOpen = $scope.collaboratorDropdownsOpen.map( + (state, idx) => { + // When opening a dropdown, close everything else + if (isOpen) { + return index === idx + // When closing a dropdown, close only the dropdown that is actually being closed, + // the rest should be left as they are. This is because if you click a new dropdown + // while a previous dropdown is opened, you will call the "onOpen" of the new one, + // and immediately call the "onClose" of the previous dropdown, and we don't want + // to automatically close the new one. + } else { + return index === idx ? false : state + } + }, + ) + // Lock the scroll if any of the edit collaborator dropdowns are open + $scope.lockCollaboratorScroll = $scope.collaboratorDropdownsOpen.find( + (state) => state === true, + ) + } +} diff --git a/src/public/modules/forms/admin/css/pop-up-modal.css b/src/public/modules/forms/admin/css/pop-up-modal.css index 2d0cdbbaf8..205445bc9a 100644 --- a/src/public/modules/forms/admin/css/pop-up-modal.css +++ b/src/public/modules/forms/admin/css/pop-up-modal.css @@ -27,6 +27,7 @@ } } +.collaborator-modal-window, .create-form-template-modal-window, .full-screen-modal-window, .delete-field-modal-window, @@ -44,6 +45,7 @@ padding: 15px 40px; } +.collaborator-modal-window .modal-content, .create-form-template-modal-window .modal-content, .full-screen-modal-window .modal-content, .submit-progress-modal-window .modal-content, 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 802bb8ae36..39d19f2283 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 @@ -2,21 +2,20 @@

-
+ -
+

{{myform.title}}

Saved at {{lastModifiedString}}

-
+
- -
-
-
- -
- - Collaborators need the Secret Key to access form responses. Share - the Secret Key in a secure manner according to your agency's - policies. -
- -
-
- -
-
-
-
- -
- -
Collaborators
-
- -
-
- {{ myform.admin.email}} -
-
- {{ ROLES.ADMIN }} -
-
-
-
- -
-
-
- {{userObj.email}} -
-
- -
- {{ permissionsToRole(userObj) }} -
- - - -
-
- -
-
-
-
-
-
-
-
-
diff --git a/src/public/modules/forms/admin/views/collaborator.client.modal.html b/src/public/modules/forms/admin/views/collaborator.client.modal.html new file mode 100644 index 0000000000..93a02959d4 --- /dev/null +++ b/src/public/modules/forms/admin/views/collaborator.client.modal.html @@ -0,0 +1,321 @@ + +
+
+ +
+ +
+
+
+ Transfer ownership to {{transferOwnerEmail}} +
+
+ Are you sure? You will lose form ownership and the right to delete this + form. You will still have Editor rights. +
+ + +
+ +
+
+ + {{ alertMessage }} +
+
+ + + Form ownership transferred. You are now an Editor. + +
+ + +
+ + + +
+
+ +
+ + +
+ + Please enter a valid email address +
+
+ + +
+ +
+ + + Share your secret key with collaborators who need to access + response data. + +
+
+
+ + +
+
+
+
+ +
+
+
+ + +
+ +
Existing collaborators
+
+ +
+
+ {{ myform.admin.email}} +
+
+ {{ ROLES.ADMIN }} +
+
+
+
+ + +
+
+
+ {{userObj.email}} +
+
+ +
+ {{ permissionsToRole(userObj) }} +
+ + + +
+
+ +
+
+
+
+
+
+
+
+
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 a8862516dc..15d50337d8 100644 --- a/src/public/modules/forms/services/form-api.client.factory.js +++ b/src/public/modules/forms/services/form-api.client.factory.js @@ -134,6 +134,11 @@ function FormApi($resource, FormErrorService, FormFields) { method: 'POST', interceptor: getInterceptor(true, 'useTemplate'), }, + transferOwner: { + url: resourceUrl + '/transfer-owner', + method: 'POST', + interceptor: getInterceptor(false), + }, }, ) } diff --git a/src/types/form.ts b/src/types/form.ts index 8c1de9af5f..f69e09b2b8 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -106,6 +106,7 @@ export interface IFormSchema extends IForm, Document { getMainFields(): Pick getUniqMyinfoAttrs(): MyInfoAttribute[] duplicate(overrideProps: Partial): Partial + transferOwner(currentOwner: IUserSchema, newOwnerEmail: string): void } export interface IPopulatedForm extends IFormSchema { 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 070e7991b3..edc5e4032b 100644 --- a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js @@ -246,6 +246,52 @@ describe('Admin-Forms Controller', () => { }) }) + describe('transferOwner', () => { + it('should update admin id and set current owner as editor', async (done) => { + const collabAdmin = await User.create({ + email: 'original@test.gov.sg', + _id: mongoose.Types.ObjectId('000000000002'), + agency: testAgency._id, + }) + + const newOwner = await User.create({ + email: 'newOwner@test.gov.sg', + _id: mongoose.Types.ObjectId('000000000003'), + agency: testAgency._id, + }) + + const form1 = new Form({ + title: 'Transfer Owner Form', + admin: collabAdmin._id, + permissionList: [roles.collaborator(newOwner.email)], + }) + await form1.save() + + req.session.user = collabAdmin + req.body.email = newOwner.email + req.form = form1 + + res.json.and.callFake((args) => { + expect(args.form.admin._id.toString()).toEqual(newOwner._id.toString()) + expect(args.form.permissionList.length).toEqual(1) + expect(args.form.permissionList[0].email).toEqual(collabAdmin.email) + expect(args.form.permissionList[0].write).toEqual(true) + + Form.findOne({ _id: args.form._id }, (err, foundForm) => { + if (!err) { + let form = foundForm.toObject() + expect(form.admin.toString()).toEqual(newOwner._id.toString()) + expect(form.permissionList.length).toEqual(1) + expect(form.permissionList[0].email).toEqual(collabAdmin.email) + expect(form.permissionList[0].write).toEqual(true) + } + done(err) + }) + }) + Controller.transferOwner(req, res) + }) + }) + describe('list', () => { it('should fetch forms with corresponding admin or collaborators and sorted by last modified', async (done) => { const currentAdmin = testUser diff --git a/tests/unit/backend/jasmine.json b/tests/unit/backend/jasmine.json index e1bfb61284..2b706accb4 100644 --- a/tests/unit/backend/jasmine.json +++ b/tests/unit/backend/jasmine.json @@ -8,4 +8,4 @@ ], "stopSpecOnExpectationFailure": false, "random": true -} +} \ No newline at end of file From cbecaa574bf1a1a43d370871ed406b5183ca0603 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Tue, 22 Sep 2020 10:44:44 +0800 Subject: [PATCH 24/29] feat: automate critical bounce handling (#318) --- .template-env | 2 +- docker-compose.yml | 2 +- docs/DEPLOYMENT_SETUP.md | 80 +- src/app/models/form.server.model.ts | 14 + .../bounce/__tests__/bounce-test-helpers.ts | 69 +- .../__tests__/bounce.controller.spec.ts | 201 +++- .../bounce/__tests__/bounce.model.spec.ts | 560 ++++++++-- .../bounce/__tests__/bounce.service.spec.ts | 965 ++++++++---------- src/app/modules/bounce/bounce.controller.ts | 40 +- src/app/modules/bounce/bounce.model.ts | 147 ++- src/app/modules/bounce/bounce.service.ts | 153 ++- src/app/modules/bounce/bounce.util.ts | 29 +- .../form/__tests__/form.service.spec.ts | 27 + src/app/modules/form/form.service.ts | 13 + src/app/services/mail.service.ts | 107 +- src/app/utils/mail.ts | 44 +- ...ce-notification-permanent.server.view.html | 117 +++ ...ce-notification-transient.server.view.html | 108 ++ .../bounce-notification.server.view.html | 23 - src/config/schema.ts | 2 +- src/types/bounce.ts | 27 +- src/types/index.ts | 1 + src/types/mail.ts | 81 ++ src/types/sns.ts | 7 +- tests/unit/backend/helpers/jest-logger.ts | 7 - .../backend/models/form.server.model.spec.ts | 30 + .../backend/services/mail.service.spec.ts | 189 +++- 27 files changed, 2142 insertions(+), 903 deletions(-) create mode 100644 src/app/modules/form/__tests__/form.service.spec.ts create mode 100644 src/app/modules/form/form.service.ts create mode 100644 src/app/views/templates/bounce-notification-permanent.server.view.html create mode 100644 src/app/views/templates/bounce-notification-transient.server.view.html delete mode 100644 src/app/views/templates/bounce-notification.server.view.html create mode 100644 src/types/mail.ts diff --git a/.template-env b/.template-env index 8ef7241b96..2f8dda0b6b 100644 --- a/.template-env +++ b/.template-env @@ -30,7 +30,7 @@ FORMSG_SDK_MODE= ## App Config # APP_NAME=FormSG # OTP_LIFE_SPAN=900000 -# BOUNCE_LIFE_SPAN=1800000 +# BOUNCE_LIFE_SPAN=86400000 # AGGREGATE_COLLECTION= # If provided, a banner with the provided message will show up in every form. diff --git a/docker-compose.yml b/docker-compose.yml index 1bc0601dd9..7ca87965b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: - IMAGE_S3_BUCKET=local-image-bucket - LOGO_S3_BUCKET=local-logo-bucket - FORMSG_SDK_MODE=development - - BOUNCE_LIFE_SPAN=1800000 + - BOUNCE_LIFE_SPAN=86400000 - AWS_ACCESS_KEY_ID=fakeKey - AWS_SECRET_ACCESS_KEY=fakeSecret - SESSION_SECRET=thisisasecret diff --git a/docs/DEPLOYMENT_SETUP.md b/docs/DEPLOYMENT_SETUP.md index 384faa974c..ffa33d004d 100644 --- a/docs/DEPLOYMENT_SETUP.md +++ b/docs/DEPLOYMENT_SETUP.md @@ -158,20 +158,20 @@ The following env variables are set in Travis: #### Email and Nodemailer -| Variable | Description | -| :-------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SES_HOST` | SMTP hostname. | -| `SES_PORT` | SMTP port number. | -| `SES_USER` | SMTP username. | -| `SES_PASS` | SMTP password. | -| `SES_MAX_MESSAGES` | Nodemailer configuration. Connection removed and new one created when this limit is reached. This helps to keep the connection up-to-date for long-running email messaging. Defaults to `100`. | -| `SES_POOL` | Connection pool to send email in parallel to the SMTP server. Defaults to `38`. | -| `MAIL_FROM` | Sender email address. Defaults to `'donotreply@mail.form.gov.sg'`. | -| `MAIL_SOCKET_TIMEOUT` | Milliseconds of inactivity to allow before killing a connection. This helps to keep the connection up-to-date for long-running email messaging. Defaults to `600000`. | -| `MAIL_LOGGER` | If set to true then logs to console. If value is not set or is false then nothing is logged. | -| `MAIL_DEBUG` | If set to `true`, then logs SMTP traffic, otherwise logs only transaction events. | -| `CHROMIUM_BIN` | Filepath to chromium binary. Required for email autoreply PDF generation with Puppeteer. | -| `BOUNCE_LIFE_SPAN` | Time in milliseconds that bounces are tracked for each form. Defaults to 10800000ms or 3 hours. Only relevant if you have set up AWS to send bounce and delivery notifications to the /emailnotifications endpoint. | +| Variable | Description | +| :-------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SES_HOST` | SMTP hostname. | +| `SES_PORT` | SMTP port number. | +| `SES_USER` | SMTP username. | +| `SES_PASS` | SMTP password. | +| `SES_MAX_MESSAGES` | Nodemailer configuration. Connection removed and new one created when this limit is reached. This helps to keep the connection up-to-date for long-running email messaging. Defaults to `100`. | +| `SES_POOL` | Connection pool to send email in parallel to the SMTP server. Defaults to `38`. | +| `MAIL_FROM` | Sender email address. Defaults to `'donotreply@mail.form.gov.sg'`. | +| `MAIL_SOCKET_TIMEOUT` | Milliseconds of inactivity to allow before killing a connection. This helps to keep the connection up-to-date for long-running email messaging. Defaults to `600000`. | +| `MAIL_LOGGER` | If set to true then logs to console. If value is not set or is false then nothing is logged. | +| `MAIL_DEBUG` | If set to `true`, then logs SMTP traffic, otherwise logs only transaction events. | +| `CHROMIUM_BIN` | Filepath to chromium binary. Required for email autoreply PDF generation with Puppeteer. | +| `BOUNCE_LIFE_SPAN` | Time in milliseconds that bounces are tracked for each form. Defaults to 86400000ms or 24 hours. Only relevant if you have set up AWS to send bounce and delivery notifications to the /emailnotifications endpoint. | ### Additional Features @@ -234,32 +234,32 @@ If this feature is enabled, forms will support authentication via [SingPass](htt Note that MyInfo is currently not supported for storage mode forms and enabling SingPass/CorpPass on storage mode forms also requires [SingPass/CorpPass for Storage Mode](#webhooks-and-singpasscorppass-for-storage-mode) to be enabled. -| Variable | Description | -| :------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SPCP_COOKIE_MAX_AGE_PRESERVED` | Duration of SingPass JWT before expiry in milliseconds. Defaults to 30 days. | -| `SINGPASS_ESRVC_ID` | e-service ID registered with National Digital Identity office for SingPass authentication. | -| `SINGPASS_PARTNER_ENTITY_ID` | Partner ID registered with National Digital Identity Office for SingPass authentication. | -| `SINGPASS_IDP_LOGIN_URL` | URL of SingPass Login Page. | -| `SINGPASS_IDP_ENDPOINT` | URL to retrieve NRIC of SingPass-validated user from. | -| `SINGPASS_IDP_ID` | Partner ID of National Digital Identity Office for SingPass authentication. | -| `CORPPASS_ESRVC_ID` | e-service ID registered with National Digital Identity office for CorpPass authentication. | -| `CORPPASS_PARTNER_ENTITY_ID` | Partner ID registered with National Digital Identity Office for CorpPass authentication. | -| `CORPPASS_IDP_LOGIN_URL` | URL of CorpPass Login Page. | -| `CORPPASS_IDP_ENDPOINT` | URL to retrieve UEN of CorpPass-validated user from. | -| `CORPPASS_IDP_ID` | Partner ID of National Digital Identity Office for CorpPass authentication. | -| `SP_FORMSG_KEY_PATH` | Path to X.509 key used for SingPass related communication with National Digital Identity office. | -| `SP_FORMSG_CERT_PATH` | Path to X.509 cert used for SingPass related communication with National Digital Identity office. | -| `SP_IDP_CERT_PATH` | Path to National Digital Identity office's X.509 cert used for SingPass related communication. | -| `CP_FORMSG_KEY_PATH` | Path to X.509 key used for CorpPass related communication with National Digital Identity office. | -| `CP_FORMSG_CERT_PATH` | Path to X.509 cert used for CorpPass related communication with National Digital Identity office. | -| `CP_IDP_CERT_PATH` | Path to National Digital Identity office's X.509 cert used for CorpPass related communication. | -| `MYINFO_CLIENT_CONFIG` | Configures [MyInfoGovClient](https://github.com/opengovsg/myinfo-gov-client). Set this to either`stg` or `prod` to fetch MyInfo data from the corresponding endpoints. | -| `MYINFO_FORMSG_KEY_PATH` | Filepath to MyInfo private key, which is used to decrypt returned responses. | -| `MYINFO_APP_KEY` | (deprecated) Directly specify contents of the MyInfo FormSG private key. Only works if `NODE_ENV` is set to `development`. | -| `IS_SP_MAINTENANCE` | If set, displays a banner message on SingPass forms. Overrides `IS_CP_MAINTENANCE`. | -| `IS_CP_MAINTENANCE` | If set, displays a banner message on CorpPass forms. | -| `FILE_SYSTEM_ID` | The id of the AWS Elastic File System (EFS) file system to mount onto the instances. | -| `CERT_PATH` | The specific directory within the network file system that is to be mounted. This directory is expected to contain the public certs and private keys relevant to SingPass, CorpPass and MyInfo. | +| Variable | Description | +| :------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SPCP_COOKIE_MAX_AGE_PRESERVED` | Duration of SingPass JWT before expiry in milliseconds. Defaults to 30 days. | +| `SINGPASS_ESRVC_ID` | e-service ID registered with National Digital Identity office for SingPass authentication. | +| `SINGPASS_PARTNER_ENTITY_ID` | Partner ID registered with National Digital Identity Office for SingPass authentication. | +| `SINGPASS_IDP_LOGIN_URL` | URL of SingPass Login Page. | +| `SINGPASS_IDP_ENDPOINT` | URL to retrieve NRIC of SingPass-validated user from. | +| `SINGPASS_IDP_ID` | Partner ID of National Digital Identity Office for SingPass authentication. | +| `CORPPASS_ESRVC_ID` | e-service ID registered with National Digital Identity office for CorpPass authentication. | +| `CORPPASS_PARTNER_ENTITY_ID` | Partner ID registered with National Digital Identity Office for CorpPass authentication. | +| `CORPPASS_IDP_LOGIN_URL` | URL of CorpPass Login Page. | +| `CORPPASS_IDP_ENDPOINT` | URL to retrieve UEN of CorpPass-validated user from. | +| `CORPPASS_IDP_ID` | Partner ID of National Digital Identity Office for CorpPass authentication. | +| `SP_FORMSG_KEY_PATH` | Path to X.509 key used for SingPass related communication with National Digital Identity office. | +| `SP_FORMSG_CERT_PATH` | Path to X.509 cert used for SingPass related communication with National Digital Identity office. | +| `SP_IDP_CERT_PATH` | Path to National Digital Identity office's X.509 cert used for SingPass related communication. | +| `CP_FORMSG_KEY_PATH` | Path to X.509 key used for CorpPass related communication with National Digital Identity office. | +| `CP_FORMSG_CERT_PATH` | Path to X.509 cert used for CorpPass related communication with National Digital Identity office. | +| `CP_IDP_CERT_PATH` | Path to National Digital Identity office's X.509 cert used for CorpPass related communication. | +| `MYINFO_CLIENT_CONFIG` | Configures [MyInfoGovClient](https://github.com/opengovsg/myinfo-gov-client). Set this to either`stg` or `prod` to fetch MyInfo data from the corresponding endpoints. | +| `MYINFO_FORMSG_KEY_PATH` | Filepath to MyInfo private key, which is used to decrypt returned responses. | +| `MYINFO_APP_KEY` | (deprecated) Directly specify contents of the MyInfo FormSG private key. Only works if `NODE_ENV` is set to `development`. | +| `IS_SP_MAINTENANCE` | If set, displays a banner message on SingPass forms. Overrides `IS_CP_MAINTENANCE`. | +| `IS_CP_MAINTENANCE` | If set, displays a banner message on CorpPass forms. | +| `FILE_SYSTEM_ID` | The id of the AWS Elastic File System (EFS) file system to mount onto the instances. | +| `CERT_PATH` | The specific directory within the network file system that is to be mounted. This directory is expected to contain the public certs and private keys relevant to SingPass, CorpPass and MyInfo. | #### Verified Emails/SMSes diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index dd3a211a88..5beb51b95a 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -89,6 +89,7 @@ const formSchemaOptions: SchemaOptions = { export interface IFormModel extends Model { getOtpData(formId: string): Promise getFullFormById(formId: string): Promise + deactivateById(formId: string): Promise } type IEncryptedFormModel = Model @@ -469,6 +470,19 @@ const compileFormModel = (db: Mongoose): IFormModel => { return data } + // Deactivate form by ID + FormSchema.statics.deactivateById = async function ( + this: IFormModel, + formId: string, + ): Promise { + const form = await this.findById(formId) + if (!form) return null + if (form.status === Status.Public) { + form.status = Status.Private + } + return form.save() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large diff --git a/src/app/modules/bounce/__tests__/bounce-test-helpers.ts b/src/app/modules/bounce/__tests__/bounce-test-helpers.ts index 2ea9aba172..b09e0edce5 100644 --- a/src/app/modules/bounce/__tests__/bounce-test-helpers.ts +++ b/src/app/modules/bounce/__tests__/bounce-test-helpers.ts @@ -1,7 +1,9 @@ import { ObjectId } from 'bson' import { cloneDeep, merge, pick } from 'lodash' +import { EmailType } from 'src/app/constants/mail' import { + BounceType, IBounce, IBounceNotification, IBounceSchema, @@ -26,7 +28,7 @@ const makeEmailNotification = ( formId: ObjectId, submissionId: ObjectId, recipientList: string[], - emailType: 'Admin (response)' | 'Email confirmation', + emailType: EmailType, ): IEmailNotification => { return { notificationType, @@ -56,15 +58,27 @@ const makeEmailNotification = ( } } -export const makeBounceNotification = ( - formId: ObjectId = new ObjectId(), - submissionId: ObjectId = new ObjectId(), - recipientList: string[] = [], - bouncedList: string[] = [], - bounceType: 'Transient' | 'Permanent' = 'Permanent', - emailType: 'Admin (response)' | 'Email confirmation' = 'Admin (response)', -): ISnsNotification => { - const Message = merge( +export const makeBounceNotification = ({ + formId, + submissionId, + recipientList, + bouncedList, + bounceType, + emailType, +}: { + formId?: ObjectId + submissionId?: ObjectId + recipientList?: string[] + bouncedList?: string[] + bounceType?: BounceType + emailType?: EmailType +} = {}): IBounceNotification => { + formId ??= new ObjectId() + submissionId ??= new ObjectId() + recipientList ??= [] + bouncedList ??= [] + emailType ??= EmailType.AdminResponse + return merge( makeEmailNotification( 'Bounce', formId, @@ -81,19 +95,27 @@ export const makeBounceNotification = ( }, }, ) as IBounceNotification - const body = cloneDeep(MOCK_SNS_BODY) - body.Message = JSON.stringify(Message) - return body } -export const makeDeliveryNotification = ( - formId: ObjectId = new ObjectId(), - submissionId: ObjectId = new ObjectId(), - recipientList: string[] = [], - deliveredList: string[] = [], - emailType: 'Admin (response)' | 'Email confirmation' = 'Admin (response)', -): ISnsNotification => { - const Message = merge( +export const makeDeliveryNotification = ({ + formId, + submissionId, + recipientList, + deliveredList, + emailType, +}: { + formId?: ObjectId + submissionId?: ObjectId + recipientList?: string[] + deliveredList?: string[] + emailType?: EmailType +} = {}): IDeliveryNotification => { + formId ??= new ObjectId() + submissionId ??= new ObjectId() + recipientList ??= [] + deliveredList ??= [] + emailType ??= EmailType.AdminResponse + return merge( makeEmailNotification( 'Delivery', formId, @@ -107,9 +129,6 @@ export const makeDeliveryNotification = ( }, }, ) as IDeliveryNotification - const body = cloneDeep(MOCK_SNS_BODY) - body.Message = JSON.stringify(Message) - return body } // Omit mongoose values from Bounce document @@ -118,7 +137,7 @@ export const extractBounceObject = ( ): Omit => { const extracted = pick(bounce.toObject(), [ 'formId', - 'hasAlarmed', + 'hasAutoEmailed', 'expireAt', 'bounces', ]) diff --git a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts index 9b2df51dbe..f66efc1b4d 100644 --- a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts @@ -1,72 +1,231 @@ +import { ObjectId } from 'bson' import { StatusCodes } from 'http-status-codes' +import mongoose from 'mongoose' +import dbHandler from 'tests/unit/backend/helpers/jest-db' import expressHandler from 'tests/unit/backend/helpers/jest-express' import { mocked } from 'ts-jest/utils' +import { EmailType } from 'src/app/constants/mail' +// eslint-disable-next-line import/first import { handleSns } from 'src/app/modules/bounce/bounce.controller' +import getBounceModel from 'src/app/modules/bounce/bounce.model' import * as BounceService from 'src/app/modules/bounce/bounce.service' -import { ISnsNotification } from 'src/types' +import * as FormService from 'src/app/modules/form/form.service' +import { IBounceSchema, ISnsNotification } from 'src/types' + +const Bounce = getBounceModel(mongoose) jest.mock('src/app/modules/bounce/bounce.service') +jest.mock('src/app/modules/form/form.service') const MockBounceService = mocked(BounceService, true) +const MockFormService = mocked(FormService, true) +const MOCK_NOTIFICATION = { someKey: 'someValue' } const MOCK_REQ = expressHandler.mockRequest({ - body: ({ someKey: 'someValue' } as unknown) as ISnsNotification, + body: ({ + Message: JSON.stringify(MOCK_NOTIFICATION), + } as unknown) as ISnsNotification, }) const MOCK_RES = expressHandler.mockResponse() - +const MOCK_EMAIL_RECIPIENTS = ['a@email.com', 'b@email.com'] +interface IMockBounce extends IBounceSchema { + isCriticalBounce: jest.Mock + areAllPermanentBounces: jest.Mock + setNotificationState: jest.Mock + hasNotified: jest.Mock + save: jest.Mock +} describe('handleSns', () => { - afterEach(() => { + let mockBounceDoc: IMockBounce + + beforeAll(async () => { + await dbHandler.connect() + const bounceDoc = await new Bounce({ + formId: new ObjectId(), + bounces: [], + }).save() + bounceDoc.isCriticalBounce = jest.fn() + bounceDoc.setNotificationState = jest.fn() + bounceDoc.save = jest.fn() + bounceDoc.areAllPermanentBounces = jest.fn() + bounceDoc.hasNotified = jest.fn() + mockBounceDoc = bounceDoc as IMockBounce + }) + + afterAll(async () => await dbHandler.closeDatabase()) + + beforeEach(() => { jest.clearAllMocks() }) - it('should not call updateBounces when requests are invalid', async () => { - MockBounceService.isValidSnsRequest.mockReturnValueOnce( - Promise.resolve(false), - ) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + + it('should return immediately when requests are invalid', async () => { + MockBounceService.isValidSnsRequest.mockResolvedValueOnce(false) await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( MOCK_REQ.body, ) - expect(MockBounceService.updateBounces).not.toHaveBeenCalled() + expect(MockBounceService.logEmailNotification).not.toHaveBeenCalled() + expect(MockBounceService.notifyAdminOfBounce).not.toHaveBeenCalled() + expect(MockBounceService.logCriticalBounce).not.toHaveBeenCalled() expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) }) - it('should call updateBounces when requests are valid', async () => { - MockBounceService.isValidSnsRequest.mockReturnValueOnce( - Promise.resolve(true), + it('should return 400 when errors are thrown in isValidSnsRequest', async () => { + MockBounceService.isValidSnsRequest.mockImplementation(() => { + throw new Error() + }) + await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.logEmailNotification).not.toHaveBeenCalled() + expect(MockBounceService.notifyAdminOfBounce).not.toHaveBeenCalled() + expect(MockBounceService.logCriticalBounce).not.toHaveBeenCalled() + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) + }) + + it('should call services correctly for permanent critical bounces', async () => { + MockBounceService.isValidSnsRequest.mockResolvedValueOnce(true) + MockBounceService.extractEmailType.mockReturnValueOnce( + EmailType.AdminResponse, + ) + MockBounceService.getUpdatedBounceDoc.mockResolvedValueOnce(mockBounceDoc) + mockBounceDoc.areAllPermanentBounces.mockReturnValueOnce(true) + mockBounceDoc.isCriticalBounce.mockReturnValueOnce(true) + mockBounceDoc.hasNotified.mockReturnValueOnce(false) + MockBounceService.notifyAdminOfBounce.mockResolvedValueOnce( + MOCK_EMAIL_RECIPIENTS, ) + await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( MOCK_REQ.body, ) - expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) + expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.extractEmailType).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.getUpdatedBounceDoc).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(mockBounceDoc.areAllPermanentBounces).toHaveBeenCalled() + expect(MockFormService.deactivateForm).toHaveBeenCalledWith( + mockBounceDoc.formId, + ) + expect(MockBounceService.notifyAdminOfBounce).toHaveBeenCalledWith( + mockBounceDoc, + ) + expect(mockBounceDoc.setNotificationState).toHaveBeenCalledWith( + MOCK_EMAIL_RECIPIENTS, + ) + expect(MockBounceService.logCriticalBounce).toHaveBeenCalledWith( + mockBounceDoc, + MOCK_NOTIFICATION, + MOCK_EMAIL_RECIPIENTS, + true, + ) + expect(mockBounceDoc.save).toHaveBeenCalled() expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.OK) }) - it('should return 400 when errors are thrown in isValidSnsRequest', async () => { - MockBounceService.isValidSnsRequest.mockImplementation(() => { + it('should return 400 when errors are thrown in getUpdatedBounceDoc', async () => { + MockBounceService.isValidSnsRequest.mockResolvedValueOnce(true) + MockBounceService.extractEmailType.mockReturnValueOnce( + EmailType.AdminResponse, + ) + MockBounceService.getUpdatedBounceDoc.mockImplementationOnce(() => { throw new Error() }) await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( MOCK_REQ.body, ) - expect(MockBounceService.updateBounces).not.toHaveBeenCalled() + expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.extractEmailType).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.getUpdatedBounceDoc).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) 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), + it('should return 400 when errors are thrown in deactivateForm', async () => { + MockBounceService.isValidSnsRequest.mockResolvedValueOnce(true) + MockBounceService.extractEmailType.mockReturnValueOnce( + EmailType.AdminResponse, ) - MockBounceService.updateBounces.mockImplementation(() => { + MockBounceService.getUpdatedBounceDoc.mockResolvedValueOnce(mockBounceDoc) + mockBounceDoc.areAllPermanentBounces.mockReturnValueOnce(true) + MockFormService.deactivateForm.mockImplementationOnce(() => { throw new Error() }) + await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( MOCK_REQ.body, ) - expect(MockBounceService.updateBounces).toHaveBeenCalledWith(MOCK_REQ.body) + expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.extractEmailType).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.getUpdatedBounceDoc).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(mockBounceDoc.areAllPermanentBounces).toHaveBeenCalled() + expect(MockFormService.deactivateForm).toHaveBeenCalledWith( + mockBounceDoc.formId, + ) + expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) + }) + + it('should return 400 when errors are thrown in notifyAdminOfBounce', async () => { + MockBounceService.isValidSnsRequest.mockResolvedValueOnce(true) + MockBounceService.extractEmailType.mockReturnValueOnce( + EmailType.AdminResponse, + ) + MockBounceService.getUpdatedBounceDoc.mockResolvedValueOnce(mockBounceDoc) + mockBounceDoc.areAllPermanentBounces.mockReturnValueOnce(true) + mockBounceDoc.isCriticalBounce.mockReturnValueOnce(true) + mockBounceDoc.hasNotified.mockReturnValueOnce(false) + MockBounceService.notifyAdminOfBounce.mockImplementationOnce(() => { + throw new Error() + }) + + await handleSns(MOCK_REQ, MOCK_RES, jest.fn()) + + expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith( + MOCK_REQ.body, + ) + expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.extractEmailType).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(MockBounceService.getUpdatedBounceDoc).toHaveBeenCalledWith( + MOCK_NOTIFICATION, + ) + expect(mockBounceDoc.areAllPermanentBounces).toHaveBeenCalled() + expect(MockFormService.deactivateForm).toHaveBeenCalledWith( + mockBounceDoc.formId, + ) + expect(MockBounceService.notifyAdminOfBounce).toHaveBeenCalledWith( + mockBounceDoc, + ) expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST) }) }) diff --git a/src/app/modules/bounce/__tests__/bounce.model.spec.ts b/src/app/modules/bounce/__tests__/bounce.model.spec.ts index d90a425095..8fa3fd7b69 100644 --- a/src/app/modules/bounce/__tests__/bounce.model.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.model.spec.ts @@ -4,6 +4,7 @@ import mongoose from 'mongoose' import dbHandler from 'tests/unit/backend/helpers/jest-db' import getBounceModel from 'src/app/modules/bounce/bounce.model' +import { BounceType } from 'src/types' import { extractBounceObject, @@ -14,6 +15,7 @@ import { const Bounce = getBounceModel(mongoose) const MOCK_EMAIL = 'email@email.com' +const MOCK_EMAIL_2 = 'email2@email.com' describe('Bounce Model', () => { beforeAll(async () => await dbHandler.connect()) @@ -31,16 +33,18 @@ describe('Bounce Model', () => { expect(omit(savedBounceObject, 'expireAt')).toEqual({ formId, bounces: [{ email: MOCK_EMAIL, hasBounced: false }], - hasAlarmed: false, + hasAutoEmailed: false, }) }) it('should save with non-defaults when they are provided', async () => { const params = { formId: new ObjectId(), - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], expireAt: new Date(Date.now()), - hasAlarmed: true, + hasAutoEmailed: true, } const savedBounce = await new Bounce(params).save() const savedBounceObject = extractBounceObject(savedBounce) @@ -62,71 +66,459 @@ describe('Bounce Model', () => { }) describe('methods', () => { - describe('merge', () => { - it('should update old bounce when valid bounce info is given', () => { + describe('hasNotified', () => { + it('should return hasAutoEmailed if it is true', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: true, + }) + expect(bounce.hasNotified()).toBe(true) + }) + + it('should return hasAutoEmailed if it is false', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: false, + }) + expect(bounce.hasNotified()).toBe(false) + }) + }) + + describe('setNotificationState', () => { + it('should set hasAutoEmailed from false to true when there are email recipients', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: false, + }) + bounce.setNotificationState([MOCK_EMAIL]) + expect(bounce.hasAutoEmailed).toBe(true) + }) + + it('should keep hasAutoEmailed as true when there are email recipients', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: true, + }) + bounce.setNotificationState([MOCK_EMAIL]) + expect(bounce.hasAutoEmailed).toBe(true) + }) + + it('should keep original hasAutoEmailed as true when there are no email recipients', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: true, + }) + bounce.setNotificationState([]) + expect(bounce.hasAutoEmailed).toBe(true) + }) + + it('should keep original hasAutoEmailed as false when there are no email recipients', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [], + hasAutoEmailed: false, + }) + bounce.setNotificationState([]) + expect(bounce.hasAutoEmailed).toBe(false) + }) + }) + + describe('getEmails', () => { + it('should return the full email list when hasBounced is false for all', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + expect(bounce.getEmails()).toEqual([MOCK_EMAIL, MOCK_EMAIL_2]) + }) + + it('should return the full email list when hasBounced is true for all', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Transient' }, + ], + }) + expect(bounce.getEmails()).toEqual([MOCK_EMAIL, MOCK_EMAIL_2]) + }) + + it('should return the full email list when hasBounced is mixed', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Transient' }, + ], + }) + expect(bounce.getEmails()).toEqual([MOCK_EMAIL, MOCK_EMAIL_2]) + }) + }) + + describe('areAllPermanentBounces', () => { + it('should return true when all bounces are permanent', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + expect(bounce.areAllPermanentBounces()).toBe(true) + }) + + it('should return false when any bounce is transient', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + expect(bounce.areAllPermanentBounces()).toBe(false) + }) + + it('should return false when any hasBounced is false', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + expect(bounce.areAllPermanentBounces()).toBe(false) + }) + }) + + describe('isCriticalBounce', () => { + it('should return true when all bounces are permanent', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + expect(bounce.isCriticalBounce()).toBe(true) + }) + + it('should return true when all bounces are transient', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], + }) + expect(bounce.isCriticalBounce()).toBe(true) + }) + + it('should return true when there is a mix of bounce types', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], + }) + expect(bounce.isCriticalBounce()).toBe(true) + }) + + it('should return false when there any hasBounced is false', () => { + const bounce = new Bounce({ + formId: new ObjectId(), + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL, hasBounced: false }, + ], + }) + expect(bounce.isCriticalBounce()).toBe(false) + }) + }) + + describe('updateBounceInfo', () => { + it('should update bounce type when bounces repeat', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + const snsInfo = makeBounceNotification({ + formId, + recipientList: [MOCK_EMAIL], + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Transient, + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], + }) + }) + + it('should set hasBounced to true when existing email bounces', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeBounceNotification({ + formId, + recipientList: [MOCK_EMAIL], + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Permanent, + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + }) + + it('should set hasBounced to true when unseen email bounces', () => { const formId = new ObjectId() - const oldBounce = new Bounce({ + const bounce = new Bounce({ formId, bounces: [{ email: MOCK_EMAIL, hasBounced: false }], }) - const latestBounce = new Bounce({ + const snsInfo = makeBounceNotification({ formId, - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], // we've never seen 2nd one before + bouncedList: [MOCK_EMAIL_2], + bounceType: BounceType.Permanent, }) - const snsInfo = JSON.parse(makeBounceNotification().Message) - oldBounce.merge(latestBounce, snsInfo) - expect(pick(oldBounce.toObject(), ['formId', 'bounces'])).toEqual({ + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ formId, - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], }) }) - it('should set hasBounced to false when email is delivered later', () => { + it('should keep existing bounce info when bouncedRecipients does not contain email', () => { const formId = new ObjectId() - const oldBounce = new Bounce({ + const bounce = new Bounce({ formId, - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], }) - const latestBounce = new Bounce({ + const snsInfo = makeBounceNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL_2], + bounceType: BounceType.Permanent, + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + }) + + it('should keep existing delivery info when bouncedRecipients does not contain email', () => { + const formId = new ObjectId() + const bounce = new Bounce({ formId, bounces: [{ email: MOCK_EMAIL, hasBounced: false }], }) - const notification = makeDeliveryNotification( + const snsInfo = makeBounceNotification({ formId, - new ObjectId(), - [MOCK_EMAIL], - [MOCK_EMAIL], - ) - const snsInfo = JSON.parse(notification.Message) - oldBounce.merge(latestBounce, snsInfo) - expect(pick(oldBounce.toObject(), ['formId', 'bounces'])).toEqual({ + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL_2], + bounceType: BounceType.Permanent, + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + }) + + it('should set hasBounced to false when bouncedRecipients does not contain unseen email', () => { + const formId = new ObjectId() + const bounce = new Bounce({ formId, bounces: [{ email: MOCK_EMAIL, hasBounced: false }], }) + const snsInfo = makeBounceNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + // we've never seen MOCK_EMAIL_2 before, but we have no bounce info about it + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Permanent, + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) }) - it('should update email list when it changes', () => { - const newEmail = 'newemail@email.com' + it('should set hasBounced to false when subsequent delivery succeeds', () => { const formId = new ObjectId() - const oldBounce = new Bounce({ + const bounce = new Bounce({ formId, - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], }) - const latestBounce = new Bounce({ + const snsInfo = makeDeliveryNotification({ formId, - bounces: [{ email: newEmail, hasBounced: false }], + recipientList: [MOCK_EMAIL], + deliveredList: [MOCK_EMAIL], }) - const notification = makeDeliveryNotification( + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ formId, - new ObjectId(), - [MOCK_EMAIL], - [MOCK_EMAIL], - ) - const snsInfo = JSON.parse(notification.Message) - oldBounce.merge(latestBounce, snsInfo) - expect(pick(oldBounce.toObject(), ['formId', 'bounces'])).toEqual({ + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + }) + + it('should keep existing info when deliveries repeat', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL], + deliveredList: [MOCK_EMAIL], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + }) + + it('should set hasBounced to false when unseen email is delivered', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + deliveredList: [MOCK_EMAIL_2], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + }) + + it('should keep existing bounce info when delivered.recipients does not contain email', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + deliveredList: [MOCK_EMAIL_2], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + }) + + it('should keep existing delivery info when delivered.recipients does not contain email', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + deliveredList: [MOCK_EMAIL_2], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + }) + + it('should set hasBounced to false when delivered.recipients contains unseen email', () => { + const formId = new ObjectId() + const bounce = new Bounce({ + formId, + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + deliveredList: [MOCK_EMAIL_2, MOCK_EMAIL_2], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: false }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + }) + }) + + it('should filter out outdated email recipients', () => { + const formId = new ObjectId() + const bounce = new Bounce({ formId, - bounces: [{ email: newEmail, hasBounced: false }], + bounces: [{ email: MOCK_EMAIL, hasBounced: false }], + }) + const snsInfo = makeDeliveryNotification({ + formId, + recipientList: [MOCK_EMAIL_2], + deliveredList: [MOCK_EMAIL_2], + }) + const updated = bounce.updateBounceInfo(snsInfo) + expect(pick(updated.toObject(), ['formId', 'bounces'])).toEqual({ + formId, + bounces: [{ email: MOCK_EMAIL_2, hasBounced: false }], }) }) }) @@ -137,39 +529,85 @@ describe('Bounce Model', () => { it('should create documents correctly when delivery notification is valid', () => { const formId = new ObjectId() const submissionId = new ObjectId() - const notification = JSON.parse( - makeDeliveryNotification( - formId, - submissionId, - [MOCK_EMAIL], - [MOCK_EMAIL], - ).Message, - ) - const actual = Bounce.fromSnsNotification(notification) + const notification = makeDeliveryNotification({ + formId, + submissionId, + recipientList: [MOCK_EMAIL], + deliveredList: [MOCK_EMAIL], + }) + + const actual = Bounce.fromSnsNotification(notification, String(formId)) expect(omit(extractBounceObject(actual!), 'expireAt')).toEqual({ formId, bounces: [{ email: MOCK_EMAIL, hasBounced: false }], - hasAlarmed: false, + hasAutoEmailed: false, + }) + expect(actual!.expireAt).toBeInstanceOf(Date) + }) + + it('should create documents correctly when transient bounce notification is valid', () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: [MOCK_EMAIL], + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Transient, + }) + + const actual = Bounce.fromSnsNotification(notification, String(formId)) + expect(omit(extractBounceObject(actual!), 'expireAt')).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + ], + hasAutoEmailed: false, + }) + expect(actual!.expireAt).toBeInstanceOf(Date) + }) + + it('should create documents correctly when permanent bounce notification is valid', () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: [MOCK_EMAIL], + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Permanent, + }) + + const actual = Bounce.fromSnsNotification(notification, String(formId)) + expect(omit(extractBounceObject(actual!), 'expireAt')).toEqual({ + formId, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], + hasAutoEmailed: false, }) expect(actual!.expireAt).toBeInstanceOf(Date) }) - it('should create documents correctly when bounce notification is valid', () => { + it('should create documents correctly when only some recipients have bounced', () => { const formId = new ObjectId() const submissionId = new ObjectId() - const notification = JSON.parse( - makeBounceNotification( - formId, - submissionId, - [MOCK_EMAIL], - [MOCK_EMAIL], - ).Message, - ) - const actual = Bounce.fromSnsNotification(notification) + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL], + bounceType: BounceType.Permanent, + }) + + const actual = Bounce.fromSnsNotification(notification, String(formId)) expect(omit(extractBounceObject(actual!), 'expireAt')).toEqual({ formId, - bounces: [{ email: MOCK_EMAIL, hasBounced: true }], - hasAlarmed: false, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: false }, + ], + hasAutoEmailed: false, }) expect(actual!.expireAt).toBeInstanceOf(Date) }) diff --git a/src/app/modules/bounce/__tests__/bounce.service.spec.ts b/src/app/modules/bounce/__tests__/bounce.service.spec.ts index c8804005d9..ce932fde0b 100644 --- a/src/app/modules/bounce/__tests__/bounce.service.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.service.spec.ts @@ -2,28 +2,32 @@ import axios from 'axios' import { ObjectId } from 'bson' import crypto from 'crypto' import dedent from 'dedent' -import { cloneDeep, omit } from 'lodash' +import { cloneDeep, omit, pick } from 'lodash' import mongoose from 'mongoose' import dbHandler from 'tests/unit/backend/helpers/jest-db' -import getMockLogger, { - resetMockLogger, -} from 'tests/unit/backend/helpers/jest-logger' +import getMockLogger from 'tests/unit/backend/helpers/jest-logger' import { mocked } from 'ts-jest/utils' +import { EMAIL_HEADERS, EmailType } from 'src/app/constants/mail' +import getFormModel from 'src/app/models/form.server.model' +import MailService from 'src/app/services/mail.service' import * as LoggerModule from 'src/config/logger' -import { IBounceNotification, ISnsNotification } from 'src/types' - import { - extractBounceObject, - makeBounceNotification, - makeDeliveryNotification, - MOCK_SNS_BODY, -} from './bounce-test-helpers' + BounceType, + IFormSchema, + ISnsNotification, + IUserSchema, +} from 'src/types' + +import { makeBounceNotification, MOCK_SNS_BODY } from './bounce-test-helpers' jest.mock('axios') const mockAxios = mocked(axios, true) jest.mock('src/config/logger') const MockLoggerModule = mocked(LoggerModule, true) +jest.mock('src/app/services/mail.service') +const MockMailService = mocked(MailService, true) + const mockShortTermLogger = getMockLogger() const mockLogger = getMockLogger() MockLoggerModule.createCloudWatchLogger.mockReturnValue(mockShortTermLogger) @@ -34,571 +38,486 @@ MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger) import getBounceModel from 'src/app/modules/bounce/bounce.model' // eslint-disable-next-line import/first import { + extractEmailType, + getUpdatedBounceDoc, isValidSnsRequest, - updateBounces, + logCriticalBounce, + logEmailNotification, + notifyAdminOfBounce, } from 'src/app/modules/bounce/bounce.service' +const Form = getFormModel(mongoose) const Bounce = getBounceModel(mongoose) -describe('isValidSnsRequest', () => { - const keys = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { - type: 'pkcs1', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - }, - }) +const MOCK_EMAIL = 'email@example.com' +const MOCK_EMAIL_2 = 'email2@example.com' +const MOCK_FORM_ID = new ObjectId() +const MOCK_ADMIN_ID = new ObjectId() +const MOCK_SUBMISSION_ID = new ObjectId() - let body: ISnsNotification +describe('BounceService', () => { + beforeAll(async () => await dbHandler.connect()) - beforeEach(() => { - body = cloneDeep(MOCK_SNS_BODY) - mockAxios.get.mockResolvedValue({ - data: keys.publicKey, - }) - }) - - it('should gracefully reject when input is empty', () => { - return expect(isValidSnsRequest(undefined!)).resolves.toBe(false) + afterAll(async () => { + await dbHandler.clearDatabase() + await dbHandler.closeDatabase() }) - it('should reject requests when their structure is invalid', () => { - const invalidBody = omit(cloneDeep(body), 'Type') as ISnsNotification - return expect(isValidSnsRequest(invalidBody)).resolves.toBe(false) + describe('extractEmailType', () => { + it('should extract the email type correctly', () => { + const notification = makeBounceNotification({ + emailType: EmailType.AdminResponse, + }) + expect(extractEmailType(notification)).toBe(EmailType.AdminResponse) + }) }) - it('should reject requests when their certificate URL is invalid', () => { - body.SigningCertURL = 'http://www.example.com' - return expect(isValidSnsRequest(body)).resolves.toBe(false) - }) + describe('getUpdatedBounceDoc', () => { + beforeEach(() => { + jest.resetAllMocks() + }) - it('should reject requests when their signature version is invalid', () => { - body.SignatureVersion = 'wrongSignatureVersion' - return expect(isValidSnsRequest(body)).resolves.toBe(false) - }) + afterEach(async () => { + await dbHandler.clearDatabase() + }) - it('should reject requests when their signature is invalid', () => { - return expect(isValidSnsRequest(body)).resolves.toBe(false) - }) + it('should return null when there is no form ID', async () => { + const notification = makeBounceNotification() + const header = notification.mail.headers.find( + (header) => header.name === EMAIL_HEADERS.formId, + ) + // Needed for TypeScript not to complain + if (header) { + header.value = '' + } + const result = await getUpdatedBounceDoc(notification) + expect(result).toBeNull() + }) - it('should accept when requests are valid', () => { - const signer = crypto.createSign('RSA-SHA1') - const baseString = - dedent`Message - ${body.Message} - MessageId - ${body.MessageId} - Timestamp - ${body.Timestamp} - TopicArn - ${body.TopicArn} - Type - ${body.Type} - ` + '\n' - signer.write(baseString) - body.Signature = signer.sign(keys.privateKey, 'base64') - return expect(isValidSnsRequest(body)).resolves.toBe(true) - }) -}) + it('should call updateBounceInfo if the document exists', async () => { + const bounceDoc = new Bounce({ + formId: String(MOCK_FORM_ID), + }) + await bounceDoc.save() + const notification = makeBounceNotification({ + formId: MOCK_FORM_ID, + }) + const result = await getUpdatedBounceDoc(notification) + expect(result?.toObject()).toEqual( + bounceDoc.updateBounceInfo(notification).toObject(), + ) + }) -describe('updateBounces', () => { - const recipientList = [ - 'email1@example.com', - 'email2@example.com', - 'email3@example.com', - ] - - beforeAll(async () => { - await dbHandler.connect() - // Avoid being affected by other modules - resetMockLogger(mockLogger) - resetMockLogger(mockShortTermLogger) + it('should call fromSnsNotification if the document does not exist', async () => { + const mock = jest.spyOn(Bounce, 'fromSnsNotification') + const notification = makeBounceNotification({ + formId: MOCK_FORM_ID, + }) + const result = await getUpdatedBounceDoc(notification) + const actual = pick(result?.toObject(), [ + 'formId', + 'bounces', + 'hasAutoEmailed', + ]) + expect(actual).toEqual({ + formId: MOCK_FORM_ID, + bounces: [], + hasAutoEmailed: false, + }) + expect(mock).toHaveBeenCalledWith(notification, String(MOCK_FORM_ID)) + }) }) - afterEach(async () => { - await dbHandler.clearDatabase() - resetMockLogger(mockLogger) - resetMockLogger(mockShortTermLogger) - }) + describe('logEmailNotification', () => { + const MOCK_RECIPIENT_LIST = [ + 'email1@example.com', + 'email2@example.com', + 'email3@example.com', + ] + beforeEach(() => jest.resetAllMocks()) - afterAll(async () => await dbHandler.closeDatabase()) - - it('should save correctly when there is a single delivery notification', async () => { - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList, - ) - await updateBounces(notification) - const actualBounceDoc = await Bounce.findOne({ formId }) - const actualBounce = extractBounceObject(actualBounceDoc!) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: false, - })) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...JSON.parse(notification.Message), - }, + it('should log email confirmations to short-term logs', async () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: MOCK_RECIPIENT_LIST, + bouncedList: MOCK_RECIPIENT_LIST, + bounceType: BounceType.Transient, + emailType: EmailType.EmailConfirmation, + }) + logEmailNotification(notification) + expect(mockLogger.info).not.toHaveBeenCalled() + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockShortTermLogger.info).toHaveBeenCalledWith(notification) }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, - }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should save correctly when there is a single non-critical bounce notification', async () => { - const bounces = { - [recipientList[0]]: true, - [recipientList[1]]: false, - [recipientList[2]]: false, - } - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // Only first email bounced - ) - await updateBounces(notification) - const actualBounceDoc = await Bounce.findOne({ formId }) - const actualBounce = extractBounceObject(actualBounceDoc!) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: bounces[email], - })) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...JSON.parse(notification.Message), - }, - }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, + it('should log admin responses to regular logs', async () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: MOCK_RECIPIENT_LIST, + bouncedList: MOCK_RECIPIENT_LIST, + bounceType: BounceType.Transient, + emailType: EmailType.AdminResponse, + }) + logEmailNotification(notification) + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Email notification', + meta: { + action: 'logEmailNotification', + ...notification, + }, + }) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockShortTermLogger.info).not.toHaveBeenCalled() }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should save correctly when there is a single critical bounce notification', async () => { - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList, - ) - const parsedNotification: IBounceNotification = JSON.parse( - notification.Message, - ) - await updateBounces(notification) - const actualBounceDoc = await Bounce.findOne({ formId }) - const actualBounce = extractBounceObject(actualBounceDoc!) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: true, - })) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...parsedNotification, - }, - }) - expect(mockLogger.warn.mock.calls[0][0]).toMatchObject({ - message: 'Critical bounce', - meta: { - action: 'updateBounces', - hasAlarmed: false, - formId: formId.toHexString(), - submissionId: submissionId.toHexString(), - bounceInfo: parsedNotification.bounce, - }, + it('should log login OTPs to regular logs', async () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: MOCK_RECIPIENT_LIST, + bouncedList: MOCK_RECIPIENT_LIST, + bounceType: BounceType.Transient, + emailType: EmailType.LoginOtp, + }) + logEmailNotification(notification) + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Email notification', + meta: { + action: 'logEmailNotification', + ...notification, + }, + }) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockShortTermLogger.info).not.toHaveBeenCalled() }) - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: true, - bounces: expectedBounces, - }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should save correctly when there are consecutive delivery notifications', async () => { - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification1 = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // First email delivered - ) - const notification2 = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList.slice(1), // Second two emails delivered - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: false, - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...JSON.parse(notification1.Message), - }, + it('should log admin notifications to regular logs', async () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: MOCK_RECIPIENT_LIST, + bouncedList: MOCK_RECIPIENT_LIST, + bounceType: BounceType.Transient, + emailType: EmailType.AdminBounce, + }) + logEmailNotification(notification) + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Email notification', + meta: { + action: 'logEmailNotification', + ...notification, + }, + }) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockShortTermLogger.info).not.toHaveBeenCalled() }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { - ...JSON.parse(notification2.Message), - }, - }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, + + it('should log verification OTPs to short-term logs', async () => { + const formId = new ObjectId() + const submissionId = new ObjectId() + const notification = makeBounceNotification({ + formId, + submissionId, + recipientList: MOCK_RECIPIENT_LIST, + bouncedList: MOCK_RECIPIENT_LIST, + bounceType: BounceType.Transient, + emailType: EmailType.VerificationOtp, + }) + logEmailNotification(notification) + expect(mockLogger.info).not.toHaveBeenCalled() + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockShortTermLogger.info).toHaveBeenCalledWith(notification) }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - it('should save correctly when there are consecutive non-critical bounce notifications', async () => { - const bounces = { - [recipientList[0]]: true, - [recipientList[1]]: true, - [recipientList[2]]: false, - } - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification1 = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // First email bounced - ) - const notification2 = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(1, 2), // Second email bounced - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: bounces[email], - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...JSON.parse(notification1.Message), - }, + describe('notifyAdminOfBounce', () => { + const MOCK_FORM_TITLE = 'FormTitle' + let testUser: IUserSchema + + beforeAll(async () => { + const { user } = await dbHandler.insertFormCollectionReqs({ + userId: MOCK_ADMIN_ID, + }) + testUser = user }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { - ...JSON.parse(notification2.Message), - }, + + afterAll(async () => { + await dbHandler.clearDatabase() }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, + + beforeEach(async () => { + jest.resetAllMocks() }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should save correctly when there are consecutive critical bounce notifications', async () => { - const formId = new ObjectId() - const submissionId1 = new ObjectId() - const submissionId2 = new ObjectId() - const notification1 = makeBounceNotification( - formId, - submissionId1, - recipientList, - recipientList.slice(0, 1), // First email bounced - ) - const parsedNotification1: IBounceNotification = JSON.parse( - notification1.Message, - ) - const notification2 = makeBounceNotification( - formId, - submissionId2, - recipientList, - recipientList.slice(1, 3), // Second and third email bounced - ) - const parsedNotification2: IBounceNotification = JSON.parse( - notification2.Message, - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: true, - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { - ...parsedNotification1, - }, + it('should auto-email when admin is not email recipient', async () => { + const form = new Form({ + admin: MOCK_ADMIN_ID, + title: MOCK_FORM_TITLE, + }) + const testForm = await form.save() + const bounceDoc = new Bounce({ + formId: testForm._id, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + const emailRecipients = await notifyAdminOfBounce(bounceDoc) + expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({ + emailRecipients: [testUser.email], + bouncedRecipients: [MOCK_EMAIL], + bounceType: BounceType.Permanent, + formTitle: testForm.title, + formId: testForm._id, + }) + expect(emailRecipients).toEqual([testUser.email]) }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { - ...parsedNotification2, - }, + + it('should auto-email when any collaborator is not email recipient', async () => { + const testForm = new Form({ + admin: MOCK_ADMIN_ID, + title: MOCK_FORM_TITLE, + }) + const collabEmail = 'collaborator@test.gov.sg' + testForm.permissionList = [{ email: collabEmail, write: true }] + await testForm.save() + const bounceDoc = new Bounce({ + formId: testForm._id, + bounces: [ + { email: testUser.email, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + const emailRecipients = await notifyAdminOfBounce(bounceDoc) + expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({ + emailRecipients: [collabEmail], + bouncedRecipients: [testUser.email], + bounceType: BounceType.Permanent, + formTitle: testForm.title, + formId: testForm._id, + }) + expect(emailRecipients).toEqual([collabEmail]) }) - expect(mockLogger.warn.mock.calls[0][0]).toMatchObject({ - message: 'Critical bounce', - meta: { - action: 'updateBounces', - hasAlarmed: false, - formId: formId.toHexString(), - submissionId: submissionId2.toHexString(), - bounceInfo: parsedNotification2.bounce, - }, + + it('should not auto-email when admin is email recipient', async () => { + const testForm = new Form({ + admin: MOCK_ADMIN_ID, + title: MOCK_FORM_TITLE, + }) + await testForm.save() + const bounceDoc = new Bounce({ + formId: testForm._id, + bounces: [ + { email: testUser.email, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + const emailRecipients = await notifyAdminOfBounce(bounceDoc) + expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled() + expect(emailRecipients).toEqual([]) }) - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: true, - bounces: expectedBounces, + + it('should not auto-email when all collabs are email recipients', async () => { + const testForm = new Form({ + admin: MOCK_ADMIN_ID, + title: MOCK_FORM_TITLE, + }) + const collabEmail = 'collaborator@test.gov.sg' + testForm.permissionList = [{ email: collabEmail, write: false }] + await testForm.save() + const bounceDoc = new Bounce({ + formId: testForm._id, + bounces: [ + { email: testUser.email, hasBounced: true, bounceType: 'Permanent' }, + { email: collabEmail, hasBounced: true, bounceType: 'Permanent' }, + ], + }) + const emailRecipients = await notifyAdminOfBounce(bounceDoc) + expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled() + expect(emailRecipients).toEqual([]) }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - it('should save correctly when there are delivery then bounce notifications', async () => { - const bounces = { - [recipientList[0]]: false, - [recipientList[1]]: true, - [recipientList[2]]: true, - } - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification1 = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // First email delivered - ) - const notification2 = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(1, 3), // Second and third email bounced - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: bounces[email], - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { ...JSON.parse(notification1.Message) }, - }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { ...JSON.parse(notification2.Message) }, + describe('logCriticalBounce', () => { + beforeEach(() => { + jest.resetAllMocks() }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, - }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should save correctly when there are bounce then delivery notifications', async () => { - const bounces = { - [recipientList[0]]: true, - [recipientList[1]]: false, - [recipientList[2]]: false, - } - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification1 = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // First email bounced - ) - const notification2 = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList.slice(1, 3), // Second and third email delivered - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: bounces[email], - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { ...JSON.parse(notification1.Message) }, + it('should log correctly when all bounces are transient', () => { + const bounceDoc = new Bounce({ + formId: MOCK_FORM_ID, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Transient' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Transient' }, + ], + hasAutoEmailed: true, + }) + const snsInfo = makeBounceNotification({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL], + }) + const autoEmailRecipients = [MOCK_EMAIL, MOCK_EMAIL_2] + logCriticalBounce(bounceDoc, snsInfo, autoEmailRecipients, false) + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Bounced submission', + meta: { + action: 'logCriticalBounce', + hasAutoEmailed: true, + hasDeactivated: false, + formId: String(MOCK_FORM_ID), + submissionId: String(MOCK_SUBMISSION_ID), + recipients: [MOCK_EMAIL, MOCK_EMAIL_2], + numRecipients: 2, + numTransient: 2, + numPermanent: 0, + autoEmailRecipients, + bounceInfo: snsInfo.bounce, + }, + }) }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { ...JSON.parse(notification2.Message) }, + + it('should log correctly when all bounces are permanent', () => { + const bounceDoc = new Bounce({ + formId: MOCK_FORM_ID, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Permanent' }, + ], + hasAutoEmailed: true, + }) + const snsInfo = makeBounceNotification({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL], + }) + const autoEmailRecipients: string[] = [] + logCriticalBounce(bounceDoc, snsInfo, autoEmailRecipients, true) + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Bounced submission', + meta: { + action: 'logCriticalBounce', + hasAutoEmailed: true, + hasDeactivated: true, + formId: String(MOCK_FORM_ID), + submissionId: String(MOCK_SUBMISSION_ID), + recipients: [MOCK_EMAIL, MOCK_EMAIL_2], + numRecipients: 2, + numTransient: 0, + numPermanent: 2, + autoEmailRecipients, + bounceInfo: snsInfo.bounce, + }, + }) }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, + + it('should log correctly when there is a mix of bounceTypes', () => { + const bounceDoc = new Bounce({ + formId: MOCK_FORM_ID, + bounces: [ + { email: MOCK_EMAIL, hasBounced: true, bounceType: 'Permanent' }, + { email: MOCK_EMAIL_2, hasBounced: true, bounceType: 'Transient' }, + ], + hasAutoEmailed: true, + }) + const snsInfo = makeBounceNotification({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + recipientList: [MOCK_EMAIL, MOCK_EMAIL_2], + bouncedList: [MOCK_EMAIL], + }) + const autoEmailRecipients: string[] = [] + logCriticalBounce(bounceDoc, snsInfo, autoEmailRecipients, false) + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Bounced submission', + meta: { + action: 'logCriticalBounce', + hasAutoEmailed: true, + hasDeactivated: false, + formId: String(MOCK_FORM_ID), + submissionId: String(MOCK_SUBMISSION_ID), + recipients: [MOCK_EMAIL, MOCK_EMAIL_2], + numRecipients: 2, + numTransient: 1, + numPermanent: 1, + autoEmailRecipients, + bounceInfo: snsInfo.bounce, + }, + }) }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) }) - it('should set hasBounced to false when a subsequent response is delivered', async () => { - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification1 = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList.slice(0, 1), // First email bounced - ) - const notification2 = makeDeliveryNotification( - formId, - submissionId, - recipientList, - recipientList, // All emails delivered - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: false, - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { ...JSON.parse(notification1.Message) }, - }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { ...JSON.parse(notification2.Message) }, + describe('isValidSnsRequest', () => { + const keys = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, }) - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: false, - bounces: expectedBounces, + + let body: ISnsNotification + + beforeEach(() => { + body = cloneDeep(MOCK_SNS_BODY) + mockAxios.get.mockResolvedValue({ + data: keys.publicKey, + }) }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should log a second critical bounce with hasAlarmed true', async () => { - const formId = new ObjectId() - const submissionId1 = new ObjectId() - const submissionId2 = new ObjectId() - const notification1 = makeBounceNotification( - formId, - submissionId1, - recipientList, - recipientList, - ) - const parsedNotification1: IBounceNotification = JSON.parse( - notification1.Message, - ) - const notification2 = makeBounceNotification( - formId, - submissionId2, - recipientList, - recipientList, - ) - const parsedNotification2: IBounceNotification = JSON.parse( - notification2.Message, - ) - await updateBounces(notification1) - await updateBounces(notification2) - const actualBounceCursor = await Bounce.find({ formId }) - const actualBounce = extractBounceObject(actualBounceCursor[0]) - const expectedBounces = recipientList.map((email) => ({ - email, - hasBounced: true, - })) - // There should only be one document after 2 notifications - expect(actualBounceCursor.length).toBe(1) - expect(mockLogger.info.mock.calls[0][0]).toMatchObject({ - meta: { ...parsedNotification1 }, + it('should gracefully reject when input is empty', () => { + return expect(isValidSnsRequest(undefined!)).resolves.toBe(false) }) - expect(mockLogger.info.mock.calls[1][0]).toMatchObject({ - meta: { ...parsedNotification2 }, + + it('should reject requests when their structure is invalid', () => { + const invalidBody = omit(cloneDeep(body), 'Type') as ISnsNotification + return expect(isValidSnsRequest(invalidBody)).resolves.toBe(false) }) - expect(mockLogger.warn.mock.calls[0][0]).toMatchObject({ - message: 'Critical bounce', - meta: { - action: 'updateBounces', - hasAlarmed: false, - formId: formId.toHexString(), - submissionId: submissionId1.toHexString(), - bounceInfo: parsedNotification1.bounce, - }, + + it('should reject requests when their certificate URL is invalid', () => { + body.SigningCertURL = 'http://www.example.com' + return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - expect(mockLogger.warn.mock.calls[1][0]).toMatchObject({ - message: 'Critical bounce', - meta: { - action: 'updateBounces', - hasAlarmed: true, - formId: formId.toHexString(), - submissionId: submissionId2.toHexString(), - bounceInfo: parsedNotification2.bounce, - }, + + it('should reject requests when their signature version is invalid', () => { + body.SignatureVersion = 'wrongSignatureVersion' + return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - expect(omit(actualBounce, 'expireAt')).toEqual({ - formId, - hasAlarmed: true, - bounces: expectedBounces, + + it('should reject requests when their signature is invalid', () => { + return expect(isValidSnsRequest(body)).resolves.toBe(false) }) - expect(actualBounce.expireAt).toBeInstanceOf(Date) - }) - it('should log email confirmations to short-term logs', async () => { - const formId = new ObjectId() - const submissionId = new ObjectId() - const notification = makeBounceNotification( - formId, - submissionId, - recipientList, - recipientList, - 'Transient', - 'Email confirmation', - ) - await updateBounces(notification) - expect(mockLogger.info).not.toHaveBeenCalled() - expect(mockLogger.warn).not.toHaveBeenCalled() - expect(mockShortTermLogger.info).toHaveBeenCalledWith( - JSON.parse(notification.Message), - ) + it('should accept when requests are valid', () => { + const signer = crypto.createSign('RSA-SHA1') + const baseString = + dedent`Message + ${body.Message} + MessageId + ${body.MessageId} + Timestamp + ${body.Timestamp} + TopicArn + ${body.TopicArn} + Type + ${body.Type} + ` + '\n' + signer.write(baseString) + body.Signature = signer.sign(keys.privateKey, 'base64') + return expect(isValidSnsRequest(body)).resolves.toBe(true) + }) }) }) diff --git a/src/app/modules/bounce/bounce.controller.ts b/src/app/modules/bounce/bounce.controller.ts index b8ba7fad70..c24ca7deb0 100644 --- a/src/app/modules/bounce/bounce.controller.ts +++ b/src/app/modules/bounce/bounce.controller.ts @@ -3,9 +3,11 @@ import { ParamsDictionary } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' -import { ISnsNotification } from '../../../types' +import { IEmailNotification, ISnsNotification } from '../../../types' +import { EmailType } from '../../constants/mail' +import * as FormService from '../form/form.service' -import * as snsService from './bounce.service' +import * as BounceService from './bounce.service' const logger = createLoggerWithLabel(module) /** @@ -23,11 +25,37 @@ export const handleSns: RequestHandler< // so we never fail on malformed input. The response code is meaningless since // it is meant to go back to AWS. try { - const isValid = await snsService.isValidSnsRequest(req.body) - if (!isValid) { - return res.sendStatus(StatusCodes.FORBIDDEN) + const isValid = await BounceService.isValidSnsRequest(req.body) + if (!isValid) return res.sendStatus(StatusCodes.FORBIDDEN) + + const notification: IEmailNotification = JSON.parse(req.body.Message) + BounceService.logEmailNotification(notification) + if ( + BounceService.extractEmailType(notification) !== EmailType.AdminResponse + ) { + return res.sendStatus(StatusCodes.OK) + } + const bounceDoc = await BounceService.getUpdatedBounceDoc(notification) + // Missing headers in notification + if (!bounceDoc) return res.sendStatus(StatusCodes.OK) + const shouldDeactivate = bounceDoc.areAllPermanentBounces() + if (shouldDeactivate) { + await FormService.deactivateForm(bounceDoc.formId) + } + if (bounceDoc.isCriticalBounce()) { + let emailRecipients: string[] = [] + if (!bounceDoc.hasNotified()) { + emailRecipients = await BounceService.notifyAdminOfBounce(bounceDoc) + bounceDoc.setNotificationState(emailRecipients) + } + BounceService.logCriticalBounce( + bounceDoc, + notification, + emailRecipients, + shouldDeactivate, + ) } - await snsService.updateBounces(req.body) + await bounceDoc.save() return res.sendStatus(StatusCodes.OK) } catch (err) { logger.warn({ diff --git a/src/app/modules/bounce/bounce.model.ts b/src/app/modules/bounce/bounce.model.ts index e3735c96ce..b377ae878d 100644 --- a/src/app/modules/bounce/bounce.model.ts +++ b/src/app/modules/bounce/bounce.model.ts @@ -1,27 +1,25 @@ +import { keyBy } from 'lodash' import { Model, Mongoose, Schema } from 'mongoose' import validator from 'validator' import { bounceLifeSpan } from '../../../config/config' import { - IBounceNotification, + BounceType, IBounceSchema, IEmailNotification, ISingleBounce, } from '../../../types' -import { EMAIL_HEADERS, EmailType } from '../../constants/mail' import { FORM_SCHEMA_ID } from '../../models/form.server.model' -import { - extractHeader, - hasEmailBounced, - isBounceNotification, - isDeliveryNotification, -} from './bounce.util' +import { hasEmailBeenDelivered, hasEmailBounced } from './bounce.util' export const BOUNCE_SCHEMA_ID = 'Bounce' export interface IBounceModel extends Model { - fromSnsNotification: (snsInfo: IEmailNotification) => IBounceSchema | null + fromSnsNotification: ( + snsInfo: IEmailNotification, + formId: string, + ) => IBounceSchema } const BounceSchema = new Schema({ @@ -30,7 +28,7 @@ const BounceSchema = new Schema({ ref: FORM_SCHEMA_ID, required: 'Form ID is required', }, - hasAlarmed: { + hasAutoEmailed: { type: Boolean, default: false, }, @@ -50,6 +48,10 @@ const BounceSchema = new Schema({ type: Boolean, default: false, }, + bounceType: { + type: String, + enum: Object.values(BounceType), + }, _id: false, }, ], @@ -66,23 +68,22 @@ BounceSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) * More info on format of SNS notifications: * https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html. * @param snsInfo the SNS notification to create a document from + * @param snsInfo the SNS notification to create a document from * @returns the created Bounce document */ BounceSchema.statics.fromSnsNotification = function ( this: IBounceModel, snsInfo: IEmailNotification, -): IBounceSchema | null { - const emailType = extractHeader(snsInfo, EMAIL_HEADERS.emailType) - const formId = extractHeader(snsInfo, EMAIL_HEADERS.formId) - // Only care about admin emails - if (emailType !== EmailType.AdminResponse || !formId) { - return null - } - const isBounce = isBounceNotification(snsInfo) + formId: string, +): IBounceSchema { const bounces: ISingleBounce[] = snsInfo.mail.commonHeaders.to.map( (email) => { - if (isBounce && hasEmailBounced(snsInfo as IBounceNotification, email)) { - return { email, hasBounced: true } + if (hasEmailBounced(snsInfo, email)) { + return { + email, + hasBounced: true, + bounceType: snsInfo.bounce.bounceType, + } } else { return { email, hasBounced: false } } @@ -92,52 +93,96 @@ BounceSchema.statics.fromSnsNotification = function ( } /** - * Updates an old bounce document with info from a new bounce document as well - * as an SNS notification. This function does 3 things: - * 1) If the old bounce document indicates that an email bounced, set - * `hasBounced` to `true` for that email. - * 2) If the new delivery notification indicates that an email was delivered - * successfully, set `hasBounced` to `false` for that email, even if the old - * bounce document indicates that that email previously bounced. - * 3) Update the old recipient list according to the newest bounce notification. - * @param latestBounces the newer bounce document to merge into the current document + * Updates an old bounce document with info from an SNS notification. * @param snsInfo the notification information to merge */ -BounceSchema.methods.merge = function ( +BounceSchema.methods.updateBounceInfo = function ( this: IBounceSchema, - latestBounces: IBounceSchema, snsInfo: IEmailNotification, -): void { - this.bounces.forEach((oldBounce) => { - // If we were previously notified that a given email has bounced, - // we want to retain that information - if (oldBounce.hasBounced) { - // Check if the latest recipient list contains that email - const matchedLatestBounce = latestBounces.bounces.find( - (newBounce) => newBounce.email === oldBounce.email, - ) - // Check if the latest notification indicates that this email - // actually succeeded. We can't just use latestBounces because - // a false in latestBounces doesn't guarantee that the email was - // delivered, only that the email has not bounced yet. - const hasSubsequentlySucceeded = - isDeliveryNotification(snsInfo) && - snsInfo.delivery.recipients.includes(oldBounce.email) - if (matchedLatestBounce) { - // Set the latest bounce status based on the latest notification - matchedLatestBounce.hasBounced = !hasSubsequentlySucceeded +): IBounceSchema { + // First, get rid of outdated emails + const latestRecipients = new Set(snsInfo.mail.commonHeaders.to) + this.bounces = this.bounces.filter((bounceInfo) => + latestRecipients.has(bounceInfo.email), + ) + // Reshape this.bounces to avoid O(n^2) computation + const bouncesByEmail = keyBy(this.bounces, 'email') + // The following block needs to work for the cross product of cases: + // (notification type) * + // (does the notification confirm delivery/bounce for this email) * + // (does bouncesByEmail contain this email) * + // (does bouncesByEmail currently say this email has bounced) + snsInfo.mail.commonHeaders.to.forEach((email) => { + if (hasEmailBounced(snsInfo, email)) { + bouncesByEmail[email] = { + email, + hasBounced: true, + bounceType: snsInfo.bounce.bounceType, } + } else if ( + hasEmailBeenDelivered(snsInfo, email) || + !bouncesByEmail[email] + ) { + bouncesByEmail[email] = { email, hasBounced: false } } }) - this.bounces = latestBounces.bounces + this.bounces = Object.values(bouncesByEmail) + return this } +/** + * Returns true if the document indicates a critical bounce (all recipients + * bounced), false otherwise. + */ BounceSchema.methods.isCriticalBounce = function ( this: IBounceSchema, ): boolean { return this.bounces.every((emailInfo) => emailInfo.hasBounced) } +/** + * Returns true if the document indicates that all recipients bounced and + * all bounces were permanent, false otherwise. + */ +BounceSchema.methods.areAllPermanentBounces = function ( + this: IBounceSchema, +): boolean { + return this.bounces.every( + (emailInfo) => + emailInfo.hasBounced && emailInfo.bounceType === BounceType.Permanent, + ) +} + +/** + * Returns the list of email recipients for this form + */ +BounceSchema.methods.getEmails = function (this: IBounceSchema): string[] { + // Return a regular array to prevent unexpected bugs with mongoose + // CoreDocumentArray + return Array.from(this.bounces.map((emailInfo) => emailInfo.email)) +} + +/** + * Sets hasAutoEmailed to true if at least one person has been emailed. + * @param emailRecipients Array of recipients who were emailed. + */ +BounceSchema.methods.setNotificationState = function ( + this: IBounceSchema, + emailRecipients: string[], +): void { + if (emailRecipients.length > 0) { + this.hasAutoEmailed = true + } +} + +/** + * Returns true if an automated email has been sent for this form, + * false otherwise. + */ +BounceSchema.methods.hasNotified = function (this: IBounceSchema): boolean { + return this.hasAutoEmailed +} + const getBounceModel = (db: Mongoose): IBounceModel => { try { return db.model(BOUNCE_SCHEMA_ID) as IBounceModel diff --git a/src/app/modules/bounce/bounce.service.ts b/src/app/modules/bounce/bounce.service.ts index aec827fa67..4fddfd35c3 100644 --- a/src/app/modules/bounce/bounce.service.ts +++ b/src/app/modules/bounce/bounce.service.ts @@ -1,6 +1,6 @@ import axios from 'axios' import crypto from 'crypto' -import { isEmpty } from 'lodash' +import { difference, isEmpty } from 'lodash' import mongoose from 'mongoose' import { @@ -8,12 +8,15 @@ import { createLoggerWithLabel, } from '../../../config/logger' import { - IBounceNotification, + BounceType, IBounceSchema, IEmailNotification, + IPopulatedForm, ISnsNotification, } from '../../../types' import { EMAIL_HEADERS, EmailType } from '../../constants/mail' +import getFormModel from '../../models/form.server.model' +import MailService from '../../services/mail.service' import getBounceModel from './bounce.model' import { extractHeader, isBounceNotification } from './bounce.util' @@ -21,6 +24,7 @@ import { extractHeader, isBounceNotification } from './bounce.util' const logger = createLoggerWithLabel(module) const shortTermLogger = createCloudWatchLogger('email') const Bounce = getBounceModel(mongoose) +const Form = getFormModel(mongoose) // Note that these need to be ordered in order to generate // the correct string to sign @@ -106,47 +110,107 @@ export const isValidSnsRequest = async ( } // Writes a log message if all recipients have bounced -const logCriticalBounce = ( +export const logCriticalBounce = ( bounceDoc: IBounceSchema, - submissionId: string | undefined, - bounceInfo: IBounceNotification['bounce'] | undefined, + notification: IEmailNotification, + autoEmailRecipients: string[], + hasDeactivated: boolean, ): void => { - if (bounceDoc.isCriticalBounce()) { - logger.warn({ - message: 'Critical bounce', + const submissionId = extractHeader(notification, EMAIL_HEADERS.submissionId) + const bounceInfo = isBounceNotification(notification) + ? notification.bounce + : undefined + // Out of all bounces, how many were transient + const numTransient = bounceDoc.bounces.reduce( + (total, bounce) => + total + + Number(bounce.hasBounced && bounce.bounceType === BounceType.Transient), + 0, + ) + logger.warn({ + message: 'Bounced submission', + meta: { + action: 'logCriticalBounce', + hasAutoEmailed: bounceDoc.hasAutoEmailed, + hasDeactivated, + formId: String(bounceDoc.formId), + submissionId: submissionId, + recipients: bounceDoc.getEmails(), + numRecipients: bounceDoc.bounces.length, + numTransient, + // Assume that this function is correctly only called when all recipients bounced + numPermanent: bounceDoc.bounces.length - numTransient, + autoEmailRecipients, + // We know for sure that critical bounces can only happen because of bounce + // notifications, so we don't expect this to be undefined + bounceInfo: bounceInfo, + }, + }) +} + +const computeValidEmails = ( + populatedForm: IPopulatedForm, + bounceDoc: IBounceSchema, +): string[] => { + const collabEmails = populatedForm.permissionList + ? populatedForm.permissionList.map((collab) => collab.email) + : [] + const possibleEmails = collabEmails.concat(populatedForm.admin.email) + return difference(possibleEmails, bounceDoc.getEmails()) +} + +export const notifyAdminOfBounce = async ( + bounceDoc: IBounceSchema, +): Promise => { + const form = await Form.getFullFormById(bounceDoc.formId) + if (!form) { + logger.error({ + message: 'Unable to retrieve form', meta: { - action: 'updateBounces', - hasAlarmed: bounceDoc.hasAlarmed, - formId: String(bounceDoc.formId), - submissionId: submissionId, - recipients: bounceDoc.bounces.map((emailInfo) => emailInfo.email), - // We know for sure that critical bounces can only happen because of bounce - // notifications, so we don't expect this to be undefined - bounceInfo: bounceInfo, + action: 'notifyAdminOfBounce', + formId: bounceDoc.formId, }, }) - // TODO (private #31): autoemail and set hasAlarmed to true. Currently - // hasAlarmed is a dangling key. - bounceDoc.hasAlarmed = true - // TODO (private #31): convert bounceType to enum. + return [] + } + const emailRecipients = computeValidEmails(form, bounceDoc) + if (emailRecipients.length > 0) { + await MailService.sendBounceNotification({ + emailRecipients, + bouncedRecipients: bounceDoc.getEmails(), + bounceType: bounceDoc.areAllPermanentBounces() + ? BounceType.Permanent + : BounceType.Transient, + formTitle: form.title, + formId: bounceDoc.formId, + }) } + return emailRecipients } /** * Logs the raw notification to the relevant log group. * @param notification The parsed SNS notification */ -const logEmailNotification = (notification: IEmailNotification): void => { +export const logEmailNotification = ( + notification: IEmailNotification, +): void => { + // This is the crucial log statement which allows us to debug bounce-related + // issues, as it logs all the details about deliveries and bounces. Email + // confirmation info goes to the short-term log group so we do not store + // form fillers' information for too long, and everything else goes into the + // main log group. + const emailType = extractHeader(notification, EMAIL_HEADERS.emailType) if ( - extractHeader(notification, EMAIL_HEADERS.emailType) === - EmailType.EmailConfirmation + emailType === EmailType.EmailConfirmation || + emailType === EmailType.VerificationOtp ) { shortTermLogger.info(notification) } else { logger.info({ message: 'Email notification', meta: { - action: 'updateBounces', + action: 'logEmailNotification', ...notification, }, }) @@ -156,27 +220,26 @@ const logEmailNotification = (notification: IEmailNotification): void => { /** * Parses an SNS notification and updates the Bounce collection. * @param body The request body of the notification + * @return the updated document from the Bounce collection or null if there are missing headers. */ -export const updateBounces = async (body: ISnsNotification): Promise => { - const notification: IEmailNotification = JSON.parse(body.Message) - // This is the crucial log statement which allows us to debug bounce-related - // issues, as it logs all the details about deliveries and bounces. Email - // confirmation info goes to the short-term log group so we do not store - // form fillers' information for too long, and everything else goes into the - // main log group. - logEmailNotification(notification) - const latestBounces = Bounce.fromSnsNotification(notification) - if (!latestBounces) return - const formId = latestBounces.formId - const submissionId = extractHeader(notification, EMAIL_HEADERS.submissionId) - const bounceInfo = isBounceNotification(notification) - ? notification.bounce - : undefined +export const getUpdatedBounceDoc = async ( + notification: IEmailNotification, +): Promise => { + const formId = extractHeader(notification, EMAIL_HEADERS.formId) + if (!formId) return null const oldBounces = await Bounce.findOne({ formId }) - if (oldBounces) { - oldBounces.merge(latestBounces, notification) - } - const bounce = oldBounces ?? latestBounces - logCriticalBounce(bounce, submissionId, bounceInfo) - await bounce.save() + return oldBounces + ? oldBounces.updateBounceInfo(notification) + : Bounce.fromSnsNotification(notification, formId) +} + +/** + * Extracts the email type of a notification. + * @param body The request body of the notification + * @return the EmailType + */ +export const extractEmailType = ( + notification: IEmailNotification, +): string | undefined => { + return extractHeader(notification, EMAIL_HEADERS.emailType) } diff --git a/src/app/modules/bounce/bounce.util.ts b/src/app/modules/bounce/bounce.util.ts index 8d9d27e751..278878f3a3 100644 --- a/src/app/modules/bounce/bounce.util.ts +++ b/src/app/modules/bounce/bounce.util.ts @@ -2,7 +2,7 @@ import { IBounceNotification, IDeliveryNotification, IEmailNotification, -} from 'src/types' +} from '../../../types' /** * Extracts custom headers which we send with all emails, such as form ID, submission ID * and email type (admin response, email confirmation OTP etc). @@ -26,11 +26,30 @@ export const extractHeader = ( * @returns true if the email as bounced, false otherwise */ export const hasEmailBounced = ( - bounceInfo: IBounceNotification, + snsInfo: IEmailNotification, email: string, -): boolean => { - return bounceInfo.bounce.bouncedRecipients.some( - (emailInfo) => emailInfo.emailAddress === email, +): snsInfo is IBounceNotification => { + return ( + isBounceNotification(snsInfo) && + snsInfo.bounce.bouncedRecipients.some( + (emailInfo) => emailInfo.emailAddress === email, + ) + ) +} + +/** + * Whether a bounce notification says a given email has been delivered. + * @param bounceInfo Bounce notification from SNS + * @param email Email address to check + * @returns true if the email as bounced, false otherwise + */ +export const hasEmailBeenDelivered = ( + snsInfo: IEmailNotification, + email: string, +): snsInfo is IDeliveryNotification => { + return ( + isDeliveryNotification(snsInfo) && + snsInfo.delivery.recipients.includes(email) ) } diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts new file mode 100644 index 0000000000..0fbb1463dd --- /dev/null +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson' +import mongoose from 'mongoose' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import getFormModel from 'src/app/models/form.server.model' +// eslint-disable-next-line import/first +import { deactivateForm } from 'src/app/modules/form/form.service' + +const MOCK_FORM_ID = new ObjectId() +const Form = getFormModel(mongoose) + +describe('FormService', () => { + beforeAll(async () => await dbHandler.connect()) + + afterAll(async () => { + await dbHandler.clearDatabase() + await dbHandler.closeDatabase() + }) + + describe('deactivateForm', () => { + it('should call Form.deactivateById', async () => { + const mock = jest.spyOn(Form, 'deactivateById') + await deactivateForm(String(MOCK_FORM_ID)) + expect(mock).toHaveBeenCalledWith(String(MOCK_FORM_ID)) + }) + }) +}) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts new file mode 100644 index 0000000000..8698b4758c --- /dev/null +++ b/src/app/modules/form/form.service.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose' + +import { IFormSchema } from 'src/types' + +import getFormModel from '../../models/form.server.model' + +const Form = getFormModel(mongoose) + +export const deactivateForm = async ( + formId: string, +): Promise => { + return Form.deactivateById(formId) +} diff --git a/src/app/services/mail.service.ts b/src/app/services/mail.service.ts index f325d2f536..e58d7948cc 100644 --- a/src/app/services/mail.service.ts +++ b/src/app/services/mail.service.ts @@ -3,24 +3,29 @@ import moment from 'moment-timezone' import { ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' -import { OperationOptions } from 'retry' import validator from 'validator' import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' import { HASH_EXPIRE_AFTER_SECONDS } from '../../shared/util/verification' import { - AutoReplyOptions, + AutoreplySummaryRenderData, + BounceNotificationHtmlData, + BounceType, IEmailFormSchema, - IFormSchema, - IPopulatedForm, ISubmissionSchema, + MailOptions, + MailServiceParams, + SendAutoReplyEmailsArgs, + SendMailOptions, + SendSingleAutoreplyMailArgs, } from '../../types' import { EMAIL_HEADERS, EmailType } from '../constants/mail' import { MailSendError } from '../modules/mail/mail.errors' import { generateAutoreplyHtml, generateAutoreplyPdf, + generateBounceNotificationHtml, generateLoginOtpHtml, generateSubmissionToAdminHtml, generateVerificationOtpHtml, @@ -29,57 +34,6 @@ import { const logger = createLoggerWithLabel(module) -type SendMailOptions = { - mailId?: string - formId?: string -} - -type SendSingleAutoreplyMailArgs = { - form: Pick - submission: Pick - autoReplyMailData: AutoReplyMailData - attachments: Mail.Attachment[] - formSummaryRenderData: AutoreplySummaryRenderData - index: number -} - -export type SendAutoReplyEmailsArgs = { - form: Pick - submission: Pick - attachments?: Mail.Attachment[] - responsesData: { question: string; answerTemplate: string[] }[] - autoReplyMailDatas: AutoReplyMailData[] -} - -type MailServiceParams = { - appName?: string - appUrl?: string - transporter?: Mail - senderMail?: string - retryParams?: Partial -} - -type AutoReplyMailData = { - email: string - subject?: AutoReplyOptions['autoReplySubject'] - sender?: AutoReplyOptions['autoReplySender'] - body?: AutoReplyOptions['autoReplyMessage'] - includeFormSummary?: AutoReplyOptions['includeFormSummary'] -} - -export type AutoreplySummaryRenderData = { - refNo: ISubmissionSchema['_id'] - formTitle: IFormSchema['title'] - submissionTime: string - // TODO (#42): Add proper types once the type is determined. - formData: any - formUrl: string -} - -export type MailOptions = Omit & { - to: string | string[] -} - const DEFAULT_RETRY_PARAMS: MailServiceParams['retryParams'] = { retries: 3, // Exponential backoff. @@ -350,6 +304,49 @@ export class MailService { }) } + /** + * Sends a notification for critical bounce + * @param args the parameter object + * @param args.emailRecipients emails to send to + * @param args.bouncedRecipients the emails which caused the critical bounce + * @param args.bounceType bounce type given by SNS + * @param args.formTitle title of form + * @param args.formId ID of form + * @throws error if mail fails, to be handled by the caller + */ + sendBounceNotification = async ({ + emailRecipients, + bouncedRecipients, + bounceType, + formTitle, + formId, + }: { + emailRecipients: string[] + bouncedRecipients: string[] + bounceType: BounceType | undefined + formTitle: string + formId: string + }) => { + const htmlData: BounceNotificationHtmlData = { + formTitle, + formLink: `${this.#appUrl}/${formId}`, + bouncedRecipients: bouncedRecipients.join(', '), + appName: this.#appName, + } + const mail: MailOptions = { + to: emailRecipients, + from: this.#senderFromString, + subject: '[Urgent] FormSG Response Delivery Failure / Bounce', + html: await generateBounceNotificationHtml(htmlData, bounceType), + headers: { + [EMAIL_HEADERS.emailType]: EmailType.AdminBounce, + [EMAIL_HEADERS.formId]: formId, + }, + } + + return this.#sendNodeMail(mail, { mailId: 'bounce' }) + } + /** * Sends a submission response email to the admin of the given form. * @param args the parameter object diff --git a/src/app/utils/mail.ts b/src/app/utils/mail.ts index f8179ed539..3495d8cbcc 100644 --- a/src/app/utils/mail.ts +++ b/src/app/utils/mail.ts @@ -5,10 +5,15 @@ import { ResultAsync } from 'neverthrow' import puppeteer from 'puppeteer-core' import validator from 'validator' -import { IFormSchema, ISubmissionSchema } from 'src/types' - import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' +import { + AutoreplyHtmlData, + AutoreplySummaryRenderData, + BounceNotificationHtmlData, + BounceType, + SubmissionToAdminHtmlData, +} from '../../types' import { MailSendError } from '../modules/mail/mail.errors' const logger = createLoggerWithLabel(module) @@ -59,19 +64,6 @@ export const generateVerificationOtpHtml = ({ ` } -type SubmissionToAdminHtmlData = { - refNo: string - formTitle: string - submissionTime: string - // TODO (#42): Add proper types once the type is determined. - formData: any[] - jsonData: { - question: string - answer: string | number - }[] - appName: string -} - export const generateSubmissionToAdminHtml = async ( htmlData: SubmissionToAdminHtmlData, ): Promise => { @@ -79,13 +71,17 @@ export const generateSubmissionToAdminHtml = async ( return ejs.renderFile(pathToTemplate, htmlData) } -type AutoreplySummaryRenderData = { - refNo: ISubmissionSchema['_id'] - formTitle: IFormSchema['title'] - submissionTime: string - // TODO (#42): Add proper types once the type is determined. - formData: any - formUrl: string +export const generateBounceNotificationHtml = async ( + htmlData: BounceNotificationHtmlData, + bounceType: BounceType | undefined, +): Promise => { + let pathToTemplate + if (bounceType === BounceType.Permanent) { + pathToTemplate = `${process.cwd()}/src/app/views/templates/bounce-notification-permanent.server.view.html` + } else { + pathToTemplate = `${process.cwd()}/src/app/views/templates/bounce-notification-transient.server.view.html` + } + return ejs.renderFile(pathToTemplate, htmlData) } export const generateAutoreplyPdf = async ( @@ -115,10 +111,6 @@ export const generateAutoreplyPdf = async ( return pdfBuffer } -type AutoreplyHtmlData = - | ({ autoReplyBody: string[] } & AutoreplySummaryRenderData) - | { autoReplyBody: string[] } - export const generateAutoreplyHtml = async ( htmlData: AutoreplyHtmlData, ): Promise => { diff --git a/src/app/views/templates/bounce-notification-permanent.server.view.html b/src/app/views/templates/bounce-notification-permanent.server.view.html new file mode 100644 index 0000000000..e7fcb103ba --- /dev/null +++ b/src/app/views/templates/bounce-notification-permanent.server.view.html @@ -0,0 +1,117 @@ + + + + +

Dear form admin(s),

+

+ We are contacting you urgently because your FormSG form '<%= formTitle %>' + (<%= formLink %>) has responses that were unable to be delivered to the + following recipients: <%= bouncedRecipients %>. +

+

Likely causes:

+
    +
  • email address contains a typo
  • +
  • email address is no longer valid or being used
  • +
+

What to do immediately

+
    +
  1. + Fill in correct and valid email addresses for your form + under Settings > ‘Emails where responses will be sent’. We recommend at + least 2 receiving email addresses to guard against response bounces. +
  2. +
  3. + Check if the form has been deactivated under Settings. If so, reactivate + the form to make it public again. +
  4. +
  5. + Submit a test response on your form, and check that you receive it. +
  6. +
  7. + Consider switching your form to Storage mode, or + setting up AutoArchiving + which automates clearing of mailbox capacity (see + Preventing future response loss below). +
  8. +
+

+ * If you do not have an email address with capacity to receive responses + right now, please consider deactivating your form temporarily (Settings + tab) so that further responses are not lost. +

+

Preventing future response loss

+

+ Due to the risk of permanent response loss from bounced + emails, we + strongly recommend users use Storage mode whenever + possible. Note that Storage mode does not support MyInfo and emailed pdf + responses; for these features you will have to use Email mode. +

+

Option 1: Storage mode

+

+ Under Storage mode, responses will not be sent to your email but are + downloadable as a spreadsheet from FormSG’s website. +

+
    +
  1. + Here is a guide to + switching your form from Email mode to Storage mode. +
  2. +
  3. + Be sure + not to lose the secret key, which you will need to access responses. Responses cannot be + recovered if this key is lost. +
  4. +
+

Other useful links

+ +

Option 2: Email mode with precautions

+

If you must use Email mode,

+
    +
  1. + Specify 2 or more email recipients for your Email mode forms in + Settings, to increase the chance of responses getting delivered to at + least one recipient. +
  2. +
  3. + Set up AutoArchiving + which automates clearing of mailbox capacity. +
  4. +
+

Recovering bounced responses

+

+ Unfortunately, once a response has bounced from your mailbox, FormSG is + not able to recover the response for you as we do not store response data. +

+

+ However, if you had listed another email recipient in Settings, the + response may have been successfully sent to them. +

+

The <%= appName %> Support Team

+ + diff --git a/src/app/views/templates/bounce-notification-transient.server.view.html b/src/app/views/templates/bounce-notification-transient.server.view.html new file mode 100644 index 0000000000..283bdd4cd5 --- /dev/null +++ b/src/app/views/templates/bounce-notification-transient.server.view.html @@ -0,0 +1,108 @@ + + + + +

Dear form admin(s),

+

+ We are contacting you urgently because your FormSG form '<%= formTitle %>' + (<%= formLink %>) has responses that were unable to be delivered to the + following recipients: <%= bouncedRecipients %>. +

+

This bounce was likely due to a full mailbox.

+

What to do immediately

+
    +
  1. + Check if <%= bouncedRecipients %> have full mailboxes. If so, free up + capacity immediately (e.g. by archiving old emails). +
  2. +
  3. + Add a valid email address with spare inbox capacity to + your form’s email recipients, under Settings > ‘Emails where responses + will be sent’. +
  4. +
  5. + Submit a test response on your form, and check that you receive it. +
  6. +
  7. + Consider switching your form to Storage mode, or + setting up AutoArchiving + which automates clearing of mailbox capacity (see + Preventing future response loss below). +
  8. +
+

Preventing future response loss

+

+ Due to the risk of permanent response loss from bounced + emails, we + strongly recommend users use Storage mode whenever + possible. Note that Storage mode does not support MyInfo and emailed pdf + responses; for these features you will have to use Email mode. +

+

Option 1: Storage mode

+

+ Under Storage mode, responses will not be sent to your email but are + downloadable as a spreadsheet from FormSG’s website. +

+
    +
  1. + Here is a guide to + switching your form from Email mode to Storage mode. +
  2. +
  3. + Be sure + not to lose the secret key, which you will need to access responses. Responses cannot be + recovered if this key is lost. +
  4. +
+

Other useful links

+ +

Option 2: Email mode with precautions

+

If you must use Email mode,

+
    +
  1. + Specify 2 or more email recipients for your Email mode forms in + Settings, to increase the chance of responses getting delivered to at + least one recipient. +
  2. +
  3. + Set up AutoArchiving + which automates clearing of mailbox capacity. +
  4. +
+

Recovering bounced responses

+

+ Unfortunately, once a response has bounced from your mailbox, FormSG is + not able to recover the response for you as we do not store response data. +

+

+ However, if you had listed another email recipient in Settings, the + response may have been successfully sent to them. +

+

The <%= appName %> Support Team

+ + diff --git a/src/app/views/templates/bounce-notification.server.view.html b/src/app/views/templates/bounce-notification.server.view.html deleted file mode 100644 index 982b32a1db..0000000000 --- a/src/app/views/templates/bounce-notification.server.view.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - -

- An email confirmation to one of your form respondents has bounced - permanently. This normally happens if the specified email address does not - exist. The details are as follows: -

-
    -
  • Form title: <%= formTitle %>
  • -
  • Submission reference number: <%= submissionId %>
  • -
  • Email subject: <%= subject %>
  • -
  • Bounced email addresses: <%= bouncedEmails %>
  • -
-

- If you need to contact your form respondents by email, please consider - using the "Verified" option for your email field. -

-
-

The <%= appName %> Support Team

- - diff --git a/src/config/schema.ts b/src/config/schema.ts index 3e58364030..d725ff29d8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -195,7 +195,7 @@ export const optionalVarsSchema: Schema = { bounceLifeSpan: { doc: 'TTL of bounce documents in milliseconds', format: 'int', - default: 10800000, + default: 86400000, env: 'BOUNCE_LIFE_SPAN', }, chromiumBin: { diff --git a/src/types/bounce.ts b/src/types/bounce.ts index 6a4cea5286..a01ce8d754 100644 --- a/src/types/bounce.ts +++ b/src/types/bounce.ts @@ -1,22 +1,35 @@ import { Document } from 'mongoose' import { IFormSchema } from './form' -import { IEmailNotification } from './sns' +import { BounceType, IEmailNotification } from './sns' -export interface ISingleBounce { - email: string - hasBounced: boolean -} +// Enforce that bounceType is present if hasBounced is true +export type ISingleBounce = + | { + email: string + hasBounced: true + // TODO (private #44): this key should be required, + // but is currently optional for backwards compatibility reasons. + bounceType?: BounceType + } + | { + email: string + hasBounced: false + } export interface IBounce { formId: IFormSchema['_id'] bounces: ISingleBounce[] - hasAlarmed: boolean + hasAutoEmailed: boolean expireAt: Date _id: Document['_id'] } export interface IBounceSchema extends IBounce, Document { - merge: (latestBounces: IBounceSchema, snsInfo: IEmailNotification) => void + updateBounceInfo: (snsInfo: IEmailNotification) => IBounceSchema isCriticalBounce: () => boolean + areAllPermanentBounces: () => boolean + getEmails: () => string[] + setNotificationState: (emailRecipients: string[]) => void + hasNotified: () => boolean } diff --git a/src/types/index.ts b/src/types/index.ts index 41a8f91191..b1e4ab0b98 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ export * from './form_logo' export * from './form_statistics_total' export * from './form' export * from './login' +export * from './mail' export * from './myinfo_hash' export * from './response' export * from './sms_count' diff --git a/src/types/mail.ts b/src/types/mail.ts new file mode 100644 index 0000000000..4d346db215 --- /dev/null +++ b/src/types/mail.ts @@ -0,0 +1,81 @@ +import Mail from 'nodemailer/lib/mailer' +import { OperationOptions } from 'retry' + +import { AutoReplyOptions } from './field' +import { IFormSchema, IPopulatedForm } from './form' +import { ISubmissionSchema } from './submission' + +export type SendMailOptions = { + mailId?: string + formId?: string +} + +export type SendSingleAutoreplyMailArgs = { + form: Pick + submission: Pick + autoReplyMailData: AutoReplyMailData + attachments: Mail.Attachment[] + formSummaryRenderData: AutoreplySummaryRenderData + index: number +} + +export type SendAutoReplyEmailsArgs = { + form: Pick + submission: Pick + attachments?: Mail.Attachment[] + responsesData: { question: string; answerTemplate: string[] }[] + autoReplyMailDatas: AutoReplyMailData[] +} + +export type MailServiceParams = { + appName?: string + appUrl?: string + transporter?: Mail + senderMail?: string + retryParams?: Partial +} + +export type AutoReplyMailData = { + email: string + subject?: AutoReplyOptions['autoReplySubject'] + sender?: AutoReplyOptions['autoReplySender'] + body?: AutoReplyOptions['autoReplyMessage'] + includeFormSummary?: AutoReplyOptions['includeFormSummary'] +} + +export type AutoreplySummaryRenderData = { + refNo: ISubmissionSchema['_id'] + formTitle: IFormSchema['title'] + submissionTime: string + // TODO (#42): Add proper types once the type is determined. + formData: any + formUrl: string +} + +export type MailOptions = Omit & { + to: string | string[] +} + +export type SubmissionToAdminHtmlData = { + refNo: string + formTitle: string + submissionTime: string + // TODO (#42): Add proper types once the type is determined. + formData: any[] + jsonData: { + question: string + answer: string | number + }[] + appName: string +} + +export type AutoreplyHtmlData = + | ({ autoReplyBody: string[] } & AutoreplySummaryRenderData) + | { autoReplyBody: string[] } + +export type BounceNotificationHtmlData = { + formTitle: string + formLink: string + bouncedRecipients: string + appName: string +} diff --git a/src/types/sns.ts b/src/types/sns.ts index 6202f72160..78aa5986c8 100644 --- a/src/types/sns.ts +++ b/src/types/sns.ts @@ -35,10 +35,15 @@ export interface IEmailNotification { } } +export enum BounceType { + Permanent = 'Permanent', + Transient = 'Transient', +} + export interface IBounceNotification extends IEmailNotification { notificationType: 'Bounce' bounce: { - bounceType: string + bounceType: BounceType bounceSubType: string bouncedRecipients: { emailAddress: string diff --git a/tests/unit/backend/helpers/jest-logger.ts b/tests/unit/backend/helpers/jest-logger.ts index 359bd80214..e044f4cb62 100644 --- a/tests/unit/backend/helpers/jest-logger.ts +++ b/tests/unit/backend/helpers/jest-logger.ts @@ -16,11 +16,4 @@ const getMockLogger = (): MockLogger => { return logger as MockLogger } -export const resetMockLogger = (logger: MockLogger) => { - logger.log.mockReset() - logger.warn.mockReset() - logger.info.mockReset() - logger.profile.mockReset() -} - export default getMockLogger diff --git a/tests/unit/backend/models/form.server.model.spec.ts b/tests/unit/backend/models/form.server.model.spec.ts index 02c8e926fb..f850d5ed01 100644 --- a/tests/unit/backend/models/form.server.model.spec.ts +++ b/tests/unit/backend/models/form.server.model.spec.ts @@ -12,6 +12,7 @@ import { IUserSchema, Permission, ResponseMode, + Status, } from 'src/types' import dbHandler from '../helpers/jest-db' @@ -674,6 +675,35 @@ describe('Form Model', () => { }) describe('Statics', () => { + describe('deactivateById', () => { + it('should correctly deactivate form for valid ID', async () => { + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: preloadedAdmin, + status: Status.Public, + }) + const form = await Form.create(formParams) + await Form.deactivateById(form._id) + const updated = await Form.findById(form._id) + expect(updated!.status).toBe('PRIVATE') + }) + + it('should not deactivate archived form', async () => { + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: preloadedAdmin, + status: Status.Archived, + }) + const form = await Form.create(formParams) + await Form.deactivateById(form._id) + const updated = await Form.findById(form._id) + expect(updated!.status).toBe('ARCHIVED') + }) + + it('should return null for invalid form ID', async () => { + const returned = await Form.deactivateById(String(new ObjectID())) + expect(returned).toBeNull() + }) + }) + describe('getFullFormById', () => { it('should return null when the formId is invalid', async () => { // Arrange diff --git a/tests/unit/backend/services/mail.service.spec.ts b/tests/unit/backend/services/mail.service.spec.ts index 798022b4ea..2f5811d02e 100644 --- a/tests/unit/backend/services/mail.service.spec.ts +++ b/tests/unit/backend/services/mail.service.spec.ts @@ -4,14 +4,16 @@ import Mail, { Attachment } from 'nodemailer/lib/mailer' import { ImportMock } from 'ts-mock-imports' import { MailSendError } from 'src/app/modules/mail/mail.errors' +import { MailService } from 'src/app/services/mail.service' +import * as MailUtils from 'src/app/utils/mail' import { AutoreplySummaryRenderData, + BounceType, + IPopulatedForm, + ISubmissionSchema, MailOptions, - MailService, SendAutoReplyEmailsArgs, -} from 'src/app/services/mail.service' -import * as MailUtils from 'src/app/utils/mail' -import { IPopulatedForm, ISubmissionSchema } from 'src/types' +} from 'src/types' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -989,4 +991,183 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) }) + + describe('sendBounceNotification', () => { + const MOCK_RECIPIENTS = [MOCK_VALID_EMAIL, MOCK_VALID_EMAIL_2] + const MOCK_BOUNCED_EMAILS = [MOCK_VALID_EMAIL_3, MOCK_VALID_EMAIL_4] + const MOCK_FORM_ID = 'mockFormId' + const MOCK_FORM_TITLE = 'You are all individuals!' + const MOCK_BOUNCE_TYPE = BounceType.Permanent + + const generateExpectedArg = async (bounceType: BounceType) => { + return { + to: MOCK_RECIPIENTS, + from: MOCK_SENDER_STRING, + subject: `[Urgent] FormSG Response Delivery Failure / Bounce`, + html: await MailUtils.generateBounceNotificationHtml( + { + appName: MOCK_APP_NAME, + bouncedRecipients: MOCK_BOUNCED_EMAILS.join(', '), + formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`, + formTitle: MOCK_FORM_TITLE, + }, + bounceType, + ), + headers: { + // Hardcode in tests in case something changes this. + 'X-Formsg-Email-Type': 'Admin (bounce notification)', + 'X-Formsg-Form-ID': MOCK_FORM_ID, + }, + } + } + + it('should send permanent bounce notification successfully', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy.mockResolvedValueOnce(mockedResponse) + + // Act + const sent = await mailService.sendBounceNotification({ + emailRecipients: MOCK_RECIPIENTS, + bounceType: BounceType.Permanent, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + const expectedArgs = await generateExpectedArg(BounceType.Permanent) + // Assert + expect(sent).toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs) + }) + + it('should send transient bounce notification successfully', async () => { + // Arrange + // sendMail should return mocked success response + const mockedResponse = 'mockedSuccessResponse' + sendMailSpy.mockResolvedValueOnce(mockedResponse) + + // Act + const sent = await mailService.sendBounceNotification({ + emailRecipients: MOCK_RECIPIENTS, + bounceType: BounceType.Transient, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + const expectedArgs = await generateExpectedArg(BounceType.Transient) + // Assert + expect(sent).toEqual(mockedResponse) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs) + }) + + it('should reject with error when email is invalid', async () => { + // Arrange + const invalidEmail = 'notAnEmail' + + // Act + const sent = mailService.sendBounceNotification({ + emailRecipients: [invalidEmail], + bounceType: MOCK_BOUNCE_TYPE, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + + // Assert + await expect(sent).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) + + // Act + const sent = await mailService.sendBounceNotification({ + emailRecipients: MOCK_RECIPIENTS, + bounceType: MOCK_BOUNCE_TYPE, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE) + + // Assert + expect(sent).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).toHaveBeenNthCalledWith(1, expectedArgs) + expect(sendMailSpy).toHaveBeenNthCalledWith(2, expectedArgs) + }) + + 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) + + // Act + const sent = mailService.sendBounceNotification({ + emailRecipients: MOCK_RECIPIENTS, + bounceType: MOCK_BOUNCE_TYPE, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE) + + // Assert + await expect(sent).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(expectedArgs) + }) + + 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) + + // Act + const sent = mailService.sendBounceNotification({ + emailRecipients: MOCK_RECIPIENTS, + bounceType: MOCK_BOUNCE_TYPE, + bouncedRecipients: MOCK_BOUNCED_EMAILS, + formId: MOCK_FORM_ID, + formTitle: MOCK_FORM_TITLE, + }) + const expectedArgs = await generateExpectedArg(MOCK_BOUNCE_TYPE) + + // Assert + await expect(sent).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(expectedArgs) + }) + }) }) From 467d221f72e1c0d4998fa78bfc8465e3756973e1 Mon Sep 17 00:00:00 2001 From: arshadali172 Date: Tue, 22 Sep 2020 10:45:59 +0800 Subject: [PATCH 25/29] chore: update spcp-auth-client library (#346) * Update version of spcp client * Update package-lock Co-authored-by: Arshad Ali --- package-lock.json | 42 ++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 692b8d1640..f0d35de817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4135,19 +4135,26 @@ "integrity": "sha512-YZx96jBfRCgIbGErhYLfnsdZ199laYJt6zknI/ea17tPhWw4+kYE09a6G5dckiN/QCBxHeZRsjLU6WRRKHleKw==" }, "@opengovsg/spcp-auth-client": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@opengovsg/spcp-auth-client/-/spcp-auth-client-1.3.4.tgz", - "integrity": "sha512-h9adtiDmqt8pqw6D0iCdjoOVzZXYyphBbVm/FxzjIz7yFgOr1/M0Y9GZcY1UjxFiRzf9q/b3VVML8ic0WvRRYg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@opengovsg/spcp-auth-client/-/spcp-auth-client-1.3.5.tgz", + "integrity": "sha512-usrY8x5v6zvaz6ktWp3O2+v2qiP5dBVDouJbwlll6LLNsP91cvbit+NfvrSCehNxEIhBzyS2QHZcRd/FfGpg+g==", "requires": { "base-64": "^0.1.0", "jsonwebtoken": "^8.3.0", "lodash": "^4.17.11", "request": "^2.87.0", "xml-crypto": "^1.1.1", - "xml-encryption": "^0.13.0", + "xml-encryption": "^1.2.1", "xml2json-light": "^1.0.6", "xmldom": "^0.3.0", - "xpath": "0.0.27" + "xpath": "0.0.29" + }, + "dependencies": { + "xpath": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.29.tgz", + "integrity": "sha512-W6vSxu0tmHCW01EwDXx45/BAAl8lBJjcRB6eSswMuycOVbUkYskG3W1LtCxcesVel/RaNe/pxtd3FWLiqHGweA==" + } } }, "@sentry/browser": { @@ -19310,6 +19317,11 @@ "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", "dev": true }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -27033,26 +27045,16 @@ } }, "xml-encryption": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-0.13.0.tgz", - "integrity": "sha512-eekB2WQOhJVoQLG7LgEXAFqex9jWmu62Gnhe1z805H75yWvf0HIll/uS731JFQnoiP5yy870BF7vJAdO8/JAcw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.1.tgz", + "integrity": "sha512-hn5w3l5p2+nGjlmM0CAhMChDzVGhW+M37jH35Z+GJIipXbn9PUlAIRZ6I5Wm7ynlqZjFrMAr83d/CIp9VZJMTA==", "requires": { - "ejs": "^2.5.6", - "node-forge": "^0.7.0", + "escape-html": "^1.0.3", + "node-forge": "^0.10.0", "xmldom": "~0.1.15", "xpath": "0.0.27" }, "dependencies": { - "ejs": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz", - "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==" - }, - "node-forge": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", - "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" - }, "xmldom": { "version": "0.1.31", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", diff --git a/package.json b/package.json index 25ada01da2..69b25a260c 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@opengovsg/formsg-sdk": "0.8.2", "@opengovsg/myinfo-gov-client": "^1.0.4", "@opengovsg/ng-file-upload": "^12.2.14", - "@opengovsg/spcp-auth-client": "^1.3.0", + "@opengovsg/spcp-auth-client": "^1.3.5", "@sentry/browser": "^5.22.3", "@sentry/integrations": "^5.22.3", "@stablelib/base64": "^1.0.0", From f5c0e1d788bdb562bf8da15c471a9b2167fd80cb Mon Sep 17 00:00:00 2001 From: shuli-ogp Date: Tue, 22 Sep 2020 10:52:30 +0800 Subject: [PATCH 26/29] chore: bump version to 4.36.0 --- CHANGELOG.md | 388 +++++++++++++++++++++++++--------------------- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 213 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af702c5b0..d6f52292cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,208 +4,242 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v4.35.1](https://github.com/opengovsg/formsg/compare/v4.34.0...v4.35.1) - -> 17 September 2020 - -- feat: update copy for email fields, intranet, privacy [`#321`](https://github.com/opengovsg/formsg/pull/321) -- refactor: turn on strict mode in Typescript configuration [`#262`](https://github.com/opengovsg/formsg/pull/262) -- fix: allow inline styles from angular-sanitize [`#316`](https://github.com/opengovsg/formsg/pull/316) -- feat(changelog): autogenerate CHANGELOG.md from conventional commits [`#306`](https://github.com/opengovsg/formsg/pull/306) -- chore: reduce number of e2e tests and other fixes [`#305`](https://github.com/opengovsg/formsg/pull/305) -- feat: merge release 4.34.1 into develop [`#312`](https://github.com/opengovsg/formsg/pull/312) -- feat: log all critical bounces [`#288`](https://github.com/opengovsg/formsg/pull/288) -- refactor(proxy): do not override X-Forwarded-Proto headers [`#304`](https://github.com/opengovsg/formsg/pull/304) -- fix(deps): bump angular-translate-loader-partial from 2.18.2 to 2.18.3 [`#298`](https://github.com/opengovsg/formsg/pull/298) -- chore(deps-dev): bump eslint-plugin-jest from 23.20.0 to 24.0.0 [`#299`](https://github.com/opengovsg/formsg/pull/299) -- fix(deps): bump ejs from 2.7.4 to 3.1.5 [`#282`](https://github.com/opengovsg/formsg/pull/282) -- chore: document env vars needed for EFS [`#303`](https://github.com/opengovsg/formsg/pull/303) -- Greater clarity for available features, project roadmap and deployment instructions; disable E2E tests [`#301`](https://github.com/opengovsg/formsg/pull/301) -- build: bump version to v4.35.0 [`fc7f9c9`](https://github.com/opengovsg/formsg/commit/fc7f9c92a5bcf65728bf0436e732f561b016be37) -- build: bump version to 4.34.1 [`d5bfd5a`](https://github.com/opengovsg/formsg/commit/d5bfd5a60a9b006ad2329eb6ec95979b38522358) -- Removed unused variable [`588b24d`](https://github.com/opengovsg/formsg/commit/588b24da28be5cb8a80847f6ee6c8f31cdc1c0b0) - -#### [v4.34.0](https://github.com/opengovsg/formsg/compare/v4.33.0...v4.34.0) +#### [v4.36.0](https://github.com/opengovsg/FormSG/compare/v4.35.0...v4.36.0) + +> 22 September 2020 + +- chore: update spcp-auth-client library [`#346`](https://github.com/opengovsg/FormSG/pull/346) +- feat: automate critical bounce handling [`#318`](https://github.com/opengovsg/FormSG/pull/318) +- feat: form ownership transfer feature [`#111`](https://github.com/opengovsg/FormSG/pull/111) +- feat: Using `neverthrow` to explicitly handle errors in AuthController [`#332`](https://github.com/opengovsg/FormSG/pull/332) +- fix(deps): bump csv-string from 3.2.0 to 4.0.1 [`#344`](https://github.com/opengovsg/FormSG/pull/344) +- fix(deps): bump csv-parse from 4.10.1 to 4.12.0 [`#340`](https://github.com/opengovsg/FormSG/pull/340) +- feat: Filter Storage Mode Responses by Submission Id [`#174`](https://github.com/opengovsg/FormSG/pull/174) +- chore(deps-dev): bump eslint-plugin-jest from 24.0.0 to 24.0.2 [`#341`](https://github.com/opengovsg/FormSG/pull/341) +- fix(deps): uninstall node-jose [`#339`](https://github.com/opengovsg/FormSG/pull/339) +- fix(deps): bump celebrate from 12.2.0 to 13.0.3 [`#338`](https://github.com/opengovsg/FormSG/pull/338) +- chore(deps-dev): bump eslint from 7.8.1 to 7.9.0 [`#331`](https://github.com/opengovsg/FormSG/pull/331) +- chore(deps-dev): bump eslint-plugin-import from 2.21.2 to 2.22.0 [`#330`](https://github.com/opengovsg/FormSG/pull/330) +- chore(deps-dev): bump ts-node from 8.10.2 to 9.0.0 [`#329`](https://github.com/opengovsg/FormSG/pull/329) +- chore(deps-dev): bump lint-staged from 10.2.11 to 10.4.0 [`#328`](https://github.com/opengovsg/FormSG/pull/328) +- chore: update travis.yml and pin localstack in docker-compose [`#337`](https://github.com/opengovsg/FormSG/pull/337) +- chore: [develop] release 4.35.1 hotfix [`#334`](https://github.com/opengovsg/FormSG/pull/334) +- fix: set nodeEnv to assigned variable if in dev environment [`#335`](https://github.com/opengovsg/FormSG/pull/335) +- test: remove storage mode attachment tests [`#336`](https://github.com/opengovsg/FormSG/pull/336) +- fix(deps): uninstall async [`#309`](https://github.com/opengovsg/FormSG/pull/309) +- fix(deps): bump winston from 3.2.1 to 3.3.3 [`#278`](https://github.com/opengovsg/FormSG/pull/278) +- fix(deps): bump whatwg-fetch from 3.0.0 to 3.4.1 [`#300`](https://github.com/opengovsg/FormSG/pull/300) +- chore(deps-dev): bump concurrently from 3.6.1 to 5.3.0 [`#308`](https://github.com/opengovsg/FormSG/pull/308) +- fix(deps): bump helmet from 3.23.1 to 4.1.0 [`#233`](https://github.com/opengovsg/FormSG/pull/233) +- refactor: remove unused key [`#325`](https://github.com/opengovsg/FormSG/pull/325) +- fix(dev): fix hot reloading and Localstack port [`#324`](https://github.com/opengovsg/FormSG/pull/324) +- fix(deps): fix npm audit issues [`#322`](https://github.com/opengovsg/FormSG/pull/322) +- build: [develop] Release 4.35.0 [`#323`](https://github.com/opengovsg/FormSG/pull/323) +- feat: update copy for email fields, intranet, privacy [`#321`](https://github.com/opengovsg/FormSG/pull/321) +- chore: bump version to v4.35.1 [`2ea5bae`](https://github.com/opengovsg/FormSG/commit/2ea5bae974a4dbfd9080408df3b177839d05b3c4) +- Fix more tests [`0c6c8ac`](https://github.com/opengovsg/FormSG/commit/0c6c8acfa95a591449e90dea229b50bc5b73a7c4) +- Fix tests [`6d0ffde`](https://github.com/opengovsg/FormSG/commit/6d0ffde17207589d34c760f1b0aa9825d0323d0b) + +#### [v4.35.0](https://github.com/opengovsg/FormSG/compare/v4.34.0...v4.35.0) + +> 15 September 2020 + +- refactor: turn on strict mode in Typescript configuration [`#262`](https://github.com/opengovsg/FormSG/pull/262) +- fix: allow inline styles from angular-sanitize [`#316`](https://github.com/opengovsg/FormSG/pull/316) +- feat(changelog): autogenerate CHANGELOG.md from conventional commits [`#306`](https://github.com/opengovsg/FormSG/pull/306) +- chore: reduce number of e2e tests and other fixes [`#305`](https://github.com/opengovsg/FormSG/pull/305) +- feat: merge release 4.34.1 into develop [`#312`](https://github.com/opengovsg/FormSG/pull/312) +- feat: log all critical bounces [`#288`](https://github.com/opengovsg/FormSG/pull/288) +- refactor(proxy): do not override X-Forwarded-Proto headers [`#304`](https://github.com/opengovsg/FormSG/pull/304) +- fix(deps): bump angular-translate-loader-partial from 2.18.2 to 2.18.3 [`#298`](https://github.com/opengovsg/FormSG/pull/298) +- chore(deps-dev): bump eslint-plugin-jest from 23.20.0 to 24.0.0 [`#299`](https://github.com/opengovsg/FormSG/pull/299) +- fix(deps): bump ejs from 2.7.4 to 3.1.5 [`#282`](https://github.com/opengovsg/FormSG/pull/282) +- chore: document env vars needed for EFS [`#303`](https://github.com/opengovsg/FormSG/pull/303) +- Greater clarity for available features, project roadmap and deployment instructions; disable E2E tests [`#301`](https://github.com/opengovsg/FormSG/pull/301) +- build: bump version to v4.35.0 [`fc7f9c9`](https://github.com/opengovsg/FormSG/commit/fc7f9c92a5bcf65728bf0436e732f561b016be37) +- build: bump version to 4.34.1 [`d5bfd5a`](https://github.com/opengovsg/FormSG/commit/d5bfd5a60a9b006ad2329eb6ec95979b38522358) + +#### [v4.34.0](https://github.com/opengovsg/FormSG/compare/v4.33.0...v4.34.0) > 8 September 2020 -- refactor: migrate /auth endpoint handling to Typescript, Domain Driven Design [`#215`](https://github.com/opengovsg/formsg/pull/215) -- chore(deps-dev): bump stylelint-config-prettier from 8.0.1 to 8.0.2 [`#280`](https://github.com/opengovsg/formsg/pull/280) -- fix: upgrade mongoose from 5.9.19 to 5.10.0 [`#289`](https://github.com/opengovsg/formsg/pull/289) -- revert: reintroduce convict [`#287`](https://github.com/opengovsg/formsg/pull/287) -- revert(convict): "refactor: use convict for configuration (#190)" [`#285`](https://github.com/opengovsg/formsg/pull/285) -- chore(deps-dev): bump @typescript-eslint/eslint-plugin and @typescript-eslint/parser [`#246`](https://github.com/opengovsg/formsg/pull/246) -- feat: verified sms modal [`#274`](https://github.com/opengovsg/formsg/pull/274) -- feat: add try-catch block to custom logger for js files [`#267`](https://github.com/opengovsg/formsg/pull/267) -- fix: fix invocations of logger that does not adhere to expected shape [`#273`](https://github.com/opengovsg/formsg/pull/273) -- chore(deps-dev): bump @babel/core from 7.10.2 to 7.11.5 [`#270`](https://github.com/opengovsg/formsg/pull/270) -- feat: upgrade localstack version [`#275`](https://github.com/opengovsg/formsg/pull/275) -- chore(deps-dev): bump testcafe from 1.8.6 to 1.9.1 [`#271`](https://github.com/opengovsg/formsg/pull/271) -- chore(deps-dev): bump @babel/preset-env from 7.11.0 to 7.11.5 [`#268`](https://github.com/opengovsg/formsg/pull/268) -- refactor: use convict for configuration [`#190`](https://github.com/opengovsg/formsg/pull/190) -- fix: revert changes to configureAws [`#266`](https://github.com/opengovsg/formsg/pull/266) -- refactor: remove redundant feature factory [`#261`](https://github.com/opengovsg/formsg/pull/261) -- chore: remove form_field.isFutureOnly key [`#235`](https://github.com/opengovsg/formsg/pull/235) -- refactor: remove unused Nodemailer env vars [`#253`](https://github.com/opengovsg/formsg/pull/253) -- feat: upgrade Sentry SDK [`#254`](https://github.com/opengovsg/formsg/pull/254) -- fix(deps): bump lodash from 4.17.19 to 4.17.20 [`#259`](https://github.com/opengovsg/formsg/pull/259) -- chore(deps-dev): bump eslint from 7.7.0 to 7.8.1 [`#258`](https://github.com/opengovsg/formsg/pull/258) -- chore(deps-dev): bump jest from 26.2.2 to 26.4.2 [`#257`](https://github.com/opengovsg/formsg/pull/257) -- refactor: typify webhook and migrate from middleware pattern [`#251`](https://github.com/opengovsg/formsg/pull/251) -- fix(dev): fix Localstack yet again [`#252`](https://github.com/opengovsg/formsg/pull/252) -- chore(deps-dev): bump prettier from 2.0.5 to 2.1.1 [`#249`](https://github.com/opengovsg/formsg/pull/249) -- fix: prevent discriminated models from being created before their base model [`#244`](https://github.com/opengovsg/formsg/pull/244) -- fix(deps): remove ajv as dependency [`#248`](https://github.com/opengovsg/formsg/pull/248) -- chore(deps-dev): bump @typescript-eslint/parser from 3.3.0 to 3.10.1 [`#247`](https://github.com/opengovsg/formsg/pull/247) -- feat: merge Release 4.33.0 into develop [`#245`](https://github.com/opengovsg/formsg/pull/245) -- Bump version [`830211a`](https://github.com/opengovsg/formsg/commit/830211a541ff55ccd2fa1c74dc2ec4bfc29839ef) - -#### [v4.33.0](https://github.com/opengovsg/formsg/compare/v4.32.1...v4.33.0) +- refactor: migrate /auth endpoint handling to Typescript, Domain Driven Design [`#215`](https://github.com/opengovsg/FormSG/pull/215) +- chore(deps-dev): bump stylelint-config-prettier from 8.0.1 to 8.0.2 [`#280`](https://github.com/opengovsg/FormSG/pull/280) +- fix: upgrade mongoose from 5.9.19 to 5.10.0 [`#289`](https://github.com/opengovsg/FormSG/pull/289) +- revert: reintroduce convict [`#287`](https://github.com/opengovsg/FormSG/pull/287) +- revert(convict): "refactor: use convict for configuration (#190)" [`#285`](https://github.com/opengovsg/FormSG/pull/285) +- chore(deps-dev): bump @typescript-eslint/eslint-plugin and @typescript-eslint/parser [`#246`](https://github.com/opengovsg/FormSG/pull/246) +- feat: verified sms modal [`#274`](https://github.com/opengovsg/FormSG/pull/274) +- feat: add try-catch block to custom logger for js files [`#267`](https://github.com/opengovsg/FormSG/pull/267) +- fix: fix invocations of logger that does not adhere to expected shape [`#273`](https://github.com/opengovsg/FormSG/pull/273) +- chore(deps-dev): bump @babel/core from 7.10.2 to 7.11.5 [`#270`](https://github.com/opengovsg/FormSG/pull/270) +- feat: upgrade localstack version [`#275`](https://github.com/opengovsg/FormSG/pull/275) +- chore(deps-dev): bump testcafe from 1.8.6 to 1.9.1 [`#271`](https://github.com/opengovsg/FormSG/pull/271) +- chore(deps-dev): bump @babel/preset-env from 7.11.0 to 7.11.5 [`#268`](https://github.com/opengovsg/FormSG/pull/268) +- refactor: use convict for configuration [`#190`](https://github.com/opengovsg/FormSG/pull/190) +- fix: revert changes to configureAws [`#266`](https://github.com/opengovsg/FormSG/pull/266) +- refactor: remove redundant feature factory [`#261`](https://github.com/opengovsg/FormSG/pull/261) +- chore: remove form_field.isFutureOnly key [`#235`](https://github.com/opengovsg/FormSG/pull/235) +- refactor: remove unused Nodemailer env vars [`#253`](https://github.com/opengovsg/FormSG/pull/253) +- feat: upgrade Sentry SDK [`#254`](https://github.com/opengovsg/FormSG/pull/254) +- fix(deps): bump lodash from 4.17.19 to 4.17.20 [`#259`](https://github.com/opengovsg/FormSG/pull/259) +- chore(deps-dev): bump eslint from 7.7.0 to 7.8.1 [`#258`](https://github.com/opengovsg/FormSG/pull/258) +- chore(deps-dev): bump jest from 26.2.2 to 26.4.2 [`#257`](https://github.com/opengovsg/FormSG/pull/257) +- refactor: typify webhook and migrate from middleware pattern [`#251`](https://github.com/opengovsg/FormSG/pull/251) +- fix(dev): fix Localstack yet again [`#252`](https://github.com/opengovsg/FormSG/pull/252) +- chore(deps-dev): bump prettier from 2.0.5 to 2.1.1 [`#249`](https://github.com/opengovsg/FormSG/pull/249) +- fix: prevent discriminated models from being created before their base model [`#244`](https://github.com/opengovsg/FormSG/pull/244) +- fix(deps): remove ajv as dependency [`#248`](https://github.com/opengovsg/FormSG/pull/248) +- chore(deps-dev): bump @typescript-eslint/parser from 3.3.0 to 3.10.1 [`#247`](https://github.com/opengovsg/FormSG/pull/247) +- feat: merge Release 4.33.0 into develop [`#245`](https://github.com/opengovsg/FormSG/pull/245) +- Bump version [`830211a`](https://github.com/opengovsg/FormSG/commit/830211a541ff55ccd2fa1c74dc2ec4bfc29839ef) + +#### [v4.33.0](https://github.com/opengovsg/FormSG/compare/v4.32.1...v4.33.0) > 1 September 2020 -- fix: use original questionCount [`#242`](https://github.com/opengovsg/formsg/pull/242) -- fix: correct left margin in acknowledgment error when activating storage mode form [`#240`](https://github.com/opengovsg/formsg/pull/240) -- feat: log more info about critical bounces [`#237`](https://github.com/opengovsg/formsg/pull/237) -- fix: remove filetype from permission levels imports [`#236`](https://github.com/opengovsg/formsg/pull/236) -- fix(deps): bump http-status-codes from 1.4.0 to 2.1.2 [`#229`](https://github.com/opengovsg/formsg/pull/229) -- refactor: use express router for modules [`#204`](https://github.com/opengovsg/formsg/pull/204) -- chore(deps-dev): bump @types/helmet from 0.0.47 to 0.0.48 [`#232`](https://github.com/opengovsg/formsg/pull/232) -- refactor(utils/attachment): typescriptify [`#166`](https://github.com/opengovsg/formsg/pull/166) -- fix(deps): bump validator from 11.1.0 to 13.1.1 [`#209`](https://github.com/opengovsg/formsg/pull/209) -- refactor: typify utils [`#171`](https://github.com/opengovsg/formsg/pull/171) -- feat: mailto option after form activation [`#213`](https://github.com/opengovsg/formsg/pull/213) -- fix(deps): bump axios from 0.19.2 to 0.20.0 [`#218`](https://github.com/opengovsg/formsg/pull/218) -- chore(deps-dev): bump @types/mongoose from 5.7.25 to 5.7.36 [`#230`](https://github.com/opengovsg/formsg/pull/230) -- feat: Bulk download of storage mode attachments in a zip file [`#141`](https://github.com/opengovsg/formsg/pull/141) -- feat: merge release v4.32.1 back into develop branch [`#226`](https://github.com/opengovsg/formsg/pull/226) -- fix(deps): bump opossum from 5.0.0 to 5.0.1 [`#221`](https://github.com/opengovsg/formsg/pull/221) -- chore(deps-dev): bump eslint from 6.8.0 to 7.7.0 [`#220`](https://github.com/opengovsg/formsg/pull/220) -- feat: standardize logger format and output [`#211`](https://github.com/opengovsg/formsg/pull/211) -- fix: fix linting not working on frontend code [`#217`](https://github.com/opengovsg/formsg/pull/217) -- fix: pass missing $state param into EditContactNumberModalController [`#216`](https://github.com/opengovsg/formsg/pull/216) -- feat: add Emergency Contact feature frontend [`#142`](https://github.com/opengovsg/formsg/pull/142) -- refactor: convert webhook service to Typescript [`#83`](https://github.com/opengovsg/formsg/pull/83) -- chore(deps-dev): bump sinon from 6.3.5 to 9.0.3 [`#207`](https://github.com/opengovsg/formsg/pull/207) -- feat: Share form secret keys across browser tabs using BroadcastChannel [`#203`](https://github.com/opengovsg/formsg/pull/203) -- chore: merge Release v4.32.0 into develop branch [`#205`](https://github.com/opengovsg/formsg/pull/205) -- Introduce minimum test coverage thresholds, coveralls.io for threshold reporting and repo badge [`#185`](https://github.com/opengovsg/formsg/pull/185) -- feat: MailService#sendNodeMail invocations to retry on 4xx errors(#227) [`61d5103`](https://github.com/opengovsg/formsg/commit/61d510312affdea6e971147dd547a6f5449b270b) -- build: bump version to 4.33.0 [`6c0951e`](https://github.com/opengovsg/formsg/commit/6c0951e877e498751c94a539ce93b03eb0ff9d53) - -#### [v4.32.1](https://github.com/opengovsg/formsg/compare/v4.32.0...v4.32.1) +- fix: use original questionCount [`#242`](https://github.com/opengovsg/FormSG/pull/242) +- fix: correct left margin in acknowledgment error when activating storage mode form [`#240`](https://github.com/opengovsg/FormSG/pull/240) +- feat: log more info about critical bounces [`#237`](https://github.com/opengovsg/FormSG/pull/237) +- fix: remove filetype from permission levels imports [`#236`](https://github.com/opengovsg/FormSG/pull/236) +- fix(deps): bump http-status-codes from 1.4.0 to 2.1.2 [`#229`](https://github.com/opengovsg/FormSG/pull/229) +- refactor: use express router for modules [`#204`](https://github.com/opengovsg/FormSG/pull/204) +- chore(deps-dev): bump @types/helmet from 0.0.47 to 0.0.48 [`#232`](https://github.com/opengovsg/FormSG/pull/232) +- refactor(utils/attachment): typescriptify [`#166`](https://github.com/opengovsg/FormSG/pull/166) +- fix(deps): bump validator from 11.1.0 to 13.1.1 [`#209`](https://github.com/opengovsg/FormSG/pull/209) +- refactor: typify utils [`#171`](https://github.com/opengovsg/FormSG/pull/171) +- feat: mailto option after form activation [`#213`](https://github.com/opengovsg/FormSG/pull/213) +- fix(deps): bump axios from 0.19.2 to 0.20.0 [`#218`](https://github.com/opengovsg/FormSG/pull/218) +- chore(deps-dev): bump @types/mongoose from 5.7.25 to 5.7.36 [`#230`](https://github.com/opengovsg/FormSG/pull/230) +- feat: Bulk download of storage mode attachments in a zip file [`#141`](https://github.com/opengovsg/FormSG/pull/141) +- feat: merge release v4.32.1 back into develop branch [`#226`](https://github.com/opengovsg/FormSG/pull/226) +- fix(deps): bump opossum from 5.0.0 to 5.0.1 [`#221`](https://github.com/opengovsg/FormSG/pull/221) +- chore(deps-dev): bump eslint from 6.8.0 to 7.7.0 [`#220`](https://github.com/opengovsg/FormSG/pull/220) +- feat: standardize logger format and output [`#211`](https://github.com/opengovsg/FormSG/pull/211) +- fix: fix linting not working on frontend code [`#217`](https://github.com/opengovsg/FormSG/pull/217) +- fix: pass missing $state param into EditContactNumberModalController [`#216`](https://github.com/opengovsg/FormSG/pull/216) +- feat: add Emergency Contact feature frontend [`#142`](https://github.com/opengovsg/FormSG/pull/142) +- refactor: convert webhook service to Typescript [`#83`](https://github.com/opengovsg/FormSG/pull/83) +- chore(deps-dev): bump sinon from 6.3.5 to 9.0.3 [`#207`](https://github.com/opengovsg/FormSG/pull/207) +- feat: Share form secret keys across browser tabs using BroadcastChannel [`#203`](https://github.com/opengovsg/FormSG/pull/203) +- chore: merge Release v4.32.0 into develop branch [`#205`](https://github.com/opengovsg/FormSG/pull/205) +- Introduce minimum test coverage thresholds, coveralls.io for threshold reporting and repo badge [`#185`](https://github.com/opengovsg/FormSG/pull/185) +- feat: MailService#sendNodeMail invocations to retry on 4xx errors(#227) [`61d5103`](https://github.com/opengovsg/FormSG/commit/61d510312affdea6e971147dd547a6f5449b270b) +- build: bump version to 4.33.0 [`6c0951e`](https://github.com/opengovsg/FormSG/commit/6c0951e877e498751c94a539ce93b03eb0ff9d53) + +#### [v4.32.1](https://github.com/opengovsg/FormSG/compare/v4.32.0...v4.32.1) > 27 August 2020 -- chore: bump version to v4.32.1 [`0bf07cf`](https://github.com/opengovsg/formsg/commit/0bf07cfc9b804a2e602a096032065d73805acfea) -- fix: split mail by semicolon in addition to comma when validating [`824380e`](https://github.com/opengovsg/formsg/commit/824380ef2a015674b5931cc3f9516036eb80a917) +- chore: bump version to v4.32.1 [`0bf07cf`](https://github.com/opengovsg/FormSG/commit/0bf07cfc9b804a2e602a096032065d73805acfea) +- fix: split mail by semicolon in addition to comma when validating [`824380e`](https://github.com/opengovsg/FormSG/commit/824380ef2a015674b5931cc3f9516036eb80a917) -#### [v4.32.0](https://github.com/opengovsg/formsg/compare/v4.30.4...v4.32.0) +#### [v4.32.0](https://github.com/opengovsg/FormSG/compare/v4.30.4...v4.32.0) > 25 August 2020 -- fix: shift userEmail retrieval to GA service [`#192`](https://github.com/opengovsg/formsg/pull/192) -- chore(deps-dev): bump @opengovsg/mockpass from 2.2.0 to 2.4.6 [`#198`](https://github.com/opengovsg/formsg/pull/198) -- feat: remove beta field validations [`#194`](https://github.com/opengovsg/formsg/pull/194) -- fix(deps): bump uid-generator from 1.0.0 to 2.0.0 [`#187`](https://github.com/opengovsg/formsg/pull/187) -- fix(deps): bump puppeteer-core from 4.0.0 to 5.2.1 [`#188`](https://github.com/opengovsg/formsg/pull/188) -- chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#197`](https://github.com/opengovsg/formsg/pull/197) -- feat: add core ApplicationError for express app [`#195`](https://github.com/opengovsg/formsg/pull/195) -- chore(deps-dev): bump typescript to 4.0.2 [`#196`](https://github.com/opengovsg/formsg/pull/196) -- fix(deps): bump font-awesome from 4.6.1 to 4.7.0 [`#186`](https://github.com/opengovsg/formsg/pull/186) -- feat: migrate `util/response` to new Submission module (service, utils, etc) [`#176`](https://github.com/opengovsg/formsg/pull/176) -- feat: log form ID in GA event labels [`#154`](https://github.com/opengovsg/formsg/pull/154) -- refactor(verification): convert to module and typescriptify [`#172`](https://github.com/opengovsg/formsg/pull/172) -- feat: support &`;'" in form title [`#156`](https://github.com/opengovsg/formsg/pull/156) -- fix: run npm audit fix to resolve security issues with minimist dependency in the selectize package [`#181`](https://github.com/opengovsg/formsg/pull/181) -- chore(deps-dev): bump jasmine from 3.5.0 to 3.6.1 [`#158`](https://github.com/opengovsg/formsg/pull/158) -- chore(deps-dev): bump env-cmd from 9.0.3 to 10.1.0 [`#133`](https://github.com/opengovsg/formsg/pull/133) -- feat: mailto link for secret key [`#150`](https://github.com/opengovsg/formsg/pull/150) -- fix: enable forceDelivery on twilio message sending [`#178`](https://github.com/opengovsg/formsg/pull/178) -- feat: increase breaker window time and add minimum volume threshold [`#165`](https://github.com/opengovsg/formsg/pull/165) -- refactor: migrate encryption util to typescript [`#167`](https://github.com/opengovsg/formsg/pull/167) -- refactor: delete render promise util [`#168`](https://github.com/opengovsg/formsg/pull/168) -- refactor: convert date util to typescript [`#161`](https://github.com/opengovsg/formsg/pull/161) -- feat: create bounce collection and alarms [`#131`](https://github.com/opengovsg/formsg/pull/131) -- [develop] Release v4.31.0 [`#155`](https://github.com/opengovsg/formsg/pull/155) -- refactor: convert MailService to a class based Typescript implementation [`#76`](https://github.com/opengovsg/formsg/pull/76) -- fix(deps): bump aws-sdk from 2.699.0 to 2.734.0 [`#146`](https://github.com/opengovsg/formsg/pull/146) -- fix(deps): bump node-cache from 5.1.1 to 5.1.2 [`#145`](https://github.com/opengovsg/formsg/pull/145) -- feat: include user ip address when sending otp [`#147`](https://github.com/opengovsg/formsg/pull/147) -- chore(deps-dev): bump htmlhint from 0.11.0 to 0.14.1 [`#116`](https://github.com/opengovsg/formsg/pull/116) -- fix(deps): bump angular-* dependency packages from 1.7.9 to 1.8.0 [`#108`](https://github.com/opengovsg/formsg/pull/108) -- feat: log IP, submissionId and formId together [`#130`](https://github.com/opengovsg/formsg/pull/130) -- fix(deps): bump crypto-js from 3.3.0 to 4.0.0 [`#110`](https://github.com/opengovsg/formsg/pull/110) -- [develop] Release 4.30.4 [`#138`](https://github.com/opengovsg/formsg/pull/138) -- fix(deps): bump express-winston from 4.0.3 to 4.0.5 [`#109`](https://github.com/opengovsg/formsg/pull/109) -- chore: add --watch flag back to build-frontend-dev script [`#128`](https://github.com/opengovsg/formsg/pull/128) -- docs: create trouble shooting guide [`#119`](https://github.com/opengovsg/formsg/pull/119) -- chore: Merge release 4.30.3 into develop [`#127`](https://github.com/opengovsg/formsg/pull/127) -- fix(deps): bump bcrypt from 3.0.8 to 5.0.0 [`#88`](https://github.com/opengovsg/formsg/pull/88) -- fix(deps): bump nodemailer from 6.4.10 to 6.4.11 [`#117`](https://github.com/opengovsg/formsg/pull/117) -- tests: fix flakiness and migrate remaining mongoose model tests to Typescript [`#122`](https://github.com/opengovsg/formsg/pull/122) -- chore: bump version to v4.32.0 [`aa34114`](https://github.com/opengovsg/formsg/commit/aa341141d47a806ece786fcccbe0faef0945ccfc) - -#### [v4.30.4](https://github.com/opengovsg/formsg/compare/v4.30.3...v4.30.4) +- fix: shift userEmail retrieval to GA service [`#192`](https://github.com/opengovsg/FormSG/pull/192) +- chore(deps-dev): bump @opengovsg/mockpass from 2.2.0 to 2.4.6 [`#198`](https://github.com/opengovsg/FormSG/pull/198) +- feat: remove beta field validations [`#194`](https://github.com/opengovsg/FormSG/pull/194) +- fix(deps): bump uid-generator from 1.0.0 to 2.0.0 [`#187`](https://github.com/opengovsg/FormSG/pull/187) +- fix(deps): bump puppeteer-core from 4.0.0 to 5.2.1 [`#188`](https://github.com/opengovsg/FormSG/pull/188) +- chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#197`](https://github.com/opengovsg/FormSG/pull/197) +- feat: add core ApplicationError for express app [`#195`](https://github.com/opengovsg/FormSG/pull/195) +- chore(deps-dev): bump typescript to 4.0.2 [`#196`](https://github.com/opengovsg/FormSG/pull/196) +- fix(deps): bump font-awesome from 4.6.1 to 4.7.0 [`#186`](https://github.com/opengovsg/FormSG/pull/186) +- feat: migrate `util/response` to new Submission module (service, utils, etc) [`#176`](https://github.com/opengovsg/FormSG/pull/176) +- feat: log form ID in GA event labels [`#154`](https://github.com/opengovsg/FormSG/pull/154) +- refactor(verification): convert to module and typescriptify [`#172`](https://github.com/opengovsg/FormSG/pull/172) +- feat: support &`;'" in form title [`#156`](https://github.com/opengovsg/FormSG/pull/156) +- fix: run npm audit fix to resolve security issues with minimist dependency in the selectize package [`#181`](https://github.com/opengovsg/FormSG/pull/181) +- chore(deps-dev): bump jasmine from 3.5.0 to 3.6.1 [`#158`](https://github.com/opengovsg/FormSG/pull/158) +- chore(deps-dev): bump env-cmd from 9.0.3 to 10.1.0 [`#133`](https://github.com/opengovsg/FormSG/pull/133) +- feat: mailto link for secret key [`#150`](https://github.com/opengovsg/FormSG/pull/150) +- fix: enable forceDelivery on twilio message sending [`#178`](https://github.com/opengovsg/FormSG/pull/178) +- feat: increase breaker window time and add minimum volume threshold [`#165`](https://github.com/opengovsg/FormSG/pull/165) +- refactor: migrate encryption util to typescript [`#167`](https://github.com/opengovsg/FormSG/pull/167) +- refactor: delete render promise util [`#168`](https://github.com/opengovsg/FormSG/pull/168) +- refactor: convert date util to typescript [`#161`](https://github.com/opengovsg/FormSG/pull/161) +- feat: create bounce collection and alarms [`#131`](https://github.com/opengovsg/FormSG/pull/131) +- [develop] Release v4.31.0 [`#155`](https://github.com/opengovsg/FormSG/pull/155) +- refactor: convert MailService to a class based Typescript implementation [`#76`](https://github.com/opengovsg/FormSG/pull/76) +- fix(deps): bump aws-sdk from 2.699.0 to 2.734.0 [`#146`](https://github.com/opengovsg/FormSG/pull/146) +- fix(deps): bump node-cache from 5.1.1 to 5.1.2 [`#145`](https://github.com/opengovsg/FormSG/pull/145) +- feat: include user ip address when sending otp [`#147`](https://github.com/opengovsg/FormSG/pull/147) +- chore(deps-dev): bump htmlhint from 0.11.0 to 0.14.1 [`#116`](https://github.com/opengovsg/FormSG/pull/116) +- fix(deps): bump angular-* dependency packages from 1.7.9 to 1.8.0 [`#108`](https://github.com/opengovsg/FormSG/pull/108) +- feat: log IP, submissionId and formId together [`#130`](https://github.com/opengovsg/FormSG/pull/130) +- fix(deps): bump crypto-js from 3.3.0 to 4.0.0 [`#110`](https://github.com/opengovsg/FormSG/pull/110) +- [develop] Release 4.30.4 [`#138`](https://github.com/opengovsg/FormSG/pull/138) +- fix(deps): bump express-winston from 4.0.3 to 4.0.5 [`#109`](https://github.com/opengovsg/FormSG/pull/109) +- chore: add --watch flag back to build-frontend-dev script [`#128`](https://github.com/opengovsg/FormSG/pull/128) +- docs: create trouble shooting guide [`#119`](https://github.com/opengovsg/FormSG/pull/119) +- chore: Merge release 4.30.3 into develop [`#127`](https://github.com/opengovsg/FormSG/pull/127) +- fix(deps): bump bcrypt from 3.0.8 to 5.0.0 [`#88`](https://github.com/opengovsg/FormSG/pull/88) +- fix(deps): bump nodemailer from 6.4.10 to 6.4.11 [`#117`](https://github.com/opengovsg/FormSG/pull/117) +- tests: fix flakiness and migrate remaining mongoose model tests to Typescript [`#122`](https://github.com/opengovsg/FormSG/pull/122) +- chore: bump version to v4.32.0 [`aa34114`](https://github.com/opengovsg/FormSG/commit/aa341141d47a806ece786fcccbe0faef0945ccfc) + +#### [v4.30.4](https://github.com/opengovsg/FormSG/compare/v4.30.3...v4.30.4) > 14 August 2020 -- Revert "feat: Filter Storage Mode Responses by Submission Id (#71)" [`ffe4218`](https://github.com/opengovsg/formsg/commit/ffe42187130d1d4147f22b37687992062af7d7c6) -- chore: bump version to 4.30.4 [`35d68de`](https://github.com/opengovsg/formsg/commit/35d68debce62bd86f001724608bbc59bce483aa3) +- Revert "feat: Filter Storage Mode Responses by Submission Id (#71)" [`ffe4218`](https://github.com/opengovsg/FormSG/commit/ffe42187130d1d4147f22b37687992062af7d7c6) +- chore: bump version to 4.30.4 [`35d68de`](https://github.com/opengovsg/FormSG/commit/35d68debce62bd86f001724608bbc59bce483aa3) -#### [v4.30.3](https://github.com/opengovsg/formsg/compare/v4.30.2...v4.30.3) +#### [v4.30.3](https://github.com/opengovsg/FormSG/compare/v4.30.2...v4.30.3) > 12 August 2020 -- fix: Revert url loader [`#125`](https://github.com/opengovsg/formsg/pull/125) -- feat: show error upon FileReader failure [`#121`](https://github.com/opengovsg/formsg/pull/121) -- [develop] Release 4.30.2 [`#114`](https://github.com/opengovsg/formsg/pull/114) -- refactor: remove unused mongoTimestamp plugin [`#120`](https://github.com/opengovsg/formsg/pull/120) -- docs: updating contributing, readme, license for open source [`#86`](https://github.com/opengovsg/formsg/pull/86) -- chore: setup jest for use with Typescript tests [`#106`](https://github.com/opengovsg/formsg/pull/106) -- fix: fix myInfoError typo [`#115`](https://github.com/opengovsg/formsg/pull/115) -- docs(readme): point build status image to new repo [`#112`](https://github.com/opengovsg/formsg/pull/112) -- feat: add getQuestion instance method to form field schema [`#103`](https://github.com/opengovsg/formsg/pull/103) -- chore(deps-dev): bump webpack-cli from 3.3.11 to 3.3.12 [`#105`](https://github.com/opengovsg/formsg/pull/105) -- feat: Filter Storage Mode Responses by Submission Id [`#71`](https://github.com/opengovsg/formsg/pull/71) -- fix(deps): bump angular-cookies from 1.7.9 to 1.8.0 [`#104`](https://github.com/opengovsg/formsg/pull/104) -- chore(deps-dev): bump angular from 1.7.9 to 1.8.0 [`#10`](https://github.com/opengovsg/formsg/pull/10) -- docs: updated docs for open source [`#95`](https://github.com/opengovsg/formsg/pull/95) -- test: add tests for verification model [`#99`](https://github.com/opengovsg/formsg/pull/99) -- chore(deps-dev): bump url-loader from 1.1.2 to 4.1.0 [`#90`](https://github.com/opengovsg/formsg/pull/90) -- refactor: migrate `utils/request` to Typescript [`#98`](https://github.com/opengovsg/formsg/pull/98) -- fix: phone validation now only accepts 8 digit #s starting with 8 or 9 [`#101`](https://github.com/opengovsg/formsg/pull/101) -- [develop] Release 4.30.1 [`#80`](https://github.com/opengovsg/formsg/pull/80) -- [develop] Release 4.30.0 [`#79`](https://github.com/opengovsg/formsg/pull/79) -- fix(deps): bump uuid from 8.2.0 to 8.3.0 [`#96`](https://github.com/opengovsg/formsg/pull/96) -- chore(deps-dev): bump jasmine-spec-reporter from 4.2.1 to 5.0.2 [`#89`](https://github.com/opengovsg/formsg/pull/89) -- chore(deps-dev): bump @babel/preset-env from 7.10.2 to 7.11.0 [`#87`](https://github.com/opengovsg/formsg/pull/87) -- fix(deps): bump lodash from 4.17.15 to 4.17.19 [`#91`](https://github.com/opengovsg/formsg/pull/91) -- refactor(logic): typescriptify [`#81`](https://github.com/opengovsg/formsg/pull/81) -- fix: update dependabot config to use v2 syntax [`#85`](https://github.com/opengovsg/formsg/pull/85) -- chore: add dependabot.yml [`#82`](https://github.com/opengovsg/formsg/pull/82) -- feat: remove allowSms beta flag [`#73`](https://github.com/opengovsg/formsg/pull/73) -- feat(FormSchema): Document new indexes for form dashboard [`#77`](https://github.com/opengovsg/formsg/pull/77) -- refactor: add _id to all model interfaces [`#75`](https://github.com/opengovsg/formsg/pull/75) -- Bump version to 4.30.3 [`4e97a48`](https://github.com/opengovsg/formsg/commit/4e97a48e52eefa621b1ef84f1d991d90e96a57b4) - -#### [v4.30.2](https://github.com/opengovsg/formsg/compare/v4.30.1...v4.30.2) +- fix: Revert url loader [`#125`](https://github.com/opengovsg/FormSG/pull/125) +- feat: show error upon FileReader failure [`#121`](https://github.com/opengovsg/FormSG/pull/121) +- [develop] Release 4.30.2 [`#114`](https://github.com/opengovsg/FormSG/pull/114) +- refactor: remove unused mongoTimestamp plugin [`#120`](https://github.com/opengovsg/FormSG/pull/120) +- docs: updating contributing, readme, license for open source [`#86`](https://github.com/opengovsg/FormSG/pull/86) +- chore: setup jest for use with Typescript tests [`#106`](https://github.com/opengovsg/FormSG/pull/106) +- fix: fix myInfoError typo [`#115`](https://github.com/opengovsg/FormSG/pull/115) +- docs(readme): point build status image to new repo [`#112`](https://github.com/opengovsg/FormSG/pull/112) +- feat: add getQuestion instance method to form field schema [`#103`](https://github.com/opengovsg/FormSG/pull/103) +- chore(deps-dev): bump webpack-cli from 3.3.11 to 3.3.12 [`#105`](https://github.com/opengovsg/FormSG/pull/105) +- feat: Filter Storage Mode Responses by Submission Id [`#71`](https://github.com/opengovsg/FormSG/pull/71) +- fix(deps): bump angular-cookies from 1.7.9 to 1.8.0 [`#104`](https://github.com/opengovsg/FormSG/pull/104) +- chore(deps-dev): bump angular from 1.7.9 to 1.8.0 [`#10`](https://github.com/opengovsg/FormSG/pull/10) +- docs: updated docs for open source [`#95`](https://github.com/opengovsg/FormSG/pull/95) +- test: add tests for verification model [`#99`](https://github.com/opengovsg/FormSG/pull/99) +- chore(deps-dev): bump url-loader from 1.1.2 to 4.1.0 [`#90`](https://github.com/opengovsg/FormSG/pull/90) +- refactor: migrate `utils/request` to Typescript [`#98`](https://github.com/opengovsg/FormSG/pull/98) +- fix: phone validation now only accepts 8 digit #s starting with 8 or 9 [`#101`](https://github.com/opengovsg/FormSG/pull/101) +- [develop] Release 4.30.1 [`#80`](https://github.com/opengovsg/FormSG/pull/80) +- [develop] Release 4.30.0 [`#79`](https://github.com/opengovsg/FormSG/pull/79) +- fix(deps): bump uuid from 8.2.0 to 8.3.0 [`#96`](https://github.com/opengovsg/FormSG/pull/96) +- chore(deps-dev): bump jasmine-spec-reporter from 4.2.1 to 5.0.2 [`#89`](https://github.com/opengovsg/FormSG/pull/89) +- chore(deps-dev): bump @babel/preset-env from 7.10.2 to 7.11.0 [`#87`](https://github.com/opengovsg/FormSG/pull/87) +- fix(deps): bump lodash from 4.17.15 to 4.17.19 [`#91`](https://github.com/opengovsg/FormSG/pull/91) +- refactor(logic): typescriptify [`#81`](https://github.com/opengovsg/FormSG/pull/81) +- fix: update dependabot config to use v2 syntax [`#85`](https://github.com/opengovsg/FormSG/pull/85) +- chore: add dependabot.yml [`#82`](https://github.com/opengovsg/FormSG/pull/82) +- feat: remove allowSms beta flag [`#73`](https://github.com/opengovsg/FormSG/pull/73) +- feat(FormSchema): Document new indexes for form dashboard [`#77`](https://github.com/opengovsg/FormSG/pull/77) +- refactor: add _id to all model interfaces [`#75`](https://github.com/opengovsg/FormSG/pull/75) +- Bump version to 4.30.3 [`4e97a48`](https://github.com/opengovsg/FormSG/commit/4e97a48e52eefa621b1ef84f1d991d90e96a57b4) + +#### [v4.30.2](https://github.com/opengovsg/FormSG/compare/v4.30.1...v4.30.2) > 5 August 2020 -- fix: get env vars directly, not from config [`5397c06`](https://github.com/opengovsg/formsg/commit/5397c06107f52d7d3d5032ce6507daeb9d0604cf) -- fix: add trailing / only for attachments [`1b9d1c0`](https://github.com/opengovsg/formsg/commit/1b9d1c045c4f250533452f8a0448b37ebd6346f7) -- chore: bump version to 4.30.2 [`f623c1a`](https://github.com/opengovsg/formsg/commit/f623c1abb8bd85dd06d36994c783ed0409cd8a5d) +- fix: get env vars directly, not from config [`5397c06`](https://github.com/opengovsg/FormSG/commit/5397c06107f52d7d3d5032ce6507daeb9d0604cf) +- fix: add trailing / only for attachments [`1b9d1c0`](https://github.com/opengovsg/FormSG/commit/1b9d1c045c4f250533452f8a0448b37ebd6346f7) +- chore: bump version to 4.30.2 [`f623c1a`](https://github.com/opengovsg/FormSG/commit/f623c1abb8bd85dd06d36994c783ed0409cd8a5d) #### v4.30.1 > 4 August 2020 -- fix: change enum to uppercase [`#72`](https://github.com/opengovsg/formsg/pull/72) -- fix: activation modal width change when activation succeeds [`#69`](https://github.com/opengovsg/formsg/pull/69) -- Extend e2e [`#65`](https://github.com/opengovsg/formsg/pull/65) -- chore: update documentation for banner environment variables [`#3`](https://github.com/opengovsg/formsg/pull/3) -- fix: add fake aws credentials [`#64`](https://github.com/opengovsg/formsg/pull/64) -- Initial commit [`203e62d`](https://github.com/opengovsg/formsg/commit/203e62dfc346cef9fb893c7b84c481b762216dea) -- chore: bump version to 4.30.1 [`9ea64ac`](https://github.com/opengovsg/formsg/commit/9ea64ac0c19df843839357223abda0d449603704) -- chore: bump version to 4.30.0 [`bf0cca8`](https://github.com/opengovsg/formsg/commit/bf0cca862f9c25b0984986aae295b42938c81884) +- fix: change enum to uppercase [`#72`](https://github.com/opengovsg/FormSG/pull/72) +- fix: activation modal width change when activation succeeds [`#69`](https://github.com/opengovsg/FormSG/pull/69) +- Extend e2e [`#65`](https://github.com/opengovsg/FormSG/pull/65) +- chore: update documentation for banner environment variables [`#3`](https://github.com/opengovsg/FormSG/pull/3) +- fix: add fake aws credentials [`#64`](https://github.com/opengovsg/FormSG/pull/64) +- Initial commit [`203e62d`](https://github.com/opengovsg/FormSG/commit/203e62dfc346cef9fb893c7b84c481b762216dea) +- chore: bump version to 4.30.1 [`9ea64ac`](https://github.com/opengovsg/FormSG/commit/9ea64ac0c19df843839357223abda0d449603704) +- chore: bump version to 4.30.0 [`bf0cca8`](https://github.com/opengovsg/FormSG/commit/bf0cca862f9c25b0984986aae295b42938c81884) diff --git a/package-lock.json b/package-lock.json index f0d35de817..34a28e6120 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "FormSG", - "version": "4.35.1", + "version": "4.36.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 69b25a260c..e2508b8034 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "4.35.1", + "version": "4.36.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " From 51832a927036357ef370eb050675fe6a2372622e Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Tue, 22 Sep 2020 11:08:41 +0800 Subject: [PATCH 27/29] chore: add awscli back to toolchain (#349) --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index b0f53d9b54..d817eee4d9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ language: node_js node_js: '12' cache: - npm + - pip addons: chrome: stable @@ -33,6 +34,7 @@ before_deploy: # Workaround to run before_deploy only once - > if ! [ "$TAG" ]; then + pip install --user awscli # Put AWS in path export PATH=$PATH:$HOME/.local/bin # Login to AWS ECR, credentials defined in $AWS_ACCESS_KEY_ID and $AWS_SECRET_ACCESS_KEY From 1cf57d9bbcbb13726abff961478d06b2bbdbcf8c Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Tue, 22 Sep 2020 13:07:57 +0800 Subject: [PATCH 28/29] fix: specify betaFlags key correctly (#352) --- src/types/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/user.ts b/src/types/user.ts index ed8a7f8762..e199da9151 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -11,7 +11,7 @@ export interface IUser { agency: IAgencySchema['_id'] contact?: string created?: Date - betaFlag?: Record + betaFlags?: Record _id?: Document['_id'] } From 8ba586e03fffa8364e0799d30fd8ce7e0c61cd56 Mon Sep 17 00:00:00 2001 From: shuli-ogp <63710093+shuli-ogp@users.noreply.github.com> Date: Tue, 22 Sep 2020 15:00:47 +0800 Subject: [PATCH 29/29] fix: revert feat: filter storage mode responses by submission id (#174) (#358) --- .../encrypt-submissions.server.controller.js | 44 ++------------- .../view-responses.client.controller.js | 29 +--------- .../modules/forms/admin/css/export-button.css | 1 + .../forms/admin/css/view-responses.css | 22 +------- .../views/view-responses.client.view.html | 56 ++----------------- .../services/submissions.client.factory.js | 6 +- 6 files changed, 16 insertions(+), 142 deletions(-) diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index 413867c43d..d364e21bfe 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -25,8 +25,6 @@ const { getProcessedResponses, } = require('../modules/submission/submission.service') -const HttpStatus = require('http-status-codes') - /** * Extracts relevant fields, injects questions, verifies visibility of field and validates answers * to produce req.body.parsedResponses @@ -225,47 +223,15 @@ exports.saveResponseToDb = function (req, res, next) { */ exports.getMetadata = function (req, res) { let pageSize = 10 - let { page, submissionId } = req.query || {} + let { page } = req.query || {} let numToSkip = parseInt(page - 1 || 0) * pageSize - let matchClause = { - form: req.form._id, - submissionType: 'encryptSubmission', - } - - if (submissionId) { - if (mongoose.Types.ObjectId.isValid(submissionId)) { - matchClause._id = mongoose.Types.ObjectId(submissionId) - // Reading from primary to avoid any contention issues with bulk queries on secondary servers - Submission.findOne(matchClause, { created: 1 }) - .read('primary') - .exec((err, result) => { - if (err) { - logger.error(getRequestIp(req), req.url, req.headers, err) - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ - message: errorHandler.getMongoErrorMessage(err), - }) - } - if (!result) { - return res.status(HttpStatus.OK).send({ metadata: [], count: 0 }) - } - let entry = { - number: 1, - refNo: result._id, - submissionTime: moment(result.created) - .tz('Asia/Singapore') - .format('Do MMM YYYY, h:mm:ss a'), - } - return res.status(HttpStatus.OK).send({ metadata: [entry], count: 1 }) - }) - } else { - return res.status(HttpStatus.OK).send({ metadata: [], count: 0 }) - } - } - Submission.aggregate([ { - $match: matchClause, + $match: { + form: req.form._id, + submissionType: 'encryptSubmission', + }, }, { $sort: { created: -1 }, 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 ab0d81f37c..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 @@ -40,12 +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() - vm.filterBySubmissionRefId = '' // whether to filter submissions by a specific ID - vm.filterBySubmissionRefIdTextbox = '' - vm.filterBySubmissionRefIdMatcher = /^[0-9A-Fa-f]{24}$/ - vm.filterBySubmissionShowFilterBox = false // Three views: // 1 - Unlock view for verifying form password @@ -323,29 +318,12 @@ function ViewResponsesController( } }) - vm.filterBySubmissionChanged = function () { - // We only reload the table if the text box has changed. This prevents excessive - // requests being sent by users clicking on the "Filter" button repeatedly. - if ( - vm.filterBySubmissionRefIdTextbox !== '' && - vm.filterBySubmissionRefId !== vm.filterBySubmissionRefIdTextbox - ) { - vm.filterBySubmissionRefId = vm.filterBySubmissionRefIdTextbox - vm.tableParams.reload() - } - } - - vm.filterBySubmissionReset = function () { - vm.filterBySubmissionShowFilterBox = false - vm.filterBySubmissionRefId = '' - vm.filterBySubmissionRefIdTextbox = '' - vm.tableParams.reload() - } - // Called by child directive unlockResponsesForm after key is verified to get responses - vm.loadResponses = function () { + vm.loadResponses = function (formPassword) { + vm.formPassword = formPassword vm.currentView = 2 vm.loading = true + vm.tableParams = new NgTableParams( { page: 1, // show first page @@ -356,7 +334,6 @@ function ViewResponsesController( let { page } = params.url() return Submissions.getMetadata({ formId: vm.myform._id, - filterBySubmissionRefId: vm.filterBySubmissionRefId, page, }) .then((data) => { diff --git a/src/public/modules/forms/admin/css/export-button.css b/src/public/modules/forms/admin/css/export-button.css index 942afd499f..602f23432c 100644 --- a/src/public/modules/forms/admin/css/export-button.css +++ b/src/public/modules/forms/admin/css/export-button.css @@ -7,6 +7,7 @@ display: flex; justify-content: center; align-items: center; + min-width: 164px; margin-left: auto; } diff --git a/src/public/modules/forms/admin/css/view-responses.css b/src/public/modules/forms/admin/css/view-responses.css index 20836838fe..662eebac1a 100644 --- a/src/public/modules/forms/admin/css/view-responses.css +++ b/src/public/modules/forms/admin/css/view-responses.css @@ -101,8 +101,7 @@ } #responses-tab .response-stats { - white-space: nowrap; - min-width: 240px; + width: 100%; } #responses-tab .datepicker-export-container { @@ -716,22 +715,3 @@ padding-left: 16px !important; border: solid 1px #ccc; } - -#filter-by-submission-reset-link { - padding-top: 13px; - padding-left: 13px; - white-space: nowrap; - cursor: pointer; -} - -#filter-by-submission-filter-link { - padding-top: 13px; - padding-left: 5px; - white-space: nowrap; - cursor: pointer; -} - -#filter-by-submission-filter-textbox-container { - display: flex; - min-width: 240px; -} 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 f6f3081780..5e9671e07c 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 @@ -31,9 +31,7 @@
-
+
-
+
-
+
{{vm.responsesCount}} response(s) to date - - - - -
-
- - - View All
-
+
-
-
-
-
+
-
-
- -
No results found
-
- Did you enter the right reference number? We can't seem to find - the response. -
-
-