From cd918040946b8ee889b79252885c1aa2d571dd06 Mon Sep 17 00:00:00 2001
From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com>
Date: Mon, 5 Apr 2021 16:33:56 +0800
Subject: [PATCH 01/75] refactor(corppass-ui): make ui changes for corppass
(#1533)
* refactor(corppass-ui): make ui changes for corppass
- updated copy for ui
- updated minimum necessary user-facing mentions of singpass and corppass to use new casing
- made necessary frontend changes for corporate singpass button
- added tooltip beside corppass option in settings > enable authentication
- updated e2e tests for new changes
* fix(start-page): increase corp login hor. padding
---
.../settings-form.client.view.html | 8 +++++++
.../settings-form.client.directive.js | 6 +++---
.../views/activate-form.client.modal.html | 6 +++---
.../views/edit-myinfo-field.client.modal.html | 2 +-
.../forms/base/componentViews/end-page.html | 2 +-
.../forms/base/componentViews/start-page.html | 21 ++++++++++++-------
.../modules/forms/base/css/start-end-page.css | 11 ++++++++++
src/public/translations/en-SG/landing.json | 10 ++++-----
tests/end-to-end/helpers/selectors.js | 2 +-
tests/end-to-end/helpers/util.js | 6 +++---
10 files changed, 50 insertions(+), 24 deletions(-)
diff --git a/src/public/modules/forms/admin/directiveViews/settings-form.client.view.html b/src/public/modules/forms/admin/directiveViews/settings-form.client.view.html
index f28dc0e3bf..484f484497 100644
--- a/src/public/modules/forms/admin/directiveViews/settings-form.client.view.html
+++ b/src/public/modules/forms/admin/directiveViews/settings-form.client.view.html
@@ -258,6 +258,14 @@
class="radiomark"
ng-class="tempForm.authType === type.val ? 'blue-border' : ''"
>
+
{{ (['SP', 'MyInfo'].includes(vm.esrvcIdError.authType) ?
- 'SingPass' : 'CorpPass') + ' returns the error code ' }}
+ 'Singpass' : 'Corppass') + ' returns the error code ' }}
{{vm.esrvcIdError.errorCode}}
{{' for the e-service ID ' }} {{vm.esrvcIdError.esrvcId}}
{{ 'Could not connect to ' + (vm.esrvcIdError.authType === 'CP' ?
- 'CorpPass' : 'SingPass') + ' to check the e-service id '}}
+ 'Corppass' : 'Singpass') + ' to check the e-service id '}}
{{vm.esrvcIdError.esrvcId}}
@@ -48,7 +48,7 @@
-
This form uses CorpPass.
+
This form uses Corppass.
Please make sure
{{vm.esrvcIdSuccess.esrvcId}} is the correct
e-service ID
diff --git a/src/public/modules/forms/admin/views/edit-myinfo-field.client.modal.html b/src/public/modules/forms/admin/views/edit-myinfo-field.client.modal.html
index 9103031dc9..81ad773f58 100644
--- a/src/public/modules/forms/admin/views/edit-myinfo-field.client.modal.html
+++ b/src/public/modules/forms/admin/views/edit-myinfo-field.client.modal.html
@@ -56,7 +56,7 @@
diff --git a/src/public/modules/forms/base/componentViews/end-page.html b/src/public/modules/forms/base/componentViews/end-page.html
index bc07a2d3a1..858e4b7669 100644
--- a/src/public/modules/forms/base/componentViews/end-page.html
+++ b/src/public/modules/forms/base/componentViews/end-page.html
@@ -12,7 +12,7 @@ {{ vm.title }}
-
You will be automatically logged out of SingPass.
+
You will be automatically logged out of Singpass.
diff --git a/src/public/modules/forms/base/componentViews/start-page.html b/src/public/modules/forms/base/componentViews/start-page.html
index c0561049e1..5753ce9f09 100644
--- a/src/public/modules/forms/base/componentViews/start-page.html
+++ b/src/public/modules/forms/base/componentViews/start-page.html
@@ -54,20 +54,20 @@
{{ vm.formTitle }}
ng-if="['SP', 'MyInfo'].includes(vm.authType) && !vm.userName && !vm.isTemplate"
ng-disabled="vm.isAdminPreview"
>
- Log in with SingPass
+ Login with Singpass
- Log in with CorpPass
-
+ Login with Singpass (Corporate)
+
@@ -100,14 +100,21 @@ {{ vm.formTitle }}
ng-if="['SP', 'MyInfo'].includes(vm.authType)"
class="form-locked-msg padded-view"
>
- Login with SingPass to access this form. Your SingPass ID will be
+ Login with Singpass to access this form. Your Singpass ID will be
included with your form submission.
- Login with CorpPass to access this form. Your Entity ID and
- CorpPass ID will be included with your form submission.
+ Corporate entity login is required for this form. Your Singpass ID
+ and corporate Entity ID will be included with your form
+ submission.
+
{
case 'MyInfo':
await t
.expect(formPage.spcpLoginBtn.textContent)
- .contains(`Log in with SingPass`)
+ .contains(`Login with Singpass`)
.click(formPage.spcpLoginBtn)
.click(mockpass.loginBtn)
.click(mockpass.nricDropdownBtn)
@@ -1143,7 +1143,7 @@ const expectSpcpLogin = async (t, authType, authData) => {
case 'SP':
await t
.expect(formPage.spcpLoginBtn.textContent)
- .contains(`Log in with SingPass`)
+ .contains(`Login with Singpass`)
.click(formPage.spcpLoginBtn)
.click(mockpass.loginBtn)
.click(mockpass.nricDropdownBtn)
@@ -1154,7 +1154,7 @@ const expectSpcpLogin = async (t, authType, authData) => {
case 'CP':
await t
.expect(formPage.spcpLoginBtn.textContent)
- .contains(`Log in with CorpPass`)
+ .contains(`Login with Singpass (Corporate)`)
.click(formPage.spcpLoginBtn)
.click(mockpass.loginBtn)
.click(mockpass.nricDropdownBtn)
From b854bb1ef479cfc040c19990e5590242f03b0df5 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 17:23:36 +0000
Subject: [PATCH 02/75] fix(deps): bump @opengovsg/spcp-auth-client from 1.4.4
to 1.4.5 (#1555)
Bumps [@opengovsg/spcp-auth-client](https://github.com/opengovsg/spcp-auth-client) from 1.4.4 to 1.4.5.
- [Release notes](https://github.com/opengovsg/spcp-auth-client/releases)
- [Commits](https://github.com/opengovsg/spcp-auth-client/compare/v1.4.4...v1.4.5)
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 bfbdff1a51..63f962ef5f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4371,9 +4371,9 @@
"integrity": "sha512-YqR6GIsum9K7Cg6wOTxwJnKP+KDOxbZ9dnQE2/M47vP0ynXyTadvwflGBukzJ/MhzrS2R6buNhFjFnVJRXJinw=="
},
"@opengovsg/spcp-auth-client": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/@opengovsg/spcp-auth-client/-/spcp-auth-client-1.4.4.tgz",
- "integrity": "sha512-GVLZphx2/9W36ZZ7nNmb/Zu/QxQbUYDC1oY0NB19IBfzlOV7FR6foqRn/6F2o9zMtC5r13nhHvxC78Vz5eMDow==",
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@opengovsg/spcp-auth-client/-/spcp-auth-client-1.4.5.tgz",
+ "integrity": "sha512-bvSNTW+2CL6gAprXSoEtkFv/9mFp6dwm0iCb9Ns56BBXy0+O/lUKI2jXZ7rBnVwXOCYtlEWB9BIqJmMLJKeOzA==",
"requires": {
"base-64": "^1.0.0",
"jsonwebtoken": "^8.3.0",
diff --git a/package.json b/package.json
index 48a4d8f487..b6b6d09415 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
"@opengovsg/formsg-sdk": "^0.8.4-beta.0",
"@opengovsg/myinfo-gov-client": "=4.0.0-beta.0",
"@opengovsg/ng-file-upload": "^12.2.15",
- "@opengovsg/spcp-auth-client": "^1.4.4",
+ "@opengovsg/spcp-auth-client": "^1.4.5",
"@sentry/browser": "^6.2.5",
"@sentry/integrations": "^6.2.5",
"@stablelib/base64": "^1.0.0",
From feeea94a5b086921466c8d9b56949f120173aba4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 17:30:56 +0000
Subject: [PATCH 03/75] chore(deps-dev): bump @typescript-eslint/eslint-plugin
(#1556)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 4.20.0 to 4.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.21.0/packages/eslint-plugin)
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, 29 insertions(+), 29 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 63f962ef5f..b2d29fdabb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5159,13 +5159,13 @@
}
},
"@typescript-eslint/eslint-plugin": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.20.0.tgz",
- "integrity": "sha512-sw+3HO5aehYqn5w177z2D82ZQlqHCwcKSMboueo7oE4KU9QiC0SAgfS/D4z9xXvpTc8Bt41Raa9fBR8T2tIhoQ==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.21.0.tgz",
+ "integrity": "sha512-FPUyCPKZbVGexmbCFI3EQHzCZdy2/5f+jv6k2EDljGdXSRc0cKvbndd2nHZkSLqCNOPk0jB6lGzwIkglXcYVsQ==",
"dev": true,
"requires": {
- "@typescript-eslint/experimental-utils": "4.20.0",
- "@typescript-eslint/scope-manager": "4.20.0",
+ "@typescript-eslint/experimental-utils": "4.21.0",
+ "@typescript-eslint/scope-manager": "4.21.0",
"debug": "^4.1.1",
"functional-red-black-tree": "^1.0.1",
"lodash": "^4.17.15",
@@ -5175,43 +5175,43 @@
},
"dependencies": {
"@typescript-eslint/experimental-utils": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.20.0.tgz",
- "integrity": "sha512-sQNlf6rjLq2yB5lELl3gOE7OuoA/6IVXJUJ+Vs7emrQMva14CkOwyQwD7CW+TkmOJ4Q/YGmoDLmbfFrpGmbKng==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.21.0.tgz",
+ "integrity": "sha512-cEbgosW/tUFvKmkg3cU7LBoZhvUs+ZPVM9alb25XvR0dal4qHL3SiUqHNrzoWSxaXA9gsifrYrS1xdDV6w/gIA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.3",
- "@typescript-eslint/scope-manager": "4.20.0",
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/typescript-estree": "4.20.0",
+ "@typescript-eslint/scope-manager": "4.21.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/typescript-estree": "4.21.0",
"eslint-scope": "^5.0.0",
"eslint-utils": "^2.0.0"
}
},
"@typescript-eslint/scope-manager": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.20.0.tgz",
- "integrity": "sha512-/zm6WR6iclD5HhGpcwl/GOYDTzrTHmvf8LLLkwKqqPKG6+KZt/CfSgPCiybshmck66M2L5fWSF/MKNuCwtKQSQ==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz",
+ "integrity": "sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0"
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0"
}
},
"@typescript-eslint/types": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.20.0.tgz",
- "integrity": "sha512-cYY+1PIjei1nk49JAPnH1VEnu7OYdWRdJhYI5wiKOUMhLTG1qsx5cQxCUTuwWCmQoyriadz3Ni8HZmGSofeC+w==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz",
+ "integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.20.0.tgz",
- "integrity": "sha512-Knpp0reOd4ZsyoEJdW8i/sK3mtZ47Ls7ZHvD8WVABNx5Xnn7KhenMTRGegoyMTx6TiXlOVgMz9r0pDgXTEEIHA==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz",
+ "integrity": "sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0",
"debug": "^4.1.1",
"globby": "^11.0.1",
"is-glob": "^4.0.1",
@@ -5220,12 +5220,12 @@
}
},
"@typescript-eslint/visitor-keys": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.20.0.tgz",
- "integrity": "sha512-NXKRM3oOVQL8yNFDNCZuieRIwZ5UtjNLYtmMx2PacEAGmbaEYtGgVHUHVyZvU/0rYZcizdrWjDo+WBtRPSgq+A==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz",
+ "integrity": "sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
"eslint-visitor-keys": "^2.0.0"
}
},
diff --git a/package.json b/package.json
index b6b6d09415..d9c08cef85 100644
--- a/package.json
+++ b/package.json
@@ -192,7 +192,7 @@
"@types/uid-generator": "^2.0.2",
"@types/uuid": "^8.3.0",
"@types/validator": "^13.1.3",
- "@typescript-eslint/eslint-plugin": "^4.20.0",
+ "@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.20.0",
"auto-changelog": "^2.2.1",
"axios-mock-adapter": "^1.19.0",
From 569d6ae9072b301a5c014aae96e12f4db743fcf8 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 17:42:12 +0000
Subject: [PATCH 04/75] chore(deps-dev): bump @types/mongodb from 3.6.10 to
3.6.12 (#1557)
Bumps [@types/mongodb](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mongodb) from 3.6.10 to 3.6.12.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mongodb)
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 b2d29fdabb..a1f7b81271 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4930,9 +4930,9 @@
"dev": true
},
"@types/mongodb": {
- "version": "3.6.10",
- "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.10.tgz",
- "integrity": "sha512-BkwAHFiZSSWdTIqbUVGmgvIsiXXjqAketeK7Izy7oSs6G3N8Bn993tK9eq6QEovQDx6OQ2FGP2KWDDxBzdlJ6Q==",
+ "version": "3.6.12",
+ "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.12.tgz",
+ "integrity": "sha512-49aEzQD5VdHPxyd5dRyQdqEveAg9LanwrH8RQipnMuulwzKmODXIZRp0umtxi1eBUfEusRkoy8AVOMr+kVuFog==",
"requires": {
"@types/bson": "*",
"@types/node": "*"
diff --git a/package.json b/package.json
index d9c08cef85..ab02a3f0c7 100644
--- a/package.json
+++ b/package.json
@@ -179,7 +179,7 @@
"@types/ip": "^1.1.0",
"@types/jest": "^26.0.22",
"@types/json-stringify-safe": "^5.0.0",
- "@types/mongodb": "^3.6.10",
+ "@types/mongodb": "^3.6.12",
"@types/mongodb-uri": "^0.9.0",
"@types/node": "^14.14.37",
"@types/nodemailer": "^6.4.1",
From dc893b82cc697dcc25ca087d06ab99988e73dd76 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 17:58:36 +0000
Subject: [PATCH 05/75] chore(deps-dev): bump eslint-plugin-jest from 24.3.3 to
24.3.4 (#1559)
Bumps [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) from 24.3.3 to 24.3.4.
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v24.3.3...v24.3.4)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 52 +++++++++++++++++++++++------------------------
package.json | 2 +-
2 files changed, 27 insertions(+), 27 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index a1f7b81271..9d7e13533f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5271,15 +5271,15 @@
}
},
"@typescript-eslint/experimental-utils": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.20.0.tgz",
- "integrity": "sha512-sQNlf6rjLq2yB5lELl3gOE7OuoA/6IVXJUJ+Vs7emrQMva14CkOwyQwD7CW+TkmOJ4Q/YGmoDLmbfFrpGmbKng==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.21.0.tgz",
+ "integrity": "sha512-cEbgosW/tUFvKmkg3cU7LBoZhvUs+ZPVM9alb25XvR0dal4qHL3SiUqHNrzoWSxaXA9gsifrYrS1xdDV6w/gIA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.3",
- "@typescript-eslint/scope-manager": "4.20.0",
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/typescript-estree": "4.20.0",
+ "@typescript-eslint/scope-manager": "4.21.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/typescript-estree": "4.21.0",
"eslint-scope": "^5.0.0",
"eslint-utils": "^2.0.0"
}
@@ -5379,29 +5379,29 @@
}
},
"@typescript-eslint/scope-manager": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.20.0.tgz",
- "integrity": "sha512-/zm6WR6iclD5HhGpcwl/GOYDTzrTHmvf8LLLkwKqqPKG6+KZt/CfSgPCiybshmck66M2L5fWSF/MKNuCwtKQSQ==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz",
+ "integrity": "sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0"
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0"
}
},
"@typescript-eslint/types": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.20.0.tgz",
- "integrity": "sha512-cYY+1PIjei1nk49JAPnH1VEnu7OYdWRdJhYI5wiKOUMhLTG1qsx5cQxCUTuwWCmQoyriadz3Ni8HZmGSofeC+w==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz",
+ "integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.20.0.tgz",
- "integrity": "sha512-Knpp0reOd4ZsyoEJdW8i/sK3mtZ47Ls7ZHvD8WVABNx5Xnn7KhenMTRGegoyMTx6TiXlOVgMz9r0pDgXTEEIHA==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz",
+ "integrity": "sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0",
"debug": "^4.1.1",
"globby": "^11.0.1",
"is-glob": "^4.0.1",
@@ -5445,12 +5445,12 @@
}
},
"@typescript-eslint/visitor-keys": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.20.0.tgz",
- "integrity": "sha512-NXKRM3oOVQL8yNFDNCZuieRIwZ5UtjNLYtmMx2PacEAGmbaEYtGgVHUHVyZvU/0rYZcizdrWjDo+WBtRPSgq+A==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz",
+ "integrity": "sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
"eslint-visitor-keys": "^2.0.0"
},
"dependencies": {
@@ -10835,9 +10835,9 @@
}
},
"eslint-plugin-jest": {
- "version": "24.3.3",
- "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.3.tgz",
- "integrity": "sha512-IQ9tLHyKEyBw1BM3IE13WxOXQm03h/7dy1KFknUVkoY2N2+Hw7lb/3YFz/4jwcrxXt2+KhA/GoiK7jt8aK19ww==",
+ "version": "24.3.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.4.tgz",
+ "integrity": "sha512-3n5oY1+fictanuFkTWPwSlehugBTAgwLnYLFsCllzE3Pl1BwywHl5fL0HFxmMjoQY8xhUDk8uAWc3S4JOHGh3A==",
"dev": true,
"requires": {
"@typescript-eslint/experimental-utils": "^4.0.1"
diff --git a/package.json b/package.json
index ab02a3f0c7..f62e3f4418 100644
--- a/package.json
+++ b/package.json
@@ -209,7 +209,7 @@
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-angular": "^4.0.1",
"eslint-plugin-import": "^2.22.1",
- "eslint-plugin-jest": "^24.3.3",
+ "eslint-plugin-jest": "^24.3.4",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-typesafe": "^0.5.2",
From d16c11673a17334e6e0265832b2547ccf82748c7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 18:04:46 +0000
Subject: [PATCH 06/75] chore(deps-dev): bump ngrok from 4.0.0 to 4.0.1 (#1560)
Bumps [ngrok](https://github.com/bubenshchykov/ngrok) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/bubenshchykov/ngrok/releases)
- [Changelog](https://github.com/bubenshchykov/ngrok/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bubenshchykov/ngrok/commits)
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 9d7e13533f..4169e31fba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17696,9 +17696,9 @@
}
},
"ngrok": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-4.0.0.tgz",
- "integrity": "sha512-fECZeX/gSHnk+Re+ycIK0SDKeTV0G86mxc6n2hbqMNwqmxYqh0lfInjZG+QhsPyAOWiIsAteydP/jFPF/WIWCA==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-4.0.1.tgz",
+ "integrity": "sha512-1RDEaP6urGt8dVzZ68mlf1799VXucJ3bEcNDOSwWoGUjgXS7MxMw+ngyw/2H/O+EMk1Fj1+Md2Au595rB4lG5w==",
"dev": true,
"requires": {
"@types/node": "^8.10.50",
diff --git a/package.json b/package.json
index f62e3f4418..fc4d38c835 100644
--- a/package.json
+++ b/package.json
@@ -231,7 +231,7 @@
"mockdate": "^3.0.5",
"mockingoose": "^2.13.2",
"mongodb-memory-server-core": "^6.9.6",
- "ngrok": "^4.0.0",
+ "ngrok": "^4.0.1",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"prettier": "^2.2.1",
"proxyquire": "^2.1.3",
From bf4f55db6dfe9e1deb1b7dde944e3b457368c2cd Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 5 Apr 2021 18:17:05 +0000
Subject: [PATCH 07/75] chore(deps-dev): bump @typescript-eslint/parser from
4.20.0 to 4.21.0 (#1558)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 4.20.0 to 4.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v4.21.0/packages/parser)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 46 +++++++++++++++++++++++-----------------------
package.json | 2 +-
2 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 4169e31fba..ce33163399 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5285,41 +5285,41 @@
}
},
"@typescript-eslint/parser": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.20.0.tgz",
- "integrity": "sha512-m6vDtgL9EABdjMtKVw5rr6DdeMCH3OA1vFb0dAyuZSa3e5yw1YRzlwFnm9knma9Lz6b2GPvoNSa8vOXrqsaglA==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.21.0.tgz",
+ "integrity": "sha512-eyNf7QmE5O/l1smaQgN0Lj2M/1jOuNg2NrBm1dqqQN0sVngTLyw8tdCbih96ixlhbF1oINoN8fDCyEH9SjLeIA==",
"dev": true,
"requires": {
- "@typescript-eslint/scope-manager": "4.20.0",
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/typescript-estree": "4.20.0",
+ "@typescript-eslint/scope-manager": "4.21.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/typescript-estree": "4.21.0",
"debug": "^4.1.1"
},
"dependencies": {
"@typescript-eslint/scope-manager": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.20.0.tgz",
- "integrity": "sha512-/zm6WR6iclD5HhGpcwl/GOYDTzrTHmvf8LLLkwKqqPKG6+KZt/CfSgPCiybshmck66M2L5fWSF/MKNuCwtKQSQ==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz",
+ "integrity": "sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0"
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0"
}
},
"@typescript-eslint/types": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.20.0.tgz",
- "integrity": "sha512-cYY+1PIjei1nk49JAPnH1VEnu7OYdWRdJhYI5wiKOUMhLTG1qsx5cQxCUTuwWCmQoyriadz3Ni8HZmGSofeC+w==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz",
+ "integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.20.0.tgz",
- "integrity": "sha512-Knpp0reOd4ZsyoEJdW8i/sK3mtZ47Ls7ZHvD8WVABNx5Xnn7KhenMTRGegoyMTx6TiXlOVgMz9r0pDgXTEEIHA==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz",
+ "integrity": "sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
- "@typescript-eslint/visitor-keys": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
+ "@typescript-eslint/visitor-keys": "4.21.0",
"debug": "^4.1.1",
"globby": "^11.0.1",
"is-glob": "^4.0.1",
@@ -5328,12 +5328,12 @@
}
},
"@typescript-eslint/visitor-keys": {
- "version": "4.20.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.20.0.tgz",
- "integrity": "sha512-NXKRM3oOVQL8yNFDNCZuieRIwZ5UtjNLYtmMx2PacEAGmbaEYtGgVHUHVyZvU/0rYZcizdrWjDo+WBtRPSgq+A==",
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz",
+ "integrity": "sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==",
"dev": true,
"requires": {
- "@typescript-eslint/types": "4.20.0",
+ "@typescript-eslint/types": "4.21.0",
"eslint-visitor-keys": "^2.0.0"
}
},
diff --git a/package.json b/package.json
index fc4d38c835..73be391b0f 100644
--- a/package.json
+++ b/package.json
@@ -193,7 +193,7 @@
"@types/uuid": "^8.3.0",
"@types/validator": "^13.1.3",
"@typescript-eslint/eslint-plugin": "^4.21.0",
- "@typescript-eslint/parser": "^4.20.0",
+ "@typescript-eslint/parser": "^4.21.0",
"auto-changelog": "^2.2.1",
"axios-mock-adapter": "^1.19.0",
"babel-loader": "^8.2.2",
From 33c00137e19275b741f7dccec4b59981a369ce4b Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Tue, 6 Apr 2021 11:02:20 +0800
Subject: [PATCH 08/75] chore: add createReqMeta to verification module logging
(#1562)
---
src/app/modules/verification/verification.controller.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/app/modules/verification/verification.controller.ts b/src/app/modules/verification/verification.controller.ts
index 6b069e8ab6..a829d760b7 100644
--- a/src/app/modules/verification/verification.controller.ts
+++ b/src/app/modules/verification/verification.controller.ts
@@ -6,6 +6,7 @@ import { SALT_ROUNDS } from '../../../shared/util/verification'
import { PublicTransaction } from '../../../types'
import { ErrorDto } from '../../../types/api'
import { generateOtpWithHash } from '../../utils/otp'
+import { createReqMeta } from '../../utils/request'
import { VerificationFactory } from './verification.factory'
import { Transaction } from './verification.types'
@@ -30,6 +31,7 @@ export const handleCreateTransaction: RequestHandler<
const logMeta = {
action: 'handleCreateTransaction',
formId,
+ ...createReqMeta(req),
}
return VerificationFactory.createTransaction(formId)
.map((transaction) => {
@@ -66,6 +68,7 @@ export const handleGetTransactionMetadata: RequestHandler<
const logMeta = {
action: 'handleGetTransactionMetadata',
transactionId,
+ ...createReqMeta(req),
}
return VerificationFactory.getTransactionMetadata(transactionId)
.map((publicTransaction) =>
@@ -99,6 +102,7 @@ export const handleResetField: RequestHandler<
action: 'handleResetField',
transactionId,
fieldId,
+ ...createReqMeta(req),
}
return VerificationFactory.resetFieldForTransaction(transactionId, fieldId)
.map(() => res.sendStatus(StatusCodes.OK))
@@ -130,6 +134,7 @@ export const handleGetOtp: RequestHandler<
action: 'handleGetOtp',
transactionId,
fieldId,
+ ...createReqMeta(req),
}
return generateOtpWithHash(logMeta, SALT_ROUNDS)
.andThen(({ otp, hashedOtp }) =>
@@ -171,6 +176,7 @@ export const handleVerifyOtp: RequestHandler<
action: 'handleVerifyOtp',
transactionId,
fieldId,
+ ...createReqMeta(req),
}
return VerificationFactory.verifyOtp(transactionId, fieldId, otp)
.map((signedData) => res.status(StatusCodes.OK).json(signedData))
From ae0fb75cdf6b4df39730f285d1009f4f3a315a36 Mon Sep 17 00:00:00 2001
From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com>
Date: Tue, 6 Apr 2021 11:04:05 +0800
Subject: [PATCH 09/75] ref(auth-api): duplicate auth endpoints to new /api/v3
router (#1551)
* refactor(auth-api): duplicate auth endpoints to new /api/v3 router
- duplicate auth endpoint functionality and update endpoints
- update v3 router to use new endpoints
- update frontend api calls to use new endpoints
* test(auth-api): duplicate integration tests for new endpoint
---
.../api/v3/auth/__tests__/auth.routes.spec.ts | 564 ++++++++++++++++++
src/app/routes/api/v3/auth/auth.routes.ts | 95 +++
src/app/routes/api/v3/auth/index.ts | 1 +
src/app/routes/api/v3/v3.routes.ts | 2 +
.../users/services/auth.client.service.js | 8 +-
5 files changed, 666 insertions(+), 4 deletions(-)
create mode 100644 src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts
create mode 100644 src/app/routes/api/v3/auth/auth.routes.ts
create mode 100644 src/app/routes/api/v3/auth/index.ts
diff --git a/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts b/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts
new file mode 100644
index 0000000000..d69a1d18f7
--- /dev/null
+++ b/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts
@@ -0,0 +1,564 @@
+import { pick } from 'lodash'
+import { errAsync, okAsync } from 'neverthrow'
+import supertest, { Session } from 'supertest-session'
+import validator from 'validator'
+
+import MailService from 'src/app/services/mail/mail.service'
+import { HashingError } from 'src/app/utils/hash'
+import * as OtpUtils from 'src/app/utils/otp'
+import { IAgencySchema } from 'src/types'
+
+import { setupApp } from 'tests/integration/helpers/express-setup'
+import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
+
+import * as AuthService from '../../../../../modules/auth/auth.service'
+import { DatabaseError } from '../../../../../modules/core/core.errors'
+import * as UserService from '../../../../../modules/user/user.service'
+import { MailSendError } from '../../../../../services/mail/mail.errors'
+import { AuthRouter } from '../auth.routes'
+
+const app = setupApp('/auth', AuthRouter)
+
+describe('auth.routes', () => {
+ let request: Session
+
+ beforeAll(async () => await dbHandler.connect())
+ beforeEach(() => {
+ request = supertest(app)
+ })
+ afterEach(async () => {
+ await dbHandler.clearDatabase()
+ jest.restoreAllMocks()
+ })
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ describe('POST /auth/email/validate', () => {
+ it('should return 400 when body.email is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/auth/email/validate')
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'email' } }),
+ )
+ })
+
+ it('should return 400 when body.email is invalid', async () => {
+ // Arrange
+ const invalidEmail = 'not an email'
+
+ // Act
+ const response = await request
+ .post('/auth/email/validate')
+ .send({ email: invalidEmail })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'email', message: 'Please enter a valid email' },
+ }),
+ )
+ })
+
+ it('should return 401 when domain of body.email does not exist in Agency collection', async () => {
+ // Arrange
+ const validEmailWithInvalidDomain = 'test@example.com'
+
+ // Act
+ const response = await request
+ .post('/auth/email/validate')
+ .send({ email: validEmailWithInvalidDomain })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual(
+ 'This is not a whitelisted public service email domain. Please log in with your official government or government-linked email address.',
+ )
+ })
+
+ it('should return 200 when domain of body.email exists in Agency collection', async () => {
+ // Arrange
+ // Insert agency
+ const validDomain = 'example.com'
+ const validEmail = `test@${validDomain}`
+ await dbHandler.insertAgency({ mailDomain: validDomain })
+
+ // Act
+ const response = await request
+ .post('/auth/email/validate')
+ .send({ email: validEmail })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.text).toEqual('OK')
+ })
+
+ 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.insertAgency({ mailDomain: validDomain })
+
+ const getAgencySpy = jest
+ .spyOn(AuthService, 'validateEmailDomain')
+ .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString)))
+
+ // Act
+ const response = await request
+ .post('/auth/email/validate')
+ .send({ email: validEmail })
+
+ // Assert
+ expect(getAgencySpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual(mockErrorString)
+ })
+ })
+
+ describe('POST /auth/otp/generate', () => {
+ const VALID_DOMAIN = 'example.com'
+ const VALID_EMAIL = `test@${VALID_DOMAIN}`
+ const INVALID_DOMAIN = 'example.org'
+
+ beforeEach(async () => dbHandler.insertAgency({ mailDomain: VALID_DOMAIN }))
+
+ it('should return 400 when body.email is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/auth/otp/generate')
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'email' } }),
+ )
+ })
+
+ it('should return 400 when body.email is invalid', async () => {
+ // Arrange
+ const invalidEmail = 'not an email'
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: invalidEmail })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'email', message: 'Please enter a valid email' },
+ }),
+ )
+ })
+
+ it('should return 401 when domain of body.email does not exist in Agency collection', async () => {
+ // Arrange
+ const validEmailWithInvalidDomain = `test@${INVALID_DOMAIN}`
+ expect(validator.isEmail(validEmailWithInvalidDomain)).toEqual(true)
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: validEmailWithInvalidDomain })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({
+ message:
+ 'This is not a whitelisted public service email domain. Please log in with your official government or government-linked email address.',
+ })
+ })
+
+ it('should return 500 when error occurs whilst creating OTP', async () => {
+ // Arrange
+ const createLoginOtpSpy = jest
+ .spyOn(AuthService, 'createLoginOtp')
+ .mockReturnValueOnce(errAsync(new HashingError()))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: VALID_EMAIL })
+
+ // Assert
+ expect(createLoginOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Failed to send login OTP. Please try again later and if the problem persists, contact us.',
+ })
+ })
+
+ it('should return 500 when error occurs whilst sending login OTP', async () => {
+ // Arrange
+ const sendLoginOtpSpy = jest
+ .spyOn(MailService, 'sendLoginOtp')
+ .mockReturnValueOnce(errAsync(new MailSendError('some error')))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: VALID_EMAIL })
+
+ // Assert
+ expect(sendLoginOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Failed to send login OTP. Please try again later and if the problem persists, contact us.',
+ })
+ })
+
+ it('should return 500 when validating domain returns a database error', async () => {
+ // Arrange
+ const getAgencySpy = jest
+ .spyOn(AuthService, 'validateEmailDomain')
+ .mockReturnValueOnce(errAsync(new DatabaseError()))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: VALID_EMAIL })
+
+ // Assert
+ expect(getAgencySpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Failed to send login OTP. Please try again later and if the problem persists, contact us.',
+ })
+ })
+
+ it('should return 200 when otp is sent successfully', async () => {
+ // Arrange
+ const sendLoginOtpSpy = jest
+ .spyOn(MailService, 'sendLoginOtp')
+ .mockReturnValueOnce(okAsync(true))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/generate')
+ .send({ email: VALID_EMAIL })
+
+ // Assert
+ expect(sendLoginOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(`OTP sent to ${VALID_EMAIL}`)
+ })
+ })
+
+ describe('POST /auth/otp/verify', () => {
+ const MOCK_VALID_OTP = '123456'
+ const VALID_DOMAIN = 'example.com'
+ const VALID_EMAIL = `test@${VALID_DOMAIN}`
+ const INVALID_DOMAIN = 'example.org'
+
+ let defaultAgency: IAgencySchema
+
+ beforeEach(async () => {
+ defaultAgency = await dbHandler.insertAgency({
+ mailDomain: VALID_DOMAIN,
+ })
+ jest.spyOn(OtpUtils, 'generateOtp').mockReturnValue(MOCK_VALID_OTP)
+ })
+
+ it('should return 400 when body.email is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/auth/otp/verify').send({
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'email' } }),
+ )
+ })
+
+ it('should return 400 when body.otp is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/auth/otp/verify').send({
+ email: VALID_EMAIL,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'otp' } }),
+ )
+ })
+
+ it('should return 400 when body.email is invalid', async () => {
+ // Arrange
+ const invalidEmail = 'not an email'
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: invalidEmail, otp: MOCK_VALID_OTP })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'email', message: 'Please enter a valid email' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.otp is less than 6 digits', async () => {
+ // Act
+ const response = await request.post('/auth/otp/verify').send({
+ email: VALID_EMAIL,
+ otp: '12345',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'otp', message: 'Please enter a valid otp' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.otp is 6 characters but does not consist purely of digits', async () => {
+ // Act
+ const response = await request.post('/auth/otp/verify').send({
+ email: VALID_EMAIL,
+ otp: '123abc',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'otp', message: 'Please enter a valid otp' },
+ }),
+ )
+ })
+
+ it('should return 401 when domain of body.email does not exist in Agency collection', async () => {
+ // Arrange
+ const validEmailWithInvalidDomain = `test@${INVALID_DOMAIN}`
+ expect(validator.isEmail(validEmailWithInvalidDomain)).toEqual(true)
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: validEmailWithInvalidDomain, otp: MOCK_VALID_OTP })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual(
+ 'This is not a whitelisted public service email domain. Please log in with your official government or government-linked email address.',
+ )
+ })
+
+ it('should return 500 when validating domain returns a database error', async () => {
+ // Arrange
+ const getAgencySpy = jest
+ .spyOn(AuthService, 'validateEmailDomain')
+ .mockReturnValueOnce(errAsync(new DatabaseError()))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: MOCK_VALID_OTP })
+
+ // Assert
+ expect(getAgencySpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual('Something went wrong. Please try again.')
+ })
+
+ it('should return 422 when hash does not exist for body.otp', async () => {
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: MOCK_VALID_OTP })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual(
+ expect.stringContaining(
+ 'OTP has expired. Please request for a new OTP.',
+ ),
+ )
+ })
+
+ it('should return 422 when body.otp is invalid', async () => {
+ // Arrange
+ const invalidOtp = '654321'
+ // Request for OTP so the hash exists.
+ await requestForOtp(VALID_EMAIL)
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: invalidOtp })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual('OTP is invalid. Please try again.')
+ })
+
+ it('should return 422 when invalid body.otp has been attempted too many times', async () => {
+ // Arrange
+ const invalidOtp = '654321'
+ // Request for OTP so the hash exists.
+ await requestForOtp(VALID_EMAIL)
+
+ // Act
+ // Attempt invalid OTP for MAX_OTP_ATTEMPTS.
+ const verifyPromises = []
+ for (let i = 0; i < AuthService.MAX_OTP_ATTEMPTS; i++) {
+ verifyPromises.push(
+ request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: invalidOtp }),
+ )
+ }
+ const results = (await Promise.all(verifyPromises)).map((resolve) =>
+ pick(resolve, ['status', 'body']),
+ )
+ // Should be all invalid OTP responses.
+ expect(results).toEqual(
+ Array(AuthService.MAX_OTP_ATTEMPTS).fill({
+ status: 422,
+ body: 'OTP is invalid. Please try again.',
+ }),
+ )
+
+ // Act again, this time with a valid OTP.
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: MOCK_VALID_OTP })
+
+ // Assert
+ // Should still reject with max OTP attempts error.
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual(
+ 'You have hit the max number of attempts. Please request for a new OTP.',
+ )
+ })
+
+ it('should return 200 with user object when body.otp is a valid OTP', async () => {
+ // Arrange
+ // Request for OTP so the hash exists.
+ await requestForOtp(VALID_EMAIL)
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: MOCK_VALID_OTP })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Body should be an user object.
+ expect(response.body).toMatchObject({
+ // Required since that's how the data is sent out from the application.
+ agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ _id: expect.any(String),
+ created: expect.any(String),
+ email: VALID_EMAIL,
+ })
+ // Should have session cookie returned.
+ const sessionCookie = request.cookies.find(
+ (cookie) => cookie.name === 'connect.sid',
+ )
+ expect(sessionCookie).toBeDefined()
+ })
+
+ it('should return 500 when upserting user document fails', async () => {
+ // Arrange
+ // Request for OTP so the hash exists.
+ await requestForOtp(VALID_EMAIL)
+
+ // Mock error returned when creating user
+ const upsertSpy = jest
+ .spyOn(UserService, 'retrieveUser')
+ .mockReturnValueOnce(errAsync(new DatabaseError('some error')))
+
+ // Act
+ const response = await request
+ .post('/auth/otp/verify')
+ .send({ email: VALID_EMAIL, otp: MOCK_VALID_OTP })
+
+ // Assert
+ // Should have reached this spy.
+ expect(upsertSpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual(
+ expect.stringContaining('Failed to process OTP.'),
+ )
+ })
+ })
+
+ describe('GET /auth/logout', () => {
+ const MOCK_VALID_OTP = '123456'
+ const VALID_DOMAIN = 'example.com'
+ const VALID_EMAIL = `test@${VALID_DOMAIN}`
+
+ beforeEach(async () => {
+ await dbHandler.insertAgency({
+ mailDomain: VALID_DOMAIN,
+ })
+ jest.spyOn(OtpUtils, 'generateOtp').mockReturnValue(MOCK_VALID_OTP)
+ })
+
+ it('should return 200 and clear cookies when signout is successful', async () => {
+ // Act
+ // Sign in user
+ await signInUser(VALID_EMAIL, MOCK_VALID_OTP)
+
+ // Arrange
+ const response = await request.get('/auth/logout')
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({ message: 'Sign out successful' })
+ // connect.sid should now be empty.
+ expect(response.header['set-cookie'][0]).toEqual(
+ expect.stringContaining('connect.sid=;'),
+ )
+ })
+
+ it('should return 200 even when user has not signed in before', async () => {
+ // Note that no log in calls have been made with request yet.
+ // Act
+ const response = await request.get('/auth/logout')
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({ message: 'Sign out successful' })
+ })
+ })
+
+ // Helper functions
+ const requestForOtp = async (email: string) => {
+ // Set that so no real mail is sent.
+ jest.spyOn(MailService, 'sendLoginOtp').mockReturnValue(okAsync(true))
+
+ const response = await request.post('/auth/otp/generate').send({ email })
+ expect(response.body).toEqual(`OTP sent to ${email}`)
+ }
+
+ const signInUser = async (email: string, otp: string) => {
+ await requestForOtp(email)
+ const response = await request.post('/auth/otp/verify').send({ email, otp })
+
+ // Assert
+ // Should have session cookie returned.
+ const sessionCookie = request.cookies.find(
+ (cookie) => cookie.name === 'connect.sid',
+ )
+ expect(sessionCookie).toBeDefined()
+ return response.body
+ }
+})
diff --git a/src/app/routes/api/v3/auth/auth.routes.ts b/src/app/routes/api/v3/auth/auth.routes.ts
new file mode 100644
index 0000000000..b931d9d6fb
--- /dev/null
+++ b/src/app/routes/api/v3/auth/auth.routes.ts
@@ -0,0 +1,95 @@
+import { celebrate, Joi, Segments } from 'celebrate'
+import { Router } from 'express'
+
+import { rateLimitConfig } from '../../../../../config/config'
+import * as AuthController from '../../../../modules/auth/auth.controller'
+import { limitRate } from '../../../../utils/limit-rate'
+
+export const AuthRouter = Router()
+/**
+ * Check if email domain is a valid agency
+ * @route POST /auth/email/validate
+ * @group admin
+ * @param body.email the user's email to validate domain for
+ * @return 200 when email domain is valid
+ * @return 401 when email domain is invalid
+ */
+AuthRouter.post(
+ '/email/validate',
+ celebrate({
+ [Segments.BODY]: Joi.object().keys({
+ email: Joi.string()
+ .required()
+ .email()
+ .message('Please enter a valid email'),
+ }),
+ }),
+ AuthController.handleCheckUser,
+)
+
+/**
+ * Send a one-time password (OTP) to the specified email address
+ * as part of the login procedure.
+ * @route POST /auth/otp/generate
+ * @group admin
+ * @param body.email the user's email to validate domain for
+ * @produces application/json
+ * @consumes application/json
+ * @return 200 when OTP has been been successfully sent
+ * @return 401 when email domain is invalid
+ * @return 500 when FormSG was unable to generate the OTP, or create/send the email that delivers the OTP to the user's email address
+ */
+AuthRouter.post(
+ '/otp/generate',
+ limitRate({ max: rateLimitConfig.sendAuthOtp }),
+ celebrate({
+ [Segments.BODY]: Joi.object().keys({
+ email: Joi.string()
+ .required()
+ .email()
+ .message('Please enter a valid email'),
+ }),
+ }),
+ AuthController.handleLoginSendOtp,
+)
+
+/**
+ * Verify the one-time password (OTP) for the specified email address
+ * as part of the login procedure.
+ * @route POST /auth/otp/verify
+ * @group admin
+ * @param body.email the user's email
+ * @param body.otp the otp to verify
+ * @headers 200.set-cookie contains the session cookie upon login
+ * @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
+ */
+AuthRouter.post(
+ '/otp/verify',
+ celebrate({
+ [Segments.BODY]: Joi.object().keys({
+ email: Joi.string()
+ .required()
+ .email()
+ .message('Please enter a valid email'),
+ otp: Joi.string()
+ .required()
+ .regex(/^\d{6}$/)
+ .message('Please enter a valid otp'),
+ }),
+ }),
+ AuthController.handleLoginVerifyOtp,
+)
+
+/**
+ * Sign the user out of the session by clearing the relevant session cookie
+ * @route GET /auth/logout
+ * @group admin
+ * @headers 200.clear-cookie clears cookie upon signout
+ * @returns 200 when user has signed out successfully
+ * @returns 400 when the request does not contain a session
+ * @returns 500 when the session fails to be destroyed
+ */
+AuthRouter.get('/logout', AuthController.handleSignout)
diff --git a/src/app/routes/api/v3/auth/index.ts b/src/app/routes/api/v3/auth/index.ts
new file mode 100644
index 0000000000..b035f15671
--- /dev/null
+++ b/src/app/routes/api/v3/auth/index.ts
@@ -0,0 +1 @@
+export { AuthRouter } from './auth.routes'
diff --git a/src/app/routes/api/v3/v3.routes.ts b/src/app/routes/api/v3/v3.routes.ts
index 8bd36bdd9e..e4a2061fe9 100644
--- a/src/app/routes/api/v3/v3.routes.ts
+++ b/src/app/routes/api/v3/v3.routes.ts
@@ -1,7 +1,9 @@
import { Router } from 'express'
import { AdminRouter } from './admin'
+import { AuthRouter } from './auth'
export const V3Router = Router()
V3Router.use('/admin', AdminRouter)
+V3Router.use('/auth', AuthRouter)
diff --git a/src/public/modules/users/services/auth.client.service.js b/src/public/modules/users/services/auth.client.service.js
index 9be1957080..78e7703c46 100644
--- a/src/public/modules/users/services/auth.client.service.js
+++ b/src/public/modules/users/services/auth.client.service.js
@@ -69,7 +69,7 @@ function Auth($q, $http, $state, $window) {
function checkUser(credentials) {
let deferred = $q.defer()
- $http.post('/auth/checkuser', credentials).then(
+ $http.post('/api/v3/auth/email/validate', credentials).then(
function (response) {
deferred.resolve(response.data)
},
@@ -82,7 +82,7 @@ function Auth($q, $http, $state, $window) {
function sendOtp(credentials) {
let deferred = $q.defer()
- $http.post('/auth/sendotp', credentials).then(
+ $http.post('/api/v3/auth/otp/generate', credentials).then(
function (response) {
deferred.resolve(response.data)
},
@@ -95,7 +95,7 @@ function Auth($q, $http, $state, $window) {
function verifyOtp(credentials) {
let deferred = $q.defer()
- $http.post('/auth/verifyotp', credentials).then(
+ $http.post('/api/v3/auth/otp/verify', credentials).then(
function (response) {
setUser(response.data)
deferred.resolve()
@@ -108,7 +108,7 @@ function Auth($q, $http, $state, $window) {
}
function signOut() {
- $http.get('/auth/signout').then(
+ $http.get('/api/v3/auth/logout').then(
function () {
$window.localStorage.removeItem('user')
// Clear contact banner on logout
From 6d276bfcebacc51f617fab2224469251654b4bad Mon Sep 17 00:00:00 2001
From: Snyk bot
Date: Tue, 6 Apr 2021 06:20:16 +0300
Subject: [PATCH 10/75] [Snyk] Security upgrade mongoose from 5.11.10 to 5.12.3
(#1538)
* fix: package.json & package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-MQUERY-1089718
* refactor(mongoose): rework type defs
* refactor(models): rework form.server.model for mongoose
- recast FormSchema as FormDocumentSchema so that we can
register methods specific to the handling of IFormDocument
- declare Schema.Types.DocumentArrayWithLooseDiscriminator
so that we can more easily register our sub-schema
TODO - submit a patch to mongoose to loosen the type def for
`DocumentArray.discriminator()`
Co-authored-by: LoneRifle
---
package-lock.json | 28 ++++++++--------
package.json | 2 +-
.../models/admin_verification.server.model.ts | 5 ++-
src/app/models/form.server.model.ts | 28 ++++++++--------
.../form_statistics_total.server.model.ts | 5 ++-
src/app/models/login.server.model.ts | 6 ++--
src/app/models/submission.server.model.ts | 13 +++++---
src/app/models/token.server.model.ts | 2 +-
src/app/models/user.server.model.ts | 5 +--
src/app/modules/bounce/bounce.model.ts | 2 +-
src/app/modules/myinfo/myinfo_hash.model.ts | 2 +-
.../modules/submission/submission.service.ts | 4 +--
.../verification/verification.model.ts | 5 ++-
.../services/sms/sms_count.server.model.ts | 2 +-
src/types/vendor/mongoose.d.ts | 33 +++++++++++++++++++
15 files changed, 95 insertions(+), 47 deletions(-)
create mode 100644 src/types/vendor/mongoose.d.ts
diff --git a/package-lock.json b/package-lock.json
index ce33163399..d5ea0aacb1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17433,17 +17433,17 @@
"integrity": "sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE="
},
"mongoose": {
- "version": "5.11.10",
- "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.10.tgz",
- "integrity": "sha512-daE2L6VW7WNywv7tL2KUkBViWvODbzr50Of1kJpIbzW3w3N5/TYcgSmhCsEDWfYGQXbun2rdd7+sOdsEC8zQSQ==",
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.12.3.tgz",
+ "integrity": "sha512-frsSR9yeldaRpSUeTegXCSB0Tu5UGq8sHuHBuEV31Jk3COyxlKFQPL7UsdMhxPUCmk74FpOYSmNwxhWBEqgzQg==",
"requires": {
"@types/mongodb": "^3.5.27",
"bson": "^1.1.4",
"kareem": "2.3.2",
- "mongodb": "3.6.3",
+ "mongodb": "3.6.5",
"mongoose-legacy-pluralize": "1.0.2",
"mpath": "0.8.3",
- "mquery": "3.2.3",
+ "mquery": "3.2.5",
"ms": "2.1.2",
"regexp-clone": "1.0.0",
"safe-buffer": "5.2.1",
@@ -17452,14 +17452,14 @@
},
"dependencies": {
"bson": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz",
- "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg=="
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
+ "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg=="
},
"mongodb": {
- "version": "3.6.3",
- "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.3.tgz",
- "integrity": "sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w==",
+ "version": "3.6.5",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.5.tgz",
+ "integrity": "sha512-mQlYKw1iGbvJJejcPuyTaytq0xxlYbIoVDm2FODR+OHxyEiMR021vc32bTvamgBjCswsD54XIRwhg3yBaWqJjg==",
"requires": {
"bl": "^2.2.1",
"bson": "^1.1.4",
@@ -17543,9 +17543,9 @@
"integrity": "sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA=="
},
"mquery": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.3.tgz",
- "integrity": "sha512-cIfbP4TyMYX+SkaQ2MntD+F2XbqaBHUYWk3j+kqdDztPWok3tgyssOZxMHMtzbV1w9DaSlvEea0Iocuro41A4g==",
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.5.tgz",
+ "integrity": "sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==",
"requires": {
"bluebird": "3.5.1",
"debug": "3.1.0",
diff --git a/package.json b/package.json
index 73be391b0f..2b861f686d 100644
--- a/package.json
+++ b/package.json
@@ -125,7 +125,7 @@
"lodash": "^4.17.21",
"moment-timezone": "0.5.33",
"mongodb-uri": "^0.9.7",
- "mongoose": "^5.11.10",
+ "mongoose": "^5.12.3",
"multiparty": ">=4.2.2",
"neverthrow": "^4.2.1",
"ng-infinite-scroll": "^1.3.0",
diff --git a/src/app/models/admin_verification.server.model.ts b/src/app/models/admin_verification.server.model.ts
index cbce56da2c..3f5d675999 100644
--- a/src/app/models/admin_verification.server.model.ts
+++ b/src/app/models/admin_verification.server.model.ts
@@ -12,7 +12,10 @@ import { USER_SCHEMA_ID } from './user.server.model'
export const ADMIN_VERIFICATION_SCHEMA_ID = 'AdminVerification'
-const AdminVerificationSchema = new Schema(
+const AdminVerificationSchema = new Schema<
+ IAdminVerificationSchema,
+ IAdminVerificationModel
+>(
{
admin: {
type: Schema.Types.ObjectId,
diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts
index aa8b879aed..460cb5765b 100644
--- a/src/app/models/form.server.model.ts
+++ b/src/app/models/form.server.model.ts
@@ -131,7 +131,7 @@ const EncryptedFormSchema = new Schema({
},
})
-const EmailFormSchema = new Schema({
+const EmailFormSchema = new Schema({
emails: {
type: [
{
@@ -159,7 +159,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
const User = getUserModel(db)
// Schema
- const FormSchema = new Schema(
+ const FormSchema = new Schema(
{
title: {
type: String,
@@ -342,7 +342,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
// Add discriminators for the various field types.
const FormFieldPath = FormSchema.path(
'form_fields',
- ) as Schema.Types.DocumentArray
+ ) as Schema.Types.DocumentArrayWithLooseDiscriminator
const TableFieldSchema = createTableFieldSchema()
@@ -376,7 +376,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
FormFieldPath.discriminator(BasicField.Table, TableFieldSchema)
const TableColumnPath = TableFieldSchema.path(
'columns',
- ) as Schema.Types.DocumentArray
+ ) as Schema.Types.DocumentArrayWithLooseDiscriminator
TableColumnPath.discriminator(
BasicField.ShortText,
createShortTextFieldSchema(),
@@ -389,13 +389,13 @@ const compileFormModel = (db: Mongoose): IFormModel => {
// Discriminator defines all possible values of startPage.logo
const StartPageLogoPath = FormSchema.path(
'startPage.logo',
- ) as Schema.Types.DocumentArray
+ ) as Schema.Types.DocumentArrayWithLooseDiscriminator
StartPageLogoPath.discriminator(FormLogoState.Custom, CustomFormLogoSchema)
// Discriminator defines different logic types
const FormLogicPath = FormSchema.path(
'form_logics',
- ) as Schema.Types.DocumentArray
+ ) as Schema.Types.DocumentArrayWithLooseDiscriminator
FormLogicPath.discriminator(LogicType.ShowFields, ShowFieldsLogicSchema)
FormLogicPath.discriminator(LogicType.PreventSubmit, PreventSubmitLogicSchema)
@@ -458,12 +458,6 @@ const compileFormModel = (db: Mongoose): IFormModel => {
}
}
- FormSchema.methods.getSettings = function (
- this: IFormDocument,
- ): FormSettings {
- return pick(this, FORM_SETTING_FIELDS)
- }
-
// Archives form.
FormSchema.methods.archive = function (this: IFormSchema) {
// Return instantly when form is already archived.
@@ -475,8 +469,16 @@ const compileFormModel = (db: Mongoose): IFormModel => {
return this.save()
}
+ const FormDocumentSchema = (FormSchema as unknown) as Schema
+
+ FormDocumentSchema.methods.getSettings = function (
+ this: IFormDocument,
+ ): FormSettings {
+ return pick(this, FORM_SETTING_FIELDS)
+ }
+
// Transfer ownership of the form to another user
- FormSchema.methods.transferOwner = async function (
+ FormDocumentSchema.methods.transferOwner = async function (
this: IFormDocument,
currentOwner: IUserSchema,
newOwner: IUserSchema,
diff --git a/src/app/models/form_statistics_total.server.model.ts b/src/app/models/form_statistics_total.server.model.ts
index 573c61effb..212eaec6b7 100644
--- a/src/app/models/form_statistics_total.server.model.ts
+++ b/src/app/models/form_statistics_total.server.model.ts
@@ -12,7 +12,10 @@ const FORM_STATS_TOTAL_SCHEMA_ID = 'FormStatisticsTotal'
const FORM_STATS_COLLECTION_NAME = 'formStatisticsTotal'
const compileFormStatisticsTotalModel = (db: Mongoose) => {
- const FormStatisticsTotalSchema = new Schema(
+ const FormStatisticsTotalSchema = new Schema<
+ IFormStatisticsTotalSchema,
+ IFormStatisticsTotalModel
+ >(
{
formId: {
type: Schema.Types.ObjectId,
diff --git a/src/app/models/login.server.model.ts b/src/app/models/login.server.model.ts
index c0a2b1f8b5..b39a4b812b 100644
--- a/src/app/models/login.server.model.ts
+++ b/src/app/models/login.server.model.ts
@@ -14,7 +14,7 @@ import { USER_SCHEMA_ID } from './user.server.model'
export const LOGIN_SCHEMA_ID = 'Login'
-const LoginSchema = new Schema(
+const LoginSchema = new Schema(
{
admin: {
type: Schema.Types.ObjectId,
@@ -133,7 +133,7 @@ LoginSchema.statics.aggregateLoginStats = function (
}
const compileLoginModel = (db: Mongoose) =>
- db.model(LOGIN_SCHEMA_ID, LoginSchema) as ILoginModel
+ db.model(LOGIN_SCHEMA_ID, LoginSchema)
/**
* Retrieves the Login model on the given Mongoose instance. If the model is
@@ -143,7 +143,7 @@ const compileLoginModel = (db: Mongoose) =>
*/
const getLoginModel = (db: Mongoose): ILoginModel => {
try {
- return db.model(LOGIN_SCHEMA_ID) as ILoginModel
+ return db.model(LOGIN_SCHEMA_ID)
} catch {
return compileLoginModel(db)
}
diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts
index f27c02e9fb..0bf1b1b2d9 100644
--- a/src/app/models/submission.server.model.ts
+++ b/src/app/models/submission.server.model.ts
@@ -25,7 +25,7 @@ import { FORM_SCHEMA_ID } from './form.server.model'
export const SUBMISSION_SCHEMA_ID = 'Submission'
-const SubmissionSchema = new Schema(
+const SubmissionSchema = new Schema(
{
form: {
type: Schema.Types.ObjectId,
@@ -143,7 +143,10 @@ const webhookResponseSchema = new Schema(
},
)
-const EncryptSubmissionSchema = new Schema({
+const EncryptSubmissionSchema = new Schema<
+ IEncryptedSubmissionSchema,
+ IEncryptSubmissionModel
+>({
encryptedContent: {
type: String,
trim: true,
@@ -347,15 +350,15 @@ const compileSubmissionModel = (db: Mongoose): ISubmissionModel => {
const Submission = db.model('Submission', SubmissionSchema)
Submission.discriminator(SubmissionType.Email, EmailSubmissionSchema)
Submission.discriminator(SubmissionType.Encrypt, EncryptSubmissionSchema)
- return db.model(
+ return db.model(
SUBMISSION_SCHEMA_ID,
SubmissionSchema,
- ) as ISubmissionModel
+ )
}
const getSubmissionModel = (db: Mongoose): ISubmissionModel => {
try {
- return db.model(SUBMISSION_SCHEMA_ID) as ISubmissionModel
+ return db.model(SUBMISSION_SCHEMA_ID)
} catch {
return compileSubmissionModel(db)
}
diff --git a/src/app/models/token.server.model.ts b/src/app/models/token.server.model.ts
index f4d2538956..c07699a2b5 100644
--- a/src/app/models/token.server.model.ts
+++ b/src/app/models/token.server.model.ts
@@ -4,7 +4,7 @@ import { IToken, ITokenModel, ITokenSchema } from '../../types'
export const TOKEN_SCHEMA_ID = 'Token'
-const TokenSchema = new Schema({
+const TokenSchema = new Schema({
email: {
type: String,
required: true,
diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts
index f0fbfc20eb..84c334b4e7 100644
--- a/src/app/models/user.server.model.ts
+++ b/src/app/models/user.server.model.ts
@@ -18,7 +18,7 @@ export const USER_SCHEMA_ID = 'User'
const compileUserModel = (db: Mongoose) => {
const Agency = getAgencyModel(db)
- const UserSchema: Schema = new Schema(
+ const UserSchema: Schema = new Schema(
{
email: {
type: String,
@@ -59,7 +59,8 @@ const compileUserModel = (db: Mongoose) => {
if (!phoneNumber) return false
return phoneNumber.isValid()
},
- message: (props) => `${props.value} is not a valid mobile number`,
+ message: (props: { value: string }) =>
+ `${props.value} is not a valid mobile number`,
},
},
lastAccessed: Date,
diff --git a/src/app/modules/bounce/bounce.model.ts b/src/app/modules/bounce/bounce.model.ts
index 8f1d9934d0..de7d7f4d47 100644
--- a/src/app/modules/bounce/bounce.model.ts
+++ b/src/app/modules/bounce/bounce.model.ts
@@ -23,7 +23,7 @@ export interface IBounceModel extends Model {
) => IBounceSchema
}
-const BounceSchema = new Schema({
+const BounceSchema = new Schema({
formId: {
type: Schema.Types.ObjectId,
ref: FORM_SCHEMA_ID,
diff --git a/src/app/modules/myinfo/myinfo_hash.model.ts b/src/app/modules/myinfo/myinfo_hash.model.ts
index bf53a7e406..03f95905f9 100644
--- a/src/app/modules/myinfo/myinfo_hash.model.ts
+++ b/src/app/modules/myinfo/myinfo_hash.model.ts
@@ -7,7 +7,7 @@ import { FORM_SCHEMA_ID } from '../../models/form.server.model'
export const MYINFO_HASH_SCHEMA_ID = 'MyInfoHash'
-const MyInfoHashSchema = new Schema(
+const MyInfoHashSchema = new Schema(
{
// We stored a hashed uinFin using a salt stored as a env var
// Note: key name not updated to reflect this for backward compatibility purposes
diff --git a/src/app/modules/submission/submission.service.ts b/src/app/modules/submission/submission.service.ts
index 45f7ce5beb..50619491c7 100644
--- a/src/app/modules/submission/submission.service.ts
+++ b/src/app/modules/submission/submission.service.ts
@@ -227,7 +227,7 @@ export const getFormSubmissionsCount = (
* @returns ok(true) if all emails were sent successfully
* @returns err(SendEmailConfirmationError) if any email failed to be sent
*/
-export const sendEmailConfirmations = ({
+export const sendEmailConfirmations = ({
form,
submission,
parsedResponses,
@@ -235,7 +235,7 @@ export const sendEmailConfirmations = ({
attachments,
}: {
form: IPopulatedForm
- submission: ISubmissionSchema
+ submission: S
parsedResponses: ProcessedFieldResponse[]
autoReplyData?: EmailRespondentConfirmationField[]
attachments?: IAttachmentInfo[]
diff --git a/src/app/modules/verification/verification.model.ts b/src/app/modules/verification/verification.model.ts
index e1be83a247..53d96c3d82 100644
--- a/src/app/modules/verification/verification.model.ts
+++ b/src/app/modules/verification/verification.model.ts
@@ -33,7 +33,10 @@ const VerificationFieldSchema = new Schema({
})
const compileVerificationModel = (db: Mongoose): IVerificationModel => {
- const VerificationSchema = new Schema({
+ const VerificationSchema = new Schema<
+ IVerificationSchema,
+ IVerificationModel
+ >({
formId: {
type: Schema.Types.ObjectId,
ref: FORM_SCHEMA_ID,
diff --git a/src/app/services/sms/sms_count.server.model.ts b/src/app/services/sms/sms_count.server.model.ts
index ea27658a2f..e7638e2ac8 100644
--- a/src/app/services/sms/sms_count.server.model.ts
+++ b/src/app/services/sms/sms_count.server.model.ts
@@ -83,7 +83,7 @@ const BouncedSubmissionSmsCountSchema = new Schema {
- const SmsCountSchema = new Schema(
+ const SmsCountSchema = new Schema(
{
msgSrvcSid: {
type: String,
diff --git a/src/types/vendor/mongoose.d.ts b/src/types/vendor/mongoose.d.ts
new file mode 100644
index 0000000000..917356050f
--- /dev/null
+++ b/src/types/vendor/mongoose.d.ts
@@ -0,0 +1,33 @@
+/**
+ * Additional type declarations for mongoose to fit our use case,
+ * to accommodate the non-standard but compatible use of types
+ * in schema registration
+ */
+declare module 'mongoose' {
+ namespace Schema {
+ namespace Types {
+ /**
+ * A DocumentArray with a discriminator function that takes in a
+ * type-generic Schema.
+ */
+ class DocumentArrayWithLooseDiscriminator extends DocumentArray {
+ /**
+ * In the built-in type declarations in
+ * version 5.12 of mongoose, discriminator() expects a Schema
+ * (and hence Schema). This does not work; a Schema
+ * for a subtype of Document is not a Schema for a Document, as
+ * Schema.methods expect to operate on the Schema's document type,
+ * which is not necessarily a Document.
+ *
+ * Address this by overriding the definition and provide a type-generic
+ * Schema argument.
+ */
+ discriminator(
+ name: string,
+ schema: Schema,
+ tag?: string,
+ ): unknown
+ }
+ }
+ }
+}
From a9dd46fb5f755f682632419a882c636c33038bf6 Mon Sep 17 00:00:00 2001
From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com>
Date: Tue, 6 Apr 2021 15:42:50 +0800
Subject: [PATCH 11/75] refactor(webhook-services): migrate webhook services to
neverthrow (#1529)
* ref: narrow types for getWebhookView
* ref: move hasProp to utils
* ref: implement service using neverthrow
* ref: implement webhook factory
* ref: delete old middleware factory
* ref: implement new middleware
* ref: use new factory function in controller
* refactor(webhook-service): add tests for webhook services
- moved webhook url validation to new file and updated references
- updated tests for webhook validation
ref #193
* fix(build): removed duplicate type for possible-database-error
* fix(webhook-service): use relative imports
* fix(webhook-factory): fix feature-guard conditional
* test(webhook-service) update webhook tests
* refactor(tests): clean up unit and model tests
* refactor(tests): do further cleaning of tests
* refactor(tests): add missing calledWith checks for mocks
* refactor(submission-model): clean up queries and webhook tests
Co-authored-by: Antariksh
---
.../webhook-verified-content.factory.js | 18 -
src/app/models/form.server.model.ts | 2 +-
src/app/models/submission.server.model.ts | 13 +
src/app/modules/myinfo/myinfo.util.ts | 15 +-
.../encrypt-submission.controller.ts | 14 +-
.../webhook/__tests__/webhook.service.spec.ts | 422 ++++++++++++++++++
.../__tests__/webhook.validation.spec.ts | 13 +-
src/app/modules/webhook/webhook.controller.ts | 33 --
src/app/modules/webhook/webhook.errors.ts | 32 +-
src/app/modules/webhook/webhook.factory.ts | 29 ++
src/app/modules/webhook/webhook.service.ts | 410 ++++++-----------
src/app/modules/webhook/webhook.types.ts | 9 +-
src/app/modules/webhook/webhook.utils.ts | 71 +--
src/app/modules/webhook/webhook.validation.ts | 62 +++
src/app/utils/has-prop.ts | 13 +
src/shared/util/stringify-safe.ts | 2 +-
src/types/submission.ts | 18 +-
.../models/submission.server.model.spec.ts | 87 +++-
.../modules/webhook/webhook.service.spec.ts | 236 ----------
19 files changed, 848 insertions(+), 651 deletions(-)
delete mode 100644 src/app/factories/webhook-verified-content.factory.js
create mode 100644 src/app/modules/webhook/__tests__/webhook.service.spec.ts
rename tests/unit/backend/modules/webhook/webhook.utils.spec.ts => src/app/modules/webhook/__tests__/webhook.validation.spec.ts (83%)
delete mode 100644 src/app/modules/webhook/webhook.controller.ts
create mode 100644 src/app/modules/webhook/webhook.factory.ts
create mode 100644 src/app/modules/webhook/webhook.validation.ts
create mode 100644 src/app/utils/has-prop.ts
delete mode 100644 tests/unit/backend/modules/webhook/webhook.service.spec.ts
diff --git a/src/app/factories/webhook-verified-content.factory.js b/src/app/factories/webhook-verified-content.factory.js
deleted file mode 100644
index 924a5a7e70..0000000000
--- a/src/app/factories/webhook-verified-content.factory.js
+++ /dev/null
@@ -1,18 +0,0 @@
-const webhook = require('../modules/webhook/webhook.controller')
-const featureManager = require('../../config/feature-manager').default
-
-const webhookVerifiedContentFactory = ({ isEnabled, props }) => {
- if (isEnabled && props) {
- return {
- post: webhook.post,
- }
- } else {
- return {
- post: (req, res, next) => next(),
- }
- }
-}
-
-module.exports = webhookVerifiedContentFactory(
- featureManager.get('webhook-verified-content'),
-)
diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts
index 460cb5765b..0638eacbc3 100644
--- a/src/app/models/form.server.model.ts
+++ b/src/app/models/form.server.model.ts
@@ -31,7 +31,7 @@ import { IPopulatedUser, IUserSchema } from '../../types/user'
import { MB } from '../constants/filesize'
import { OverrideProps } from '../modules/form/admin-form/admin-form.types'
import { transformEmails } from '../modules/form/form.utils'
-import { validateWebhookUrl } from '../modules/webhook/webhook.utils'
+import { validateWebhookUrl } from '../modules/webhook/webhook.validation'
import getAgencyModel from './agency.server.model'
import {
diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts
index 0bf1b1b2d9..b8314c5d51 100644
--- a/src/app/models/submission.server.model.ts
+++ b/src/app/models/submission.server.model.ts
@@ -11,6 +11,7 @@ import {
IEncryptSubmissionModel,
ISubmissionModel,
ISubmissionSchema,
+ IWebhookResponse,
IWebhookResponseSchema,
MyInfoAttribute,
SubmissionCursorData,
@@ -188,6 +189,18 @@ EncryptSubmissionSchema.methods.getWebhookView = function (
}
}
+EncryptSubmissionSchema.statics.addWebhookResponse = function (
+ this: IEncryptSubmissionModel,
+ submissionId: string,
+ webhookResponse: IWebhookResponse,
+): Promise {
+ return this.findByIdAndUpdate(
+ submissionId,
+ { $push: { webhookResponses: webhookResponse } },
+ { new: true, setDefaultsOnInsert: true, runValidators: true },
+ ).exec()
+}
+
EncryptSubmissionSchema.statics.findSingleMetadata = function (
this: IEncryptSubmissionModel,
formId: string,
diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts
index cfc4c88814..a77db98b0c 100644
--- a/src/app/modules/myinfo/myinfo.util.ts
+++ b/src/app/modules/myinfo/myinfo.util.ts
@@ -16,6 +16,7 @@ import {
IPopulatedForm,
MapRouteError,
} from '../../../types'
+import { hasProp } from '../../utils/has-prop'
import { DatabaseError, MissingFeatureError } from '../core/core.errors'
import { FormNotFoundError } from '../form/form.errors'
import {
@@ -303,20 +304,6 @@ export const validateMyInfoForm = (
return ok(form as IMyInfoForm)
}
-/**
- * Utility to narrow type of an object by determining whether
- * it contains the given property.
- * @param obj Object
- * @param prop Property to check
- */
-export const hasProp = (
- // eslint-disable-next-line @typescript-eslint/ban-types
- obj: object | Record,
- prop: K,
-): obj is Record => {
- return prop in obj
-}
-
/**
* Type guard for MyInfo cookie.
* @param cookie Unknown object
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
index f0c2c79729..ff72ee44a6 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
@@ -31,7 +31,7 @@ import { mapVerifyMyInfoError } from '../../myinfo/myinfo.util'
import { SpcpFactory } from '../../spcp/spcp.factory'
import { getPopulatedUserById } from '../../user/user.service'
import { VerifiedContentFactory } from '../../verified-content/verified-content.factory'
-import { pushData as webhookPushData } from '../../webhook/webhook.service'
+import { WebhookFactory } from '../../webhook/webhook.factory'
import {
getProcessedResponses,
sendEmailConfirmations,
@@ -365,12 +365,16 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
})
// Fire webhooks if available
+ // Note that we push data to webhook endpoints on a best effort basis
+ // As such, we should not await on these post requests
const webhookUrl = form.webhook?.url
- const submissionWebhookView = submission.getWebhookView()
if (webhookUrl) {
- // Note that we push data to webhook endpoints on a best effort basis
- // As such, we should not await on these post requests
- void webhookPushData(webhookUrl, submissionWebhookView)
+ void WebhookFactory.sendWebhook(
+ submission,
+ webhookUrl,
+ ).andThen((response) =>
+ WebhookFactory.saveWebhookRecord(submission._id, response),
+ )
}
// Send Email Confirmations
diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts
new file mode 100644
index 0000000000..89d592655b
--- /dev/null
+++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts
@@ -0,0 +1,422 @@
+import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
+import { ObjectID } from 'bson'
+import { SubmissionNotFoundError } from 'dist/backend/app/modules/submission/submission.errors'
+import mongoose from 'mongoose'
+import { mocked } from 'ts-jest/utils'
+
+import getFormModel from 'src/app/models/form.server.model'
+import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model'
+import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors'
+import * as WebhookValidationModule from 'src/app/modules/webhook/webhook.validation'
+import { transformMongoError } from 'src/app/utils/handle-mongo-error'
+import * as HasPropModule from 'src/app/utils/has-prop'
+import formsgSdk from 'src/config/formsg-sdk'
+import {
+ IEncryptedSubmissionSchema,
+ IWebhookResponse,
+ ResponseMode,
+ WebhookView,
+} from 'src/types'
+
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
+
+import { saveWebhookRecord, sendWebhook } from '../webhook.service'
+
+// define suite-wide mocks
+jest.mock('axios')
+const MockAxios = mocked(axios, true)
+
+jest.mock('src/app/modules/webhook/webhook.validation')
+const MockWebhookValidationModule = mocked(WebhookValidationModule, true)
+
+// define test constants
+const FormModel = getFormModel(mongoose)
+const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
+
+const MOCK_WEBHOOK_URL = 'https://form.gov.sg/endpoint'
+const DEFAULT_ERROR_MSG = 'a generic error has occurred'
+const AXIOS_ERROR_MSG = 'an axios error has occurred'
+
+const MOCK_AXIOS_SUCCESS_RESPONSE: AxiosResponse = {
+ data: {
+ result: 'test-result',
+ },
+ status: 200,
+ statusText: 'success',
+ headers: {},
+ config: {},
+}
+const MOCK_AXIOS_FAILURE_RESPONSE: AxiosResponse = {
+ data: {
+ result: 'test-result',
+ },
+ status: 400,
+ statusText: 'failed',
+ headers: {},
+ config: {},
+}
+const MOCK_WEBHOOK_SUCCESS_RESPONSE: Pick = {
+ response: {
+ data: '{"result":"test-result"}',
+ status: 200,
+ statusText: 'success',
+ headers: '{}',
+ },
+}
+const MOCK_WEBHOOK_FAILURE_RESPONSE: Pick = {
+ response: {
+ data: '{"result":"test-result"}',
+ status: 400,
+ statusText: 'failed',
+ headers: '{}',
+ },
+}
+const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick<
+ IWebhookResponse,
+ 'response'
+> = {
+ response: {
+ data: '',
+ status: 0,
+ statusText: '',
+ headers: '',
+ },
+}
+
+describe('webhook.service', () => {
+ beforeAll(async () => await dbHandler.connect())
+ afterEach(async () => {
+ await dbHandler.clearDatabase()
+ })
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ // test variables
+ let testEncryptedSubmission: IEncryptedSubmissionSchema
+ let testConfig: AxiosRequestConfig
+ let testSubmissionWebhookView: WebhookView | null
+ let testSignature: string
+
+ beforeEach(async () => {
+ jest.restoreAllMocks()
+
+ // prepare for form creation workflow
+ const MOCK_ADMIN_OBJ_ID = new ObjectID()
+ const MOCK_EPOCH = 1487076708000
+ const preloaded = await dbHandler.insertFormCollectionReqs({
+ userId: MOCK_ADMIN_OBJ_ID,
+ })
+
+ jest.spyOn(Date, 'now').mockImplementation(() => MOCK_EPOCH)
+
+ // instantiate new form and save
+ const testEncryptedForm = await FormModel.create({
+ title: 'Test Form',
+ admin: preloaded.user._id,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'fake-public-key',
+ })
+
+ // initialise encrypted submussion
+ testEncryptedSubmission = await EncryptSubmissionModel.create({
+ form: testEncryptedForm._id,
+ authType: testEncryptedForm.authType,
+ myInfoFields: [],
+ encryptedContent: 'encrypted-content',
+ verifiedContent: 'verified-content',
+ version: 1,
+ webhookResponses: [],
+ })
+
+ // initialise webhook related variables
+ testSubmissionWebhookView = testEncryptedSubmission.getWebhookView()
+
+ testSignature = formsgSdk.webhooks.generateSignature({
+ uri: MOCK_WEBHOOK_URL,
+ submissionId: testEncryptedSubmission._id,
+ formId: testEncryptedForm._id,
+ epoch: MOCK_EPOCH,
+ })
+
+ testConfig = {
+ headers: {
+ 'X-FormSG-Signature': `t=${MOCK_EPOCH},s=${testEncryptedSubmission._id},f=${testEncryptedForm._id},v1=${testSignature}`,
+ },
+ maxRedirects: 0,
+ }
+ })
+
+ describe('saveWebhookRecord', () => {
+ it('should return transform mongo error if database update for webhook fails', async () => {
+ // Arrange
+ const mockWebhookResponse = {
+ ...MOCK_WEBHOOK_SUCCESS_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ } as IWebhookResponse
+
+ const mockDBError = new Error(DEFAULT_ERROR_MSG)
+
+ const addWebhookResponseSpy = jest
+ .spyOn(EncryptSubmissionModel, 'addWebhookResponse')
+ .mockRejectedValueOnce(mockDBError)
+
+ // Act
+ const actual = await saveWebhookRecord(
+ testEncryptedSubmission._id,
+ mockWebhookResponse,
+ )
+
+ // Assert
+ const expectedError = transformMongoError(mockDBError)
+
+ expect(addWebhookResponseSpy).toHaveBeenCalledWith(
+ testEncryptedSubmission._id,
+ mockWebhookResponse,
+ )
+ expect(actual._unsafeUnwrapErr()).toEqual(expectedError)
+ })
+
+ it('should return submission not found error if submission id cannot be found in database', async () => {
+ // Arrange
+ const mockWebhookResponse = {
+ ...MOCK_WEBHOOK_SUCCESS_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ } as IWebhookResponse
+
+ // Act
+ const actual = await saveWebhookRecord(
+ new ObjectID(),
+ mockWebhookResponse,
+ )
+
+ // Assert
+ const expectedError = new SubmissionNotFoundError(
+ 'Unable to find submission ID to update webhook response',
+ )
+
+ expect(actual._unsafeUnwrapErr()).toEqual(expectedError)
+ })
+
+ it('should return updated submission with new webhook response if the record is successfully saved', async () => {
+ // Arrange
+ const mockWebhookResponse = {
+ _id: testEncryptedSubmission._id,
+ created: testEncryptedSubmission.created,
+ ...MOCK_WEBHOOK_SUCCESS_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ } as IWebhookResponse
+
+ const expectedSubmission = new EncryptSubmissionModel({
+ ...testEncryptedSubmission,
+ })
+ expectedSubmission.webhookResponses = [mockWebhookResponse]
+
+ const addWebhookResponseSpy = jest
+ .spyOn(EncryptSubmissionModel, 'addWebhookResponse')
+ .mockResolvedValue(expectedSubmission)
+
+ // Act
+ const actual = await saveWebhookRecord(
+ testEncryptedSubmission._id,
+ mockWebhookResponse,
+ )
+
+ // Assert
+ expect(addWebhookResponseSpy).toHaveBeenCalledWith(
+ testEncryptedSubmission._id,
+ mockWebhookResponse,
+ )
+ expect(actual._unsafeUnwrap()).toEqual(expectedSubmission)
+ })
+ })
+
+ describe('sendWebhook', () => {
+ it('should return webhook url validation error if webhook url is not valid', async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockRejectedValueOnce(
+ new WebhookValidationError(DEFAULT_ERROR_MSG),
+ )
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedError = new WebhookValidationError(DEFAULT_ERROR_MSG)
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ expect(actual._unsafeUnwrapErr()).toEqual(expectedError)
+ })
+
+ it('should return default webhook url validation error if webhook url is not valid and validate webhook url returns a non webhook url validation error', async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockRejectedValueOnce(
+ new Error(),
+ )
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedError = new WebhookValidationError(
+ 'Webhook URL is non-HTTPS or points to private IP',
+ )
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ expect(actual._unsafeUnwrapErr()).toEqual(expectedError)
+ })
+
+ it('should resolve with webhook failed with axios error message if axios post fails due to an axios error', async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockResolvedValueOnce()
+
+ const MOCK_AXIOS_ERROR: AxiosError = {
+ name: '',
+ message: AXIOS_ERROR_MSG,
+ config: {},
+ code: '',
+ response: MOCK_AXIOS_FAILURE_RESPONSE,
+ isAxiosError: true,
+ toJSON: () => jest.fn(),
+ }
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ MockAxios.post.mockRejectedValue(MOCK_AXIOS_ERROR)
+ MockAxios.isAxiosError.mockReturnValue(true)
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedResult = {
+ errorMessage: AXIOS_ERROR_MSG,
+ ...MOCK_WEBHOOK_FAILURE_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ }
+
+ expect(MockAxios.post).toHaveBeenCalledWith(
+ MOCK_WEBHOOK_URL,
+ testSubmissionWebhookView,
+ testConfig,
+ )
+ expect(actual._unsafeUnwrap()).toEqual(expectedResult)
+ })
+
+ it("should resolve with unknown error's error message and default response format if axios post fails due to an unknown error", async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockResolvedValueOnce()
+
+ MockAxios.post.mockRejectedValue(new Error(DEFAULT_ERROR_MSG))
+ MockAxios.isAxiosError.mockReturnValue(false)
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedResult = {
+ errorMessage: DEFAULT_ERROR_MSG,
+ ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ }
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ expect(MockAxios.post).toHaveBeenCalledWith(
+ MOCK_WEBHOOK_URL,
+ testSubmissionWebhookView,
+ testConfig,
+ )
+ expect(actual._unsafeUnwrap()).toEqual(expectedResult)
+ })
+
+ it('should resolve with an empty error message and default response format if axios post fails due to an unknown error which has no message', async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockResolvedValueOnce()
+
+ const mockOriginalError = new Error(DEFAULT_ERROR_MSG)
+
+ MockAxios.post.mockRejectedValue(mockOriginalError)
+ MockAxios.isAxiosError.mockReturnValue(false)
+ const hasPropSpy = jest
+ .spyOn(HasPropModule, 'hasProp')
+ .mockReturnValueOnce(false)
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedResult = {
+ errorMessage: '',
+ ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ }
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ expect(hasPropSpy).toHaveBeenCalledWith(mockOriginalError, 'message')
+ expect(MockAxios.post).toHaveBeenCalledWith(
+ MOCK_WEBHOOK_URL,
+ testSubmissionWebhookView,
+ testConfig,
+ )
+ expect(actual._unsafeUnwrap()).toEqual(expectedResult)
+ })
+
+ it('should resolve without error message if axios post succeeds', async () => {
+ // Arrange
+ MockWebhookValidationModule.validateWebhookUrl.mockResolvedValueOnce()
+
+ MockAxios.post.mockResolvedValue(MOCK_AXIOS_SUCCESS_RESPONSE)
+
+ // Act
+ const actual = await sendWebhook(
+ testEncryptedSubmission,
+ MOCK_WEBHOOK_URL,
+ )
+
+ // Assert
+ const expectedResult = {
+ ...MOCK_WEBHOOK_SUCCESS_RESPONSE,
+ signature: testSignature,
+ webhookUrl: MOCK_WEBHOOK_URL,
+ }
+
+ expect(
+ MockWebhookValidationModule.validateWebhookUrl,
+ ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL)
+ expect(MockAxios.post).toHaveBeenCalledWith(
+ MOCK_WEBHOOK_URL,
+ testSubmissionWebhookView,
+ testConfig,
+ )
+ expect(actual._unsafeUnwrap()).toEqual(expectedResult)
+ })
+ })
+})
diff --git a/tests/unit/backend/modules/webhook/webhook.utils.spec.ts b/src/app/modules/webhook/__tests__/webhook.validation.spec.ts
similarity index 83%
rename from tests/unit/backend/modules/webhook/webhook.utils.spec.ts
rename to src/app/modules/webhook/__tests__/webhook.validation.spec.ts
index f93cbbe283..eec2425228 100644
--- a/tests/unit/backend/modules/webhook/webhook.utils.spec.ts
+++ b/src/app/modules/webhook/__tests__/webhook.validation.spec.ts
@@ -2,7 +2,7 @@ import { promises as dns } from 'dns'
import { mocked } from 'ts-jest/utils'
import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors'
-import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.utils'
+import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.validation'
import config from 'src/config/config'
jest.mock('dns', () => ({
@@ -34,7 +34,7 @@ describe('Webhook URL validation', () => {
)
})
- it('should reject URLs which do not resolve to any IP', async () => {
+ it('should reject URLs if DNS resolution fails', async () => {
MockDns.resolve.mockRejectedValueOnce([])
await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual(
new WebhookValidationError(
@@ -43,6 +43,15 @@ describe('Webhook URL validation', () => {
)
})
+ it('should reject URLs which do not resolve to any IPs', async () => {
+ MockDns.resolve.mockResolvedValueOnce([])
+ await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual(
+ new WebhookValidationError(
+ `${MOCK_WEBHOOK_URL} does not resolve to any IP address.`,
+ ),
+ )
+ })
+
it('should reject URLs which resolve to private IPs', async () => {
MockDns.resolve.mockResolvedValueOnce(['127.0.0.1'])
await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual(
diff --git a/src/app/modules/webhook/webhook.controller.ts b/src/app/modules/webhook/webhook.controller.ts
deleted file mode 100644
index 8fc6b9b904..0000000000
--- a/src/app/modules/webhook/webhook.controller.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { NextFunction, Request, Response } from 'express'
-
-import { pushData } from './webhook.service'
-import { WebhookRequestLocals } from './webhook.types'
-
-/**
- * POST submission to a specified URL. Only works for encrypted submissions.
- * The webhook is fired on a best-effort basis, so the next middleware
- * is always called.
- * @param {Express.Request} req Express request object
- * @param {Object} req.form The form object containing the webhook URL
- * @param {Object} req.submission The submission saved to the database
- * @param {Express.Response} res Express response object
- * @param {function} next Next middleware
- */
-export const post = (
- req: Request & WebhookRequestLocals,
- res: Response,
- next: NextFunction,
-) => {
- // TODO: Once we move away from the middleware pattern, there should not be a webhook controller
- // There should only be a webhook service, which is called within the submission controller
- // This will also remove the need for retrieval of form/submission from req.
- const { form, submission } = req
- const webhookUrl = form.webhook?.url
- const submissionWebhookView = submission.getWebhookView()
- if (webhookUrl) {
- // Note that we push data to webhook endpoints on a best effort basis
- // As such, we should not await on these post requests
- void pushData(webhookUrl, submissionWebhookView)
- }
- return next()
-}
diff --git a/src/app/modules/webhook/webhook.errors.ts b/src/app/modules/webhook/webhook.errors.ts
index e31d8e9035..210f4ff2ce 100644
--- a/src/app/modules/webhook/webhook.errors.ts
+++ b/src/app/modules/webhook/webhook.errors.ts
@@ -1,3 +1,5 @@
+import { AxiosError } from 'axios'
+
import { ApplicationError } from '../core/core.errors'
/**
@@ -5,7 +7,35 @@ import { ApplicationError } from '../core/core.errors'
* if the submissionWebhookView is null or the webhookUrl is an invalid URL
*/
export class WebhookValidationError extends ApplicationError {
- constructor(message: string) {
+ constructor(message = 'Webhook URL is non-HTTPS or points to private IP') {
+ super(message)
+ }
+}
+
+/**
+ * Webhook returned non-200 status, but error is not instance of AxiosError
+ */
+export class WebhookFailedWithUnknownError extends ApplicationError {
+ meta: {
+ originalError: unknown
+ }
+
+ constructor(error: unknown, message = 'Webhook POST failed') {
+ super(message)
+ this.meta = { originalError: error }
+ }
+}
+
+/**
+ * Webhook returned non-200 status, error is instance of AxiosError
+ */
+export class WebhookFailedWithAxiosError extends ApplicationError {
+ meta: {
+ originalError: AxiosError
+ }
+
+ constructor(error: AxiosError, message = 'Webhook POST failed') {
super(message)
+ this.meta = { originalError: error }
}
}
diff --git a/src/app/modules/webhook/webhook.factory.ts b/src/app/modules/webhook/webhook.factory.ts
new file mode 100644
index 0000000000..dc974b6011
--- /dev/null
+++ b/src/app/modules/webhook/webhook.factory.ts
@@ -0,0 +1,29 @@
+import { errAsync } from 'neverthrow'
+
+import FeatureManager, {
+ FeatureNames,
+ RegisteredFeature,
+} from '../../../config/feature-manager'
+import { MissingFeatureError } from '../core/core.errors'
+
+import * as WebhookService from './webhook.service'
+
+interface IWebhookFactory {
+ sendWebhook: typeof WebhookService.sendWebhook
+ saveWebhookRecord: typeof WebhookService.saveWebhookRecord
+}
+
+export const createWebhookFactory = ({
+ isEnabled,
+ props,
+}: RegisteredFeature): IWebhookFactory => {
+ if (isEnabled && props) return WebhookService
+ const error = new MissingFeatureError(FeatureNames.SpcpMyInfo)
+ return {
+ sendWebhook: () => errAsync(error),
+ saveWebhookRecord: () => errAsync(error),
+ }
+}
+
+const webhookFeature = FeatureManager.get(FeatureNames.WebhookVerifiedContent)
+export const WebhookFactory = createWebhookFactory(webhookFeature)
diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts
index 947e394e76..682501d96b 100644
--- a/src/app/modules/webhook/webhook.service.ts
+++ b/src/app/modules/webhook/webhook.service.ts
@@ -1,178 +1,32 @@
-import axios, { AxiosError, AxiosResponse } from 'axios'
+import axios from 'axios'
import { get } from 'lodash'
import mongoose from 'mongoose'
+import { errAsync, okAsync, ResultAsync } from 'neverthrow'
import formsgSdk from '../../../config/formsg-sdk'
import { createLoggerWithLabel } from '../../../config/logger'
-// Prevents JSON.stringify error for circular JSONs and BigInts
-import { stringifySafe } from '../../../shared/util/stringify-safe'
import {
- IFormSchema,
+ IEncryptedSubmissionSchema,
ISubmissionSchema,
IWebhookResponse,
- WebhookView,
} from '../../../types'
import { getEncryptSubmissionModel } from '../../models/submission.server.model'
+import { transformMongoError } from '../../utils/handle-mongo-error'
+import { hasProp } from '../../utils/has-prop'
+import { PossibleDatabaseError } from '../core/core.errors'
+import { SubmissionNotFoundError } from '../submission/submission.errors'
-import { WebhookValidationError } from './webhook.errors'
-import { WebhookParams } from './webhook.types'
-import { validateWebhookUrl } from './webhook.utils'
+import {
+ WebhookFailedWithAxiosError,
+ WebhookFailedWithUnknownError,
+ WebhookValidationError,
+} from './webhook.errors'
+import { formatWebhookResponse } from './webhook.utils'
+import { validateWebhookUrl } from './webhook.validation'
const logger = createLoggerWithLabel(module)
const EncryptSubmission = getEncryptSubmissionModel(mongoose)
-/**
- * Logs webhook failure in console and database.
- * @param {error} error Error object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {Object} webhookParams.submissionWebhookView POST body
- * @param {string} webhookParams.submissionId
- * @param {string} webhookParams.formId
- * @param {string} webhookParams.now Epoch for POST header
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const handleWebhookFailure = async (
- error: Error | AxiosError,
- webhookParams: WebhookParams,
-): Promise => {
- logWebhookFailure(error, webhookParams)
- return updateSubmissionsDb(
- webhookParams.formId,
- webhookParams.submissionId,
- getFailureDbUpdate(error, webhookParams),
- )
-}
-
-/**
- * Logs webhook success in console and database.
- * @param {response} response Response object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {Object} webhookParams.submissionWebhookView POST body
- * @param {string} webhookParams.submissionId
- * @param {string} webhookParams.formId
- * @param {string} webhookParams.now Epoch for POST header
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const handleWebhookSuccess = async (
- response: AxiosResponse,
- webhookParams: WebhookParams,
-): Promise => {
- logWebhookSuccess(response, webhookParams)
- return updateSubmissionsDb(
- webhookParams.formId,
- webhookParams.submissionId,
- getSuccessDbUpdate(response, webhookParams),
- )
-}
-
-/**
- * Sends webhook POST.
- * Note that the arguments are the same as those in webhookParams
- * for handleWebhookSuccess and handleWebhookFailure, just destructured.
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {Object} webhookParams.submissionWebhookView POST body
- * @param {string} webhookParams.submissionId
- * @param {string} webhookParams.formId
- * @param {string} webhookParams.now Epoch for POST header
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const postWebhook = ({
- webhookUrl,
- submissionWebhookView,
- submissionId,
- formId,
- now,
- signature,
-}: WebhookParams): Promise => {
- return axios.post(webhookUrl, submissionWebhookView, {
- headers: {
- 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({
- epoch: now,
- submissionId,
- formId,
- signature,
- }),
- },
- maxRedirects: 0,
- })
-}
-
-/**
- * Logging for webhook success
- * @param {response} response Response object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {Object} webhookParams.submissionWebhookView POST body
- * @param {string} webhookParams.submissionId
- * @param {string} webhookParams.formId
- * @param {string} webhookParams.now Epoch for POST header
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const logWebhookSuccess = (
- response: AxiosResponse,
- { webhookUrl, submissionId, formId, now, signature }: WebhookParams,
-): void => {
- const status = get(response, 'status')
-
- logger.info({
- message: 'Webhook POST succeeded',
- meta: {
- action: 'logWebhookSuccess',
- status,
- submissionId,
- formId,
- now,
- webhookUrl,
- signature,
- },
- })
-}
-
-/**
- * Logging for webhook failure
- * @param {error} error Error object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {Object} webhookParams.submissionWebhookView POST body
- * @param {string} webhookParams.submissionId
- * @param {string} webhookParams.formId
- * @param {string} webhookParams.now Epoch for POST header
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const logWebhookFailure = (
- error: Error | AxiosError,
- { webhookUrl, submissionId, formId, now, signature }: Partial,
-): void => {
- const logMeta = {
- action: 'logWebhookFailure',
- submissionId,
- formId,
- now,
- webhookUrl,
- signature,
- }
-
- if (error instanceof WebhookValidationError) {
- logger.error({
- message: 'Webhook not attempted',
- meta: logMeta,
- error,
- })
- } else {
- logger.error({
- message: 'Webhook POST failed',
- meta: {
- ...logMeta,
- status: get(error, 'response.status'),
- },
- error,
- })
- }
-}
-
/**
* Updates the submission in the database with the webhook response
* @param {ObjectId} formId Form that submission to update belongs to
@@ -183,111 +37,49 @@ const logWebhookFailure = (
* @param {string} updateObj.headers stringified headers received from webhook endpoint
* @param {string} updateObj.data stringified data received from webhook endpoint
*/
-const updateSubmissionsDb = async (
- formId: IFormSchema['_id'],
+export const saveWebhookRecord = (
submissionId: ISubmissionSchema['_id'],
- updateObj: IWebhookResponse,
-): Promise => {
- try {
- const { nModified } = await EncryptSubmission.updateOne(
- { _id: submissionId },
- { $push: { webhookResponses: updateObj } },
- )
- if (nModified !== 1) {
- // Pass on to catch block
- throw new Error('Submission not found in database.')
- }
- } catch (error) {
- logger.error({
- message: 'Database update for webhook status failed',
- meta: {
- action: 'updateSubmissionsDb',
- formId,
- submissionId,
- updateObj: stringifySafe(updateObj),
- },
- error,
- })
- }
-}
-
-/**
- * Formats webhook success info into an object to update Submissions collection
- * @param {response} response Response object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const getSuccessDbUpdate = (
- response: AxiosResponse,
- { webhookUrl, signature }: Pick,
-): IWebhookResponse => {
- return { webhookUrl, signature, ...getFormattedResponse(response) }
-}
-
-/**
- * Formats webhook failure info into an object to update Submissions collection
- * @param {error} error Error object returned by axios
- * @param {Object} webhookParams Parameters which fully specify webhook
- * @param {string} webhookParams.webhookUrl URL to POST to
- * @param {string} webhookParams.signature Signature generated by FormSG SDK
- */
-const getFailureDbUpdate = (
- error: Error | AxiosError,
- { webhookUrl, signature }: Pick,
-): IWebhookResponse => {
- const errorMessage = get(error, 'message')
- const update: IWebhookResponse = {
- webhookUrl,
- signature,
- errorMessage,
- }
- if (!(error instanceof WebhookValidationError)) {
- const { response } = getFormattedResponse(get(error, 'response'))
- update.response = response
- }
- return update
-}
-
-/**
- * Formats a response object for update in the Submissions collection
- * @param {response} response Response object returned by axios
- */
-const getFormattedResponse = (
- response: AxiosResponse,
-): Pick => {
- return {
- response: {
- status: get(response, 'status'),
- statusText: get(response, 'statusText'),
- headers: stringifySafe(get(response, 'headers')),
- data: stringifySafe(get(response, 'data')),
+ record: IWebhookResponse,
+): ResultAsync<
+ IEncryptedSubmissionSchema,
+ PossibleDatabaseError | SubmissionNotFoundError
+> => {
+ return ResultAsync.fromPromise(
+ EncryptSubmission.addWebhookResponse(submissionId, record),
+ (error) => {
+ logger.error({
+ message: 'Database update for webhook status failed',
+ meta: {
+ action: 'saveWebhookRecord',
+ submissionId,
+ record,
+ },
+ error,
+ })
+ return transformMongoError(error)
},
- }
+ ).andThen((updatedSubmission) => {
+ if (!updatedSubmission)
+ return errAsync(
+ new SubmissionNotFoundError(
+ 'Unable to find submission ID to update webhook response',
+ ),
+ )
+ return okAsync(updatedSubmission)
+ })
}
-/**
- * Validates webhook url, posts data to it and updates submission document with response
- * @param {string} webhookUrl Endpoint to push data to
- * @param {Object} submissionWebhookView Metadata containing form information and crucial submission data
- */
-export const pushData = async (
- webhookUrl: WebhookParams['webhookUrl'],
- submissionWebhookView: WebhookView | null,
-): Promise => {
+export const sendWebhook = (
+ submission: IEncryptedSubmissionSchema,
+ webhookUrl: string,
+): ResultAsync<
+ IWebhookResponse,
+ | WebhookValidationError
+ | WebhookFailedWithAxiosError
+ | WebhookFailedWithUnknownError
+> => {
const now = Date.now()
- // Log and return, this should not happen.
- if (!submissionWebhookView) {
- logWebhookFailure(
- new WebhookValidationError('submissionWebhookView was null'),
- {
- webhookUrl,
- now,
- },
- )
- return
- }
-
+ const submissionWebhookView = submission.getWebhookView()
const { submissionId, formId } = submissionWebhookView.data
const signature = formsgSdk.webhooks.generateSignature({
@@ -295,22 +87,98 @@ export const pushData = async (
submissionId,
formId,
epoch: now,
- }) as string
+ })
- const webhookParams = {
- webhookUrl,
- submissionWebhookView,
+ const logMeta = {
+ action: 'sendWebhook',
submissionId,
formId,
now,
+ webhookUrl,
signature,
}
- try {
- await validateWebhookUrl(webhookParams.webhookUrl)
- const response = await postWebhook(webhookParams)
- return handleWebhookSuccess(response, webhookParams)
- } catch (error) {
- return handleWebhookFailure(error, webhookParams)
- }
+ return ResultAsync.fromPromise(validateWebhookUrl(webhookUrl), (error) => {
+ logger.error({
+ message: 'Webhook URL failed validation',
+ meta: logMeta,
+ error,
+ })
+ return error instanceof WebhookValidationError
+ ? error
+ : new WebhookValidationError()
+ })
+ .andThen(() =>
+ ResultAsync.fromPromise(
+ axios.post(webhookUrl, submissionWebhookView, {
+ headers: {
+ 'X-FormSG-Signature': formsgSdk.webhooks.constructHeader({
+ epoch: now,
+ submissionId,
+ formId,
+ signature,
+ }),
+ },
+ maxRedirects: 0,
+ }),
+ (error) => {
+ logger.error({
+ message: 'Webhook POST failed',
+ meta: {
+ ...logMeta,
+ isAxiosError: axios.isAxiosError(error),
+ status: get(error, 'response.status'),
+ },
+ error,
+ })
+ if (axios.isAxiosError(error)) {
+ return new WebhookFailedWithAxiosError(error)
+ }
+ return new WebhookFailedWithUnknownError(error)
+ },
+ ),
+ )
+ .map((response) => {
+ // Capture response for logging purposes
+ logger.info({
+ message: 'Webhook POST succeeded',
+ meta: {
+ ...logMeta,
+ status: get(response, 'status'),
+ },
+ })
+ return {
+ signature,
+ webhookUrl,
+ response: formatWebhookResponse(response),
+ }
+ })
+ .orElse((error) => {
+ // Webhook was not posted
+ if (error instanceof WebhookValidationError) return errAsync(error)
+
+ // Webhook was posted but failed
+ if (error instanceof WebhookFailedWithUnknownError) {
+ const originalError = error.meta.originalError
+ const errorMessage = hasProp(originalError, 'message')
+ ? originalError.message
+ : ''
+ return okAsync({
+ signature,
+ webhookUrl,
+ errorMessage,
+ // Not Axios error so no guarantee of having response.
+ // Hence allow formatting function to return default shape.
+ response: formatWebhookResponse(),
+ })
+ }
+
+ const axiosError = error.meta.originalError
+ return okAsync({
+ signature,
+ webhookUrl,
+ errorMessage: axiosError.message,
+ response: formatWebhookResponse(axiosError.response),
+ })
+ })
}
diff --git a/src/app/modules/webhook/webhook.types.ts b/src/app/modules/webhook/webhook.types.ts
index 4e3a1bb2c6..0a77e16e4d 100644
--- a/src/app/modules/webhook/webhook.types.ts
+++ b/src/app/modules/webhook/webhook.types.ts
@@ -1,10 +1,9 @@
import {
- IEncryptedSubmission,
- IForm,
+ IEncryptedSubmissionSchema,
+ IFormSchema,
ISubmissionSchema,
WebhookView,
} from '../../../types'
-import { IFormSchema } from '../../../types/form'
export interface WebhookParams {
webhookUrl: string
@@ -16,6 +15,6 @@ export interface WebhookParams {
}
export interface WebhookRequestLocals {
- form: IForm
- submission: IEncryptedSubmission
+ form: IFormSchema
+ submission: IEncryptedSubmissionSchema
}
diff --git a/src/app/modules/webhook/webhook.utils.ts b/src/app/modules/webhook/webhook.utils.ts
index 5e355927d6..fef9a3a545 100644
--- a/src/app/modules/webhook/webhook.utils.ts
+++ b/src/app/modules/webhook/webhook.utils.ts
@@ -1,62 +1,17 @@
-import { promises as dns } from 'dns'
-import ip from 'ip'
+import { AxiosResponse } from 'axios'
-import config from '../../../config/config'
-import { isValidHttpsUrl } from '../../../shared/util/url-validation'
-
-import { WebhookValidationError } from './webhook.errors'
+import { stringifySafe } from '../../../shared/util/stringify-safe'
+import { IWebhookResponse } from '../../../types'
/**
- * Checks that a URL is valid for use in webhooks.
- * @param webhookUrl Webhook URL
- * @returns Resolves if URL is valid, otherwise rejects.
- * @throws {WebhookValidationError} If URL is invalid so webhook should not be attempted.
+ * Formats a response object for update in the Submissions collection
+ * @param {response} response Response object returned by axios
*/
-export const validateWebhookUrl = (webhookUrl: string): Promise => {
- return new Promise((resolve, reject) => {
- if (!isValidHttpsUrl(webhookUrl)) {
- return reject(
- new WebhookValidationError(`${webhookUrl} is not a valid HTTPS URL.`),
- )
- }
- const webhookUrlParsed = new URL(webhookUrl)
- const appUrlParsed = new URL(config.app.appUrl)
- if (webhookUrlParsed.hostname === appUrlParsed.hostname) {
- return reject(
- new WebhookValidationError(
- `You cannot send responses back to ${config.app.appUrl}.`,
- ),
- )
- }
- dns
- .resolve(webhookUrlParsed.hostname)
- .then((addresses) => {
- if (!addresses.length) {
- return reject(
- new WebhookValidationError(
- `${webhookUrl} does not resolve to any IP address.`,
- ),
- )
- }
- const privateIps = addresses.filter((addr) => ip.isPrivate(addr))
- if (privateIps.length) {
- return reject(
- new WebhookValidationError(
- `${webhookUrl} resolves to the following private IPs: ${privateIps.join(
- ', ',
- )}`,
- ),
- )
- }
- return resolve()
- })
- .catch(() => {
- return reject(
- new WebhookValidationError(
- `Error encountered during DNS resolution for ${webhookUrl}.` +
- ` Check that the URL is correct.`,
- ),
- )
- })
- })
-}
+export const formatWebhookResponse = (
+ response?: AxiosResponse,
+): IWebhookResponse['response'] => ({
+ status: response?.status ?? 0,
+ statusText: response?.statusText ?? '',
+ headers: stringifySafe(response?.headers) ?? '',
+ data: stringifySafe(response?.data) ?? '',
+})
diff --git a/src/app/modules/webhook/webhook.validation.ts b/src/app/modules/webhook/webhook.validation.ts
new file mode 100644
index 0000000000..5e355927d6
--- /dev/null
+++ b/src/app/modules/webhook/webhook.validation.ts
@@ -0,0 +1,62 @@
+import { promises as dns } from 'dns'
+import ip from 'ip'
+
+import config from '../../../config/config'
+import { isValidHttpsUrl } from '../../../shared/util/url-validation'
+
+import { WebhookValidationError } from './webhook.errors'
+
+/**
+ * Checks that a URL is valid for use in webhooks.
+ * @param webhookUrl Webhook URL
+ * @returns Resolves if URL is valid, otherwise rejects.
+ * @throws {WebhookValidationError} If URL is invalid so webhook should not be attempted.
+ */
+export const validateWebhookUrl = (webhookUrl: string): Promise => {
+ return new Promise((resolve, reject) => {
+ if (!isValidHttpsUrl(webhookUrl)) {
+ return reject(
+ new WebhookValidationError(`${webhookUrl} is not a valid HTTPS URL.`),
+ )
+ }
+ const webhookUrlParsed = new URL(webhookUrl)
+ const appUrlParsed = new URL(config.app.appUrl)
+ if (webhookUrlParsed.hostname === appUrlParsed.hostname) {
+ return reject(
+ new WebhookValidationError(
+ `You cannot send responses back to ${config.app.appUrl}.`,
+ ),
+ )
+ }
+ dns
+ .resolve(webhookUrlParsed.hostname)
+ .then((addresses) => {
+ if (!addresses.length) {
+ return reject(
+ new WebhookValidationError(
+ `${webhookUrl} does not resolve to any IP address.`,
+ ),
+ )
+ }
+ const privateIps = addresses.filter((addr) => ip.isPrivate(addr))
+ if (privateIps.length) {
+ return reject(
+ new WebhookValidationError(
+ `${webhookUrl} resolves to the following private IPs: ${privateIps.join(
+ ', ',
+ )}`,
+ ),
+ )
+ }
+ return resolve()
+ })
+ .catch(() => {
+ return reject(
+ new WebhookValidationError(
+ `Error encountered during DNS resolution for ${webhookUrl}.` +
+ ` Check that the URL is correct.`,
+ ),
+ )
+ })
+ })
+}
diff --git a/src/app/utils/has-prop.ts b/src/app/utils/has-prop.ts
new file mode 100644
index 0000000000..afddfda0f6
--- /dev/null
+++ b/src/app/utils/has-prop.ts
@@ -0,0 +1,13 @@
+/**
+ * Utility to narrow type of an object by determining whether
+ * it contains the given property.
+ * @param obj Object
+ * @param prop Property to check
+ */
+
+export const hasProp = (
+ obj: unknown,
+ prop: K,
+): obj is Record => {
+ return typeof obj === 'object' && obj !== null && prop in obj
+}
diff --git a/src/shared/util/stringify-safe.ts b/src/shared/util/stringify-safe.ts
index 131c7a1163..c343ab04f7 100644
--- a/src/shared/util/stringify-safe.ts
+++ b/src/shared/util/stringify-safe.ts
@@ -17,7 +17,7 @@ import stringify from 'json-stringify-safe'
* JSON.stringify.
* @param obj the object to be stringified
*/
-export const stringifySafe = (obj: any): string => {
+export const stringifySafe = (obj: any): string | undefined => {
const bigIntReplacer = (_key: any, value: any): any => {
// eslint-disable-next-line valid-typeof
return typeof value === 'bigint' ? Number(value) : value
diff --git a/src/types/submission.ts b/src/types/submission.ts
index 306577bf53..0edf9d46d0 100644
--- a/src/types/submission.ts
+++ b/src/types/submission.ts
@@ -58,9 +58,7 @@ export interface WebhookView {
data: WebhookData
}
-export interface ISubmissionSchema extends ISubmission, Document {
- getWebhookView(): WebhookView | null
-}
+export interface ISubmissionSchema extends ISubmission, Document {}
export type FindFormsWithSubsAboveResult = {
_id: IFormSchema['_id']
@@ -83,7 +81,7 @@ export interface IEmailSubmission extends ISubmission {
version: never
attachmentMetadata: never
webhookResponses: never
- getWebhookView(): WebhookView | null
+ getWebhookView(): null
}
export type IEmailSubmissionSchema = IEmailSubmission & ISubmissionSchema
@@ -98,7 +96,7 @@ export interface IEncryptedSubmission extends ISubmission {
version: number
attachmentMetadata?: Map
webhookResponses?: IWebhookResponse[]
- getWebhookView(): WebhookView | null
+ getWebhookView(): WebhookView
}
export type IEncryptedSubmissionSchema = IEncryptedSubmission &
@@ -179,6 +177,16 @@ export type IEncryptSubmissionModel = Model &
formId: string,
submissionId: string,
): Promise
+
+ /**
+ * Adds a record of a webhook response to a submission
+ * @param submissionId ID of submission to update
+ * @param webhookResponse Response data to push
+ */
+ addWebhookResponse(
+ submissionId: string,
+ webhookResponse: IWebhookResponse,
+ ): Promise
}
export interface IWebhookResponseSchema extends IWebhookResponse, Document {}
diff --git a/tests/unit/backend/models/submission.server.model.spec.ts b/tests/unit/backend/models/submission.server.model.spec.ts
index 0de2776b9a..666fa26042 100644
--- a/tests/unit/backend/models/submission.server.model.spec.ts
+++ b/tests/unit/backend/models/submission.server.model.spec.ts
@@ -2,16 +2,20 @@ import { ObjectID } from 'bson'
import { times } from 'lodash'
import mongoose from 'mongoose'
-import getSubmissionModel from 'src/app/models/submission.server.model'
+import getSubmissionModel, {
+ getEncryptSubmissionModel,
+} from 'src/app/models/submission.server.model'
import {
AuthType,
ISubmissionSchema,
+ IWebhookResponse,
SubmissionType,
} from '../../../../src/types'
import dbHandler from '../helpers/jest-db'
const Submission = getSubmissionModel(mongoose)
+const EncryptedSubmission = getEncryptSubmissionModel(mongoose)
// TODO: Add more tests for the rest of the submission schema.
describe('Submission Model', () => {
@@ -151,5 +155,86 @@ describe('Submission Model', () => {
expect(actualWebhookView).toBeNull()
})
})
+
+ describe('addWebhookResponse', () => {
+ it('should return updated submission with webhook response when submission ID is valid', async () => {
+ // Arrange
+ const formId = new ObjectID()
+ const submission = await EncryptedSubmission.create({
+ submissionType: SubmissionType.Encrypt,
+ form: formId,
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ version: 1,
+ authType: AuthType.NIL,
+ myInfoFields: [],
+ recipientEmails: [],
+ responseHash: 'hash',
+ responseSalt: 'salt',
+ hasBounced: false,
+ })
+
+ const webhookResponse = new Object({
+ _id: submission._id,
+ created: submission.created,
+ signature: 'some signature',
+ webhookUrl: 'https://form.gov.sg/endpoint',
+ response: {
+ data: '{"result":"test-result"}',
+ status: 200,
+ statusText: 'success',
+ headers: '{}',
+ },
+ }) as IWebhookResponse
+
+ // Act
+ const actualSubmission = await EncryptedSubmission.addWebhookResponse(
+ submission._id,
+ webhookResponse,
+ )
+ const webhookResponses = actualSubmission!.toObject().webhookResponses!
+
+ // Assert
+ expect(webhookResponses[0].signature).toEqual(webhookResponse.signature)
+ expect(webhookResponses[0].webhookUrl).toEqual(
+ webhookResponse.webhookUrl,
+ )
+ expect(webhookResponses[0].response).toEqual(webhookResponse.response)
+ })
+
+ it('should return null when submission id is invalid', async () => {
+ // Arrange
+ const formId = new ObjectID()
+ const submission = await EncryptedSubmission.create({
+ submissionType: SubmissionType.Encrypt,
+ form: formId,
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ version: 1,
+ authType: AuthType.NIL,
+ myInfoFields: [],
+ recipientEmails: [],
+ responseHash: 'hash',
+ responseSalt: 'salt',
+ hasBounced: false,
+ })
+
+ const webhookResponse = {
+ _id: submission._id,
+ created: submission.created,
+ signature: 'some signature',
+ webhookUrl: 'https://form.gov.sg/endpoint',
+ } as IWebhookResponse
+
+ const invalidSubmissionId = new ObjectID().toHexString()
+
+ // Act
+ const actualSubmission = await EncryptedSubmission.addWebhookResponse(
+ invalidSubmissionId,
+ webhookResponse,
+ )
+
+ // Assert
+ expect(actualSubmission).toBeNull()
+ })
+ })
})
})
diff --git a/tests/unit/backend/modules/webhook/webhook.service.spec.ts b/tests/unit/backend/modules/webhook/webhook.service.spec.ts
deleted file mode 100644
index d5d7486784..0000000000
--- a/tests/unit/backend/modules/webhook/webhook.service.spec.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
-import { ObjectID } from 'bson'
-import mongoose from 'mongoose'
-import { mocked } from 'ts-jest/utils'
-
-import getFormModel from 'src/app/models/form.server.model'
-import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model'
-import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors'
-import { pushData } from 'src/app/modules/webhook/webhook.service'
-import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.utils'
-import formsgSdk from 'src/config/formsg-sdk'
-import {
- IEncryptedSubmissionSchema,
- IWebhookResponse,
- ResponseMode,
- WebhookView,
-} from 'src/types'
-
-import dbHandler from 'tests/unit/backend/helpers/jest-db'
-
-const Form = getFormModel(mongoose)
-const EncryptSubmission = getEncryptSubmissionModel(mongoose)
-
-// Define constants
-const MOCK_ADMIN_OBJ_ID = new ObjectID()
-const MOCK_WEBHOOK_URL = 'https://form.gov.sg/endpoint'
-const ERROR_MSG = 'test-message'
-const MOCK_SUCCESS_RESPONSE: AxiosResponse = {
- data: {
- result: 'test-result',
- },
- status: 200,
- statusText: 'success',
- headers: {},
- config: {},
-}
-const MOCK_FAILURE_RESPONSE: AxiosResponse = {
- data: {
- result: 'test-result',
- },
- status: 400,
- statusText: 'failed',
- headers: {},
- config: {},
-}
-const MOCK_STRINGIFIED_SUCCESS_RESPONSE: Pick = {
- response: {
- data: '{"result":"test-result"}',
- status: 200,
- statusText: 'success',
- headers: '{}',
- },
-}
-const MOCK_STRINGIFIED_FAILURE_RESPONSE: Pick = {
- response: {
- data: `{"result":"test-result"}`,
- status: 400,
- statusText: 'failed',
- headers: '{}',
- },
-}
-const MOCK_EPOCH = 1487076708000
-
-// Set up mocks
-jest.mock('axios')
-const mockAxios = mocked(axios, true)
-jest.mock('src/app/modules/webhook/webhook.utils')
-const mockValidateWebhookUrl = mocked(validateWebhookUrl, true)
-jest.spyOn(Date, 'now').mockImplementation(() => MOCK_EPOCH)
-
-describe('WebhooksService', () => {
- beforeAll(async () => await dbHandler.connect())
- afterEach(async () => {
- await dbHandler.clearDatabase()
- })
- afterAll(async () => await dbHandler.closeDatabase())
-
- let testEncryptSubmission: IEncryptedSubmissionSchema
- let testConfig: AxiosRequestConfig
- let testSubmissionWebhookView: WebhookView | null
- let testSignature: string
-
- beforeEach(async () => {
- const preloaded = await dbHandler.insertFormCollectionReqs({
- userId: MOCK_ADMIN_OBJ_ID,
- })
- const testEncryptForm = new Form({
- title: 'Test Form',
- admin: preloaded.user._id,
- responseMode: ResponseMode.Encrypt,
- publicKey: 'fake-public-key',
- })
- await testEncryptForm.save()
-
- testEncryptSubmission = new EncryptSubmission({
- form: testEncryptForm._id,
- authType: testEncryptForm.authType,
- myInfoFields: [],
- encryptedContent: 'encrypted-content',
- verifiedContent: 'verified-content',
- version: 1,
- })
- await testEncryptSubmission.save()
-
- testSubmissionWebhookView = testEncryptSubmission.getWebhookView()
-
- testSignature = formsgSdk.webhooks.generateSignature({
- uri: MOCK_WEBHOOK_URL,
- submissionId: testEncryptSubmission._id,
- formId: testEncryptForm._id,
- epoch: MOCK_EPOCH,
- }) as string
-
- testConfig = {
- headers: {
- 'X-FormSG-Signature': `t=${MOCK_EPOCH},s=${testEncryptSubmission._id},f=${testEncryptForm._id},v1=${testSignature}`,
- },
- maxRedirects: 0,
- }
- })
-
- describe('postWebhook', () => {
- it('should not make post request if submissionWebhookView is null', async () => {
- // Act
- await pushData(MOCK_WEBHOOK_URL, null)
-
- // Assert
- expect(mockAxios.post).toHaveBeenCalledTimes(0)
- })
-
- it('should update submission document with successful webhook response if post succeeds', async () => {
- // Arrange
- mockAxios.post.mockImplementationOnce((url, data, config) => {
- expect(url).toEqual(MOCK_WEBHOOK_URL)
- expect(data).toEqual(testSubmissionWebhookView)
- expect(config).toEqual(testConfig)
- return Promise.resolve(MOCK_SUCCESS_RESPONSE)
- })
- mockValidateWebhookUrl.mockImplementationOnce((url) => {
- expect(url).toEqual(MOCK_WEBHOOK_URL)
- return Promise.resolve()
- })
-
- // Act
- await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView)
-
- // Assert
- const submission = await EncryptSubmission.findById(
- testEncryptSubmission._id,
- )
- expect(submission?.webhookResponses![0]).toEqual(
- expect.objectContaining({
- webhookUrl: MOCK_WEBHOOK_URL,
- signature: testSignature,
- response: expect.objectContaining(
- MOCK_STRINGIFIED_SUCCESS_RESPONSE.response,
- ),
- }),
- )
- })
-
- it('should update submission document with failed webhook response if validation fails', async () => {
- // Arrange
- mockValidateWebhookUrl.mockImplementationOnce((url) => {
- expect(url).toEqual(MOCK_WEBHOOK_URL)
- return Promise.reject(new WebhookValidationError(ERROR_MSG))
- })
-
- // Act
- await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView)
-
- // Assert
- const submission = await EncryptSubmission.findById(
- testEncryptSubmission._id,
- )
- expect(submission?.webhookResponses![0]).toEqual(
- expect.objectContaining({
- webhookUrl: MOCK_WEBHOOK_URL,
- signature: testSignature,
- errorMessage: ERROR_MSG,
- }),
- )
- })
-
- it('should update submission document with failed webhook response if post fails', async () => {
- // Arrange
- class MockAxiosError extends Error {
- isAxiosError: boolean
- toJSON: () => {}
- config: Record
- response: AxiosResponse
- constructor(msg: string, response: AxiosResponse) {
- super(msg)
- this.isAxiosError = false
- this.response = response
- this.toJSON = () => {
- return {}
- }
- this.config = {}
- }
- }
- const mockAxiosError: AxiosError = new MockAxiosError(
- ERROR_MSG,
- MOCK_FAILURE_RESPONSE,
- )
- mockAxios.post.mockImplementationOnce((url, data, config) => {
- expect(url).toEqual(MOCK_WEBHOOK_URL)
- expect(data).toEqual(testSubmissionWebhookView)
- expect(config).toEqual(testConfig)
- return Promise.reject(mockAxiosError)
- })
- mockValidateWebhookUrl.mockImplementationOnce((url) => {
- expect(url).toEqual(MOCK_WEBHOOK_URL)
- return Promise.resolve()
- })
-
- // Act
- await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView)
-
- // Assert
- const submission = await EncryptSubmission.findById(
- testEncryptSubmission._id,
- )
- expect(submission?.webhookResponses![0]).toEqual(
- expect.objectContaining({
- webhookUrl: MOCK_WEBHOOK_URL,
- signature: testSignature,
- errorMessage: ERROR_MSG,
- response: expect.objectContaining(
- MOCK_STRINGIFIED_FAILURE_RESPONSE.response,
- ),
- }),
- )
- })
- })
-})
From afcabbbb245681c0c63445c3f20bb934f1874fc2 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 6 Apr 2021 17:29:07 +0000
Subject: [PATCH 12/75] chore(deps-dev): bump concurrently from 6.0.0 to 6.0.1
(#1568)
Bumps [concurrently](https://github.com/kimmobrunfeldt/concurrently) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/kimmobrunfeldt/concurrently/releases)
- [Commits](https://github.com/kimmobrunfeldt/concurrently/compare/v6.0.0...v6.0.1)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 30 +++++++++++++++---------------
package.json | 2 +-
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 63dd73cd2a..e922b24064 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8140,9 +8140,9 @@
}
},
"concurrently": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.0.0.tgz",
- "integrity": "sha512-Ik9Igqnef2ONLjN2o/OVx1Ow5tymVvvEwQeYCQdD/oV+CN9oWhxLk7ibcBdOtv0UzBqHCEKRwbKceYoTK8t3fQ==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.0.1.tgz",
+ "integrity": "sha512-YCF/Wf31a910hXu7eGN9/SyHKD/usw3Shw4IPYuqIsxxC39v92engYlIlOs/zXnBJtX/6aVuhgzfhZeGJkhU4w==",
"dev": true,
"requires": {
"chalk": "^4.1.0",
@@ -8231,9 +8231,9 @@
"dev": true
},
"string-width": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
- "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+ "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
@@ -8271,9 +8271,9 @@
}
},
"y18n": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz",
- "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.6.tgz",
+ "integrity": "sha512-PlVX4Y0lDTN6E2V4ES2tEdyvXkeKzxa8c/vo0pxPr/TqbztddTP0yn7zZylIyiAuxerqj0Q5GhpJ1YJCP8LaZQ==",
"dev": true
},
"yargs": {
@@ -8292,9 +8292,9 @@
}
},
"yargs-parser": {
- "version": "20.2.5",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.5.tgz",
- "integrity": "sha512-jYRGS3zWy20NtDtK2kBgo/TlAoy5YUuhD9/LZ7z7W4j1Fdw2cqD0xEEclf8fxc8xjD6X5Qr+qQQwCEsP8iRiYg==",
+ "version": "20.2.7",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz",
+ "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==",
"dev": true
}
}
@@ -20590,9 +20590,9 @@
}
},
"rxjs": {
- "version": "6.6.3",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
- "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==",
+ "version": "6.6.7",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
+ "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
"dev": true,
"requires": {
"tslib": "^1.9.0"
diff --git a/package.json b/package.json
index 29ecad5b0d..d2652615c1 100644
--- a/package.json
+++ b/package.json
@@ -197,7 +197,7 @@
"auto-changelog": "^2.2.1",
"axios-mock-adapter": "^1.19.0",
"babel-loader": "^8.2.2",
- "concurrently": "^6.0.0",
+ "concurrently": "^6.0.1",
"copy-webpack-plugin": "^6.0.2",
"core-js": "^3.9.1",
"coveralls": "^3.1.0",
From 02d283ee96c0e91bdb5acc210d83d31b52c973e5 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 6 Apr 2021 17:34:38 +0000
Subject: [PATCH 13/75] fix(deps): bump libphonenumber-js from 1.9.14 to 1.9.16
(#1569)
Bumps [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) from 1.9.14 to 1.9.16.
- [Release notes](https://gitlab.com/catamphetamine/libphonenumber-js/tags)
- [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/compare/v1.9.14...v1.9.16)
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 e922b24064..e9560313be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15775,9 +15775,9 @@
}
},
"libphonenumber-js": {
- "version": "1.9.14",
- "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.14.tgz",
- "integrity": "sha512-lQEHej1NQwKwmn89ixSfvj+m7Gm6AsafuxU1BMsS22VUizQPnamVslA2d5wsHHaaXOExAvlcYjUF1C7ieTzoCg=="
+ "version": "1.9.16",
+ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.16.tgz",
+ "integrity": "sha512-PaHT7nTtnejZ0HHekAaA0olv6BUTKZGtKM4SCQS0yE3XjFuVo/tjePMHUAr32FKwIZfyPky1ExMUuaiBAUmV6w=="
},
"lie": {
"version": "3.3.0",
diff --git a/package.json b/package.json
index d2652615c1..7fa48285d1 100644
--- a/package.json
+++ b/package.json
@@ -121,7 +121,7 @@
"json-stringify-safe": "^5.0.1",
"jszip": "^3.6.0",
"jwt-decode": "^3.1.2",
- "libphonenumber-js": "^1.9.14",
+ "libphonenumber-js": "^1.9.16",
"lodash": "^4.17.21",
"moment-timezone": "0.5.33",
"mongodb-uri": "^0.9.7",
From 4b6600b3a56c0b14be8b0db02ac51cf8c5e489b8 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 6 Apr 2021 17:46:54 +0000
Subject: [PATCH 14/75] fix(deps): bump aws-sdk from 2.879.0 to 2.880.0 (#1570)
Bumps [aws-sdk](https://github.com/aws/aws-sdk-js) from 2.879.0 to 2.880.0.
- [Release notes](https://github.com/aws/aws-sdk-js/releases)
- [Changelog](https://github.com/aws/aws-sdk-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js/compare/v2.879.0...v2.880.0)
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 e9560313be..48c76ee8a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6246,9 +6246,9 @@
"integrity": "sha512-24q5Rh3bno7ldoyCq99d6hpnLI+PAMocdeVaaGt/5BTQMprvDwQToHfNnruqN11odCHZZIQbRBw+nZo1lTCH9g=="
},
"aws-sdk": {
- "version": "2.879.0",
- "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.879.0.tgz",
- "integrity": "sha512-HRfjGwST1U9AvCJFAyqpAJwbjFR4LqUyEUk77qdJpdYHL9pGPHdnEfGRkBkPn36xcC7Em6gVvFveVoEihbQUyQ==",
+ "version": "2.880.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.880.0.tgz",
+ "integrity": "sha512-/dBk3ejw22ED2edzGfmJB83KXDA4wLIw5Hb+2YMhly+gOWecvevy0tML2+YN/cmxyTy+wT0E0sM7fm1v7kmHtw==",
"requires": {
"buffer": "4.9.2",
"events": "1.1.1",
diff --git a/package.json b/package.json
index 7fa48285d1..626ef3574c 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,7 @@
"angular-ui-bootstrap": "~2.5.6",
"angular-ui-router": "~1.0.29",
"aws-info": "^1.2.0",
- "aws-sdk": "^2.879.0",
+ "aws-sdk": "^2.880.0",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"bluebird": "^3.5.2",
From 67eb96b6448cdb7455ee69014d1a2c869324f3d7 Mon Sep 17 00:00:00 2001
From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com>
Date: Wed, 7 Apr 2021 11:02:05 +0800
Subject: [PATCH 15/75] refactor(user-api): duplicate user endpoints to new
/api/v3 router (#1553)
* refactor(user-api): duplicate user endpoints to new /api/v3 router
- duplicate user endpoint functionality and update endpoints
- duplicated integration tests for new endpoint
- update v3 router to use new endpoints
- update frontend api calls to use new endpoints
* fix(auth-client-service): add missing /
* fix(v3-routes): fix import order
---
src/app/modules/user/user.controller.ts | 4 +-
.../api/v3/user/__tests__/user.routes.spec.ts | 539 ++++++++++++++++++
src/app/routes/api/v3/user/index.ts | 1 +
src/app/routes/api/v3/user/user.routes.ts | 67 +++
src/app/routes/api/v3/v3.routes.ts | 2 +
...-contact-number-modal.client.controller.js | 4 +-
.../users/services/auth.client.service.js | 2 +-
7 files changed, 614 insertions(+), 5 deletions(-)
create mode 100644 src/app/routes/api/v3/user/__tests__/user.routes.spec.ts
create mode 100644 src/app/routes/api/v3/user/index.ts
create mode 100644 src/app/routes/api/v3/user/user.routes.ts
diff --git a/src/app/modules/user/user.controller.ts b/src/app/modules/user/user.controller.ts
index ca1e7120b9..2e32d23057 100644
--- a/src/app/modules/user/user.controller.ts
+++ b/src/app/modules/user/user.controller.ts
@@ -20,7 +20,7 @@ const logger = createLoggerWithLabel(module)
/**
* Generates an OTP and sends the OTP to the given contact in request body.
- * @route POST /contact/sendotp
+ * @route POST /contact/otp/generate
* @returns 200 if OTP was successfully sent
* @returns 401 if user id does not match current session user or if user is not currently logged in
* @returns 422 on OTP creation or SMS send failure
@@ -95,7 +95,7 @@ export const handleContactSendOtp: RequestHandler<
/**
* Verifies given OTP with the hashed OTP data, and updates the user's contact
* number if the hash matches.
- * @route POST /contact/verifyotp
+ * @route POST /contact/otp/verify
* @returns 200 when user contact update success
* @returns 401 if user id does not match current session user or if user is not currently logged in
* @returns 422 when OTP is invalid
diff --git a/src/app/routes/api/v3/user/__tests__/user.routes.spec.ts b/src/app/routes/api/v3/user/__tests__/user.routes.spec.ts
new file mode 100644
index 0000000000..3cd8bcec6d
--- /dev/null
+++ b/src/app/routes/api/v3/user/__tests__/user.routes.spec.ts
@@ -0,0 +1,539 @@
+import { ObjectId } from 'bson-ext'
+import { pick } from 'lodash'
+import mongoose from 'mongoose'
+import { errAsync, okAsync } from 'neverthrow'
+import supertest, { Session } from 'supertest-session'
+
+import getUserModel from 'src/app/models/user.server.model'
+import { SmsSendError } from 'src/app/services/sms/sms.errors'
+import * as SmsService from 'src/app/services/sms/sms.service'
+import * as OtpUtils from 'src/app/utils/otp'
+import { IAgencySchema, IUserSchema } from 'src/types'
+
+import { createAuthedSession } from 'tests/integration/helpers/express-auth'
+import { setupApp } from 'tests/integration/helpers/express-setup'
+import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
+
+import { DatabaseError } from '../../../../../modules/core/core.errors'
+import * as UserService from '../../../../../modules/user/user.service'
+import UserRouter from '../user.routes'
+
+const UserModel = getUserModel(mongoose)
+
+const app = setupApp('/user', UserRouter, {
+ setupWithAuth: true,
+})
+
+describe('user.routes', () => {
+ const VALID_DOMAIN = 'example.com'
+ const VALID_MAILNAME = 'test'
+ const VALID_EMAIL = `${VALID_MAILNAME}@${VALID_DOMAIN}`
+ const USER_ID = new ObjectId()
+ // Obtained from Twilio's
+ // https://www.twilio.com/blog/2018/04/twilio-test-credentials-magic-numbers.html
+ const VALID_CONTACT = '+15005550006'
+
+ let request: Session
+ let defaultAgency: IAgencySchema
+ let defaultUser: IUserSchema
+
+ beforeAll(async () => await dbHandler.connect())
+ beforeEach(async () => {
+ request = supertest(app)
+ const { user, agency } = await dbHandler.insertFormCollectionReqs({
+ mailDomain: VALID_DOMAIN,
+ mailName: VALID_MAILNAME,
+ userId: USER_ID,
+ })
+ defaultUser = user
+ defaultAgency = agency
+ })
+ afterEach(async () => {
+ await dbHandler.clearDatabase()
+ jest.restoreAllMocks()
+ })
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ describe('GET /user', () => {
+ it('should return 200 with current logged in user if session user is valid', async () => {
+ // Arrange
+ // Log in user.
+ const session = await createAuthedSession(defaultUser.email, request)
+
+ // Act
+ const response = await session.get('/user')
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Response should contain user object.
+ expect(response.body).toEqual(
+ expect.objectContaining({
+ ...JSON.parse(JSON.stringify(defaultUser.toObject())),
+ // Should be object since agency key should be populated.
+ agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ }),
+ )
+ })
+
+ it('should return 401 if user id does not exist in session', async () => {
+ // Act
+ const response = await request.get('/user')
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({
+ message: 'User is unauthorized.',
+ })
+ })
+
+ it('should return 500 when retrieving user returns a database error', async () => {
+ // Arrange
+ // Log in user.
+ const session = await createAuthedSession(VALID_EMAIL, request)
+
+ const mockErrorString = 'Database goes boom'
+ // Mock database error from service call.
+ const retrieveUserSpy = jest
+ .spyOn(UserService, 'getPopulatedUserById')
+ .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString)))
+
+ // Act
+ const response = await session.get('/user')
+
+ // Assert
+ expect(retrieveUserSpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({ message: mockErrorString })
+ })
+ })
+
+ describe('POST /user/contact/otp/generate', () => {
+ it('should return 200 when otp is sent successfully', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ const sendSmsOtpSpy = jest
+ .spyOn(SmsService, 'sendAdminContactOtp')
+ .mockReturnValueOnce(okAsync(true))
+
+ // Act
+ const response = await session.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(sendSmsOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(200)
+ expect(response.text).toEqual('OK')
+ })
+
+ it('should return 400 when body.contact is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/user/contact/otp/generate').send({
+ userId: defaultUser.email,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'contact' } }),
+ )
+ })
+
+ it('should return 400 when body.userId is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'userId' } }),
+ )
+ })
+
+ it('should return 401 when body.userId does not match current session userId', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ const invalidUserId = new ObjectId()
+
+ // Act
+ const response = await session.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: invalidUserId,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual('User is unauthorized.')
+ })
+
+ it('should return 401 when user is not currently logged in', async () => {
+ // Act
+ // POSTing without first logging in.
+ const response = await request.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual('User is unauthorized.')
+ })
+
+ it('should return 422 when userId cannot be found in the database', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await session.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual('User not found')
+ })
+
+ it('should return 422 when OTP fails to be sent', async () => {
+ // Arrange
+ const mockErrorString = 'mock sms send error! oh no'
+ const session = await createAuthedSession(defaultUser.email, request)
+ const sendSmsOtpSpy = jest
+ .spyOn(SmsService, 'sendAdminContactOtp')
+ .mockReturnValueOnce(errAsync(new SmsSendError(mockErrorString)))
+
+ // Act
+ const response = await session.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(sendSmsOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual(mockErrorString)
+ })
+
+ it('should return 500 when creating an OTP returns a database error', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ const mockErrorString = 'Big database oof'
+ const createOtpSpy = jest
+ .spyOn(UserService, 'createContactOtp')
+ .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString)))
+
+ // Act
+ const response = await session.post('/user/contact/otp/generate').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(createOtpSpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual(mockErrorString)
+ })
+ })
+
+ describe('POST /user/contact/otp/verify', () => {
+ const MOCK_VALID_OTP = '123456'
+
+ beforeEach(async () => {
+ jest.spyOn(OtpUtils, 'generateOtp').mockReturnValue(MOCK_VALID_OTP)
+ })
+
+ it('should return 200 with updated user when verification is successful', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ // Default user should not have any contact number yet.
+ expect(defaultUser.contact).not.toBeDefined()
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: MOCK_VALID_OTP,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Body should be an user object.
+ expect(response.body).toEqual({
+ ...JSON.parse(JSON.stringify(defaultUser.toObject())),
+ agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ // This time with the new contact number.
+ contact: VALID_CONTACT,
+ // Dynamic date strings to be returned.
+ updatedAt: expect.any(String),
+ lastAccessed: expect.any(String),
+ })
+ })
+
+ it('should return 400 when body.contact is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/user/contact/otp/verify').send({
+ userId: defaultUser.email,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'contact' } }),
+ )
+ })
+
+ it('should return 400 when body.userId is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'userId' } }),
+ )
+ })
+
+ it('should return 400 when body.otp is not provided as a param', async () => {
+ // Act
+ const response = await request.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'otp' } }),
+ )
+ })
+
+ it('should return 401 when body.userId does not match current session userId', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ const invalidUserId = new ObjectId()
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: MOCK_VALID_OTP,
+ userId: invalidUserId,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual('User is unauthorized.')
+ })
+
+ it('should return 401 when user is not currently logged in', async () => {
+ // Act
+ // POSTing without first logging in.
+ const response = await request.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: MOCK_VALID_OTP,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual('User is unauthorized.')
+ })
+
+ it('should return 401 when hashes does not exist for current contact', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: MOCK_VALID_OTP,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual(
+ 'OTP has expired. Please request for a new OTP.',
+ )
+ })
+
+ it('should return 401 when given otp does not match hashed otp', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: '999999',
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual('OTP is invalid. Please try again.')
+ })
+
+ it('should return 401 when given contact does not match hashed contact', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ const invalidContact = '999'
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: invalidContact,
+ otp: MOCK_VALID_OTP,
+ userId: defaultUser._id,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual(
+ 'Contact number given does not match the number the OTP is sent to. Please try again with the correct contact number.',
+ )
+ })
+
+ it('should return 401 when otp has been attempted too many times', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ const invalidOtp = '999999'
+
+ // Act
+ // Attempt invalid OTP for MAX_OTP_ATTEMPTS.
+ const verifyPromises = []
+ for (let i = 0; i < UserService.MAX_OTP_ATTEMPTS; i++) {
+ verifyPromises.push(
+ session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ otp: invalidOtp,
+ userId: defaultUser._id,
+ }),
+ )
+ }
+ const results = (await Promise.all(verifyPromises)).map((resolve) =>
+ pick(resolve, ['status', 'body']),
+ )
+ // Should be all invalid OTP responses.
+ expect(results).toEqual(
+ Array(UserService.MAX_OTP_ATTEMPTS).fill({
+ status: 401,
+ body: 'OTP is invalid. Please try again.',
+ }),
+ )
+
+ // Act again, this time with a valid OTP.
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ // Should still reject with max OTP attempts error.
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual(
+ 'You have hit the max number of attempts. Please request for a new OTP.',
+ )
+ })
+
+ it('should return 422 when user cannot be found in the database', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual('User not found')
+ })
+
+ it('should return 500 when database errors occurs whilst verifying otp', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ const mockErrorString = 'Database pewpew'
+
+ const incrementSpy = jest
+ .spyOn(UserService, 'verifyContactOtp')
+ .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString)))
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(incrementSpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual(mockErrorString)
+ })
+
+ it('should return 500 when database errors occurs whilst updating contact', async () => {
+ // Arrange
+ const session = await createAuthedSession(defaultUser.email, request)
+ await requestForContactOtp(defaultUser, VALID_CONTACT, session)
+ const mockErrorString = 'Database pewpew'
+
+ const uodateSpy = jest
+ .spyOn(UserService, 'updateUserContact')
+ .mockReturnValueOnce(errAsync(new DatabaseError(mockErrorString)))
+
+ // Act
+ const response = await session.post('/user/contact/otp/verify').send({
+ contact: VALID_CONTACT,
+ userId: defaultUser._id,
+ otp: MOCK_VALID_OTP,
+ })
+
+ // Assert
+ expect(uodateSpy).toBeCalled()
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual(mockErrorString)
+ })
+ })
+})
+
+// Helper methods
+const requestForContactOtp = async (
+ user: IUserSchema,
+ contact: string,
+ authedSession: Session,
+) => {
+ // Set that so no real mail is sent.
+ const sendSmsOtpSpy = jest
+ .spyOn(SmsService, 'sendAdminContactOtp')
+ .mockReturnValueOnce(okAsync(true))
+
+ // Act
+ const response = await authedSession.post('/user/contact/otp/generate').send({
+ userId: user._id,
+ contact,
+ })
+
+ // Assert
+ expect(sendSmsOtpSpy).toHaveBeenCalled()
+ expect(response.status).toEqual(200)
+ expect(response.text).toEqual('OK')
+}
diff --git a/src/app/routes/api/v3/user/index.ts b/src/app/routes/api/v3/user/index.ts
new file mode 100644
index 0000000000..7d436ca496
--- /dev/null
+++ b/src/app/routes/api/v3/user/index.ts
@@ -0,0 +1 @@
+export { UserRouter } from './user.routes'
diff --git a/src/app/routes/api/v3/user/user.routes.ts b/src/app/routes/api/v3/user/user.routes.ts
new file mode 100644
index 0000000000..d08165f93c
--- /dev/null
+++ b/src/app/routes/api/v3/user/user.routes.ts
@@ -0,0 +1,67 @@
+import { celebrate, Joi, Segments } from 'celebrate'
+import { Router } from 'express'
+
+import * as UserController from '../../../../modules/user/user.controller'
+
+export const UserRouter = Router()
+
+// * / main route
+
+/**
+ * Retrieves and returns the session user from the database.
+ * @route GET /
+ * @returns 200 with the retrieved user if session user is valid
+ * @returns 401 if user id does not exist in session
+ * @returns 500 when user cannot be found or database errors occurs
+ */
+UserRouter.get('/', UserController.handleFetchUser)
+
+// * /contact subroute
+
+/**
+ * Send a contact verification one-time password (OTP) to the specified contact
+ * number as part of the contact verification process
+ * @route POST /user/contact/otp/generate
+ * @param body.contact the contact number to send otp to
+ * @param body.userId the id of the user
+ * @returns 200 if OTP was successfully sent
+ * @returns 422 on OTP creation or SMS send failure, or if the user cannot be found
+ * @returns 500 on application or database errors
+ */
+UserRouter.post(
+ '/contact/otp/generate',
+ celebrate({
+ [Segments.BODY]: Joi.object().keys({
+ contact: Joi.string().required(),
+ userId: Joi.string().required(),
+ }),
+ }),
+ UserController.handleContactSendOtp,
+)
+
+/**
+ * Verify the contact verification one-time password (OTP) for the user as part
+ * of the contact verification process
+ * @route POST /user/contact/otp/verify
+ * @param body.userId the user's id to verify
+ * @param body.otp the otp to verify
+ * @param body.contact the contact of the user to check stored match
+ * @returns 200 when user contact update success
+ * @returns 422 when OTP is invalid
+ * @returns 500 when OTP is malformed or for unknown errors
+ */
+UserRouter.post(
+ '/contact/otp/verify',
+ celebrate({
+ [Segments.BODY]: Joi.object().keys({
+ userId: Joi.string().required(),
+ otp: Joi.string()
+ .required()
+ .regex(/^\d{6}$/),
+ contact: Joi.string().required(),
+ }),
+ }),
+ UserController.handleContactVerifyOtp,
+)
+
+export default UserRouter
diff --git a/src/app/routes/api/v3/v3.routes.ts b/src/app/routes/api/v3/v3.routes.ts
index e4a2061fe9..1349a2ed96 100644
--- a/src/app/routes/api/v3/v3.routes.ts
+++ b/src/app/routes/api/v3/v3.routes.ts
@@ -2,8 +2,10 @@ import { Router } from 'express'
import { AdminRouter } from './admin'
import { AuthRouter } from './auth'
+import { UserRouter } from './user'
export const V3Router = Router()
V3Router.use('/admin', AdminRouter)
+V3Router.use('/user', UserRouter)
V3Router.use('/auth', AuthRouter)
diff --git a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
index 51a0bb4543..60b0b48ee9 100644
--- a/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
+++ b/src/public/modules/core/controllers/edit-contact-number-modal.client.controller.js
@@ -97,7 +97,7 @@ function EditContactNumberModalController(
$scope.otpForm.otp.$setPristine()
$scope.otpForm.otp.$setUntouched()
$http
- .post('/user/contact/sendotp', {
+ .post('/api/v3/user/otp/generate', {
contact: vm.contact.number,
userId: vm.user._id,
})
@@ -126,7 +126,7 @@ function EditContactNumberModalController(
vm.otp.isFetching = true
// Check with backend if the otp is correct
$http
- .post('/user/contact/verifyotp', {
+ .post('/api/v3/user/otp/verify', {
contact: vm.contact.number,
otp: vm.otp.value,
userId: vm.user._id,
diff --git a/src/public/modules/users/services/auth.client.service.js b/src/public/modules/users/services/auth.client.service.js
index 78e7703c46..2ca5083b7d 100644
--- a/src/public/modules/users/services/auth.client.service.js
+++ b/src/public/modules/users/services/auth.client.service.js
@@ -56,7 +56,7 @@ function Auth($q, $http, $state, $window) {
function refreshUser() {
return $http
- .get('/user')
+ .get('/api/v3/user')
.then(({ data }) => {
setUser(data)
return data
From 1c6bc68911734ef16edf472d5c9b13d8140db7a1 Mon Sep 17 00:00:00 2001
From: Frank Chen
Date: Tue, 6 Apr 2021 21:14:57 -0700
Subject: [PATCH 16/75] refactor: Refactor attachment upload into a service
(#1547)
* refactor: Refactor attachment upload into a service
* Make changes based on mantariksh@'s comments
* Add formId to logging metadata for attachment uploading
* remove extraneous space
---
src/app/modules/core/core.errors.ts | 9 +++
.../encrypt-submission.controller.ts | 73 +++++-------------
.../encrypt-submission.service.ts | 77 ++++++++++++++++++-
.../encrypt-submission.types.ts | 2 +
.../encrypt-submission.utils.ts | 7 ++
5 files changed, 114 insertions(+), 54 deletions(-)
diff --git a/src/app/modules/core/core.errors.ts b/src/app/modules/core/core.errors.ts
index 938f340feb..95282fd36e 100644
--- a/src/app/modules/core/core.errors.ts
+++ b/src/app/modules/core/core.errors.ts
@@ -73,3 +73,12 @@ export class MissingFeatureError extends ApplicationError {
)
}
}
+
+/**
+ * Error thrown when attachment upload fails
+ */
+export class AttachmentUploadError extends ApplicationError {
+ constructor(message = 'Error while uploading encrypted attachments to S3') {
+ super(message)
+ }
+}
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
index ff72ee44a6..0efe0da7c7 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
@@ -1,4 +1,3 @@
-import crypto from 'crypto'
import { RequestHandler } from 'express'
import { Query } from 'express-serve-static-core'
import { StatusCodes } from 'http-status-codes'
@@ -6,7 +5,6 @@ import JSONStream from 'JSONStream'
import mongoose from 'mongoose'
import { SetOptional } from 'type-fest'
-import { aws as AwsConfig } from '../../../../config/config'
import { createLoggerWithLabel } from '../../../../config/logger'
import {
AuthType,
@@ -14,7 +12,6 @@ import {
ResWithHashedFields,
ResWithUinFin,
SubmissionMetadataList,
- WithParsedResponses,
} from '../../../../types'
import { ErrorDto } from '../../../../types/api'
import { getEncryptSubmissionModel } from '../../../models/submission.server.model'
@@ -45,6 +42,7 @@ import {
getSubmissionMetadataList,
transformAttachmentMetasToSignedUrls,
transformAttachmentMetaStream,
+ uploadAttachments,
} from './encrypt-submission.service'
import { EncryptSubmissionBody } from './encrypt-submission.types'
import {
@@ -167,12 +165,6 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
})
}
const processedResponses = processedResponsesResult.value
- // eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;(req.body as WithParsedResponses<
- typeof req.body
- >).parsedResponses = processedResponses
- // Prevent downstream functions from using responses by deleting it.
- // TODO(#1104): We want to remove the mutability of state that comes with delete.
delete (req.body as SetOptional).responses
// Checks if user is SPCP-authenticated before allowing submission
@@ -214,7 +206,7 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
uinFin,
formId,
).andThen((hashes) =>
- MyInfoFactory.checkMyInfoHashes(req.body.parsedResponses, hashes),
+ MyInfoFactory.checkMyInfoHashes(processedResponses, hashes),
)
if (myinfoResult.isErr()) {
logger.error({
@@ -279,35 +271,26 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
}
// Save Responses to Database
- // TODO(frankchn): Extract S3 upload functionality to a service
const formData = req.body.encryptedContent
- const attachmentData = req.body.attachments || {}
const { verified } = res.locals
- const attachmentMetadata = new Map()
- const attachmentUploadPromises = []
-
- // Object.keys(attachmentData[fieldId].encryptedFile) [ 'submissionPublicKey', 'nonce', 'binary' ]
- for (const fieldId in attachmentData) {
- const individualAttachment = JSON.stringify(attachmentData[fieldId])
-
- const hashStr = crypto
- .createHash('sha256')
- .update(individualAttachment)
- .digest('hex')
-
- const uploadKey =
- form._id + '/' + crypto.randomBytes(20).toString('hex') + '/' + hashStr
-
- attachmentMetadata.set(fieldId, uploadKey)
- attachmentUploadPromises.push(
- AwsConfig.s3
- .upload({
- Bucket: AwsConfig.attachmentS3Bucket,
- Key: uploadKey,
- Body: Buffer.from(individualAttachment),
- })
- .promise(),
+ let attachmentMetadata = new Map()
+
+ if (req.body.attachments) {
+ const attachmentUploadResult = await uploadAttachments(
+ form._id,
+ req.body.attachments,
)
+
+ if (attachmentUploadResult.isErr()) {
+ const { statusCode, errorMessage } = mapRouteError(
+ attachmentUploadResult.error,
+ )
+ return res.status(statusCode).json({
+ message: errorMessage,
+ })
+ } else {
+ attachmentMetadata = attachmentUploadResult.value
+ }
}
const submission = new EncryptSubmission({
@@ -320,22 +303,6 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
version: req.body.version,
})
- try {
- await Promise.all(attachmentUploadPromises)
- } catch (err) {
- logger.error({
- message: 'Attachment upload error',
- meta: logMeta,
- error: err,
- })
- return res.status(StatusCodes.BAD_REQUEST).json({
- message:
- 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.',
- submissionId: submission._id,
- spcpSubmissionFailure: false,
- })
- }
-
let savedSubmission
try {
savedSubmission = await submission.save()
@@ -385,7 +352,7 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
return sendEmailConfirmations({
form,
- parsedResponses: req.body.parsedResponses,
+ parsedResponses: processedResponses,
submission: savedSubmission,
}).mapErr((error) => {
logger.error({
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
index 84f6f18ab7..583317314f 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
@@ -1,4 +1,6 @@
+import { ManagedUpload } from 'aws-sdk/clients/s3'
import Bluebird from 'bluebird'
+import crypto from 'crypto'
import mongoose from 'mongoose'
import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow'
import { Transform } from 'stream'
@@ -17,7 +19,11 @@ import {
import { getEncryptSubmissionModel } from '../../../models/submission.server.model'
import { isMalformedDate } from '../../../utils/date'
import { getMongoErrorMessage } from '../../../utils/handle-mongo-error'
-import { DatabaseError, MalformedParametersError } from '../../core/core.errors'
+import {
+ AttachmentUploadError,
+ DatabaseError,
+ MalformedParametersError,
+} from '../../core/core.errors'
import { CreatePresignedUrlError } from '../../form/admin-form/admin-form.errors'
import { isFormEncryptMode } from '../../form/form.utils'
import {
@@ -25,9 +31,78 @@ import {
SubmissionNotFoundError,
} from '../submission.errors'
+import { AttachmentMetadata } from './encrypt-submission.types'
+
const logger = createLoggerWithLabel(module)
const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
+type AttachmentReducerData = {
+ attachmentMetadata: AttachmentMetadata // type alias for Map
+ attachmentUploadPromises: Promise[]
+}
+
+/**
+ * Uploads a set of submissions to S3 and returns a map of attachment IDs to S3 object keys
+ *
+ * @param formId the id of the form to upload attachments for
+ * @param attachmentData Attachment blob data from the client (including the attachment)
+ *
+ * @returns ok(AttachmentMetadata) A map of field id to the s3 key of the uploaded attachment
+ * @returns err(AttachmentUploadError) if the upload has failed
+ */
+export const uploadAttachments = (
+ formId: string,
+ attachmentData: Record,
+): ResultAsync => {
+ const { attachmentMetadata, attachmentUploadPromises } = Object.keys(
+ attachmentData,
+ ).reduce(
+ (accumulator: AttachmentReducerData, fieldId: string) => {
+ const individualAttachment = JSON.stringify(attachmentData[fieldId])
+
+ const hashStr = crypto
+ .createHash('sha256')
+ .update(individualAttachment)
+ .digest('hex')
+
+ const uploadKey =
+ formId + '/' + crypto.randomBytes(20).toString('hex') + '/' + hashStr
+
+ accumulator.attachmentMetadata.set(fieldId, uploadKey)
+ accumulator.attachmentUploadPromises.push(
+ AwsConfig.s3
+ .upload({
+ Bucket: AwsConfig.attachmentS3Bucket,
+ Key: uploadKey,
+ Body: Buffer.from(individualAttachment),
+ })
+ .promise(),
+ )
+
+ return accumulator
+ },
+ {
+ attachmentMetadata: new Map(),
+ attachmentUploadPromises: [],
+ },
+ )
+
+ return ResultAsync.fromPromise(
+ Promise.all(attachmentUploadPromises),
+ (error) => {
+ logger.error({
+ message: 'S3 attachment upload error',
+ meta: {
+ action: 'uploadAttachments',
+ formId,
+ },
+ error,
+ })
+ return new AttachmentUploadError()
+ },
+ ).map(() => attachmentMetadata)
+}
+
/**
* Returns a cursor to the stream of the submissions of the given form id.
*
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
index 5a9211de86..ed468f7232 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
@@ -34,3 +34,5 @@ export type EncryptSubmissionBodyAfterProcess = {
export type WithAttachmentsData = T & { attachmentData: Attachments }
export type WithFormData = T & { formData: string }
+
+export type AttachmentMetadata = Map
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
index 8dfb731cb9..12c5fbc189 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
@@ -10,6 +10,7 @@ import {
VerifyCaptchaError,
} from '../../../services/captcha/captcha.errors'
import {
+ AttachmentUploadError,
DatabaseConflictError,
DatabaseError,
DatabasePayloadSizeError,
@@ -54,6 +55,12 @@ export const mapRouteError: MapRouteError = (
coreErrorMessage = 'Sorry, something went wrong. Please try again.',
) => {
switch (error.constructor) {
+ case AttachmentUploadError:
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorMessage:
+ 'Could not upload attachments for submission. For assistance, please contact the person who asked you to fill in this form.',
+ }
case MissingFeatureError:
case CreateRedirectUrlError:
return {
From 324a5101e22d4a2c5d08c4e9663a601adeec2833 Mon Sep 17 00:00:00 2001
From: seaerchin <44049504+seaerchin@users.noreply.github.com>
Date: Wed, 7 Apr 2021 17:22:59 +0800
Subject: [PATCH 17/75] refactor: migrate admin-console client service to ts
(#1531)
* feat(public/services/billingservice): added billing service
* feat(public/services/exampleservice): added example service
* test(public/services/billingservice): adds tests for getBillingInfo
* test(public/services/exampleservice): adds tests for getSingleExampleForm and getExampleForms
* refactor(billingservice): added dto types to fe/be and type annnotation on get for fe
* style(public/exampleservice): added curly braces; added type annotation on methods
* refactor(examples): adds dto to params and updated fe file naming to fit be
* test(examplesservice): updated tests to use new dto shape
* refactor(billing): updated billing fe/be to use dto
* test(billingservice): updated tests to use dto for params
* refactor(examples-list/controller): updated to use ExamplesService
* refactor(billing/client/controller): replaced AdminService with new BillingService
* refactor(forms/client/routes): replaced AdminConsole with ExamplesService
* chore(main.js): deletes old admin-console.client.service
* test(billingservice): updated test blocks
* test(exampleservice): updated test blocks
* style(types/billing): imported LoginStatistic rather than redefining
---
.../__tests__/billing.controller.spec.ts | 4 +-
.../billing/__tests__/billing.routes.spec.ts | 4 +-
src/app/modules/billing/billing.controller.ts | 11 +--
.../modules/examples/examples.controller.ts | 20 ++--
src/public/main.js | 1 -
.../forms/config/forms.client.routes.js | 10 +-
.../controllers/billing.client.controller.js | 21 ++---
.../examples-list.client.controller.js | 20 ++--
.../services/admin-console.client.service.js | 68 --------------
src/public/services/BillingService.ts | 21 +++++
src/public/services/ExamplesService.ts | 42 +++++++++
.../services/__tests__/BillingService.test.ts | 52 +++++++++++
.../__tests__/ExamplesService.test.ts | 91 +++++++++++++++++++
src/types/api/billing.ts | 12 +++
src/types/api/examples.ts | 20 ++++
src/types/api/index.ts | 2 +
16 files changed, 291 insertions(+), 108 deletions(-)
delete mode 100644 src/public/modules/users/services/admin-console.client.service.js
create mode 100644 src/public/services/BillingService.ts
create mode 100644 src/public/services/ExamplesService.ts
create mode 100644 src/public/services/__tests__/BillingService.test.ts
create mode 100644 src/public/services/__tests__/ExamplesService.test.ts
create mode 100644 src/types/api/billing.ts
create mode 100644 src/types/api/examples.ts
diff --git a/src/app/modules/billing/__tests__/billing.controller.spec.ts b/src/app/modules/billing/__tests__/billing.controller.spec.ts
index 374c936c7b..6b421a3de1 100644
--- a/src/app/modules/billing/__tests__/billing.controller.spec.ts
+++ b/src/app/modules/billing/__tests__/billing.controller.spec.ts
@@ -92,7 +92,9 @@ describe('billing.controller', () => {
...EXPECTED_SERVICE_CALL_ARGS,
)
expect(mockRes.status).toBeCalledWith(500)
- expect(mockRes.json).toBeCalledWith('Error in retrieving billing records')
+ expect(mockRes.json).toBeCalledWith({
+ message: 'Error in retrieving billing records',
+ })
})
})
})
diff --git a/src/app/modules/billing/__tests__/billing.routes.spec.ts b/src/app/modules/billing/__tests__/billing.routes.spec.ts
index 3dfa904ba7..5bc76cde85 100644
--- a/src/app/modules/billing/__tests__/billing.routes.spec.ts
+++ b/src/app/modules/billing/__tests__/billing.routes.spec.ts
@@ -206,7 +206,9 @@ describe('billing.routes', () => {
// Assert
expect(retrieveStatsSpy).toBeCalled()
expect(response.status).toEqual(500)
- expect(response.body).toEqual('Error in retrieving billing records')
+ expect(response.body).toEqual({
+ message: 'Error in retrieving billing records',
+ })
})
})
})
diff --git a/src/app/modules/billing/billing.controller.ts b/src/app/modules/billing/billing.controller.ts
index 09d0032c34..77d9e6fb3b 100644
--- a/src/app/modules/billing/billing.controller.ts
+++ b/src/app/modules/billing/billing.controller.ts
@@ -4,6 +4,7 @@ import { StatusCodes } from 'http-status-codes'
import moment from 'moment-timezone'
import { createLoggerWithLabel } from '../../../config/logger'
+import { BillingInfoDto, BillingQueryDto, ErrorDto } from '../../../types/api'
import { createReqMeta } from '../../utils/request'
import { BillingFactory } from './billing.factory'
@@ -20,13 +21,9 @@ const logger = createLoggerWithLabel(module)
*/
export const handleGetBillInfo: RequestHandler<
ParamsDictionary,
+ ErrorDto | BillingInfoDto,
unknown,
- unknown,
- {
- esrvcId: string
- yr: string
- mth: string
- }
+ BillingQueryDto
> = async (req, res) => {
const { esrvcId, mth, yr } = req.query
const authedUser = (req.session as Express.AuthedSession).user
@@ -54,7 +51,7 @@ export const handleGetBillInfo: RequestHandler<
})
return res
.status(StatusCodes.INTERNAL_SERVER_ERROR)
- .json('Error in retrieving billing records')
+ .json({ message: 'Error in retrieving billing records' })
}
// Retrieved login stats successfully.
diff --git a/src/app/modules/examples/examples.controller.ts b/src/app/modules/examples/examples.controller.ts
index 0de411868e..df080efa23 100644
--- a/src/app/modules/examples/examples.controller.ts
+++ b/src/app/modules/examples/examples.controller.ts
@@ -3,10 +3,15 @@ import { ParamsDictionary, Query } from 'express-serve-static-core'
import { StatusCodes } from 'http-status-codes'
import { createLoggerWithLabel } from '../../../config/logger'
+import {
+ ErrorDto,
+ ExampleFormsQueryDto,
+ ExampleFormsResult,
+ ExampleSingleFormResult,
+} from '../../../types/api'
import { createReqMeta } from '../../utils/request'
import { ExamplesFactory } from './examples.factory'
-import { ExamplesQueryParams } from './examples.types'
import { mapRouteError } from './examples.utils'
const logger = createLoggerWithLabel(module)
@@ -14,15 +19,16 @@ const logger = createLoggerWithLabel(module)
/**
* Handler for GET /examples endpoint.
* @security session
+ * @param exampleFormsQuery The search terms to find forms for
* @returns 200 with an array of forms to be listed on the examples page
* @returns 401 when user does not exist in session
* @returns 500 when error occurs whilst querying the database
*/
export const handleGetExamples: RequestHandler<
ParamsDictionary,
+ ErrorDto | ExampleFormsResult,
unknown,
- unknown,
- Query & ExamplesQueryParams
+ Query & ExampleFormsQueryDto
> = (req, res) => {
return ExamplesFactory.getExampleForms(req.query)
.map((result) => res.status(StatusCodes.OK).json(result))
@@ -44,14 +50,16 @@ export const handleGetExamples: RequestHandler<
/**
* Handler for GET /examples/:formId endpoint.
* @security session
+ * @param formId The id of the example form
* @returns 200 with the retrieved form example
* @returns 401 when user does not exist in session
* @returns 404 when the form with given formId does not exist in the database
* @returns 500 when error occurs whilst querying the database
*/
-export const handleGetExampleByFormId: RequestHandler<{
- formId: string
-}> = (req, res) => {
+export const handleGetExampleByFormId: RequestHandler<
+ { formId: string },
+ ExampleSingleFormResult | ErrorDto
+> = (req, res) => {
const { formId } = req.params
return ExamplesFactory.getSingleExampleForm(formId)
diff --git a/src/public/main.js b/src/public/main.js
index 1871c9ff2a..1bdfeacf96 100644
--- a/src/public/main.js
+++ b/src/public/main.js
@@ -283,7 +283,6 @@ require('./modules/users/config/users.client.routes.js')
// User services
require('./modules/users/services/auth.client.service.js')
-require('./modules/users/services/admin-console.client.service.js')
// User controllers
require('./modules/users/controllers/authentication.client.controller.js')
diff --git a/src/public/modules/forms/config/forms.client.routes.js b/src/public/modules/forms/config/forms.client.routes.js
index edfc03eaac..5f5467c213 100644
--- a/src/public/modules/forms/config/forms.client.routes.js
+++ b/src/public/modules/forms/config/forms.client.routes.js
@@ -1,5 +1,7 @@
'use strict'
+const ExamplesService = require('../../../services/ExamplesService')
+
// Setting up route
angular.module('forms').config([
'$stateProvider',
@@ -72,22 +74,22 @@ angular.module('forms').config([
url: '/{formId:[0-9a-fA-F]{24}}/use-template',
templateUrl: 'modules/users/views/examples.client.view.html',
resolve: {
- AdminConsole: 'AdminConsole',
Auth: 'Auth',
FormErrorService: 'FormErrorService',
// If the user is logged in, this field will contain the form data of the provided formId,
// otherwise it will only contain the formId itself.
FormData: [
- 'AdminConsole',
+ '$q',
'Auth',
'FormErrorService',
'$stateParams',
- function (AdminConsole, Auth, FormErrorService, $stateParams) {
+ function ($q, Auth, FormErrorService, $stateParams) {
if (!Auth.getUser()) {
return $stateParams.formId
}
- return AdminConsole.getSingleExampleForm($stateParams.formId)
+ return $q
+ .when(ExamplesService.getSingleExampleForm($stateParams.formId))
.then(function (response) {
response.form.isTemplate = true
return response.form
diff --git a/src/public/modules/users/controllers/billing.client.controller.js b/src/public/modules/users/controllers/billing.client.controller.js
index fa1d7aaf1e..48388f2115 100644
--- a/src/public/modules/users/controllers/billing.client.controller.js
+++ b/src/public/modules/users/controllers/billing.client.controller.js
@@ -1,25 +1,20 @@
'use strict'
const CsvGenerator = require('../../forms/helpers/CsvGenerator')
+const BillingService = require('../../../services/BillingService')
angular
.module('users')
.controller('BillingController', [
+ '$q',
'$state',
'$timeout',
- 'AdminConsole',
'Auth',
'NgTableParams',
BillingController,
])
-function BillingController(
- $state,
- $timeout,
- AdminConsole,
- Auth,
- NgTableParams,
-) {
+function BillingController($q, $state, $timeout, Auth, NgTableParams) {
const vm = this
vm.user = Auth.getUser()
@@ -102,10 +97,12 @@ function BillingController(
esrvcId = esrvcId || vm.esrvcId
vm.loading = true
vm.searchError = false
- AdminConsole.getBillingInfo(
- vm.selectedTimePeriod.yr,
- vm.selectedTimePeriod.mth,
- esrvcId,
+ $q.when(
+ BillingService.getBillingInfo({
+ yr: vm.selectedTimePeriod.yr,
+ mth: vm.selectedTimePeriod.mth,
+ esrvcId,
+ }),
).then(
function (response) {
// Remove loader
diff --git a/src/public/modules/users/controllers/examples-list.client.controller.js b/src/public/modules/users/controllers/examples-list.client.controller.js
index ee9918975a..c1a6c6047c 100644
--- a/src/public/modules/users/controllers/examples-list.client.controller.js
+++ b/src/public/modules/users/controllers/examples-list.client.controller.js
@@ -1,10 +1,12 @@
'use strict'
+const ExamplesService = require('../../../services/ExamplesService')
+
const PAGE_SIZE = 16
angular.module('users').controller('ExamplesController', [
+ '$q',
'$scope',
- 'AdminConsole',
'Auth',
'GTag',
'$state',
@@ -17,8 +19,8 @@ angular.module('users').controller('ExamplesController', [
])
function ExamplesController(
+ $q,
$scope,
- AdminConsole,
Auth,
GTag,
$state,
@@ -129,12 +131,14 @@ function ExamplesController(
const shouldGetTotalNumResults = pageNo === 0 && searchTerm !== ''
// Get next page of forms and add to ui
- AdminConsole.getExampleForms({
- pageNo,
- searchTerm,
- agency,
- shouldGetTotalNumResults,
- }).then(
+ $q.when(
+ ExamplesService.getExampleForms({
+ pageNo,
+ searchTerm,
+ agency,
+ shouldGetTotalNumResults,
+ }),
+ ).then(
function (response) {
/* Form Properties
------------------
diff --git a/src/public/modules/users/services/admin-console.client.service.js b/src/public/modules/users/services/admin-console.client.service.js
deleted file mode 100644
index c50aa97418..0000000000
--- a/src/public/modules/users/services/admin-console.client.service.js
+++ /dev/null
@@ -1,68 +0,0 @@
-angular.module('users').factory('AdminConsole', ['$q', '$http', AdminConsole])
-
-function AdminConsole($q, $http) {
- return {
- getBillingInfo: function (yr, mth, esrvcId) {
- let deferred = $q.defer()
- $http({
- url: '/billing',
- method: 'GET',
- params: { yr, mth, esrvcId },
- }).then(
- function (response) {
- deferred.resolve(response.data)
- },
- function () {
- deferred.reject('Billing information could not be obtained.')
- },
- )
- return deferred.promise
- },
- /**
- * Retrieve example forms for listing
- */
- getExampleForms: function ({
- pageNo,
- searchTerm,
- agency,
- shouldGetTotalNumResults,
- }) {
- let deferred = $q.defer()
- $http({
- url: '/examples',
- method: 'GET',
- params: { pageNo, searchTerm, agency, shouldGetTotalNumResults },
- headers: { 'If-Modified-Since': '0' },
- // disable IE ajax request caching (so search requests don't get cached)
- }).then(
- function (response) {
- deferred.resolve(response.data)
- },
- function (error) {
- deferred.reject(error)
- },
- )
- return deferred.promise
- },
- /**
- * Retrieve a single form for examples
- */
- getSingleExampleForm: function (formId) {
- let deferred = $q.defer()
- $http({
- url: `/examples/${formId}`,
- method: 'GET',
- headers: { 'If-Modified-Since': '0' },
- // disable IE ajax request caching (so search requests don't get cached)
- }).then(
- function (response) {
- deferred.resolve(response.data)
- },
- function (error) {
- deferred.reject(error)
- },
- )
- return deferred.promise
- },
- }
-}
diff --git a/src/public/services/BillingService.ts b/src/public/services/BillingService.ts
new file mode 100644
index 0000000000..1a77743300
--- /dev/null
+++ b/src/public/services/BillingService.ts
@@ -0,0 +1,21 @@
+import axios from 'axios'
+
+import { BillingInfoDto, BillingQueryDto } from '../../types/api/billing'
+
+// Exported for testing
+export const BILLING_ENDPOINT = '/billing'
+
+/**
+ * Gets the billing information for the given month and year
+ * @param billingQueryParams The formId and the specific month to get the information for
+ * @returns Promise The billing statistics of the given month
+ */
+export const getBillingInfo = (
+ billingQueryParams: BillingQueryDto,
+): Promise => {
+ return axios
+ .get(BILLING_ENDPOINT, {
+ params: billingQueryParams,
+ })
+ .then(({ data }) => data)
+}
diff --git a/src/public/services/ExamplesService.ts b/src/public/services/ExamplesService.ts
new file mode 100644
index 0000000000..0575cdc711
--- /dev/null
+++ b/src/public/services/ExamplesService.ts
@@ -0,0 +1,42 @@
+import axios from 'axios'
+
+import {
+ ExampleFormsQueryDto,
+ ExampleFormsResult,
+ ExampleSingleFormResult,
+} from '../../types/api'
+
+export const EXAMPLES_ENDPOINT = '/examples'
+
+/**
+ * Gets example forms that matches the specified parameters for listing
+ * @param exampleFormsSearchParams The search terms to query the backend for
+ * @returns The list of retrieved examples if `shouldGetTotalNumResults` is false
+ * @returns The list of retrieved examples with the total results if `shouldGetTotalNumResults` is true
+ */
+export const getExampleForms = (
+ exampleFormsSearchParams: ExampleFormsQueryDto,
+): Promise => {
+ return axios
+ .get(EXAMPLES_ENDPOINT, {
+ params: exampleFormsSearchParams,
+ // disable IE ajax request caching (so search requests don't get cached)
+ headers: { 'If-Modified-Since': '0' },
+ })
+ .then(({ data }) => data)
+}
+/**
+ * Gets a single form for examples
+ * @param formId The id of the form to search for
+ * @returns The information of the example form
+ */
+export const getSingleExampleForm = (
+ formId: string,
+): Promise => {
+ return axios
+ .get(`${EXAMPLES_ENDPOINT}/${formId}`, {
+ // disable IE ajax request caching (so search requests don't get cached)
+ headers: { 'If-Modified-Since': '0' },
+ })
+ .then(({ data }) => data)
+}
diff --git a/src/public/services/__tests__/BillingService.test.ts b/src/public/services/__tests__/BillingService.test.ts
new file mode 100644
index 0000000000..8aa0bb7473
--- /dev/null
+++ b/src/public/services/__tests__/BillingService.test.ts
@@ -0,0 +1,52 @@
+import MockAxios from 'jest-mock-axios'
+
+import * as BillingService from '../BillingService'
+
+jest.mock('axios', () => MockAxios)
+
+describe('BillingService', () => {
+ describe('getBillingInfo', () => {
+ const MOCK_DATA = {
+ adminEmail: 'Big Mock',
+ formName: 'Mock Form',
+ total: 0,
+ formId: 'Mock',
+ authType: 'NIL',
+ }
+ const MOCK_PARAMS = { yr: '2020', mth: '12', esrvcId: 'mock' }
+ it('should return the billing information when the GET request succeeds', async () => {
+ // Arrange
+ MockAxios.get.mockResolvedValueOnce({ data: MOCK_DATA })
+
+ // Act
+ const actual = await BillingService.getBillingInfo(MOCK_PARAMS)
+
+ // Assert
+ expect(MockAxios.get).toHaveBeenCalledWith(
+ BillingService.BILLING_ENDPOINT,
+ {
+ params: MOCK_PARAMS,
+ },
+ )
+ expect(actual).toEqual(MOCK_DATA)
+ })
+
+ it('should reject with the provided error message when the GET request fails', async () => {
+ // Arrange
+ const expected = new Error('Mock Error')
+ MockAxios.get.mockRejectedValueOnce(expected)
+
+ // Act
+ const actualPromise = BillingService.getBillingInfo(MOCK_PARAMS)
+
+ // Assert
+ await expect(actualPromise).rejects.toEqual(expected)
+ expect(MockAxios.get).toHaveBeenCalledWith(
+ BillingService.BILLING_ENDPOINT,
+ {
+ params: MOCK_PARAMS,
+ },
+ )
+ })
+ })
+})
diff --git a/src/public/services/__tests__/ExamplesService.test.ts b/src/public/services/__tests__/ExamplesService.test.ts
new file mode 100644
index 0000000000..b50f0edcc5
--- /dev/null
+++ b/src/public/services/__tests__/ExamplesService.test.ts
@@ -0,0 +1,91 @@
+import MockAxios from 'jest-mock-axios'
+
+import * as ExamplesService from '../ExamplesService'
+
+jest.mock('axios', () => MockAxios)
+
+describe('ExamplesService', () => {
+ afterEach(() => jest.clearAllMocks())
+ describe('getExampleForms', () => {
+ const MOCK_PARAMS = {
+ pageNo: 1,
+ searchTerm: 'mock',
+ agency: 'Mock Gov',
+ shouldGetTotalNumResults: false,
+ }
+ const MOCK_HEADERS = { 'If-Modified-Since': '0' }
+ it('should return example forms data when the GET request succeeds', async () => {
+ // Arrange
+ const expected = {}
+ MockAxios.get.mockResolvedValueOnce({ data: {} })
+
+ // Act
+ const actual = await ExamplesService.getExampleForms(MOCK_PARAMS)
+
+ // Assert
+ expect(MockAxios.get).toHaveBeenCalledWith(
+ ExamplesService.EXAMPLES_ENDPOINT,
+ {
+ params: MOCK_PARAMS,
+ headers: MOCK_HEADERS,
+ },
+ )
+ expect(actual).toEqual(expected)
+ })
+
+ it('should reject with the provided error message when the GET request fails', async () => {
+ // Arrange
+ const expected = new Error('Mock Error')
+ MockAxios.get.mockRejectedValueOnce(expected)
+
+ // Act
+ const actualPromise = ExamplesService.getExampleForms(MOCK_PARAMS)
+
+ // Assert
+ await expect(actualPromise).rejects.toEqual(expected)
+ expect(MockAxios.get).toHaveBeenCalledWith(
+ ExamplesService.EXAMPLES_ENDPOINT,
+ {
+ params: MOCK_PARAMS,
+ headers: MOCK_HEADERS,
+ },
+ )
+ })
+ })
+
+ describe('getSingleExampleForm', () => {
+ const MOCK_FORM_ID = 'MOCK'
+ const MOCK_HEADERS = { 'If-Modified-Since': '0' }
+ it('should return example single form data when the GET request succeeds', async () => {
+ // Arrange
+ const expected = {}
+ const expectedMockEndpoint = `${ExamplesService.EXAMPLES_ENDPOINT}/${MOCK_FORM_ID}`
+ MockAxios.get.mockResolvedValueOnce({ data: {} })
+
+ // Act
+ const actual = await ExamplesService.getSingleExampleForm(MOCK_FORM_ID)
+
+ // Assert
+ expect(MockAxios.get).toHaveBeenCalledWith(expectedMockEndpoint, {
+ headers: MOCK_HEADERS,
+ })
+ expect(actual).toEqual(expected)
+ })
+
+ it('should reject with the provided error message when the GET request fails', async () => {
+ // Arrange
+ const expected = new Error('Mock Error')
+ const expectedMockEndpoint = `${ExamplesService.EXAMPLES_ENDPOINT}/${MOCK_FORM_ID}`
+ MockAxios.get.mockRejectedValueOnce(expected)
+
+ // Act
+ const actualPromise = ExamplesService.getSingleExampleForm(MOCK_FORM_ID)
+
+ // Assert
+ await expect(actualPromise).rejects.toEqual(expected)
+ expect(MockAxios.get).toHaveBeenCalledWith(expectedMockEndpoint, {
+ headers: MOCK_HEADERS,
+ })
+ })
+ })
+})
diff --git a/src/types/api/billing.ts b/src/types/api/billing.ts
new file mode 100644
index 0000000000..60e3f03fcb
--- /dev/null
+++ b/src/types/api/billing.ts
@@ -0,0 +1,12 @@
+import { LoginStatistic } from '../../types'
+
+// yr: The year to get the billing information for
+// mth: The month to get the billing information for
+// esrvcId: The id of the form
+export type BillingQueryDto = {
+ esrvcId: string
+ yr: string
+ mth: string
+}
+
+export type BillingInfoDto = { loginStats: LoginStatistic[] }
diff --git a/src/types/api/examples.ts b/src/types/api/examples.ts
new file mode 100644
index 0000000000..7cda15f7b1
--- /dev/null
+++ b/src/types/api/examples.ts
@@ -0,0 +1,20 @@
+import {
+ QueryPageResult,
+ QueryPageResultWithTotal,
+ SingleFormResult,
+} from '../../app/modules/examples/examples.types'
+
+// pageNo: The page to render
+// searchTerm: The term to search on
+// agency: The agency to search on - this can be all agencies or the user's agency
+// shouldGetTotalNumResults: Whether to return all the results or not
+export type ExampleFormsQueryDto = {
+ pageNo: number
+ searchTerm?: string
+ agency?: string
+ shouldGetTotalNumResults?: boolean
+}
+
+export type ExampleFormsResult = QueryPageResult | QueryPageResultWithTotal
+// NOTE: Renaming for clarity that this type refers to an example
+export type ExampleSingleFormResult = SingleFormResult
diff --git a/src/types/api/index.ts b/src/types/api/index.ts
index 977a0c0c32..20d19312a1 100644
--- a/src/types/api/index.ts
+++ b/src/types/api/index.ts
@@ -1,2 +1,4 @@
export * from './core'
export * from './form'
+export * from './billing'
+export * from './examples'
From 9f99776254b2272dd52b458e82eb4f06bcc03bc3 Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Wed, 7 Apr 2021 18:08:33 +0800
Subject: [PATCH 18/75] fix(test): use app import instead of dist import
(#1576)
---
src/app/modules/webhook/__tests__/webhook.service.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts
index 89d592655b..a1b74c5952 100644
--- a/src/app/modules/webhook/__tests__/webhook.service.spec.ts
+++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts
@@ -1,6 +1,5 @@
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ObjectID } from 'bson'
-import { SubmissionNotFoundError } from 'dist/backend/app/modules/submission/submission.errors'
import mongoose from 'mongoose'
import { mocked } from 'ts-jest/utils'
@@ -20,6 +19,7 @@ import {
import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { SubmissionNotFoundError } from '../../submission/submission.errors'
import { saveWebhookRecord, sendWebhook } from '../webhook.service'
// define suite-wide mocks
From 3defb138225f8c3025b65afc49df2ebdd1bffb50 Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Wed, 7 Apr 2021 18:09:08 +0800
Subject: [PATCH 19/75] fix: convert bigint to string, not number to avoid
losing fidelity (#1577)
also converted tests to Typescript tests
---
src/shared/util/stringify-safe.ts | 8 +++-----
...fy-safe.spec.js => stringify-safe.spec.ts} | 19 +++++++++++--------
2 files changed, 14 insertions(+), 13 deletions(-)
rename tests/unit/backend/utils/{stringify-safe.spec.js => stringify-safe.spec.ts} (76%)
diff --git a/src/shared/util/stringify-safe.ts b/src/shared/util/stringify-safe.ts
index c343ab04f7..d08ef50bd6 100644
--- a/src/shared/util/stringify-safe.ts
+++ b/src/shared/util/stringify-safe.ts
@@ -18,9 +18,7 @@ import stringify from 'json-stringify-safe'
* @param obj the object to be stringified
*/
export const stringifySafe = (obj: any): string | undefined => {
- const bigIntReplacer = (_key: any, value: any): any => {
- // eslint-disable-next-line valid-typeof
- return typeof value === 'bigint' ? Number(value) : value
- }
- return stringify(obj, bigIntReplacer)
+ return stringify(obj, (_key, value) =>
+ typeof value === 'bigint' ? value.toString() : value,
+ )
}
diff --git a/tests/unit/backend/utils/stringify-safe.spec.js b/tests/unit/backend/utils/stringify-safe.spec.ts
similarity index 76%
rename from tests/unit/backend/utils/stringify-safe.spec.js
rename to tests/unit/backend/utils/stringify-safe.spec.ts
index 4d7a06da2a..fba269da57 100644
--- a/tests/unit/backend/utils/stringify-safe.spec.js
+++ b/tests/unit/backend/utils/stringify-safe.spec.ts
@@ -1,14 +1,11 @@
-/* global BigInt */
+import { cloneDeep } from 'lodash'
-const {
- stringifySafe,
-} = require('../../../../dist/backend/shared/util/stringify-safe')
-const { cloneDeep } = require('lodash')
+import { stringifySafe } from 'src/shared/util/stringify-safe'
// Tests that the stringifySafe function works as expected, i.e. correctly
// deals with circular references and BigInt.
describe('Safe stringify function', () => {
- let json, expected
+ let json: Record, expected: Record
beforeEach(() => {
json = {
complicated: {
@@ -48,9 +45,15 @@ describe('Safe stringify function', () => {
expect(stringifySafe(json)).toEqual(JSON.stringify(expected))
})
- it('should convert BigInt to Number', () => {
- expected.bigInt = 10
+ it('should convert BigInt of < Number.MAX_SAFE_INTEGER to string', () => {
+ expected.bigInt = '10'
json.bigInt = BigInt(10)
expect(stringifySafe(json)).toEqual(JSON.stringify(expected))
})
+
+ it('should convert BigInt of > Number.MAX_SAFE_INTEGER to string', () => {
+ expected.bigInt = '9007199254740995'
+ json.bigInt = BigInt('9007199254740995')
+ expect(stringifySafe(json)).toEqual(JSON.stringify(expected))
+ })
})
From 2fb08470564a9e6c1460778f3c5fbfdb4bd98c9b Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Wed, 7 Apr 2021 18:44:13 +0800
Subject: [PATCH 20/75] ref: migrate (most) /adminform endpoints to TypeScript,
add integration tests (#1567)
---
package-lock.json | 40 +-
package.json | 1 +
.../auth/__tests__/auth.routes.spec.ts | 3 +-
.../__tests__/admin-form.routes.spec.ts | 4621 +++++++++++++++++
.../form/admin-form/admin-form.controller.ts | 42 +-
.../form/admin-form/admin-form.routes.ts | 497 ++
.../encrypt-submission.controller.ts | 1 +
.../user/__tests__/user.routes.spec.ts | 9 +-
src/app/routes/admin-forms.server.routes.js | 501 +-
src/app/utils/__mocks__/limit-rate.ts | 6 +
src/loaders/express/index.ts | 2 +
tests/integration/helpers/express-auth.ts | 13 +
tests/integration/helpers/express-setup.ts | 10 +-
tests/unit/backend/helpers/serialize-data.ts | 3 +
tsconfig.build.json | 1 +
15 files changed, 5205 insertions(+), 545 deletions(-)
create mode 100644 src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
create mode 100644 src/app/modules/form/admin-form/admin-form.routes.ts
create mode 100644 src/app/utils/__mocks__/limit-rate.ts
create mode 100644 tests/unit/backend/helpers/serialize-data.ts
diff --git a/package-lock.json b/package-lock.json
index 48c76ee8a2..8ea585172d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4169,6 +4169,14 @@
}
}
},
+ "@joi/date": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@joi/date/-/date-2.1.0.tgz",
+ "integrity": "sha512-2zN5m0LgxZp/cynHGbzEImVmFIa+n+IOb/Nlw5LX/PLJneeCwG1NbiGw7MvPjsAKUGQK8z31Nn6V6lEN+4fZhg==",
+ "requires": {
+ "moment": "2.x.x"
+ }
+ },
"@mapbox/node-pre-gyp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.0.tgz",
@@ -9973,26 +9981,18 @@
"dev": true
},
"elliptic": {
- "version": "6.5.3",
- "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
- "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
+ "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
"dev": true,
"requires": {
- "bn.js": "^4.4.0",
- "brorand": "^1.0.1",
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
"hash.js": "^1.0.0",
- "hmac-drbg": "^1.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0",
- "minimalistic-crypto-utils": "^1.0.0"
- },
- "dependencies": {
- "bn.js": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
- "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
- "dev": true
- }
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
}
},
"emittery": {
@@ -25885,9 +25885,9 @@
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
"y18n": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
- "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
+ "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
"dev": true
},
"yallist": {
diff --git a/package.json b/package.json
index 626ef3574c..850c399991 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
},
"dependencies": {
"@babel/runtime": "^7.13.10",
+ "@joi/date": "^2.1.0",
"@opengovsg/angular-daterangepicker-webpack": "^1.1.5",
"@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0",
"@opengovsg/angular-recaptcha-fallback": "^5.0.0",
diff --git a/src/app/modules/auth/__tests__/auth.routes.spec.ts b/src/app/modules/auth/__tests__/auth.routes.spec.ts
index 14bead75c4..66d44b6430 100644
--- a/src/app/modules/auth/__tests__/auth.routes.spec.ts
+++ b/src/app/modules/auth/__tests__/auth.routes.spec.ts
@@ -11,6 +11,7 @@ import { IAgencySchema } from 'src/types'
import { setupApp } from 'tests/integration/helpers/express-setup'
import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data'
import { MailSendError } from '../../../services/mail/mail.errors'
import { DatabaseError } from '../../core/core.errors'
@@ -463,7 +464,7 @@ describe('auth.routes', () => {
// Body should be an user object.
expect(response.body).toMatchObject({
// Required since that's how the data is sent out from the application.
- agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ agency: jsonParseStringify(defaultAgency.toObject()),
_id: expect.any(String),
created: expect.any(String),
email: VALID_EMAIL,
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
new file mode 100644
index 0000000000..cc51e48fc6
--- /dev/null
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
@@ -0,0 +1,4621 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { ObjectId } from 'bson-ext'
+import { format, subDays } from 'date-fns'
+import { cloneDeep, take, times } from 'lodash'
+import mongoose from 'mongoose'
+import { errAsync, okAsync } from 'neverthrow'
+import SparkMD5 from 'spark-md5'
+import supertest, { Session } from 'supertest-session'
+
+import getFormModel, {
+ getEmailFormModel,
+ getEncryptedFormModel,
+} from 'src/app/models/form.server.model'
+import getFormFeedbackModel from 'src/app/models/form_feedback.server.model'
+import getSubmissionModel, {
+ getEncryptSubmissionModel,
+} from 'src/app/models/submission.server.model'
+import getUserModel from 'src/app/models/user.server.model'
+import * as AuthService from 'src/app/modules/auth/auth.service'
+import {
+ DatabaseError,
+ DatabasePayloadSizeError,
+} from 'src/app/modules/core/core.errors'
+import { saveSubmissionMetadata } from 'src/app/modules/submission/email-submission/email-submission.service'
+import { SubmissionHash } from 'src/app/modules/submission/email-submission/email-submission.types'
+import { aws } from 'src/config/config'
+import { EditFieldActions, VALID_UPLOAD_FILE_TYPES } from 'src/shared/constants'
+import {
+ BasicField,
+ IFormDocument,
+ IPopulatedEmailForm,
+ IPopulatedForm,
+ IUserSchema,
+ ResponseMode,
+ Status,
+ SubmissionCursorData,
+ SubmissionType,
+} from 'src/types'
+
+import {
+ createAuthedSession,
+ logoutSession,
+} from 'tests/integration/helpers/express-auth'
+import { setupApp } from 'tests/integration/helpers/express-setup'
+import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
+import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data'
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data'
+
+import { insertFormFeedback } from '../../public-form/public-form.service'
+import { AdminFormsRouter } from '../admin-form.routes'
+import * as AdminFormService from '../admin-form.service'
+
+// Prevent rate limiting.
+jest.mock('src/app/utils/limit-rate')
+
+const UserModel = getUserModel(mongoose)
+const FormModel = getFormModel(mongoose)
+const EmailFormModel = getEmailFormModel(mongoose)
+const EncryptFormModel = getEncryptedFormModel(mongoose)
+const FormFeedbackModel = getFormFeedbackModel(mongoose)
+const SubmissionModel = getSubmissionModel(mongoose)
+const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
+
+const app = setupApp(undefined, AdminFormsRouter, {
+ setupWithAuth: true,
+})
+
+describe('admin-form.routes', () => {
+ let request: Session
+ let defaultUser: IUserSchema
+
+ beforeAll(async () => await dbHandler.connect())
+ beforeEach(async () => {
+ request = supertest(app)
+ const { user } = await dbHandler.insertFormCollectionReqs()
+ // Default all requests to come from authenticated user.
+ request = await createAuthedSession(user.email, request)
+ defaultUser = user
+ })
+ afterEach(async () => {
+ await dbHandler.clearDatabase()
+ jest.restoreAllMocks()
+ })
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ describe('GET /adminform', () => {
+ it('should return 200 with empty array when user has no forms', async () => {
+ // Act
+ const response = await request.get('/adminform')
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual([])
+ })
+
+ it('should return 200 with a list of forms managed by the user', async () => {
+ // Arrange
+ // Create separate user
+ const collabUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'collab-user',
+ shortName: 'collabUser',
+ })
+ ).user
+
+ const ownForm = await EmailFormModel.create({
+ title: 'Own form',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ const collabForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: collabUser._id,
+ permissionList: [{ email: defaultUser.email }],
+ })
+ // Create already archived form, should not be fetched even though
+ // owner is defaultUser
+ await EmailFormModel.create({
+ title: 'Archived form',
+ emails: defaultUser.email,
+ admin: defaultUser._id,
+ status: Status.Archived,
+ })
+ // Create form that user is not collaborator/admin of. Should not be
+ // fetched.
+ await EncryptFormModel.create({
+ title: 'Does not matter',
+ publicKey: 'abracadabra',
+ admin: collabUser._id,
+ // No permissions for anyone else.
+ })
+
+ // Act
+ const response = await request.get('/adminform')
+
+ // Assert
+ // Should only receive ownForm and collabForm
+ const expected = await FormModel.find({
+ _id: {
+ $in: [ownForm._id, collabForm._id],
+ },
+ })
+ .select('_id title admin lastModified status responseMode')
+ .sort('-lastModified')
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .lean()
+ expect(response.body).toEqual(jsonParseStringify(expected))
+ expect(response.status).toEqual(200)
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get('/adminform')
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 422 when user of given id cannot be found in the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get('/adminform')
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database errors occur', async () => {
+ // Arrange
+ // Mock database error.
+ jest
+ .spyOn(FormModel, 'getMetaByUserIdOrEmail')
+ .mockRejectedValueOnce(new Error('something went wrong'))
+
+ // Act
+ const response = await request.get('/adminform')
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('POST /adminform', () => {
+ it('should return 200 with newly created email mode form', async () => {
+ // Arrange
+ const createEmailParams = {
+ form: {
+ emails: defaultUser.email,
+ responseMode: 'email',
+ title: 'email mode form test',
+ // Extra keys should be fine.
+ someExtraKey: 'extra value that will be ignored.',
+ },
+ }
+
+ // Act
+ const response = await request.post('/adminform').send(createEmailParams)
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(
+ expect.objectContaining({
+ admin: String(defaultUser._id),
+ emails: [defaultUser.email],
+ responseMode: ResponseMode.Email,
+ status: Status.Private,
+ title: createEmailParams.form.title,
+ form_fields: [],
+ form_logics: [],
+ }),
+ )
+ })
+
+ it('should return 200 with newly created storage mode form', async () => {
+ // Arrange
+ const createStorageParams = {
+ form: {
+ responseMode: 'encrypt',
+ title: 'storage mode form test',
+ publicKey: 'some random public key',
+ },
+ }
+
+ // Act
+ const response = await request
+ .post('/adminform')
+ .send(createStorageParams)
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(
+ expect.objectContaining({
+ admin: String(defaultUser._id),
+ publicKey: createStorageParams.form.publicKey,
+ responseMode: ResponseMode.Encrypt,
+ status: Status.Private,
+ title: createStorageParams.form.title,
+ form_fields: [],
+ form_logics: [],
+ }),
+ )
+ })
+
+ it('should return 400 when body.form.publicKey is missing', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ responseMode: 'encrypt',
+ title: 'storage mode form test',
+ // Missing publicKey value.
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'form.publicKey' } }),
+ )
+ })
+
+ it('should return 400 when body.form.responseMode is missing', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ // responseMode missing.
+ title: 'storage mode form test',
+ emails: 'some@example.com',
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'form.responseMode' } }),
+ )
+ })
+
+ it('should return 400 when body.form.title is missing', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ // title is missing.
+ responseMode: ResponseMode.Email,
+ emails: 'some@example.com',
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'form.title' } }),
+ )
+ })
+
+ it('should return 400 when body.form.emails is missing when creating an email form', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ // body.emails missing.
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'form.emails' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.form.emails is an empty string when creating an email form', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: '',
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'form.emails',
+ message: '"form.emails" is not allowed to be empty',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.form.emails is an empty array when creating an email form', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: [],
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'form.emails',
+ message: '"form.emails" must contain at least 1 items',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.form.publicKey is missing when creating a storage mode form', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ // publicKey missing.
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'form.publicKey',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.form.publicKey is an empty string when creating a storage mode form', async () => {
+ // Act
+ const response = await request.post('/adminform').send({
+ form: {
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: '',
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'form.publicKey',
+ message: '"form.publicKey" contains an invalid value',
+ },
+ }),
+ )
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.post('/adminform').send('does not matter')
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 413 when a payload for created form exceeds the size limit', async () => {
+ // Arrange
+ const createStorageParams = {
+ form: {
+ responseMode: 'encrypt',
+ title: 'storage mode form test',
+ publicKey: 'some random public key',
+ },
+ }
+
+ const payloadSizeError = new DatabasePayloadSizeError(
+ 'Creating a real > 16MB file in tests did not seem like a good idea',
+ )
+ jest
+ .spyOn(AdminFormService, 'createForm')
+ .mockReturnValueOnce(errAsync(payloadSizeError))
+
+ // Act
+ const response = await request
+ .post('/adminform')
+ .send(createStorageParams)
+
+ // Assert
+ expect(response.status).toEqual(413)
+ expect(response.body).toEqual({ message: payloadSizeError.message })
+ })
+
+ it('should return 422 when user cannot be found in the database', async () => {
+ // Arrange
+ const createEmailParams = {
+ form: {
+ emails: defaultUser.email,
+ responseMode: 'email',
+ title: 'email mode form test',
+ },
+ }
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.post('/adminform').send(createEmailParams)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 422 when form creation results in a database validation error', async () => {
+ // Arrange
+ const emailParamsWithInvalidDomain = {
+ form: {
+ emails: defaultUser.email,
+ responseMode: 'email',
+ title: 'email mode form test should fail',
+ permissionList: [{ email: 'invalidEmailDomain@example.com' }],
+ },
+ }
+
+ // Act
+ const response = await request
+ .post('/adminform')
+ .send(emailParamsWithInvalidDomain)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({
+ message:
+ 'Error: [Failed to update collaborators list.]. Please refresh and try again. If you still need help, email us at form@open.gov.sg.',
+ })
+ })
+
+ it('should return 500 when database error occurs whilst creating a form', async () => {
+ // Arrange
+ const createStorageParams = {
+ form: {
+ responseMode: 'encrypt',
+ title: 'storage mode form test',
+ publicKey: 'some random public key',
+ },
+ }
+
+ const databaseError = new DatabaseError('something went wrong')
+ jest
+ .spyOn(AdminFormService, 'createForm')
+ .mockReturnValueOnce(errAsync(databaseError))
+
+ // Act
+ const response = await request
+ .post('/adminform')
+ .send(createStorageParams)
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: databaseError.message,
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform', () => {
+ it('should return 200 with retrieved form when user is admin', async () => {
+ // Arrange
+ const ownForm = await EmailFormModel.create({
+ title: 'Own form',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(`/${ownForm._id}/adminform`)
+
+ // Assert
+ const expected = await FormModel.findById(ownForm._id)
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .lean()
+ expect(response.status).toEqual(200)
+ expect(response.body).not.toBeNull()
+ expect(response.body).toEqual({
+ form: jsonParseStringify(expected),
+ })
+ })
+
+ it('should return 200 with retrieved form when user has read permissions', async () => {
+ // Arrange
+ // Create separate user
+ const collabUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'collab-user',
+ shortName: 'collabUser',
+ })
+ ).user
+
+ const collabForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: collabUser._id,
+ permissionList: [{ email: defaultUser.email, write: false }],
+ })
+
+ // Act
+ const response = await request.get(`/${collabForm._id}/adminform`)
+
+ // Assert
+ const expected = await FormModel.findById(collabForm._id)
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .lean()
+ expect(response.status).toEqual(200)
+ expect(response.body).not.toBeNull()
+ expect(response.body).toEqual({
+ form: jsonParseStringify(expected),
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(`/${new ObjectId()}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(`/${inaccessibleForm._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request.get(`/${invalidFormId}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to retrieve is archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(`/${archivedForm._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(`/${new ObjectId()}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving form', async () => {
+ // Arrange
+ jest
+ .spyOn(FormModel, 'getFullFormById')
+ .mockRejectedValueOnce(new Error('some error'))
+
+ // Act
+ const response = await request.get(`/${new ObjectId()}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('PUT /:formId/adminform', () => {
+ // Skipping tests for these as the endpoints will be migrated soon.
+ it.todo('test for every single update, reorder, duplicate, etc form fields')
+
+ it('should return 200 with updated form when body.form.editFormField is provided', async () => {
+ // Arrange
+ const formToUpdate = (await EmailFormModel.create({
+ title: 'Form to update',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ form_fields: [generateDefaultField(BasicField.Date)],
+ })) as IPopulatedForm
+
+ const updatedDescription = 'some new description'
+
+ // Act
+ const response = await request
+ .put(`/${formToUpdate._id}/adminform`)
+ .send({
+ form: {
+ editFormField: {
+ action: { name: EditFieldActions.Update },
+ field: {
+ ...formToUpdate.form_fields[0].toObject(),
+ description: updatedDescription,
+ },
+ },
+ },
+ })
+
+ // Assert
+ const expected = await EmailFormModel.findById(formToUpdate._id)
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .lean()
+ expect(response.status).toEqual(200)
+ expect(expected?.form_fields![0].description).toEqual(updatedDescription)
+ expect(expected?.__v).toEqual(1)
+ expect(response.body).toEqual(jsonParseStringify(expected))
+ })
+
+ it('should return 400 when form has invalid updates to be performed', async () => {
+ // Arrange
+ const formToUpdate = (await EmailFormModel.create({
+ title: 'Form to update',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ form_fields: [generateDefaultField(BasicField.Date)],
+ })) as IPopulatedForm
+ // Delete field
+ const clonedForm = cloneDeep(formToUpdate)
+ clonedForm.form_fields = []
+ await clonedForm.save()
+
+ // Act
+ const response = await request
+ .put(`/${formToUpdate._id}/adminform`)
+ .send({
+ form: {
+ editFormField: {
+ action: { name: EditFieldActions.Update },
+ field: {
+ ...formToUpdate.form_fields[0].toObject(),
+ description: 'some new description',
+ },
+ },
+ },
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'Field to be updated does not exist',
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ const formToUpdate = await EmailFormModel.create({
+ title: 'Form to update',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .put(`/${formToUpdate._id}/adminform`)
+ .send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have permissions to update form', async () => {
+ // Arrange
+ // Create separate user
+ const collabUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'collab-user',
+ shortName: 'collabUser',
+ })
+ ).user
+ const randomForm = await EncryptFormModel.create({
+ title: 'form that user has no write access to',
+ admin: collabUser._id,
+ publicKey: 'some random key',
+ // Current user only has read access.
+ permissionList: [{ email: defaultUser.email }],
+ })
+
+ // Act
+ const response = await request.put(`/${randomForm._id}/adminform`).send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: `User ${defaultUser.email} not authorized to perform write operation on Form ${randomForm._id} with title: ${randomForm.title}.`,
+ })
+ })
+
+ it('should return 404 when form to update cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId()
+
+ // Act
+ const response = await request.put(`/${invalidFormId}/adminform`).send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ // Create archived form.
+ const archivedForm = await EmailFormModel.create({
+ title: 'Form already archived',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ status: Status.Archived,
+ })
+
+ // Act
+ const response = await request
+ .put(`/${archivedForm._id}/adminform`)
+ .send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be found in the database', async () => {
+ // Arrange
+ const formToArchive = await EmailFormModel.create({
+ title: 'Form to archive',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .put(`/${formToArchive._id}/adminform`)
+ .send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst updating form', async () => {
+ // Arrange
+ const formToUpdate = (await EmailFormModel.create({
+ title: 'Form to update',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })) as IPopulatedForm
+ formToUpdate.save = jest
+ .fn()
+ .mockRejectedValue(new Error('something happened'))
+
+ jest
+ .spyOn(AuthService, 'getFormAfterPermissionChecks')
+ .mockReturnValue(okAsync(formToUpdate))
+
+ // Act
+ const response = await request
+ .put(`/${formToUpdate._id}/adminform`)
+ .send({
+ form: { permissionList: [{ email: 'test@example.com' }] },
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Error: [something happened]. Please refresh and try again. If you still need help, email us at form@open.gov.sg.',
+ })
+ })
+ })
+
+ describe('DELETE /:formId/adminform', () => {
+ it('should return 200 with success message when form is successfully archived', async () => {
+ // Arrange
+ const formToArchive = await EmailFormModel.create({
+ title: 'Form to archive',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ expect(formToArchive.status).toEqual(Status.Private)
+
+ // Act
+ const response = await request.delete(`/${formToArchive._id}/adminform`)
+
+ // Assert
+ const form = await EmailFormModel.findById(formToArchive._id)
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ expect(form?.status).toEqual(Status.Archived)
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+ const formToArchive = await EmailFormModel.create({
+ title: 'Form to archive',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.delete(`/${formToArchive._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have permissions to archive form', async () => {
+ // Arrange
+ // Create separate user
+ const collabUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'collab-user',
+ shortName: 'collabUser',
+ })
+ ).user
+ const randomForm = await EncryptFormModel.create({
+ title: 'form that user has no delete access to',
+ admin: collabUser._id,
+ publicKey: 'some random key',
+ // Current user only has write access but not admin.
+ permissionList: [{ email: defaultUser.email, write: true }],
+ })
+
+ // Act
+ const response = await request.delete(`/${randomForm._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: `User ${defaultUser.email} not authorized to perform delete operation on Form ${randomForm._id} with title: ${randomForm.title}.`,
+ })
+ })
+
+ it('should return 404 when form to archive cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId()
+
+ // Act
+ const response = await request.delete(`/${invalidFormId}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ // Create archived form.
+ const archivedForm = await EmailFormModel.create({
+ title: 'Form already archived',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ status: Status.Archived,
+ })
+
+ // Act
+ const response = await request.delete(`/${archivedForm._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be found in the database', async () => {
+ // Arrange
+ const formToArchive = await EmailFormModel.create({
+ title: 'Form to archive',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.delete(`/${formToArchive._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst archiving form', async () => {
+ // Arrange
+ const formToArchive = await EmailFormModel.create({
+ title: 'Form to archive',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ // Mock database error during archival.
+ jest
+ .spyOn(AdminFormService, 'archiveForm')
+ .mockReturnValueOnce(errAsync(new DatabaseError()))
+
+ // Act
+ const response = await request.delete(`/${formToArchive._id}/adminform`)
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('POST /:formId/adminform', () => {
+ it('should return 200 with the duplicated form dashboard view', async () => {
+ // Arrange
+ // Create form.
+ const formToDupe = await EmailFormModel.create({
+ title: 'Original form title',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ const dupeParams = {
+ responseMode: ResponseMode.Encrypt,
+ title: 'new duplicated form title',
+ publicKey: 'some public key',
+ }
+
+ // Act
+ const response = await request
+ .post(`/${formToDupe._id}/adminform`)
+ .send(dupeParams)
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(
+ expect.objectContaining({
+ _id: expect.any(String),
+ admin: expect.objectContaining({
+ _id: String(defaultUser._id),
+ }),
+ responseMode: dupeParams.responseMode,
+ title: dupeParams.title,
+ status: Status.Private,
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is missing when duplicating to an email form', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ // body.emails missing.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'emails' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is an empty string when duplicating to an email form', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: '',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'emails',
+ message: '"emails" is not allowed to be empty',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is an empty array when duplicating to an email form', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: [],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'emails',
+ message: '"emails" must contain at least 1 items',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.publicKey is missing when duplicating to a storage mode form', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ // publicKey missing.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'publicKey',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.publicKey is an empty string when duplicating to a storage mode form', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: '',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'publicKey',
+ message: '"publicKey" contains an invalid value',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.title is missing', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ // title is missing.
+ responseMode: ResponseMode.Email,
+ emails: 'test@example.com',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'title' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.responseMode is missing', async () => {
+ // Arrange
+ const formToDupe = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ })
+
+ // Act
+ const response = await request.post(`/${formToDupe._id}/adminform`).send({
+ title: 'something',
+ // responseMode missing.
+ emails: 'test@example.com',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'responseMode' },
+ }),
+ )
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+ // Create form.
+ const formToDupe = await EmailFormModel.create({
+ title: 'Original form title',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${formToDupe._id}/adminform`)
+ .send('does not matter')
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ // Create separate user
+ const someUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const randomForm = await EncryptFormModel.create({
+ title: 'form that user has no delete access to',
+ admin: someUser._id,
+ publicKey: 'some random key',
+ // Current user has no access to this form,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.post(`/${randomForm._id}/adminform`).send({
+ responseMode: ResponseMode.Encrypt,
+ title: 'new duplicated form title',
+ publicKey: 'some public key',
+ })
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: `User ${defaultUser.email} not authorized to perform read operation on Form ${randomForm._id} with title: ${randomForm.title}.`,
+ })
+ })
+
+ it('should return 404 when form to duplicate cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId()
+
+ // Act
+ const response = await request.post(`/${invalidFormId}/adminform`).send({
+ responseMode: ResponseMode.Encrypt,
+ title: 'new duplicated form title',
+ publicKey: 'some public key',
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ // Create archived form.
+ const archivedForm = await EmailFormModel.create({
+ title: 'Form already archived',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ status: Status.Archived,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${archivedForm._id}/adminform`)
+ .send({
+ responseMode: ResponseMode.Email,
+ emails: 'anyrandomEmail@example.com',
+ title: 'cool new title',
+ })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be found in the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.post(`/${new ObjectId()}/adminform`).send({
+ responseMode: ResponseMode.Encrypt,
+ title: 'does not matter',
+ publicKey: 'some public key',
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst duplicating form', async () => {
+ // Arrange
+ // Create form.
+ const formToDupe = await EmailFormModel.create({
+ title: 'Original form title',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Force validation error that will be returned as database error
+ // TODO(#614): Return transformMongoError instead of DatabaseError for better mongoose error handling.
+ const invalidEmailDupeParams = {
+ responseMode: ResponseMode.Email,
+ emails: 'notAnEmail, should return error',
+ title: 'cool new title',
+ }
+
+ // Act
+ const response = await request
+ .post(`/${formToDupe._id}/adminform`)
+ .send(invalidEmailDupeParams)
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'Please provide valid email addresses',
+ ),
+ })
+ })
+ })
+
+ describe('POST /:formId/adminform/transfer-owner', () => {
+ it('should return 200 with updated form and owner transferred', async () => {
+ // Arrange
+ const formToTransfer = await EmailFormModel.create({
+ title: 'Original form title',
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ const newOwner = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'new-owner',
+ shortName: 'newOwner',
+ })
+ ).user
+
+ // Act
+ const response = await request
+ .post(`/${formToTransfer._id}/adminform/transfer-owner`)
+ .send({
+ email: newOwner.email,
+ })
+
+ // Assert
+ const expected = {
+ form: expect.objectContaining({
+ _id: String(formToTransfer._id),
+ // Admin should be new owner.
+ admin: expect.objectContaining({
+ _id: String(newOwner._id),
+ }),
+ // Original owner should still have write permissions.
+ permissionList: [
+ expect.objectContaining({
+ email: defaultUser.email,
+ write: true,
+ }),
+ ],
+ }),
+ }
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expected)
+ })
+
+ it('should return 400 when body.email is missing', async () => {
+ // Arrange
+ const someFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .post(`/${someFormId}/adminform/transfer-owner`)
+ // Missing email.
+ .send({})
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'email' } }),
+ )
+ })
+
+ it('should return 400 when body.email is an invalid email', async () => {
+ // Arrange
+ const someFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .post(`/${someFormId}/adminform/transfer-owner`)
+ .send({ email: 'not an email' })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'email', message: 'Please enter a valid email' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.email contains multiple emails', async () => {
+ // Arrange
+ const someFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .post(`/${someFormId}/adminform/transfer-owner`)
+ .send({ email: 'first@example.com,second@example.com' })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'email', message: 'Please enter a valid email' },
+ }),
+ )
+ })
+
+ it('should return 400 when the new owner is not in the database', async () => {
+ // Arrange
+ const emailNotInDb = 'notInDb@example.com'
+ const formToTransfer = await EncryptFormModel.create({
+ title: 'Original form title',
+ admin: defaultUser._id,
+ publicKey: 'some public key',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${formToTransfer._id}/adminform/transfer-owner`)
+ .send({ email: emailNotInDb })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: `${emailNotInDb} must have logged in once before being added as Owner`,
+ })
+ })
+
+ it('should return 400 when the new owner is already the current owner', async () => {
+ // Arrange
+ const formToTransfer = await EncryptFormModel.create({
+ title: 'Original form title',
+ admin: defaultUser._id,
+ publicKey: 'some public key',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${formToTransfer._id}/adminform/transfer-owner`)
+ .send({ email: defaultUser.email })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'You are already the owner of this form',
+ })
+ })
+
+ it('should return 403 when current user is not the owner of the form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const yetAnotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'anotheranother-user',
+ shortName: 'someOtherUser',
+ })
+ ).user
+ const notOwnerForm = await EmailFormModel.create({
+ title: 'Original form title',
+ emails: [anotherUser.email],
+ admin: anotherUser._id,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${notOwnerForm._id}/adminform/transfer-owner`)
+ .send({ email: yetAnotherUser.email })
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform delete operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form to transfer cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+
+ // Act
+ const response = await request
+ .post(`/${invalidFormId}/adminform/transfer-owner`)
+ .send({ email: anotherUser.email })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({
+ message: 'Form not found',
+ })
+ })
+
+ it('should return 410 when the form to transfer is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'Original form title',
+ admin: defaultUser._id,
+ publicKey: 'some public key',
+ // Already deleted.
+ status: Status.Archived,
+ })
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+
+ // Act
+ const response = await request
+ .post(`/${archivedForm._id}/adminform/transfer-owner`)
+ .send({ email: anotherUser.email })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when the user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const formToTransfer = await EncryptFormModel.create({
+ title: 'Original form title',
+ admin: defaultUser._id,
+ publicKey: 'some public key',
+ })
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .post(`/${formToTransfer._id}/adminform/transfer-owner`)
+ .send({ email: anotherUser.email })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+ })
+
+ describe('GET /:formId/adminform/template', () => {
+ it("should return 200 with target form's public view", async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Create public form
+ const publicForm = await FormModel.create({
+ title: 'some public form',
+ status: Status.Public,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ form_fields: [
+ generateDefaultField(BasicField.Date),
+ generateDefaultField(BasicField.Nric),
+ ],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${publicForm._id}/adminform/template`,
+ )
+
+ // Assert
+ const populatedForm = await publicForm
+ .populate({ path: 'admin', populate: { path: 'agency' } })
+ .execPopulate()
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({
+ form: jsonParseStringify(populatedForm.getPublicView()),
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/template`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when the target form is private', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Create private form
+ const privateForm = await FormModel.create({
+ title: 'some private form',
+ status: Status.Private,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ form_fields: [generateDefaultField(BasicField.Nric)],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${privateForm._id}/adminform/template`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ formTitle: privateForm.title,
+ isPageFound: true,
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 404 when the form cannot be found', async () => {
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/template`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when the form is already archived', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const archivedForm = await FormModel.create({
+ title: 'some archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ form_fields: [generateDefaultField(BasicField.Nric)],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/template`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been deleted' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving form', async () => {
+ // Arrange
+ const formToRetrieve = await FormModel.create({
+ title: 'some form',
+ status: Status.Public,
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ form_fields: [generateDefaultField(BasicField.Nric)],
+ })
+ // Mock database error.
+ jest
+ .spyOn(FormModel, 'getFullFormById')
+ .mockRejectedValueOnce(new Error('something went wrong'))
+
+ // Act
+ const response = await request.get(
+ `/${formToRetrieve._id}/adminform/template`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('POST /:formId/adminform/copy', () => {
+ let formToCopy: IFormDocument
+ let anotherUser: IUserSchema
+
+ beforeEach(async () => {
+ anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ formToCopy = (await EncryptFormModel.create({
+ title: 'some form',
+ admin: anotherUser._id,
+ publicKey: 'some random key',
+ // Must be public to copy
+ status: Status.Public,
+ })) as IFormDocument
+ })
+
+ it('should return 200 with duplicated form dashboard view when copying to an email mode form', async () => {
+ // Act
+ const bodyParams = {
+ title: 'some title',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ }
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send(bodyParams)
+
+ // Assert
+ const expected = expect.objectContaining({
+ _id: expect.any(String),
+ admin: expect.objectContaining({
+ _id: defaultUser.id,
+ }),
+ title: bodyParams.title,
+ responseMode: bodyParams.responseMode,
+ })
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expected)
+ })
+
+ it('should return 200 with duplicated form dashboard view when copying to a storage mode form', async () => {
+ // Act
+ const bodyParams = {
+ title: 'some other title',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ }
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send(bodyParams)
+
+ // Assert
+ const expected = expect.objectContaining({
+ _id: expect.any(String),
+ admin: expect.objectContaining({
+ _id: defaultUser.id,
+ }),
+ title: bodyParams.title,
+ responseMode: bodyParams.responseMode,
+ })
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expected)
+ })
+
+ it('should return 400 when body.responseMode is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'some title',
+ // body.responseMode is missing
+ emails: [defaultUser.email],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'responseMode' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.responseMode is invalid', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'some title',
+ responseMode: 'some rubbish',
+ emails: [defaultUser.email],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'responseMode',
+ message: `"responseMode" must be one of [${Object.values(
+ ResponseMode,
+ ).join(', ')}]`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.title is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ // body.title missing
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'title' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is missing when copying to an email form', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ // body.emails missing.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'emails' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is an empty string when copying to an email form', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: '',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'emails',
+ message: '"emails" is not allowed to be empty',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.emails is an empty array when copying to an email form', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'new email form',
+ responseMode: ResponseMode.Email,
+ emails: [],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'emails',
+ message: '"emails" must contain at least 1 items',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.publicKey is missing when copying to a storage mode form', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ // publicKey missing.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'publicKey',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.publicKey is an empty string when copying to a storage mode form', async () => {
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'new storage mode form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: '',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'publicKey',
+ message: '"publicKey" contains an invalid value',
+ },
+ }),
+ )
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.post(`/${new ObjectId()}/adminform/copy`)
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when form to copy is private', async () => {
+ // Arrange
+ const bodyParams = {
+ title: 'some title',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ }
+ // Create private form
+ const privateForm = await FormModel.create({
+ title: 'some private form',
+ status: Status.Private,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ form_fields: [generateDefaultField(BasicField.Nric)],
+ })
+
+ // Act
+ const response = await request
+ .post(`/${privateForm._id}/adminform/copy`)
+ .send(bodyParams)
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: 'Form must be public to be copied',
+ })
+ })
+
+ it('should return 404 when the form cannot be found', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/copy`)
+ .send({
+ title: 'some new title',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'booyeah',
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({
+ message: 'Form not found',
+ })
+ })
+
+ it('should return 410 when the form to copy is archived', async () => {
+ // Arrange
+ // Create archived form.
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: anotherUser._id,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${archivedForm._id}/adminform/copy`)
+ .send({
+ title: 'some new title',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'booyeah',
+ })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({
+ message: 'Form has been deleted',
+ })
+ })
+
+ it('should return 422 when the user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'some new title',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'booyeah',
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst copying form', async () => {
+ // Arrange
+ // Mock database error.
+ const mockErrorString = 'something went wrong'
+ jest
+ .spyOn(FormModel, 'create')
+ // @ts-ignore
+ .mockRejectedValueOnce(new Error(mockErrorString))
+
+ // Act
+ const response = await request
+ .post(`/${formToCopy._id}/adminform/copy`)
+ .send({
+ title: 'some new title',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'booyeah',
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: `Error: [${mockErrorString}]. Please refresh and try again. If you still need help, email us at form@open.gov.sg.`,
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/preview', () => {
+ it("should return 200 with own form's public form view even when private", async () => {
+ // Arrange
+ const formToPreview = await EncryptFormModel.create({
+ title: 'some form',
+ admin: defaultUser._id,
+ publicKey: 'some random key',
+ // Private status.
+ status: Status.Private,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${formToPreview._id}/adminform/preview`,
+ )
+
+ // Assert
+ const expectedForm = (
+ await formToPreview
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .execPopulate()
+ ).getPublicView()
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({
+ form: jsonParseStringify(expectedForm),
+ })
+ })
+
+ it("should return 200 with collaborator's form's public form view", async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const collabFormToPreview = await EmailFormModel.create({
+ title: 'some form',
+ admin: anotherUser._id,
+ emails: [anotherUser.email],
+ // Only read permissions.
+ permissionList: [{ email: defaultUser.email }],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${collabFormToPreview._id}/adminform/preview`,
+ )
+
+ // Assert
+ const expectedForm = (
+ await collabFormToPreview
+ .populate({
+ path: 'admin',
+ populate: {
+ path: 'agency',
+ },
+ })
+ .execPopulate()
+ ).getPublicView()
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({ form: jsonParseStringify(expectedForm) })
+ })
+ it('should return 403 when user does not have read permissions for form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ const unauthedForm = await EmailFormModel.create({
+ title: 'some form',
+ admin: anotherUser._id,
+ emails: [anotherUser.email],
+ // defaultUser does not have read permissions.
+ })
+
+ // Act
+ const response = await request.get(
+ `/${unauthedForm._id}/adminform/preview`,
+ )
+
+ // Arrange
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form to preview cannot be found', async () => {
+ // Act
+ const response = await request.get(`/${new ObjectId()}/adminform/preview`)
+
+ // Arrange
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({
+ message: 'Form not found',
+ })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/preview`,
+ )
+
+ // Arrange
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({
+ message: 'Form has been archived',
+ })
+ })
+
+ it('should return 422 when user in session cannot be found in the database', async () => {
+ // Arrange
+ const formToPreview = await EmailFormModel.create({
+ title: 'some other form',
+ admin: defaultUser._id,
+ status: Status.Public,
+ emails: [defaultUser.email],
+ })
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${formToPreview._id}/adminform/preview`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({
+ message: 'User not found',
+ })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving form to preview', async () => {
+ // Arrange
+ const formToPreview = await EmailFormModel.create({
+ title: 'some other form',
+ admin: defaultUser._id,
+ status: Status.Public,
+ emails: [defaultUser.email],
+ })
+ // Mock database error.
+ jest
+ .spyOn(FormModel, 'getFullFormById')
+ .mockRejectedValueOnce(new Error('something went wrong'))
+
+ // Act
+ const response = await request.get(
+ `/${formToPreview._id}/adminform/preview`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/feedback', () => {
+ let formForFeedback: IFormDocument
+ beforeEach(async () => {
+ formForFeedback = (await EncryptFormModel.create({
+ title: 'form to view feedback',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })) as IFormDocument
+ })
+
+ it('should return 200 with empty feedback meta when no feedback exists', async () => {
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({
+ count: 0,
+ feedback: [],
+ })
+ })
+
+ it('should return 200 with form feedback meta when feedback exists', async () => {
+ // Arrange
+ const formFeedbacks = [
+ { formId: formForFeedback._id, rating: 5, comment: 'nice' },
+ { formId: formForFeedback._id, rating: 2, comment: 'not nice' },
+ ]
+ await insertFormFeedback(formFeedbacks[0])
+ await insertFormFeedback(formFeedbacks[1])
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ const expected = {
+ average: (
+ formFeedbacks.reduce((a, b) => a + b.rating, 0) / formFeedbacks.length
+ ).toFixed(2),
+ count: formFeedbacks.length,
+ feedback: [
+ expect.objectContaining({
+ comment: formFeedbacks[0].comment,
+ rating: formFeedbacks[0].rating,
+ date: expect.any(String),
+ dateShort: expect.any(String),
+ timestamp: expect.any(Number),
+ index: 1,
+ }),
+ expect.objectContaining({
+ comment: formFeedbacks[1].comment,
+ rating: formFeedbacks[1].rating,
+ date: expect.any(String),
+ dateShort: expect.any(String),
+ timestamp: expect.any(Number),
+ index: 2,
+ }),
+ ],
+ }
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expected)
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions for form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${inaccessibleForm._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form cannot be found', async () => {
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving form feedback', async () => {
+ // Arrange
+ // Mock database error
+ // @ts-ignore
+ jest.spyOn(FormFeedbackModel, 'find').mockImplementationOnce(() => ({
+ sort: jest.fn().mockReturnThis(),
+ exec: jest
+ .fn()
+ .mockRejectedValueOnce(new Error('something went wrong')),
+ }))
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Error: [something went wrong]. Please refresh and try again. If you still need help, email us at form@open.gov.sg.',
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/feedback/count', () => {
+ let formForFeedback: IFormDocument
+ beforeEach(async () => {
+ formForFeedback = (await EncryptFormModel.create({
+ title: 'form to view feedback',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })) as IFormDocument
+ })
+
+ it('should return 200 with 0 count when no feedback exists', async () => {
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(0)
+ })
+
+ it('should return 200 with feedback count when feedback exists', async () => {
+ // Arrange
+ const formFeedbacks = [
+ { formId: formForFeedback._id, rating: 5, comment: 'nice' },
+ { formId: formForFeedback._id, rating: 2, comment: 'not nice' },
+ ]
+ await insertFormFeedback(formFeedbacks[0])
+ await insertFormFeedback(formFeedbacks[1])
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(formFeedbacks.length)
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions for form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${inaccessibleForm._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form cannot be found', async () => {
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving form feedback', async () => {
+ // Arrange
+ // Mock database error
+ // @ts-ignore
+ jest.spyOn(FormFeedbackModel, 'countDocuments').mockReturnValueOnce({
+ exec: jest
+ .fn()
+ .mockRejectedValueOnce(new Error('something went wrong')),
+ })
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message:
+ 'Error: [something went wrong]. Please refresh and try again. If you still need help, email us at form@open.gov.sg.',
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/feedback/download', () => {
+ let formForFeedback: IFormDocument
+ beforeEach(async () => {
+ formForFeedback = (await EncryptFormModel.create({
+ title: 'form to view feedback',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })) as IFormDocument
+ })
+
+ it('should return 200 with feedback stream when feedbacks exist', async () => {
+ // Arrange
+ const formFeedbacks = [
+ { formId: formForFeedback._id, rating: 5, comment: 'nice' },
+ { formId: formForFeedback._id, rating: 2, comment: 'not nice' },
+ ]
+ await insertFormFeedback(formFeedbacks[0])
+ await insertFormFeedback(formFeedbacks[1])
+
+ // Act
+ const response = await request
+ .get(`/${formForFeedback._id}/adminform/feedback/download`)
+ .buffer()
+ .parse((res, cb) => {
+ let buffer = ''
+ res.on('data', (chunk) => {
+ buffer += chunk
+ })
+ res.on('end', () => {
+ cb(null, JSON.parse(buffer))
+ })
+ })
+
+ // Assert
+ const expected = await FormFeedbackModel.find({
+ formId: formForFeedback._id,
+ }).exec()
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(jsonParseStringify(expected))
+ })
+
+ it('should return 200 with empty stream when feedbacks do not exist', async () => {
+ // Act
+ const response = await request
+ .get(`/${formForFeedback._id}/adminform/feedback/download`)
+ .buffer()
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual([])
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions for form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${inaccessibleForm._id}/adminform/feedback/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form cannot be found', async () => {
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/feedback/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/feedback/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Delete user after login.
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${formForFeedback._id}/adminform/feedback`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+ })
+
+ describe('GET /:formId/adminform/submissions', () => {
+ let defaultForm: IFormDocument
+
+ beforeEach(async () => {
+ defaultForm = (await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'any public key',
+ admin: defaultUser._id,
+ })) as IFormDocument
+ })
+
+ it('should return 200 with encrypted submission data of queried submissionId (without attachments)', async () => {
+ // Arrange
+ const expectedSubmissionParams = {
+ encryptedContent: 'any encrypted content',
+ verifiedContent: 'any verified content',
+ }
+ const submission = await createSubmission({
+ form: defaultForm,
+ ...expectedSubmissionParams,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(submission._id),
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({
+ attachmentMetadata: {},
+ content: expectedSubmissionParams.encryptedContent,
+ refNo: String(submission._id),
+ submissionTime: expect.any(String),
+ verified: expectedSubmissionParams.verifiedContent,
+ })
+ })
+
+ it('should return 200 with encrypted submission data of queried submissionId (with attachments)', async () => {
+ // Arrange
+ const expectedSubmissionParams = {
+ encryptedContent: 'any encrypted content',
+ verifiedContent: 'any verified content',
+ attachmentMetadata: new Map([
+ ['fieldId1', 'some.attachment.url'],
+ ['fieldId2', 'some.other.attachment.url'],
+ ]),
+ }
+ const submission = await createSubmission({
+ form: defaultForm,
+ ...expectedSubmissionParams,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(submission._id),
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({
+ attachmentMetadata: {
+ fieldId1: expect.stringContaining(
+ expectedSubmissionParams.attachmentMetadata.get('fieldId1') ?? 'no',
+ ),
+ fieldId2: expect.stringContaining(
+ expectedSubmissionParams.attachmentMetadata.get('fieldId2') ?? 'no',
+ ),
+ },
+ content: expectedSubmissionParams.encryptedContent,
+ refNo: String(submission._id),
+ submissionTime: expect.any(String),
+ verified: expectedSubmissionParams.verifiedContent,
+ })
+ })
+
+ it('should return 400 when form of given formId is not an encrypt mode form', async () => {
+ // Arrange
+ const emailForm = await EmailFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${emailForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'Attempted to submit encrypt form to email endpoint',
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request
+ .get(`/${inaccessibleForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when submission cannot be found', async () => {
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({
+ message: 'Unable to find encrypted submission from database',
+ })
+ })
+
+ it('should return 404 when form to retrieve submission for cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .get(`/${invalidFormId}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to retrieve submission for is archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${archivedForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .get(`/${new ObjectId()}/adminform/submissions`)
+ .query({
+ submissionId: String(new ObjectId()),
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving submission', async () => {
+ // Arrange
+ jest
+ .spyOn(EncryptSubmissionModel, 'findEncryptedSubmissionById')
+ .mockRejectedValueOnce(new Error('ohno'))
+ const submission = await createSubmission({
+ form: defaultForm,
+ encryptedContent: 'any encrypted content',
+ verifiedContent: 'any verified content',
+ })
+
+ // Act
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(submission._id),
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: expect.stringContaining('ohno'),
+ })
+ })
+
+ it('should return 500 when error occurs whilst creating presigned attachment urls', async () => {
+ // Arrange
+ // Mock error.
+ jest
+ .spyOn(aws.s3, 'getSignedUrlPromise')
+ .mockRejectedValueOnce(new Error('something went wrong'))
+
+ const submission = await createSubmission({
+ form: defaultForm,
+ encryptedContent: 'any encrypted content',
+ verifiedContent: 'any verified content',
+ attachmentMetadata: new Map([
+ ['fieldId1', 'some.attachment.url'],
+ ['fieldId2', 'some.other.attachment.url'],
+ ]),
+ })
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions`)
+ .query({
+ submissionId: String(submission._id),
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Failed to create attachment URL',
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/submissions/count', () => {
+ it('should return 200 with 0 count when email mode form has no submissions', async () => {
+ // Arrange
+ const newForm = await EmailFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${newForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(0)
+ })
+
+ it('should return 200 with 0 count when storage mode form has no submissions', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${newForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(0)
+ })
+
+ it('should return 200 with form submission counts for email mode forms', async () => {
+ // Arrange
+ const expectedSubmissionCount = 5
+ const newForm = (await EmailFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })) as IPopulatedEmailForm
+ // Insert submissions
+ const mockSubmissionHash: SubmissionHash = {
+ hash: 'some hash',
+ salt: 'some salt',
+ }
+ await Promise.all(
+ times(expectedSubmissionCount, () =>
+ saveSubmissionMetadata(newForm, mockSubmissionHash),
+ ),
+ )
+
+ // Act
+ const response = await request.get(
+ `/${newForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expectedSubmissionCount)
+ })
+
+ it('should return 200 with form submission counts for storage mode forms', async () => {
+ // Arrange
+ const expectedSubmissionCount = 3
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+ await Promise.all(
+ times(expectedSubmissionCount, (count) => {
+ return SubmissionModel.create({
+ submissionType: SubmissionType.Encrypt,
+ form: newForm._id,
+ authType: newForm.authType,
+ myInfoFields: newForm.getUniqueMyInfoAttrs(),
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ attachmentMetadata: new Map(),
+ version: 1,
+ })
+ }),
+ )
+
+ // Act
+ const response = await request.get(
+ `/${newForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(expectedSubmissionCount)
+ })
+
+ it('should return 200 with counts of submissions made between given start and end dates.', async () => {
+ // Arrange
+ const expectedSubmissionCount = 3
+ const newForm = (await EmailFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })) as IPopulatedEmailForm
+ // Insert submissions
+ const mockSubmissionHash: SubmissionHash = {
+ hash: 'some hash',
+ salt: 'some salt',
+ }
+ const results = await Promise.all(
+ times(expectedSubmissionCount, () =>
+ saveSubmissionMetadata(newForm, mockSubmissionHash),
+ ),
+ )
+ // Update first submission to be 5 days ago.
+ const now = new Date()
+ const firstSubmission = results[0]._unsafeUnwrap()
+ firstSubmission.created = subDays(now, 5)
+ await firstSubmission.save()
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ startDate: format(subDays(now, 6), 'yyyy-MM-dd'),
+ endDate: format(subDays(now, 3), 'yyyy-MM-dd'),
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual(1)
+ })
+
+ it('should return 400 when query.startDate is missing when query.endDate is provided', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ endDate: '2021-04-06',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ query: {
+ key: 'endDate',
+ message:
+ '"endDate" date references "ref:startDate" which must have a valid date format',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when query.endDate is missing when query.startDate is provided', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ startDate: '2021-04-06',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ query: {
+ key: '',
+ message:
+ '"value" contains [startDate] without its required peers [endDate]',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when query.startDate is malformed', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ startDate: 'not a date',
+ endDate: '2021-04-06',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ query: {
+ key: 'startDate',
+ message: '"startDate" must be in YYYY-MM-DD format',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when query.endDate is malformed', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ startDate: '2021-04-06',
+ // Wrong format
+ endDate: '04-06-1993',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ query: {
+ key: 'endDate',
+ message: '"endDate" must be in YYYY-MM-DD format',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when query.endDate is before query.startDate', async () => {
+ // Arrange
+ const newForm = await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${newForm._id}/adminform/submissions/count`)
+ .query({
+ startDate: '2021-04-06',
+ endDate: '2020-01-01',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ query: {
+ key: 'endDate',
+ message: '"endDate" must be greater than "ref:startDate"',
+ },
+ }),
+ )
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${inaccessibleForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form to retrieve submission counts for cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request.get(
+ `/${invalidFormId}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to retrieve submission counts for is archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst counting submissions', async () => {
+ // Arrange
+ const form = await EmailFormModel.create({
+ title: 'normal form',
+ status: Status.Private,
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+ // @ts-ignore
+ jest.spyOn(SubmissionModel, 'countDocuments').mockReturnValueOnce({
+ exec: jest.fn().mockRejectedValueOnce(new Error('some error')),
+ })
+
+ // Act
+ const response = await request.get(
+ `/${form._id}/adminform/submissions/count`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: 'Something went wrong. Please try again.',
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/submissions/metadata', () => {
+ let defaultForm: IFormDocument
+
+ beforeEach(async () => {
+ defaultForm = (await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'any public key',
+ admin: defaultUser._id,
+ })) as IFormDocument
+ })
+
+ it('should return 200 with empty results if no metadata exists', async () => {
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 1,
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ expect(response.body).toEqual({ count: 0, metadata: [] })
+ })
+
+ it('should return 200 with requested page of metadata when metadata exists', async () => {
+ // Arrange
+ // Create 11 submissions
+ const submissions = (
+ await Promise.all(
+ times(11, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ }),
+ ),
+ )
+ )
+ // @ts-ignore
+ .sort((a, b) => b.created! - a.created!)
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 1,
+ })
+
+ // Assert
+ // Take first 10 submissions
+ const expected = take(submissions, 10).map((s, index) => ({
+ number: 11 - index,
+ refNo: String(s._id),
+ submissionTime: expect.any(String),
+ }))
+ expect(response.status).toEqual(200)
+ // Should be 11, but only return metadata of last 10 submissions due to page size.
+ expect(response.body).toEqual({
+ count: 11,
+ metadata: expected,
+ })
+ })
+
+ it('should return 200 with empty results if query.page does not have metadata', async () => {
+ // Arrange
+ // Create single submission
+ await createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content`,
+ verifiedContent: `any verified content`,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ // Page 2 should have no submissions
+ page: 2,
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Single submission count, but no metadata returned
+ expect(response.body).toEqual({
+ count: 1,
+ metadata: [],
+ })
+ })
+
+ it('should return 200 with metadata of single submissionId when query.submissionId is provided', async () => {
+ // Arrange
+ // Create 3 submissions
+ const submissions = await Promise.all(
+ times(3, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ }),
+ ),
+ )
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ submissionId: String(submissions[1]._id),
+ })
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Only return the single submission id's metadata
+ expect(response.body).toEqual({
+ count: 1,
+ metadata: [
+ {
+ number: 1,
+ refNo: String(submissions[1]._id),
+ submissionTime: expect.any(String),
+ },
+ ],
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request
+ .get(`/${inaccessibleForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form to retrieve submission metadata for cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .get(`/${invalidFormId}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to retrieve submission metadata for is archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .get(`/${archivedForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .get(`/${new ObjectId()}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving submission metadata list', async () => {
+ // Arrange
+ jest
+ .spyOn(EncryptSubmissionModel, 'findAllMetadataByFormId')
+ .mockRejectedValueOnce(new Error('ohno'))
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ page: 10,
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: expect.stringContaining('ohno'),
+ })
+ })
+
+ it('should return 500 when database error occurs whilst retrieving single submission metadata', async () => {
+ // Arrange
+ jest
+ .spyOn(EncryptSubmissionModel, 'findSingleMetadata')
+ .mockRejectedValueOnce(new Error('ohno'))
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/metadata`)
+ .query({
+ submissionId: new ObjectId().toHexString(),
+ })
+
+ // Assert
+ expect(response.status).toEqual(500)
+ expect(response.body).toEqual({
+ message: expect.stringContaining('ohno'),
+ })
+ })
+ })
+
+ describe('GET /:formId/adminform/submissions/download', () => {
+ let defaultForm: IFormDocument
+
+ beforeEach(async () => {
+ defaultForm = (await EncryptFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'any public key',
+ admin: defaultUser._id,
+ })) as IFormDocument
+ })
+
+ it('should return 200 with stream of encrypted responses without attachment URLs when query.downloadAttachments is false', async () => {
+ // Arrange
+ const submissions = await Promise.all(
+ times(11, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ attachmentMetadata: new Map([
+ ['fieldId1', `some.attachment.url.${count}`],
+ ['fieldId2', `some.other.attachment.url.${count}`],
+ ]),
+ }),
+ ),
+ )
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/download`)
+ .query({ downloadAttachments: false })
+ .buffer()
+ .parse((res, cb) => {
+ let buffer = ''
+ res.on('data', (chunk) => {
+ buffer += chunk
+ })
+ res.on('end', () => cb(null, buffer))
+ })
+
+ // Assert
+ const expectedSorted = submissions
+ .map((s) =>
+ jsonParseStringify({
+ _id: s._id,
+ submissionType: s.submissionType,
+ // Expect returned submissions to not have attachment metadata.
+ attachmentMetadata: {},
+ encryptedContent: s.encryptedContent,
+ verifiedContent: s.verifiedContent,
+ created: s.created,
+ }),
+ )
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+
+ const actualSorted = (response.body as string)
+ .split('\n')
+ .map(
+ (submissionStr: string) =>
+ JSON.parse(submissionStr) as SubmissionCursorData,
+ )
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+
+ expect(response.status).toEqual(200)
+ expect(actualSorted).toEqual(expectedSorted)
+ })
+
+ it('should return 200 with stream of encrypted responses with attachment URLs when query.downloadAttachments is true', async () => {
+ // Arrange
+ const submissions = await Promise.all(
+ times(5, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ attachmentMetadata: new Map([
+ ['fieldId1', `some.attachment.url.${count}`],
+ ['fieldId2', `some.other.attachment.url.${count}`],
+ ]),
+ }),
+ ),
+ )
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/download`)
+ .query({ downloadAttachments: true })
+ .buffer()
+ .parse((res, cb) => {
+ let buffer = ''
+ res.on('data', (chunk) => {
+ buffer += chunk
+ })
+ res.on('end', () => cb(null, buffer))
+ })
+
+ // Assert
+ const expectedSorted = submissions
+ .map((s) =>
+ jsonParseStringify({
+ _id: s._id,
+ submissionType: s.submissionType,
+ // Expect returned submissions to also have attachment metadata.
+ attachmentMetadata: s.attachmentMetadata,
+ encryptedContent: s.encryptedContent,
+ verifiedContent: s.verifiedContent,
+ created: s.created,
+ }),
+ )
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+ .map((s) => ({
+ ...s,
+ // Require second map due to stringify stage prior to this.
+ attachmentMetadata: {
+ fieldId1: expect.stringContaining(s.attachmentMetadata['fieldId1']),
+ fieldId2: expect.stringContaining(s.attachmentMetadata['fieldId2']),
+ },
+ }))
+
+ const actualSorted = (response.body as string)
+ .split('\n')
+ .map(
+ (submissionStr: string) =>
+ JSON.parse(submissionStr) as SubmissionCursorData,
+ )
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+
+ expect(response.status).toEqual(200)
+ expect(actualSorted).toEqual(expectedSorted)
+ })
+
+ it('should return 200 with stream of encrypted responses between given query.startDate and query.endDate', async () => {
+ // Arrange
+ const submissions = await Promise.all(
+ times(5, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ attachmentMetadata: new Map([
+ ['fieldId1', `some.attachment.url.${count}`],
+ ['fieldId2', `some.other.attachment.url.${count}`],
+ ]),
+ }),
+ ),
+ )
+ // Set 2 submissions to be submitted 3-4 days ago.
+ const now = new Date()
+ submissions[2].created = subDays(now, 3)
+ submissions[4].created = subDays(now, 4)
+ await submissions[2].save()
+ await submissions[4].save()
+ const expectedSubmissionIds = [
+ String(submissions[2]._id),
+ String(submissions[4]._id),
+ ]
+
+ // Act
+ const response = await request
+ .get(`/${defaultForm._id}/adminform/submissions/download`)
+ .query({
+ startDate: format(subDays(now, 4), 'yyyy-MM-dd'),
+ endDate: format(subDays(now, 3), 'yyyy-MM-dd'),
+ })
+ .buffer()
+ .parse((res, cb) => {
+ let buffer = ''
+ res.on('data', (chunk) => {
+ buffer += chunk
+ })
+ res.on('end', () => cb(null, buffer))
+ })
+
+ // Assert
+ const expectedSorted = submissions
+ .map((s) =>
+ jsonParseStringify({
+ _id: s._id,
+ submissionType: s.submissionType,
+ // Expect returned submissions to not have attachment metadata since query is false.
+ attachmentMetadata: {},
+ encryptedContent: s.encryptedContent,
+ verifiedContent: s.verifiedContent,
+ created: s.created,
+ }),
+ )
+ .filter((s) => expectedSubmissionIds.includes(s._id))
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+
+ const actualSorted = (response.body as string)
+ .split('\n')
+ .map(
+ (submissionStr: string) =>
+ JSON.parse(submissionStr) as SubmissionCursorData,
+ )
+ .sort((a, b) => String(a._id).localeCompare(String(b._id)))
+
+ expect(response.status).toEqual(200)
+ expect(actualSorted).toEqual(expectedSorted)
+ })
+
+ it('should return 400 when form of given formId is not an encrypt mode form', async () => {
+ // Arrange
+ const emailForm = await EmailFormModel.create({
+ title: 'new form',
+ responseMode: ResponseMode.Email,
+ emails: [defaultUser.email],
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${emailForm._id}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'Attempted to submit encrypt form to email endpoint',
+ })
+ })
+
+ it('should return 401 when user is not logged in', async () => {
+ // Arrange
+ await logoutSession(request)
+
+ // Act
+ const response = await request.get(
+ `/${defaultForm._id}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 403 when user does not have read permissions to form', async () => {
+ // Arrange
+ const anotherUser = (
+ await dbHandler.insertFormCollectionReqs({
+ userId: new ObjectId(),
+ mailName: 'some-user',
+ shortName: 'someUser',
+ })
+ ).user
+ // Form that defaultUser has no access to.
+ const inaccessibleForm = await EncryptFormModel.create({
+ title: 'Collab form',
+ publicKey: 'some public key',
+ admin: anotherUser._id,
+ permissionList: [],
+ })
+
+ // Act
+ const response = await request.get(
+ `/${inaccessibleForm._id}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(403)
+ expect(response.body).toEqual({
+ message: expect.stringContaining(
+ 'not authorized to perform read operation',
+ ),
+ })
+ })
+
+ it('should return 404 when form to download submissions for cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request.get(
+ `/${invalidFormId}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to download submissions for is archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request.get(
+ `/${archivedForm._id}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request.get(
+ `/${new ObjectId()}/adminform/submissions/download`,
+ )
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+ })
+
+ describe('POST /:formId/adminform/images', () => {
+ const DEFAULT_POST_PARAMS = {
+ fileId: 'some file id',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: VALID_UPLOAD_FILE_TYPES[0],
+ }
+
+ it('should return 200 with presigned POST URL object', async () => {
+ // Arrange
+ const form = await EncryptFormModel.create({
+ title: 'form',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${form._id}/adminform/images`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Should equal mocked result.
+ expect(response.body).toEqual({
+ url: expect.any(String),
+ fields: expect.objectContaining({
+ 'Content-MD5': DEFAULT_POST_PARAMS.fileMd5Hash,
+ 'Content-Type': DEFAULT_POST_PARAMS.fileType,
+ key: DEFAULT_POST_PARAMS.fileId,
+ // Should have correct permissions.
+ acl: 'public-read',
+ bucket: expect.any(String),
+ }),
+ })
+ })
+
+ it('should return 400 when body.fileId is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ // missing fileId.
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: VALID_UPLOAD_FILE_TYPES[0],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileId' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileId is an empty string', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ fileId: '',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: VALID_UPLOAD_FILE_TYPES[1],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileId',
+ message: '"fileId" is not allowed to be empty',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileType is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ // Missing fileType.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileType' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileType is invalid', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: 'some random type',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileType',
+ message: `"fileType" must be one of [${VALID_UPLOAD_FILE_TYPES.join(
+ ', ',
+ )}]`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileMd5Hash is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ fileId: 'some id',
+ // Missing fileMd5Hash
+ fileType: VALID_UPLOAD_FILE_TYPES[2],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileMd5Hash' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileMd5Hash is not a base64 string', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: 'rubbish hash',
+ fileType: VALID_UPLOAD_FILE_TYPES[2],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileMd5Hash',
+ message: '"fileMd5Hash" must be a valid base64 string',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when creating presigned POST URL object errors', async () => {
+ // Arrange
+ // Mock error.
+ jest
+ .spyOn(aws.s3, 'createPresignedPost')
+ // @ts-ignore
+ .mockImplementationOnce((_opts, cb) =>
+ cb(new Error('something went wrong')),
+ )
+ const form = await EncryptFormModel.create({
+ title: 'form',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${form._id}/adminform/images`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'Error occurred whilst uploading file',
+ })
+ })
+
+ it('should return 404 when form to upload image to cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .post(`/${invalidFormId}/adminform/images`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to upload image to is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${archivedForm._id}/adminform/images`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/images`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+ })
+
+ describe('POST /:formId/adminform/logos', () => {
+ const DEFAULT_POST_PARAMS = {
+ fileId: 'some other file id',
+ fileMd5Hash: SparkMD5.hash('test file name again'),
+ fileType: VALID_UPLOAD_FILE_TYPES[2],
+ }
+
+ it('should return 200 with presigned POST URL object', async () => {
+ // Arrange
+ const form = await EncryptFormModel.create({
+ title: 'form',
+ admin: defaultUser._id,
+ publicKey: 'does not matter',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${form._id}/adminform/logos`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(200)
+ // Should equal mocked result.
+ expect(response.body).toEqual({
+ url: expect.any(String),
+ fields: expect.objectContaining({
+ 'Content-MD5': DEFAULT_POST_PARAMS.fileMd5Hash,
+ 'Content-Type': DEFAULT_POST_PARAMS.fileType,
+ key: DEFAULT_POST_PARAMS.fileId,
+ // Should have correct permissions.
+ acl: 'public-read',
+ bucket: expect.any(String),
+ }),
+ })
+ })
+
+ it('should return 400 when body.fileId is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ // missing fileId.
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: VALID_UPLOAD_FILE_TYPES[0],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileId' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileId is an empty string', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ fileId: '',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: VALID_UPLOAD_FILE_TYPES[1],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileId',
+ message: '"fileId" is not allowed to be empty',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileType is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ // Missing fileType.
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileType' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileType is invalid', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: SparkMD5.hash('test file name'),
+ fileType: 'some random type',
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileType',
+ message: `"fileType" must be one of [${VALID_UPLOAD_FILE_TYPES.join(
+ ', ',
+ )}]`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileMd5Hash is missing', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ fileId: 'some id',
+ // Missing fileMd5Hash
+ fileType: VALID_UPLOAD_FILE_TYPES[2],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: { key: 'fileMd5Hash' },
+ }),
+ )
+ })
+
+ it('should return 400 when body.fileMd5Hash is not a base64 string', async () => {
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send({
+ fileId: 'some id',
+ fileMd5Hash: 'rubbish hash',
+ fileType: VALID_UPLOAD_FILE_TYPES[2],
+ })
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'fileMd5Hash',
+ message: '"fileMd5Hash" must be a valid base64 string',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when creating presigned POST URL object errors', async () => {
+ // Arrange
+ // Mock error.
+ jest
+ .spyOn(aws.s3, 'createPresignedPost')
+ // @ts-ignore
+ .mockImplementationOnce((_opts, cb) =>
+ cb(new Error('something went wrong')),
+ )
+ const form = await EncryptFormModel.create({
+ title: 'form',
+ admin: defaultUser._id,
+ publicKey: 'does not matter again',
+ })
+
+ // Act
+ const response = await request
+ .post(`/${form._id}/adminform/logos`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(400)
+ expect(response.body).toEqual({
+ message: 'Error occurred whilst uploading file',
+ })
+ })
+
+ it('should return 404 when form to upload logo to cannot be found', async () => {
+ // Arrange
+ const invalidFormId = new ObjectId().toHexString()
+
+ // Act
+ const response = await request
+ .post(`/${invalidFormId}/adminform/logos`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(404)
+ expect(response.body).toEqual({ message: 'Form not found' })
+ })
+
+ it('should return 410 when form to upload logo to is already archived', async () => {
+ // Arrange
+ const archivedForm = await EncryptFormModel.create({
+ title: 'archived form',
+ status: Status.Archived,
+ responseMode: ResponseMode.Encrypt,
+ publicKey: 'does not matter',
+ admin: defaultUser._id,
+ })
+
+ // Act
+ const response = await request
+ .post(`/${archivedForm._id}/adminform/logos`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(410)
+ expect(response.body).toEqual({ message: 'Form has been archived' })
+ })
+
+ it('should return 422 when user in session cannot be retrieved from the database', async () => {
+ // Arrange
+ // Clear user collection
+ await dbHandler.clearCollection(UserModel.collection.name)
+
+ // Act
+ const response = await request
+ .post(`/${new ObjectId()}/adminform/logos`)
+ .send(DEFAULT_POST_PARAMS)
+
+ // Assert
+ expect(response.status).toEqual(422)
+ expect(response.body).toEqual({ message: 'User not found' })
+ })
+ })
+})
+
+// Helper utils
+const createSubmission = ({
+ form,
+ encryptedContent,
+ verifiedContent,
+ attachmentMetadata,
+}: {
+ form: IFormDocument
+ encryptedContent: string
+ attachmentMetadata?: Map
+ verifiedContent?: string
+}) => {
+ return SubmissionModel.create({
+ submissionType: SubmissionType.Encrypt,
+ form: form._id,
+ authType: form.authType,
+ myInfoFields: form.getUniqueMyInfoAttrs(),
+ attachmentMetadata,
+ encryptedContent,
+ verifiedContent,
+ version: 1,
+ })
+}
diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts
index ae9d28738d..319204363d 100644
--- a/src/app/modules/form/admin-form/admin-form.controller.ts
+++ b/src/app/modules/form/admin-form/admin-form.controller.ts
@@ -59,24 +59,21 @@ const logger = createLoggerWithLabel(module)
*/
export const handleListDashboardForms: RequestHandler = async (req, res) => {
const authedUserId = (req.session as Express.AuthedSession).user._id
- const dashboardResult = await getDashboardForms(authedUserId)
- if (dashboardResult.isErr()) {
- const { error } = dashboardResult
- logger.error({
- message: 'Error listing dashboard forms',
- meta: {
- action: 'handleListDashboardForms',
- userId: authedUserId,
- },
- error,
+ return getDashboardForms(authedUserId)
+ .map((dashboardView) => res.json(dashboardView))
+ .mapErr((error) => {
+ logger.error({
+ message: 'Error listing dashboard forms',
+ meta: {
+ action: 'handleListDashboardForms',
+ userId: authedUserId,
+ },
+ error,
+ })
+ const { errorMessage, statusCode } = mapRouteError(error)
+ return res.status(statusCode).json({ message: errorMessage })
})
- const { errorMessage, statusCode } = mapRouteError(error)
- return res.status(statusCode).json({ message: errorMessage })
- }
-
- // Success.
- return res.json(dashboardResult.value)
}
/**
@@ -675,6 +672,16 @@ export const handleDuplicateAdminForm: RequestHandler<
)
}
+/**
+ * Handler for GET /:formId/adminform/template
+ * @security session
+ *
+ * @returns 200 with target form's public view
+ * @returns 403 when the target form is private
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 500 when database error occurs
+ */
export const handleGetTemplateForm: RequestHandler<{ formId: string }> = (
req,
res,
@@ -785,9 +792,10 @@ export const handleCopyTemplateForm: RequestHandler<
* @security session
*
* @returns 200 with updated form with transferred owners
+ * @returns 400 when new owner is not in the database yet
+ * @returns 400 when new owner is already current owner
* @returns 403 when user is not the current owner of the form
* @returns 404 when form cannot be found
- * @returns 409 when new owner is not in the database yet, or if new owner is current owner
* @returns 410 when form is archived
* @returns 422 when user in session cannot be retrieved from the database
* @returns 500 when database error occurs
diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts
new file mode 100644
index 0000000000..934d559a9f
--- /dev/null
+++ b/src/app/modules/form/admin-form/admin-form.routes.ts
@@ -0,0 +1,497 @@
+/**
+ * Old routes that has not been migrated to their new /api/v3/ root endpoints.
+ */
+
+import JoiDate from '@joi/date'
+import { celebrate, Joi as BaseJoi, Segments } from 'celebrate'
+import { Router } from 'express'
+
+import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants'
+import { IForm, ResponseMode } from '../../../../types'
+import { withUserAuthentication } from '../../auth/auth.middlewares'
+import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller'
+
+import * as AdminFormController from './admin-form.controller'
+import { DuplicateFormBody } from './admin-form.types'
+
+export const AdminFormsRouter = Router()
+
+const Joi = BaseJoi.extend(JoiDate) as typeof BaseJoi
+
+// Validators
+const createFormValidator = celebrate({
+ [Segments.BODY]: {
+ form: Joi.object>()
+ .keys({
+ // Require valid responsesMode field.
+ responseMode: Joi.string()
+ .valid(...Object.values(ResponseMode))
+ .required(),
+ // Require title field.
+ title: Joi.string().min(4).max(200).required(),
+ // Require emails string (for backwards compatibility) or string
+ // array if form to be created in Email mode.
+ emails: Joi.alternatives()
+ .try(Joi.array().items(Joi.string()).min(1), Joi.string())
+ .when('responseMode', {
+ is: ResponseMode.Email,
+ then: Joi.required(),
+ }),
+ // Require publicKey field if form to be created in Storage mode.
+ publicKey: Joi.string()
+ .allow('')
+ .when('responseMode', {
+ is: ResponseMode.Encrypt,
+ then: Joi.string().required().disallow(''),
+ }),
+ })
+ .required()
+ // Allow other form schema keys to be passed for form creation.
+ .unknown(true),
+ },
+})
+
+const duplicateFormValidator = celebrate({
+ [Segments.BODY]: Joi.object({
+ // Require valid responsesMode field.
+ responseMode: Joi.string()
+ .valid(...Object.values(ResponseMode))
+ .required(),
+ // Require title field.
+ title: Joi.string().min(4).max(200).required(),
+ // Require emails string (for backwards compatibility) or string array
+ // if form to be duplicated in Email mode.
+ emails: Joi.alternatives()
+ .try(Joi.array().items(Joi.string()).min(1), Joi.string())
+ .when('responseMode', {
+ is: ResponseMode.Email,
+ then: Joi.required(),
+ }),
+ // Require publicKey field if form to be duplicated in Storage mode.
+ publicKey: Joi.string()
+ .allow('')
+ .when('responseMode', {
+ is: ResponseMode.Encrypt,
+ then: Joi.string().required().disallow(''),
+ }),
+ }),
+})
+
+const fileUploadValidator = celebrate({
+ [Segments.BODY]: {
+ fileId: Joi.string().required(),
+ fileMd5Hash: Joi.string().base64().required(),
+ fileType: Joi.string()
+ .valid(...VALID_UPLOAD_FILE_TYPES)
+ .required(),
+ },
+})
+
+AdminFormsRouter.route('/adminform')
+ // All HTTP methods of route protected with authentication.
+ .all(withUserAuthentication)
+ /**
+ * List the forms managed by the user
+ * @route GET /adminform
+ * @security session
+ *
+ * @returns 200 with a list of forms managed by the user
+ * @returns 401 when user is not logged in
+ * @returns 422 when user of given id cannnot be found in the database
+ * @returns 500 when database errors occur
+ */
+ .get(AdminFormController.handleListDashboardForms)
+ /**
+ * Create a new form
+ * @route POST /adminform
+ * @security session
+ *
+ * @returns 200 with newly created form
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 409 when a database conflict error occurs
+ * @returns 413 when payload for created form exceeds size limit
+ * @returns 422 when user of given id cannnot be found in the database
+ * @returns 422 when form parameters are invalid
+ * @returns 500 when database error occurs
+ */
+ .post(createFormValidator, AdminFormController.handleCreateForm)
+
+AdminFormsRouter.route('/:formId([a-fA-F0-9]{24})/adminform')
+ // All HTTP methods of route protected with authentication.
+ .all(withUserAuthentication)
+ /**
+ * Return the specified form to the user.
+ * @route GET /:formId/adminform
+ * @security session
+ *
+ * @returns 200 with retrieved form with formId if user has read permissions
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+ .get(AdminFormController.handleGetAdminForm)
+ /**
+ * Update the specified form.
+ * @route PUT /:formId/adminform
+ * @security session
+ *
+ * @returns 200 with updated form
+ * @returns 400 when form field has invalid updates to be performed
+ * @returns 401 when user does not exist in session
+ * @returns 403 when current user does not have permissions to update form
+ * @returns 404 when form to update cannot be found
+ * @returns 409 when saving updated form incurs a conflict in the database
+ * @returns 410 when form to update is archived
+ * @returns 413 when updated form is too large to be saved in the database
+ * @returns 422 when an invalid update is attempted on the form
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+ .put(AdminFormController.handleUpdateForm)
+ /**
+ * Archive the specified form.
+ * @route DELETE /:formId/adminform
+ * @security session
+ *
+ * @returns 200 with success message when successfully archived
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to archive form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is already archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+ .delete(AdminFormController.handleArchiveForm)
+ /**
+ * Duplicate the specified form.
+ * @route POST /:formId/adminform
+ * @security session
+ *
+ * @returns 200 with the duplicate form dashboard view
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+ .post(duplicateFormValidator, AdminFormController.handleDuplicateAdminForm)
+
+/**
+ * Transfer form ownership to another user
+ * @route POST /:formId/adminform/transfer-owner
+ * @security session
+ *
+ * @returns 200 with updated form with transferred owners
+ * @returns 400 when Joi validation fails
+ * @returns 400 when new owner is not in the database yet
+ * @returns 400 when new owner is already current owner
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user is not the current owner of the form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.post(
+ '/:formId([a-fA-F0-9]{24})/adminform/transfer-owner',
+ withUserAuthentication,
+ celebrate({
+ [Segments.BODY]: {
+ email: Joi.string()
+ .required()
+ .email({
+ minDomainSegments: 2, // Number of segments required for the domain
+ tlds: { allow: true }, // TLD (top level domain) validation
+ multiple: false, // Disallow multiple emails
+ })
+ .message('Please enter a valid email'),
+ },
+ }),
+ AdminFormController.handleTransferFormOwnership,
+)
+
+/**
+ * Return the template form to the user.
+ * Only allows for public forms, for any logged in user.
+ * @route GET /:formId/adminform/template
+ * @security session
+ *
+ * @returns 200 with target form's public view
+ * @returns 401 when user does not exist in session
+ * @returns 403 when the target form is private
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/template',
+ withUserAuthentication,
+ AdminFormController.handleGetTemplateForm,
+)
+
+/**
+ * Duplicate a specified form and return that form to the user.
+ * @route GET /:formId/adminform/copy
+ * @security session
+ *
+ * @returns 200 with the duplicate form dashboard view
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when form is private
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.post(
+ '/:formId([a-fA-F0-9]{24})/adminform/copy',
+ withUserAuthentication,
+ duplicateFormValidator,
+ AdminFormController.handleCopyTemplateForm,
+)
+
+/**
+ * Return the preview form to the user.
+ * Allows for both public and private forms, only for users with at least read permission.
+ * @route GET /:formId/adminform/preview
+ * @security session
+ *
+ * @returns 200 with target form's public view
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/preview',
+ withUserAuthentication,
+ AdminFormController.handlePreviewAdminForm,
+)
+
+/**
+ * Retrieve feedback for a public form
+ * @route GET /:formId/adminform/feedback
+ * @security session
+ *
+ * @returns 200 with feedback response
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/feedback',
+ withUserAuthentication,
+ AdminFormController.handleGetFormFeedbacks,
+)
+
+/**
+ * Count the number of feedback for a form
+ * @route GET /{formId}/adminform/feedback/count
+ * @security session
+ *
+ * @returns 200 with feedback counts of given form
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/feedback/count',
+ withUserAuthentication,
+ AdminFormController.handleCountFormFeedback,
+)
+
+/**
+ * Stream download all feedback for a form
+ * @route GET /{formId}/adminform/feedback/download
+ * @security session
+ *
+ * @returns 200 with feedback stream
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database or stream error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/feedback/download',
+ withUserAuthentication,
+ AdminFormController.handleStreamFormFeedback,
+)
+
+/**
+ * Retrieve actual response for a storage mode form
+ * @route GET /:formId/adminform/submissions
+ * @security session
+ *
+ * @returns 200 with encrypted submission data response
+ * @returns 400 when form is not an encrypt mode form
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have read permissions for form
+ * @returns 404 when submissionId cannot be found in the database
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when any errors occurs in database query or generating signed URL
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/submissions',
+ withUserAuthentication,
+ celebrate({
+ [Segments.QUERY]: {
+ submissionId: Joi.string()
+ .regex(/^[0-9a-fA-F]{24}$/)
+ .required(),
+ },
+ }),
+ EncryptSubmissionController.handleGetEncryptedResponse,
+)
+
+/**
+ * Count the number of submissions for a public form
+ * @route GET /:formId/adminform/submissions/count
+ * @security session
+ *
+ * @returns 200 with submission counts of given form
+ * @returns 400 when query.startDate or query.endDate is malformed
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/submissions/count',
+ withUserAuthentication,
+ celebrate({
+ [Segments.QUERY]: Joi.object()
+ .keys({
+ startDate: Joi.date().format('YYYY-MM-DD').raw(),
+ endDate: Joi.date()
+ .format('YYYY-MM-DD')
+ .greater(Joi.ref('startDate'))
+ .raw(),
+ })
+ .and('startDate', 'endDate'),
+ }),
+ AdminFormController.handleCountFormSubmissions,
+)
+
+/**
+ * Retrieve metadata of responses for a form with encrypted storage
+ * @route GET /:formId/adminform/submissions/metadata
+ * @security session
+ *
+ * @returns 200 with paginated submission metadata when no submissionId is provided
+ * @returns 200 with single submission metadata of submissionId when provided
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have permissions to access form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/submissions/metadata',
+ withUserAuthentication,
+ celebrate({
+ [Segments.QUERY]: {
+ submissionId: Joi.string().optional(),
+ page: Joi.number().min(1).when('submissionId', {
+ not: Joi.exist(),
+ then: Joi.required(),
+ }),
+ },
+ }),
+ EncryptSubmissionController.handleGetMetadata,
+)
+
+/**
+ * Stream download all encrypted responses for a form
+ * @route GET /:formId/adminform/submissions/download
+ * @security session
+ *
+ * @returns 200 with stream of encrypted responses
+ * @returns 400 if form is not an encrypt mode form
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have read permissions for form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 if any errors occurs in stream pipeline or error retrieving form
+ */
+AdminFormsRouter.get(
+ '/:formId([a-fA-F0-9]{24})/adminform/submissions/download',
+ withUserAuthentication,
+ celebrate({
+ [Segments.QUERY]: Joi.object()
+ .keys({
+ startDate: Joi.date().format('YYYY-MM-DD').raw(),
+ endDate: Joi.date()
+ .format('YYYY-MM-DD')
+ .greater(Joi.ref('startDate'))
+ .raw(),
+ downloadAttachments: Joi.boolean().default(false),
+ })
+ .and('startDate', 'endDate'),
+ }),
+ EncryptSubmissionController.handleStreamEncryptedResponses,
+)
+
+/**
+ * Upload images
+ * @route POST /:formId/adminform/images
+ * @security session
+ *
+ * @returns 200 with presigned POST URL object
+ * @returns 400 when error occurs whilst creating presigned POST URL object
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have write permissions for form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ */
+AdminFormsRouter.post(
+ '/:formId([a-fA-F0-9]{24})/adminform/images',
+ withUserAuthentication,
+ fileUploadValidator,
+ AdminFormController.handleCreatePresignedPostUrlForImages,
+)
+
+/**
+ * Upload logos
+ * @route POST /:formId/adminform/logos
+ * @security session
+ *
+ * @returns 200 with presigned POST URL object
+ * @returns 400 when error occurs whilst creating presigned POST URL object
+ * @returns 400 when Joi validation fails
+ * @returns 401 when user does not exist in session
+ * @returns 403 when user does not have write permissions for form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ */
+AdminFormsRouter.post(
+ '/:formId([a-fA-F0-9]{24})/adminform/logos',
+ withUserAuthentication,
+ fileUploadValidator,
+ AdminFormController.handleCreatePresignedPostUrlForLogos,
+)
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
index 0efe0da7c7..6f447ccf93 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
@@ -493,6 +493,7 @@ export const handleStreamEncryptedResponses: RequestHandler<
/**
* Handler for GET /:formId/adminform/submissions
+ * @security session
*
* @returns 200 with encrypted submission data response
* @returns 400 when form is not an encrypt mode form
diff --git a/src/app/modules/user/__tests__/user.routes.spec.ts b/src/app/modules/user/__tests__/user.routes.spec.ts
index 2fc22868c2..d63b05527d 100644
--- a/src/app/modules/user/__tests__/user.routes.spec.ts
+++ b/src/app/modules/user/__tests__/user.routes.spec.ts
@@ -14,6 +14,7 @@ import { createAuthedSession } from 'tests/integration/helpers/express-auth'
import { setupApp } from 'tests/integration/helpers/express-setup'
import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data'
import { DatabaseError } from '../../core/core.errors'
import UserRouter from '../user.routes'
@@ -69,9 +70,9 @@ describe('user.routes', () => {
// Response should contain user object.
expect(response.body).toEqual(
expect.objectContaining({
- ...JSON.parse(JSON.stringify(defaultUser.toObject())),
+ ...jsonParseStringify(defaultUser.toObject()),
// Should be object since agency key should be populated.
- agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ agency: jsonParseStringify(defaultAgency.toObject()),
}),
)
})
@@ -266,8 +267,8 @@ describe('user.routes', () => {
expect(response.status).toEqual(200)
// Body should be an user object.
expect(response.body).toEqual({
- ...JSON.parse(JSON.stringify(defaultUser.toObject())),
- agency: JSON.parse(JSON.stringify(defaultAgency.toObject())),
+ ...jsonParseStringify(defaultUser.toObject()),
+ agency: jsonParseStringify(defaultAgency.toObject()),
// This time with the new contact number.
contact: VALID_CONTACT,
// Dynamic date strings to be returned.
diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js
index 8716329c44..b8a6146169 100644
--- a/src/app/routes/admin-forms.server.routes.js
+++ b/src/app/routes/admin-forms.server.routes.js
@@ -12,23 +12,14 @@ const EmailSubmissionsMiddleware = require('../../app/modules/submission/email-s
const SubmissionsMiddleware = require('../../app/modules/submission/submission.middleware')
const AdminFormController = require('../modules/form/admin-form/admin-form.controller')
const { withUserAuthentication } = require('../modules/auth/auth.middlewares')
-const EncryptSubmissionController = require('../modules/submission/encrypt-submission/encrypt-submission.controller')
const {
PermissionLevel,
} = require('../modules/form/admin-form/admin-form.types')
const EncryptSubmissionMiddleware = require('../modules/submission/encrypt-submission/encrypt-submission.middleware')
const SpcpController = require('../modules/spcp/spcp.controller')
-const { BasicField, ResponseMode } = require('../../types')
+const { BasicField } = require('../../types')
const VerifiedContentMiddleware = require('../modules/verified-content/verified-content.middlewares')
-const YYYY_MM_DD_REGEX = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/
-
-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.
@@ -42,336 +33,6 @@ let authActiveForm = (requiredPermission) => [
]
module.exports = function (app) {
- /**
- * @typedef ErrorMessage
- * @property {string} message.required - the error message
- */
-
- /**
- * @typedef FormCreateRequest
- * @property {object} form.required - the form to be created
- */
-
- app
- .route('/adminform')
- /**
- * List the forms managed by the user
- * @route GET /adminform
- * @group admin - endpoints to manage forms
- * @produces application/json
- * @returns {Array.} 200 - the forms managed by the user
- * @returns {ErrorMessage.model} 400 - error encountered while finding the forms
- * @security OTP
- */
- .get(withUserAuthentication, AdminFormController.handleListDashboardForms)
- /**
- * Create a new form in FormSG
- * @route POST /adminform
- * @group admin - endpoints to manage forms
- * @param {FormCreateRequest.model} form.body.required - the form
- * @produces application/json
- * @returns {object} 200 - the created form
- * @returns {ErrorMessage.model} 400 - invalid input
- * @returns {ErrorMessage.model} 405 - error encountered while creating the form
- * @security OTP
- */
- .post(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- form: Joi.object()
- .keys({
- // Require valid responsesMode field.
- responseMode: Joi.string()
- .valid(...Object.values(ResponseMode))
- .required(),
- // Require title field.
- title: Joi.string().min(4).max(200).required(),
- // Require emails string (for backwards compatibility) or string
- // array if form to be created in Email mode.
- emails: Joi.alternatives()
- .try(Joi.array().items(Joi.string()), Joi.string())
- .when('responseMode', {
- is: ResponseMode.Email,
- then: Joi.required(),
- }),
- // Require publicKey field if form to be created in Storage mode.
- publicKey: Joi.string()
- .allow('')
- .when('responseMode', {
- is: ResponseMode.Encrypt,
- then: Joi.string().required().disallow(''),
- }),
- })
- .required()
- // Allow other form schema keys to be passed for form creation.
- .unknown(true),
- },
- }),
- AdminFormController.handleCreateForm,
- )
-
- /**
- * @typedef AdminForm
- * @property {object} form.required - the form
- */
-
- /**
- * @typedef DuplicateRequest
- * @property {number} name.required - the number suffix to apply to the duplicated form
- */
-
- // Collection of routes that operate on the entire form object
- app
- .route('/:formId([a-fA-F0-9]{24})/adminform')
- /**
- * Return the specified form to the user
- * @route GET /{formId}/adminform
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 403 - user does not have write permission
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {AdminForm.model} 200 - the form
- * @security OTP
- */
- .get(withUserAuthentication, AdminFormController.handleGetAdminForm)
- /**
- * Update the specified form
- * @route PUT /{formId}/adminform
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 403 - user does not have write permission
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {ErrorMessage.model} 405 - error encountered while saving the form
- * @returns {object} 200 - the updated form
- * @security OTP
- */
- .put(withUserAuthentication, AdminFormController.handleUpdateForm)
- /**
- * Archive the specified form
- * @route DELETE /{formId}/adminform
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 403 - user does not have delete permission
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {ErrorMessage.model} 405 - error encountered while archiving the form
- * @returns {object} 200 - the archived form
- * @security OTP
- */
- .delete(withUserAuthentication, AdminFormController.handleArchiveForm)
- /**
- * Duplicate the specified form
- * @route POST /{formId}/adminform
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @param {DuplicateRequest.model} name.body.required - the suffix to apply to the duplicated form
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - error encountered while retrieving the form
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 403 - user does not have write permission
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {ErrorMessage.model} 405 - error encountered while duplicating the form
- * @returns {object} 200 - the duplicated form
- * @security OTP
- */
- .post(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- // Require valid responsesMode field.
- responseMode: Joi.string()
- .valid(...Object.values(ResponseMode))
- .required(),
- // Require title field.
- title: Joi.string().min(4).max(200).required(),
- // Require emails string (for backwards compatibility) or string array
- // if form to be duplicated in Email mode.
- emails: Joi.alternatives()
- .try(Joi.array().items(Joi.string()), Joi.string())
- .when('responseMode', {
- is: ResponseMode.Email,
- then: Joi.required(),
- }),
- // Require publicKey field if form to be duplicated in Storage mode.
- publicKey: Joi.string()
- .allow('')
- .when('responseMode', {
- is: ResponseMode.Encrypt,
- then: Joi.string().required().disallow(''),
- }),
- },
- }),
- AdminFormController.handleDuplicateAdminForm,
- )
-
- /**
- * Return the template form to the user.
- * Only allows for public forms, for any logged in user.
- * @route GET /{formId}/adminform/template
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {AdminForm.model} 200 - the form
- * @security OTP
- */
- app
- .route('/:formId([a-fA-F0-9]{24})/adminform/template')
- .get(withUserAuthentication, AdminFormController.handleGetTemplateForm)
-
- /**
- * Return the preview form to the user.
- * Allows for both public and private forms, only for users with at least read permission.
- * This endpoint is also used to retrieve the form object for duplication.
- * @route GET /{formId}/adminform/preview
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 403 - user does not have at least read permission
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {AdminForm.model} 200 - the form
- * @security OTP
- */
- app
- .route('/:formId([a-fA-F0-9]{24})/adminform/preview')
- .get(withUserAuthentication, AdminFormController.handlePreviewAdminForm)
-
- /**
- * Duplicate a specified form and return that form to the user.
- * @route GET /{formId}/adminform/copy
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - invalid formId
- * @returns {ErrorMessage.model} 401 - user not logged in
- * @returns {ErrorMessage.model} 404 - form has been archived or form not found
- * @returns {AdminForm.model} 200 - the form
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/copy').post(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- // Require valid responsesMode field.
- responseMode: Joi.string()
- .valid(...Object.values(ResponseMode))
- .required(),
- // Require title field.
- title: Joi.string().min(4).max(200).required(),
- // Require emails string (for backwards compatibility) or string array
- // if form to be duplicated in Email mode.
- emails: Joi.alternatives()
- .try(Joi.array().items(Joi.string()), Joi.string())
- .when('responseMode', {
- is: ResponseMode.Email,
- then: Joi.required(),
- }),
- // Require publicKey field if form to be duplicated in Storage mode.
- publicKey: Joi.string()
- .allow('')
- .when('responseMode', {
- is: ResponseMode.Encrypt,
- then: Joi.string().required().disallow(''),
- }),
- },
- }),
- AdminFormController.handleCopyTemplateForm,
- )
-
- /**
- * @typedef FeedbackResponse
- * @property {number} average.required - the average rating
- * @property {number} count.required - the total number of feedback received
- * @property {Array.} feedback.required - all the feedback in an array
- */
-
- /**
- * @typedef Feedback
- * @property {number} timestamp.required - the time in epoch milliseconds that the feedback was received
- * @property {number} rating.required - the user's rating of the form
- * @property {string} comment.required - any comments the user might have
- * @property {string} date.required - the date the feedback was received, in the moment format `D MMM YYYY`
- * @property {string} dateShort.required - the date the feedback was received, in the moment format `D MMM`
- */
-
- /**
- * Retrieve feedback for a public form
- * @route GET /{formId}/adminform/feedback
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - Errors while querying for feedback
- * @returns {FeedbackResponse.model} 200 - form feedback was saved
- * @security OTP
- */
- app
- .route('/:formId([a-fA-F0-9]{24})/adminform/feedback')
- .get(withUserAuthentication, AdminFormController.handleGetFormFeedbacks)
-
- /**
- * Count the number of feedback for a form
- * @route GET /{formId}/adminform/feedback/count
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {Error.model} 400 - Errors while querying for feedback
- * @returns {number} 200 - the feedback count
- * @security OTP
- */
- app
- .route('/:formId([a-fA-F0-9]{24})/adminform/feedback/count')
- .get(withUserAuthentication, AdminFormController.handleCountFormFeedback)
-
- /**
- * Stream download all feedback for a form
- * @route GET /{formId}/adminform/feedback/download
- * @group forms - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @param {Feedback.model} feedback.body.required - the user's feedback
- * @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/feedback/download')
- .get(withUserAuthentication, AdminFormController.handleStreamFormFeedback)
-
- /**
- * 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(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- email: Joi.string()
- .required()
- .email(emailValOpts)
- .message('Please enter a valid email'),
- },
- }),
- AdminFormController.handleTransferFormOwnership,
- )
-
/**
* 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
@@ -501,164 +162,4 @@ module.exports = function (app) {
adminForms.passThroughSaveMetadataToDb,
SubmissionsMiddleware.sendEmailConfirmations,
)
-
- /**
- * Retrieve actual response for a form with encrypted storage
- * @route GET /{formId}/adminform/submissions
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @param {number} submissionId.query.required - the submission id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - Errors while querying for response
- * @returns {Object} 200 - Response document
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/submissions').get(
- withUserAuthentication,
- celebrate({
- [Segments.QUERY]: {
- submissionId: Joi.string()
- .regex(/^[0-9a-fA-F]{24}$/)
- .required(),
- },
- }),
- EncryptSubmissionController.handleGetEncryptedResponse,
- )
-
- /**
- * Count the number of submissions for a public form
- * @route GET /{formId}/adminform/submissions/count
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {Error.model} 400 - Errors while querying for feedback
- * @returns {number} 200 - the submission count
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/submissions/count').get(
- withUserAuthentication,
- celebrate({
- [Segments.QUERY]: Joi.object()
- .keys({
- // Ensure YYYY-MM-DD format.
- startDate: Joi.string().regex(YYYY_MM_DD_REGEX),
- // Ensure YYYY-MM-DD format.
- endDate: Joi.string().regex(YYYY_MM_DD_REGEX),
- })
- .and('startDate', 'endDate'),
- }),
- AdminFormController.handleCountFormSubmissions,
- )
-
- /**
- * @typedef metadataResponse
- * @property {Array.} metadata.required - all the metadata in an array
- * @property {number} numResults.required - the total number of responses
- */
-
- /**
- * Retrieve metadata of responses for a form with encrypted storage
- * @route GET /{formId}/adminform/submissions/metadata
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - Errors while querying for response
- * @returns {metadataResponse.model} 200 - Metadata of responses
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/submissions/metadata').get(
- withUserAuthentication,
- celebrate({
- [Segments.QUERY]: {
- submissionId: Joi.string().optional(),
- page: Joi.number().min(1).when('submissionId', {
- not: Joi.exist(),
- then: Joi.required(),
- }),
- },
- }),
- EncryptSubmissionController.handleGetMetadata,
- )
-
- /**
- * Stream download all encrypted responses for a form
- * @route GET /{formId}/adminform/submissions/download
- * @group admin - endpoints to manage forms
- * @param {string} formId.path.required - the form id
- * @produces application/json
- * @returns {ErrorMessage.model} 500 - Errors while querying for response
- * @returns {Object} 200 - Response document
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/submissions/download').get(
- withUserAuthentication,
- celebrate({
- [Segments.QUERY]: Joi.object()
- .keys({
- // Ensure YYYY-MM-DD format.
- startDate: Joi.string().regex(YYYY_MM_DD_REGEX),
- // Ensure YYYY-MM-DD format.
- endDate: Joi.string().regex(YYYY_MM_DD_REGEX),
- downloadAttachments: Joi.boolean().default(false),
- })
- .and('startDate', 'endDate'),
- }),
- EncryptSubmissionController.handleStreamEncryptedResponses,
- )
-
- /**
- * Upload images
- * @route POST /{formId}/adminform/images
- * @group admin - endpoints to manage forms
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - Error while creating presigned post
- * @returns {Object} 200 - Response document
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/images').post(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- fileId: Joi.string()
- .required()
- .error(() => 'Please enter a valid file id'),
- fileMd5Hash: Joi.string()
- .base64()
- .required()
- .error(() => 'Error - your file could not be verified'),
- fileType: Joi.string()
- .required()
- .error(() => 'Error - your file could not be verified'),
- },
- }),
- AdminFormController.handleCreatePresignedPostUrlForImages,
- )
-
- /**
- * Upload logos
- * @route POST /{formId}/adminform/logos
- * @group admin - endpoints to manage forms
- * @produces application/json
- * @returns {ErrorMessage.model} 400 - Error while creating presigned post
- * @returns {Object} 200 - Response document
- * @security OTP
- */
- app.route('/:formId([a-fA-F0-9]{24})/adminform/logos').post(
- withUserAuthentication,
- celebrate({
- [Segments.BODY]: {
- fileId: Joi.string()
- .required()
- .error(() => 'Please enter a valid file id'),
- fileMd5Hash: Joi.string()
- .base64()
- .required()
- .error(() => 'Error - your file could not be verified'),
- fileType: Joi.string()
- .required()
- .error(() => 'Error - your file could not be verified'),
- },
- }),
- AdminFormController.handleCreatePresignedPostUrlForLogos,
- )
}
diff --git a/src/app/utils/__mocks__/limit-rate.ts b/src/app/utils/__mocks__/limit-rate.ts
new file mode 100644
index 0000000000..774b2a501f
--- /dev/null
+++ b/src/app/utils/__mocks__/limit-rate.ts
@@ -0,0 +1,6 @@
+import { RequestHandler } from 'express'
+
+// Remove rate limiting in tests.
+export const limitRate = jest
+ .fn()
+ .mockImplementation((): RequestHandler => (req, res, next) => next())
diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts
index f19586c9e7..31129db757 100644
--- a/src/loaders/express/index.ts
+++ b/src/loaders/express/index.ts
@@ -13,6 +13,7 @@ import { AuthRouter } from '../../app/modules/auth/auth.routes'
import { BillingRouter } from '../../app/modules/billing/billing.routes'
import { BounceRouter } from '../../app/modules/bounce/bounce.routes'
import { ExamplesRouter } from '../../app/modules/examples/examples.routes'
+import { AdminFormsRouter } from '../../app/modules/form/admin-form/admin-form.routes'
import { HomeRouter } from '../../app/modules/home/home.routes'
import { MYINFO_ROUTER_PREFIX } from '../../app/modules/myinfo/myinfo.constants'
import { MyInfoRouter } from '../../app/modules/myinfo/myinfo.routes'
@@ -163,6 +164,7 @@ const loadExpressApp = async (connection: Connection) => {
app.use('/corppass/login', CorppassLoginRouter)
// Use constant for registered routes with MyInfo servers
app.use(MYINFO_ROUTER_PREFIX, MyInfoRouter)
+ app.use(AdminFormsRouter)
// New routes in preparation for API refactor.
app.use('/api', ApiRouter)
diff --git a/tests/integration/helpers/express-auth.ts b/tests/integration/helpers/express-auth.ts
index 703f955fe5..9c65cc791f 100644
--- a/tests/integration/helpers/express-auth.ts
+++ b/tests/integration/helpers/express-auth.ts
@@ -54,3 +54,16 @@ export const createAuthedSession = async (
otpSpy.mockClear()
return request
}
+
+export const logoutSession = async (request: Session): Promise => {
+ const response = await request.get('/auth/signout')
+
+ expect(response.status).toEqual(200)
+
+ const sessionCookie = request.cookies.find(
+ (cookie) => cookie.name === 'connect.sid',
+ )
+ expect(sessionCookie).not.toBeDefined()
+
+ return request
+}
diff --git a/tests/integration/helpers/express-setup.ts b/tests/integration/helpers/express-setup.ts
index 59674596f2..e51319da6a 100644
--- a/tests/integration/helpers/express-setup.ts
+++ b/tests/integration/helpers/express-setup.ts
@@ -28,7 +28,7 @@ const testSessionMiddlewares = () => {
}
export const setupApp = (
- route: string,
+ route: string | undefined,
router: Router,
options: { showLogs?: boolean; setupWithAuth?: boolean } = {},
): Express => {
@@ -45,12 +45,16 @@ export const setupApp = (
app.use(loggingMiddleware())
}
- app.use(route, router)
-
if (options.setupWithAuth) {
app.use('/auth', AuthRouter)
}
+ if (route) {
+ app.use(route, router)
+ } else {
+ app.use(router)
+ }
+
app.use(errorHandlerMiddlewares())
return app
diff --git a/tests/unit/backend/helpers/serialize-data.ts b/tests/unit/backend/helpers/serialize-data.ts
new file mode 100644
index 0000000000..06950f933d
--- /dev/null
+++ b/tests/unit/backend/helpers/serialize-data.ts
@@ -0,0 +1,3 @@
+export const jsonParseStringify = (obj: unknown) => {
+ return JSON.parse(JSON.stringify(obj))
+}
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 0d82b575a6..d4e13596f7 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -6,6 +6,7 @@
"**/*.spec.ts",
"**/*.test.ts",
"**/__tests__/",
+ "**/__mocks__/",
"src/public/"
]
}
From b709fcfe486705134d529474f88e2a91f02ace68 Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Thu, 8 Apr 2021 10:22:15 +0800
Subject: [PATCH 21/75] fix: /adminform integration tests being flakey by
loosening some checks (#1584)
* test: loosen test checks due to non-deterministic mongo query
* test: fix flakiness due to Date
* test: remove invalid comment
---
.../__tests__/admin-form.routes.spec.ts | 36 ++++++++++---------
1 file changed, 19 insertions(+), 17 deletions(-)
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
index cc51e48fc6..2bd52a224e 100644
--- a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { ObjectId } from 'bson-ext'
import { format, subDays } from 'date-fns'
-import { cloneDeep, take, times } from 'lodash'
+import { cloneDeep, times } from 'lodash'
import mongoose from 'mongoose'
import { errAsync, okAsync } from 'neverthrow'
import SparkMD5 from 'spark-md5'
@@ -3559,19 +3559,16 @@ describe('admin-form.routes', () => {
it('should return 200 with requested page of metadata when metadata exists', async () => {
// Arrange
// Create 11 submissions
- const submissions = (
- await Promise.all(
- times(11, (count) =>
- createSubmission({
- form: defaultForm,
- encryptedContent: `any encrypted content ${count}`,
- verifiedContent: `any verified content ${count}`,
- }),
- ),
- )
+ const submissions = await Promise.all(
+ times(11, (count) =>
+ createSubmission({
+ form: defaultForm,
+ encryptedContent: `any encrypted content ${count}`,
+ verifiedContent: `any verified content ${count}`,
+ }),
+ ),
)
- // @ts-ignore
- .sort((a, b) => b.created! - a.created!)
+ const createdSubmissionIds = submissions.map((s) => String(s._id))
// Act
const response = await request
@@ -3581,10 +3578,11 @@ describe('admin-form.routes', () => {
})
// Assert
- // Take first 10 submissions
- const expected = take(submissions, 10).map((s, index) => ({
+ const expected = times(10, (index) => ({
number: 11 - index,
- refNo: String(s._id),
+ // Loosen refNo checks due to non-deterministic aggregation query.
+ // Just expect refNo is one of the possible ones.
+ refNo: expect.toBeOneOf(createdSubmissionIds),
submissionTime: expect.any(String),
}))
expect(response.status).toEqual(200)
@@ -3935,6 +3933,7 @@ describe('admin-form.routes', () => {
it('should return 200 with stream of encrypted responses between given query.startDate and query.endDate', async () => {
// Arrange
+ const now = new Date()
const submissions = await Promise.all(
times(5, (count) =>
createSubmission({
@@ -3945,11 +3944,11 @@ describe('admin-form.routes', () => {
['fieldId1', `some.attachment.url.${count}`],
['fieldId2', `some.other.attachment.url.${count}`],
]),
+ created: now,
}),
),
)
// Set 2 submissions to be submitted 3-4 days ago.
- const now = new Date()
submissions[2].created = subDays(now, 3)
submissions[4].created = subDays(now, 4)
await submissions[2].save()
@@ -4602,11 +4601,13 @@ const createSubmission = ({
encryptedContent,
verifiedContent,
attachmentMetadata,
+ created,
}: {
form: IFormDocument
encryptedContent: string
attachmentMetadata?: Map
verifiedContent?: string
+ created?: Date
}) => {
return SubmissionModel.create({
submissionType: SubmissionType.Encrypt,
@@ -4616,6 +4617,7 @@ const createSubmission = ({
attachmentMetadata,
encryptedContent,
verifiedContent,
+ created,
version: 1,
})
}
From 98ae6c1cc78b375b3795d2cb5bf74b1325120258 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 8 Apr 2021 03:06:13 +0000
Subject: [PATCH 22/75] chore(deps-dev): bump testcafe from 1.13.0 to 1.14.0
(#1580)
Bumps [testcafe](https://github.com/DevExpress/testcafe) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/DevExpress/testcafe/releases)
- [Changelog](https://github.com/DevExpress/testcafe/blob/master/CHANGELOG.md)
- [Commits](https://github.com/DevExpress/testcafe/compare/v1.13.0...v1.14.0)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 70 ++++++++++++++++++++++++-----------------------
package.json | 2 +-
2 files changed, 37 insertions(+), 35 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 8ea585172d..2e64d96535 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2808,9 +2808,9 @@
"dev": true
},
"@babel/types": {
- "version": "7.13.12",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.12.tgz",
- "integrity": "sha512-K4nY2xFN4QMvQwkQ+zmBDp6ANMbVNw6BbxWmYA4qNjhR9W+Lj/8ky5MEY2Me5r+B2c6/v6F53oMndG+f9s3IiA==",
+ "version": "7.13.14",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz",
+ "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.12.11",
@@ -2861,9 +2861,9 @@
"dev": true
},
"@babel/types": {
- "version": "7.13.12",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.12.tgz",
- "integrity": "sha512-K4nY2xFN4QMvQwkQ+zmBDp6ANMbVNw6BbxWmYA4qNjhR9W+Lj/8ky5MEY2Me5r+B2c6/v6F53oMndG+f9s3IiA==",
+ "version": "7.13.14",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz",
+ "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.12.11",
@@ -3408,13 +3408,14 @@
}
},
"@babel/preset-flow": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.12.13.tgz",
- "integrity": "sha512-gcEjiwcGHa3bo9idURBp5fmJPcyFPOszPQjztXrOjUE2wWVqc6fIVJPgWPIQksaQ5XZ2HWiRsf2s1fRGVjUtVw==",
+ "version": "7.13.13",
+ "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.13.13.tgz",
+ "integrity": "sha512-MDtwtamMifqq3R2mC7l3A3uFalUb3NH5TIBQWjN/epEPlZktcLq4se3J+ivckKrLMGsR7H9LW8+pYuIUN9tsKg==",
"dev": true,
"requires": {
- "@babel/helper-plugin-utils": "^7.12.13",
- "@babel/plugin-transform-flow-strip-types": "^7.12.13"
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/helper-validator-option": "^7.12.17",
+ "@babel/plugin-transform-flow-strip-types": "^7.13.0"
},
"dependencies": {
"@babel/helper-plugin-utils": {
@@ -3439,15 +3440,16 @@
}
},
"@babel/preset-react": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.13.tgz",
- "integrity": "sha512-TYM0V9z6Abb6dj1K7i5NrEhA13oS5ujUYQYDfqIBXYHOc2c2VkFgc+q9kyssIyUfy4/hEwqrgSlJ/Qgv8zJLsA==",
+ "version": "7.13.13",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.13.13.tgz",
+ "integrity": "sha512-gx+tDLIE06sRjKJkVtpZ/t3mzCDOnPG+ggHZG9lffUbX8+wC739x20YQc9V35Do6ZAxaUc/HhVHIiOzz5MvDmA==",
"dev": true,
"requires": {
- "@babel/helper-plugin-utils": "^7.12.13",
+ "@babel/helper-plugin-utils": "^7.13.0",
+ "@babel/helper-validator-option": "^7.12.17",
"@babel/plugin-transform-react-display-name": "^7.12.13",
- "@babel/plugin-transform-react-jsx": "^7.12.13",
- "@babel/plugin-transform-react-jsx-development": "^7.12.12",
+ "@babel/plugin-transform-react-jsx": "^7.13.12",
+ "@babel/plugin-transform-react-jsx-development": "^7.12.17",
"@babel/plugin-transform-react-pure-annotations": "^7.12.1"
},
"dependencies": {
@@ -4926,9 +4928,9 @@
"dev": true
},
"@types/minimatch": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
- "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==",
"dev": true
},
"@types/minimist": {
@@ -22683,9 +22685,9 @@
}
},
"testcafe": {
- "version": "1.13.0",
- "resolved": "https://registry.npmjs.org/testcafe/-/testcafe-1.13.0.tgz",
- "integrity": "sha512-zJt+opIZ+JolTklgqVePBFGSSz6Ld6ySb/7AUUhMwJ+e8ob4P+eMjGjGosgMeCJIPHzDMFDqYfULTANi3CXsKw==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/testcafe/-/testcafe-1.14.0.tgz",
+ "integrity": "sha512-k2Cj7TYtIkraa+te9LB52VeiSWlA68KWm5A6Er37hf01Ke0XtloKLB7OlojJKrcMi/76fEaya6hCXMAHxSiQeA==",
"dev": true,
"requires": {
"@babel/core": "^7.12.1",
@@ -22763,8 +22765,8 @@
"source-map-support": "^0.5.16",
"strip-bom": "^2.0.0",
"testcafe-browser-tools": "2.0.15",
- "testcafe-hammerhead": "19.5.0",
- "testcafe-legacy-api": "4.2.5",
+ "testcafe-hammerhead": "21.0.0",
+ "testcafe-legacy-api": "5.0.0",
"testcafe-reporter-json": "^2.1.0",
"testcafe-reporter-list": "^2.1.0",
"testcafe-reporter-minimal": "^2.1.0",
@@ -22799,9 +22801,9 @@
"dev": true
},
"@types/node": {
- "version": "10.17.55",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.55.tgz",
- "integrity": "sha512-koZJ89uLZufDvToeWO5BrC4CR4OUfHnUz2qoPs/daQH6qq3IN62QFxCTZ+bKaCE0xaoCAJYE4AXre8AbghCrhg==",
+ "version": "10.17.56",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.56.tgz",
+ "integrity": "sha512-LuAa6t1t0Bfw4CuSR0UITsm1hP17YL+u82kfHGrHUWdhlBtH7sa7jGY5z7glGaIj/WDYDkRtgGd+KCjCzxBW1w==",
"dev": true
},
"array-union": {
@@ -23437,9 +23439,9 @@
}
},
"testcafe-hammerhead": {
- "version": "19.5.0",
- "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-19.5.0.tgz",
- "integrity": "sha512-KtSPudvokOrLdsYH1kp8wFqG/JY7zg72/soam2ubrgxv13wcNB1szp1YN4rJnJdZgdGvSfeO1hsrPKhi4Ax+Qw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-21.0.0.tgz",
+ "integrity": "sha512-ctIM2nF93Xgm4jHGbwMtSD1aRJu5d62+jy6T0LrjZcT/312PPWU4a1X2leE4ID9fxDOK+KxAMRuptkZTcR7GUQ==",
"dev": true,
"requires": {
"acorn-hammerhead": "0.4.0",
@@ -23556,9 +23558,9 @@
}
},
"testcafe-legacy-api": {
- "version": "4.2.5",
- "resolved": "https://registry.npmjs.org/testcafe-legacy-api/-/testcafe-legacy-api-4.2.5.tgz",
- "integrity": "sha512-QYCcX+w6NP3SxWnQEHDhODXPncaTl2IwMb2hWK+UxRLEUnoLBoyCj14/+nNINEQ9ogSkFp1zqgeuRcAtIzo+lg==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/testcafe-legacy-api/-/testcafe-legacy-api-5.0.0.tgz",
+ "integrity": "sha512-Peb5NJLP7g6HTihMrrGKV7YnGvD6xw4eNPA7Fx44r1GzncuVY/fV1lIL0EQyr8uz0bS5+Hgr0fHSiZ/E4hCjeQ==",
"dev": true,
"requires": {
"async": "0.2.6",
diff --git a/package.json b/package.json
index 850c399991..118da4bbf3 100644
--- a/package.json
+++ b/package.json
@@ -246,7 +246,7 @@
"supertest": "^6.1.3",
"supertest-session": "^4.1.0",
"terser-webpack-plugin": "^1.2.3",
- "testcafe": "^1.13.0",
+ "testcafe": "^1.14.0",
"ts-jest": "^26.5.4",
"ts-loader": "^7.0.5",
"ts-mock-imports": "^1.3.3",
From b01d60a01ba97e4a00be4719b4832c916086816c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 8 Apr 2021 03:13:10 +0000
Subject: [PATCH 23/75] fix(deps): bump aws-sdk from 2.880.0 to 2.882.0 (#1585)
Bumps [aws-sdk](https://github.com/aws/aws-sdk-js) from 2.880.0 to 2.882.0.
- [Release notes](https://github.com/aws/aws-sdk-js/releases)
- [Changelog](https://github.com/aws/aws-sdk-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js/compare/v2.880.0...v2.882.0)
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 2e64d96535..6ac8a54707 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6256,9 +6256,9 @@
"integrity": "sha512-24q5Rh3bno7ldoyCq99d6hpnLI+PAMocdeVaaGt/5BTQMprvDwQToHfNnruqN11odCHZZIQbRBw+nZo1lTCH9g=="
},
"aws-sdk": {
- "version": "2.880.0",
- "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.880.0.tgz",
- "integrity": "sha512-/dBk3ejw22ED2edzGfmJB83KXDA4wLIw5Hb+2YMhly+gOWecvevy0tML2+YN/cmxyTy+wT0E0sM7fm1v7kmHtw==",
+ "version": "2.882.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.882.0.tgz",
+ "integrity": "sha512-MC1tKQdvIBmSQmyFmS6McGGPrN6yvHmhP0SS0ovx+zF/BbvHPTpi5hIgqPSAkdb8/s0I16QtbwiLQgPT2pf1tA==",
"requires": {
"buffer": "4.9.2",
"events": "1.1.1",
diff --git a/package.json b/package.json
index 118da4bbf3..d60b176cdd 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,7 @@
"angular-ui-bootstrap": "~2.5.6",
"angular-ui-router": "~1.0.29",
"aws-info": "^1.2.0",
- "aws-sdk": "^2.880.0",
+ "aws-sdk": "^2.882.0",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"bluebird": "^3.5.2",
From 76c66d1ced58b2fd2c2516b648f2b8eca3d0706f Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Thu, 8 Apr 2021 11:28:49 +0800
Subject: [PATCH 24/75] test: migrate file-validation util tests to TypeScript
(#1578)
---
.../backend/utils/file-validation.spec.js | 112 ------------------
.../backend/utils/file-validation.spec.ts | 108 +++++++++++++++++
2 files changed, 108 insertions(+), 112 deletions(-)
delete mode 100644 tests/unit/backend/utils/file-validation.spec.js
create mode 100644 tests/unit/backend/utils/file-validation.spec.ts
diff --git a/tests/unit/backend/utils/file-validation.spec.js b/tests/unit/backend/utils/file-validation.spec.js
deleted file mode 100644
index 7441dd5587..0000000000
--- a/tests/unit/backend/utils/file-validation.spec.js
+++ /dev/null
@@ -1,112 +0,0 @@
-const {
- getFileExtension,
- isInvalidFileExtension,
- getInvalidFileExtensionsInZip,
-} = require('../../../../dist/backend/shared/util/file-validation')
-
-describe('getFileExtension', () => {
- const tests = [
- {
- name: 'handles file name with extension',
- input: 'image.jpg',
- expected: '.jpg',
- },
- {
- name: 'handles file name with no extension',
- input: 'image',
- expected: '',
- },
- {
- name: 'handles file name with multiple dots',
- input: 'file.a.txt',
- expected: '.txt',
- },
- {
- name: 'handles file name with multiple consecutive dots',
- input: 'file...a.zip',
- expected: '.zip',
- },
- ]
- tests.forEach((t) => {
- it(t.name, () => {
- const actual = getFileExtension(t.input)
- expect(actual).toEqual(t.expected)
- })
- })
-})
-
-describe('isInvalidFileExtension', () => {
- const tests = [
- {
- name: 'should return false when given valid extension',
- input: '.jpg',
- expected: false,
- },
- {
- name: 'should return true when given invalid extension',
- input: '.invalid',
- expected: true,
- },
- {
- name: 'should return false when given valid extension that is mixed case',
- input: '.jPG',
- expected: false,
- },
- {
- name:
- 'should return true when given invalid extension that is mixed case',
- input: '.InvalId',
- expected: true,
- },
- ]
- tests.forEach((t) => {
- it(t.name, () => {
- const actual = isInvalidFileExtension(t.input)
- expect(actual).toEqual(t.expected)
- })
- })
-})
-
-describe('getInvalidFileExtensionsInZip on server', () => {
- const fs = require('fs')
- const tests = [
- {
- name: 'should return [] when there is only valid files',
- file: './tests/unit/backend/resources/onlyvalid.zip',
- expected: [],
- },
- {
- name: 'should return invalid extensions when there is only invalid ext',
- file: './tests/unit/backend/resources/onlyinvalid.zip',
- expected: ['.a', '.abc', '.py'],
- },
- {
- name: 'should return only invalid extensions',
- file: './tests/unit/backend/resources/invalidandvalid.zip',
- expected: ['.a', '.oo'],
- },
- {
- name: 'should exclude repeated invalid extensions',
- file: './tests/unit/backend/resources/repeated.zip',
- expected: ['.a'],
- },
- {
- name: 'should exclude folders',
- file: './tests/unit/backend/resources/folder.zip',
- expected: [],
- },
- {
- name: 'should include invalid extensions in nested zip files',
- file: './tests/unit/backend/resources/nestedInvalid.zip',
- expected: ['.a', '.oo'],
- },
- ]
- tests.forEach((t) => {
- it(t.name, async () => {
- const fn = getInvalidFileExtensionsInZip('server')
- const file = fs.readFileSync(t.file, 'binary')
- const actual = await fn(file)
- expect(actual).toEqual(t.expected)
- })
- })
-})
diff --git a/tests/unit/backend/utils/file-validation.spec.ts b/tests/unit/backend/utils/file-validation.spec.ts
new file mode 100644
index 0000000000..20b51dfed8
--- /dev/null
+++ b/tests/unit/backend/utils/file-validation.spec.ts
@@ -0,0 +1,108 @@
+import fs from 'fs'
+
+import { FilePlatforms } from 'src/shared/constants'
+import {
+ getFileExtension,
+ getInvalidFileExtensionsInZip,
+ isInvalidFileExtension,
+} from 'src/shared/util/file-validation'
+
+describe('File validation utils', () => {
+ describe('getFileExtension', () => {
+ it('should handle file name with extension', () => {
+ const actual = getFileExtension('image.jpg')
+ expect(actual).toEqual('.jpg')
+ })
+
+ it('should handle file name with no extension', async () => {
+ const actual = getFileExtension('image-no-extension')
+ expect(actual).toEqual('')
+ })
+
+ it('should handle file name with multiple periods', async () => {
+ const actual = getFileExtension('file.a.txt')
+ expect(actual).toEqual('.txt')
+ })
+
+ it('should handle file name with consecutive periodsa', async () => {
+ const actual = getFileExtension('file....a.zip')
+ expect(actual).toEqual('.zip')
+ })
+ })
+
+ describe('isInvalidFileExtension', () => {
+ it('should return false when given valid extension', () => {
+ const actual = isInvalidFileExtension('.jpg')
+ expect(actual).toEqual(false)
+ })
+
+ it('should return true when given invalid extension', () => {
+ const actual = isInvalidFileExtension('.invalid')
+ expect(actual).toEqual(true)
+ })
+
+ it('should return false when given valid extension that is mixed case', () => {
+ const actual = isInvalidFileExtension('.jPG')
+ expect(actual).toEqual(false)
+ })
+
+ it('should return true when given invalid extension that is mixed case', () => {
+ const actual = isInvalidFileExtension('.sPoNgEbOb')
+ expect(actual).toEqual(true)
+ })
+ })
+
+ describe('getInvalidFileExtensionsInZip on server', () => {
+ it('should return empty array when there is only valid files', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync(
+ './tests/unit/backend/resources/onlyvalid.zip',
+ )
+ const actual = await fn(file)
+ expect(actual).toEqual([])
+ })
+
+ it('should return invalid extensions when zipped files are all invalid file extensions', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync(
+ './tests/unit/backend/resources/onlyinvalid.zip',
+ )
+ const actual = await fn(file)
+ expect(actual).toEqual(['.a', '.abc', '.py'])
+ })
+
+ it('should return only invalid extensions when zip has some valid file extensions', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync(
+ './tests/unit/backend/resources/invalidandvalid.zip',
+ )
+ const actual = await fn(file)
+ expect(actual).toEqual(['.a', '.oo'])
+ })
+
+ it('should exclude repeated invalid extensions', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync(
+ './tests/unit/backend/resources/repeated.zip',
+ )
+ const actual = await fn(file)
+ expect(actual).toEqual(['.a'])
+ })
+
+ it('should exclude folders', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync('./tests/unit/backend/resources/folder.zip')
+ const actual = await fn(file)
+ expect(actual).toEqual([])
+ })
+
+ it('should include invalid extensions in nested zip files', async () => {
+ const fn = getInvalidFileExtensionsInZip(FilePlatforms.Server)
+ const file = fs.readFileSync(
+ './tests/unit/backend/resources/nestedInvalid.zip',
+ )
+ const actual = await fn(file)
+ expect(actual).toEqual(['.a', '.oo'])
+ })
+ })
+})
From 1b0b7ed989cf594a622f5efdf27742ff1e8da525 Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Thu, 8 Apr 2021 13:18:43 +0800
Subject: [PATCH 25/75] test: migrate logic.spec from javascript to typescript
(#1587)
---
tests/unit/backend/utils/logic.spec.js | 816 -------------------
tests/unit/backend/utils/logic.spec.ts | 1004 ++++++++++++++++++++++++
2 files changed, 1004 insertions(+), 816 deletions(-)
delete mode 100644 tests/unit/backend/utils/logic.spec.js
create mode 100644 tests/unit/backend/utils/logic.spec.ts
diff --git a/tests/unit/backend/utils/logic.spec.js b/tests/unit/backend/utils/logic.spec.js
deleted file mode 100644
index 45fbde26ef..0000000000
--- a/tests/unit/backend/utils/logic.spec.js
+++ /dev/null
@@ -1,816 +0,0 @@
-const {
- getVisibleFieldIds,
- getLogicUnitPreventingSubmit,
-} = require('../../../../dist/backend/shared/util/logic')
-describe('Logic validation', () => {
- /**
- * Mock a field
- * @param {String} fieldId
- */
- const makeField = (fieldId) => {
- return { _id: fieldId }
- }
- /**
- * Mock a response
- * @param {String} fieldId field id of the field that this response is meant for
- * @param {String} answer
- * @param {Array} [answerArray] array of answers passed in for checkbox and table
- * @param {Boolean} [isVisible]
- */
- const makeResponse = (
- fieldId,
- answer,
- answerArray = null,
- isVisible = true,
- ) => {
- let response = { _id: fieldId, answer, isVisible }
- if (answerArray) response.answerArray = answerArray
- return response
- }
- describe('visibility for different states', () => {
- const form = { _id: 'f1' }
- const conditionField = makeField('001')
- const logicField = makeField('002')
- const logicResponse = makeResponse(logicField._id, 'lorem')
- form.form_fields = [conditionField, logicField]
- it('should compute the correct visibility for "is equals to"', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is equals to',
- value: 0,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 0), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 1), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- it('should compute the correct visibility for "is less than or equal to"', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is less than or equal to',
- value: 99,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 98), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 99), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 100), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- it('should compute the correct visibility for "is more than or equal to"', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is more than or equal to',
- value: 22,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 23), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 22), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 21), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- it('should compute the correct visibility for "is either"', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'multi-select',
- _id: '58169',
- field: conditionField._id,
- state: 'is either',
- value: ['Option 1', 'Option 2'],
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 'Option 1'), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 'Option 2'), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(conditionField._id, 'Option 3'), logicResponse],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- })
- describe('preventing submission for different states', () => {
- const form = { _id: 'f1' }
- const conditionField = makeField('001')
- const logicField = makeField('002')
- const logicResponse = makeResponse(logicField._id, 'lorem')
- form.form_fields = [conditionField, logicField]
- it('should compute that submission should be prevented for "is equals to"', () => {
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is equals to',
- value: 0,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 0), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 1), logicResponse],
- form,
- ),
- ).toBeUndefined()
- })
- it('should compute that submission should be prevented for "is less than or equal to"', () => {
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is less than or equal to',
- value: 99,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 98), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 99), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 100), logicResponse],
- form,
- ),
- ).toBeUndefined()
- })
- it('should compute that submission should be prevented for "is more than or equal to"', () => {
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is more than or equal to',
- value: 22,
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 23), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 22), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 21), logicResponse],
- form,
- ),
- ).toBeUndefined()
- })
- it('should compute that submission should be prevented for "is either"', () => {
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'multi-select',
- _id: '58169',
- field: conditionField._id,
- state: 'is either',
- value: ['Option 1', 'Option 2'],
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 'Option 1'), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 'Option 2'), logicResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(conditionField._id, 'Option 3'), logicResponse],
- form,
- ),
- ).toBeUndefined()
- })
- })
- describe('show fields with multiple conditions', () => {
- const form = { _id: 'f1' }
- const conditionField1 = makeField('001')
- const conditionField2 = makeField('002')
- const logicField = makeField('003')
- const logicResponse = makeResponse(logicField._id, 'lorem')
- form.form_fields = [conditionField1, conditionField2, logicField]
- it('should compute the correct visibility for AND conditions', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- _id: '5df11ee1e6b6e7108a939c8a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- {
- ifValueType: 'single-select',
- _id: '45633',
- field: conditionField2._id,
- state: 'is equals to',
- value: 20,
- },
- ],
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(false)
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- it('should compute the correct visibility for OR conditions', () => {
- form.form_logics = [
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5df11ee1e6b6e7108a939c8a',
- logicType: 'showFields',
- },
- {
- show: [logicField._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '89906',
- field: conditionField2._id,
- state: 'is equals to',
- value: 20,
- },
- ],
- _id: '5df127a2e6b6e7108a939c90',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ).has(logicField._id),
- ).toBe(false)
- })
- })
- describe('prevent submit with multiple conditions', () => {
- const form = { _id: 'f1' }
- const conditionField1 = makeField('001')
- const conditionField2 = makeField('002')
- const logicField = makeField('003')
- const logicResponse = makeResponse(logicField._id, 'lorem')
- form.form_fields = [conditionField1, conditionField2, logicField]
- it('should correctly prevent submission for AND conditions', () => {
- form.form_logics = [
- {
- show: [],
- _id: '5df11ee1e6b6e7108a939c8a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- {
- ifValueType: 'single-select',
- _id: '45633',
- field: conditionField2._id,
- state: 'is equals to',
- value: 20,
- },
- ],
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ),
- ).toBeUndefined()
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ),
- ).toBeUndefined()
- })
- it('should correctly prevent submission for OR conditions', () => {
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5df11ee1e6b6e7108a939c8a',
- logicType: 'preventSubmit',
- },
- {
- show: [],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '89906',
- field: conditionField2._id,
- state: 'is equals to',
- value: 20,
- },
- ],
- _id: '5df127a2e6b6e7108a939c90',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'Yes'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 20),
- logicResponse,
- ],
- form,
- ),
- ).toEqual(form.form_logics[1])
- expect(
- getLogicUnitPreventingSubmit(
- [
- makeResponse(conditionField1._id, 'No'),
- makeResponse(conditionField2._id, 100),
- logicResponse,
- ],
- form,
- ),
- ).toBeUndefined()
- })
- })
- describe('visibility for others value', () => {
- const form = { _id: 'f1' }
- const radioButton = {
- _id: '001',
- fieldType: 'radiobutton',
- fieldOptions: ['Option 1', 'Option 2'],
- othersRadioButton: true,
- }
- const textField = { _id: '002', fieldType: 'textfield' }
- it('should compute the correct visibility for radiobutton Others on clientside', () => {
- const textFieldResponse = Object.assign({}, textField, {
- fieldValue: 'lorem',
- })
- form.form_fields = [radioButton, textField]
- form.form_logics = [
- {
- show: [textField._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: radioButton._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- const fillInRadioButton = (fieldValue) =>
- Object.assign({}, radioButton, { fieldValue, isVisible: true })
- expect(
- getVisibleFieldIds(
- [fillInRadioButton('radioButtonOthers'), textFieldResponse],
- form,
- ).has(textField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [fillInRadioButton('Option 1'), textFieldResponse],
- form,
- ).has(textField._id),
- ).toBe(false)
- })
- it('should compute the correct visibility for radiobutton Others on serverside', () => {
- const textFieldResponse = makeResponse('002', 'lorem')
- form.form_fields = [radioButton, textField]
- form.form_logics = [
- {
- show: [textField._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: radioButton._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- expect(
- getVisibleFieldIds(
- [makeResponse(radioButton._id, 'Others: School'), textFieldResponse],
- form,
- ).has(textField._id),
- ).toBe(true)
- expect(
- getVisibleFieldIds(
- [makeResponse(radioButton._id, 'Option 1'), textFieldResponse],
- form,
- ).has(textField._id),
- ).toBe(false)
- })
- })
- describe('visibility for circular logic', () => {
- const form = { _id: 'f1' }
- const field1 = makeField('001')
- const field2 = makeField('002')
- const visibleField = makeField('003')
- form.form_logics = [
- {
- show: [field2._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: field1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5df11ee1e6b6e7108a939c8a',
- logicType: 'showFields',
- },
- {
- show: [field1._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '89906',
- field: field2._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5df127a2e6b6e7108a939c90',
- logicType: 'showFields',
- },
- ]
- it('should compute the correct visibility for circular logic where all fields are hidden', () => {
- form.form_fields = [field1, field2]
- for (let field1Response of ['Yes', 'No']) {
- for (let field2Response of ['Yes', 'No']) {
- const visibleFieldIds = getVisibleFieldIds(
- [
- makeResponse(field1._id, field1Response),
- makeResponse(field2._id, field2Response),
- ],
- form,
- )
- expect(visibleFieldIds.has(field1._id)).toBe(false)
- expect(visibleFieldIds.has(field2._id)).toBe(false)
- }
- }
- })
- it('should compute the correct visibility for circular logic with a mix of shown and hidden fields', () => {
- form.form_fields = [field1, field2, visibleField]
- for (let field1Response of ['Yes', 'No']) {
- for (let field2Response of ['Yes', 'No']) {
- const visibleFieldIds = getVisibleFieldIds(
- [
- makeResponse(field1._id, field1Response),
- makeResponse(field2._id, field2Response),
- makeResponse(visibleField._id, 'Yes'),
- ],
- form,
- )
- expect(visibleFieldIds.has(field1._id)).toBe(false)
- expect(visibleFieldIds.has(field2._id)).toBe(false)
- expect(visibleFieldIds.has(visibleField._id)).toBe(true)
- }
- }
- })
- })
- describe('prevent submit for others value', () => {
- const form = { _id: 'f1' }
- const radioButton = {
- _id: '001',
- fieldType: 'radiobutton',
- fieldOptions: ['Option 1', 'Option 2'],
- othersRadioButton: true,
- }
- const textField = { _id: '002', fieldType: 'textfield' }
- it('should correctly prevent submission for radiobutton Others on clientside', () => {
- const textFieldResponse = Object.assign({}, textField, {
- fieldValue: 'lorem',
- })
- form.form_fields = [radioButton, textField]
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: radioButton._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- const fillInRadioButton = (fieldValue) =>
- Object.assign({}, radioButton, { fieldValue, isVisible: true })
- expect(
- getLogicUnitPreventingSubmit(
- [fillInRadioButton('radioButtonOthers'), textFieldResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [fillInRadioButton('Option 1'), textFieldResponse],
- form,
- ),
- ).toBeUndefined()
- })
- it('should correctly prevent submission for radiobutton Others on serverside', () => {
- const textFieldResponse = makeResponse('002', 'lorem')
- form.form_fields = [radioButton, textField]
- form.form_logics = [
- {
- show: [],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: radioButton._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'preventSubmit',
- },
- ]
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(radioButton._id, 'Others: School'), textFieldResponse],
- form,
- ),
- ).toEqual(form.form_logics[0])
- expect(
- getLogicUnitPreventingSubmit(
- [makeResponse(radioButton._id, 'Option 1'), textFieldResponse],
- form,
- ),
- ).toBeUndefined()
- })
- })
-})
diff --git a/tests/unit/backend/utils/logic.spec.ts b/tests/unit/backend/utils/logic.spec.ts
new file mode 100644
index 0000000000..675ff223f6
--- /dev/null
+++ b/tests/unit/backend/utils/logic.spec.ts
@@ -0,0 +1,1004 @@
+import { ObjectId } from 'bson-ext'
+
+import {
+ getLogicUnitPreventingSubmit,
+ getVisibleFieldIds,
+} from 'src/shared/util/logic'
+import {
+ BasicField,
+ FieldResponse,
+ IFieldSchema,
+ IFormDocument,
+ IPreventSubmitLogicSchema,
+ IRadioFieldSchema,
+ IShortTextFieldSchema,
+ IShowFieldsLogicSchema,
+ LogicConditionState,
+ LogicIfValue,
+ LogicType,
+} from 'src/types'
+
+describe('Logic validation', () => {
+ /** Mock a field's bare essentials */
+ const makeField = (fieldId: string) => ({ _id: fieldId } as IFieldSchema)
+
+ /**
+ * Mock a response
+ * @param fieldId field id of the field that this response is meant for
+ * @param answer
+ * @param answerArray array of answers passed in for checkbox and table
+ * @param isVisible whether field is visible
+ */
+ const makeResponse = (
+ fieldId: string,
+ answer: string | number,
+ answerArray: string[] | null = null,
+ isVisible = true,
+ ): FieldResponse => {
+ const response: Record = { _id: fieldId, answer, isVisible }
+ if (answerArray) {
+ response.answerArray = answerArray
+ }
+ return response as FieldResponse
+ }
+
+ describe('visibility for different states', () => {
+ const CONDITION_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_RESPONSE = makeResponse(LOGIC_FIELD._id, 'lorem')
+ const MOCK_LOGIC_ID = new ObjectId().toHexString()
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = {
+ _id: new ObjectId(),
+ form_fields: [CONDITION_FIELD, LOGIC_FIELD],
+ } as IFormDocument
+ })
+
+ it('should compute the correct visibility for "is equals to"', () => {
+ // Arrange
+ const equalsCondition = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 0,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ form.form_logics = [equalsCondition]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 0), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 1), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+
+ it('should compute the correct visibility for "is less than or equal to"', () => {
+ // Arrange
+ const lteCondition = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Lte,
+ value: 99,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ form.form_logics = [lteCondition]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 98), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 99), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 100), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+
+ it('should compute the correct visibility for "is more than or equal to"', () => {
+ // Arrange
+ const gteCondition = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Gte,
+ value: 22,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+ form.form_logics = [gteCondition]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 23), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 22), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 21), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+
+ it('should compute the correct visibility for "is either"', () => {
+ // Arrange
+ const validOptions = ['Option 1', 'Option 2']
+ const eitherCondition = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.MultiSelect,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Either,
+ value: validOptions,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ form.form_logics = [eitherCondition]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, validOptions[0]), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, validOptions[1]), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(CONDITION_FIELD._id, 'invalid option'), LOGIC_RESPONSE],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+ })
+
+ describe('preventing submission for different states', () => {
+ const CONDITION_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_RESPONSE = makeResponse(LOGIC_FIELD._id, 'lorem')
+ const MOCK_LOGIC_ID = new ObjectId().toHexString()
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = {
+ _id: new ObjectId(),
+ form_fields: [CONDITION_FIELD, LOGIC_FIELD],
+ } as IFormDocument
+ })
+
+ it('should compute that submission should be prevented for "is equals to"', () => {
+ // Arrange
+ const equalCondition = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 0,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: "oh no you don't",
+ } as IPreventSubmitLogicSchema
+
+ form.form_logics = [equalCondition]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 0), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 1), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+
+ it('should compute that submission should be prevented for "is less than or equal to"', () => {
+ // Arrange
+ const lteCondition = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Lte,
+ value: 99,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: "oh no you don't",
+ } as IPreventSubmitLogicSchema
+
+ form.form_logics = [lteCondition]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 98), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 99), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 100), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+
+ it('should compute that submission should be prevented for "is more than or equal to"', () => {
+ // Arrange
+ const gteCondition = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.Number,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Gte,
+ value: 22,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: "oh no you don't",
+ } as IPreventSubmitLogicSchema
+
+ form.form_logics = [gteCondition]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 23), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 22), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 21), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+
+ it('should compute that submission should be prevented for "is either"', () => {
+ // Arrange
+ const validOptions = ['Option 1', 'Option 2']
+ const eitherCondition = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.MultiSelect,
+ _id: '58169',
+ field: CONDITION_FIELD._id,
+ state: LogicConditionState.Either,
+ value: validOptions,
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: "oh no you don't",
+ } as IPreventSubmitLogicSchema
+
+ form.form_logics = [eitherCondition]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, validOptions[0]), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, validOptions[1]), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(CONDITION_FIELD._id, 'Option 3'), LOGIC_RESPONSE],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+ })
+
+ describe('show fields with multiple conditions', () => {
+ const CONDITION_FIELD_1 = makeField(new ObjectId().toHexString())
+ const CONDITION_FIELD_2 = makeField(new ObjectId().toHexString())
+ const LOGIC_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_RESPONSE = makeResponse(LOGIC_FIELD._id, 'lorem')
+ const MOCK_LOGIC_ID_1 = new ObjectId().toHexString()
+ const MOCK_LOGIC_ID_2 = new ObjectId().toHexString()
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = {
+ _id: new ObjectId(),
+ form_fields: [CONDITION_FIELD_1, CONDITION_FIELD_2, LOGIC_FIELD],
+ } as IFormDocument
+ })
+
+ it('should compute the correct visibility for AND conditions', () => {
+ // Arrange
+ const multipleEqualConditions = {
+ show: [LOGIC_FIELD._id],
+ _id: MOCK_LOGIC_ID_1,
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '9577',
+ field: CONDITION_FIELD_1._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '45633',
+ field: CONDITION_FIELD_2._id,
+ state: LogicConditionState.Equal,
+ value: 20,
+ },
+ ],
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ form.form_logics = [multipleEqualConditions]
+
+ // Act
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+
+ it('should compute the correct visibility for OR conditions', () => {
+ // Arrange
+ const equalCondition = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '9577',
+ field: CONDITION_FIELD_1._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ ],
+ _id: MOCK_LOGIC_ID_1,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ const equalCondition2 = {
+ show: [LOGIC_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '89906',
+ field: CONDITION_FIELD_2._id,
+ state: LogicConditionState.Equal,
+ value: 20,
+ },
+ ],
+ _id: MOCK_LOGIC_ID_2,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ form.form_logics = [equalCondition, equalCondition2]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ).has(LOGIC_FIELD._id),
+ ).toEqual(false)
+ })
+ })
+
+ describe('prevent submit with multiple conditions', () => {
+ const CONDITION_FIELD_1 = makeField(new ObjectId().toHexString())
+ const CONDITION_FIELD_2 = makeField(new ObjectId().toHexString())
+ const LOGIC_FIELD = makeField(new ObjectId().toHexString())
+ const LOGIC_RESPONSE = makeResponse(LOGIC_FIELD._id, 'lorem')
+ const MOCK_LOGIC_ID_1 = new ObjectId().toHexString()
+ const MOCK_LOGIC_ID_2 = new ObjectId().toHexString()
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = {
+ _id: new ObjectId(),
+ form_fields: [CONDITION_FIELD_1, CONDITION_FIELD_2, LOGIC_FIELD],
+ } as IFormDocument
+ })
+
+ it('should correctly prevent submission for AND conditions', () => {
+ // Arrange
+ const multipleEqualConditions = {
+ _id: MOCK_LOGIC_ID_1,
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '9577',
+ field: CONDITION_FIELD_1._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '45633',
+ field: CONDITION_FIELD_2._id,
+ state: LogicConditionState.Equal,
+ value: 20,
+ },
+ ],
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: 'orh hor i tell teacher',
+ } as IPreventSubmitLogicSchema
+ form.form_logics = [multipleEqualConditions]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toBeUndefined()
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+
+ it('should correctly prevent submission for OR conditions', () => {
+ // Arrange
+ const equalCondition = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '9577',
+ field: CONDITION_FIELD_1._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ ],
+ _id: MOCK_LOGIC_ID_1,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: 'this one cannot',
+ } as IPreventSubmitLogicSchema
+
+ const equalCondition2 = {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '89906',
+ field: CONDITION_FIELD_2._id,
+ state: LogicConditionState.Equal,
+ value: 20,
+ },
+ ],
+ _id: MOCK_LOGIC_ID_2,
+ logicType: LogicType.PreventSubmit,
+ preventSubmitMessage: 'this one also cannot',
+ } as IPreventSubmitLogicSchema
+
+ form.form_logics = [equalCondition, equalCondition2]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'Yes'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 20),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toEqual(form.form_logics[1])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(CONDITION_FIELD_1._id, 'No'),
+ makeResponse(CONDITION_FIELD_2._id, 100),
+ LOGIC_RESPONSE,
+ ],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+ })
+
+ describe('visibility for others value', () => {
+ const MOCK_LOGIC_ID = new ObjectId().toHexString()
+
+ const MOCK_RADIO_FIELD = {
+ _id: new ObjectId().toHexString(),
+ fieldType: BasicField.Radio,
+ fieldOptions: ['Option 1', 'Option 2'],
+ othersRadioButton: true,
+ } as IRadioFieldSchema
+
+ const MOCK_TEXT_FIELD = {
+ _id: new ObjectId().toHexString(),
+ fieldType: BasicField.ShortText,
+ } as IShortTextFieldSchema
+
+ const fillInRadioButton = (fieldValue: string) =>
+ Object.assign({}, MOCK_RADIO_FIELD, { fieldValue, isVisible: true })
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = {
+ _id: new ObjectId(),
+ } as IFormDocument
+ })
+
+ it('should compute the correct visibility for radiobutton Others on clientside', () => {
+ // Arrange
+ const equalCondition = {
+ show: [MOCK_TEXT_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '58169',
+ field: MOCK_RADIO_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 'Others',
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ const textFieldResponse = Object.assign({}, MOCK_TEXT_FIELD, {
+ fieldValue: 'lorem',
+ })
+ form.form_fields = [MOCK_RADIO_FIELD, MOCK_TEXT_FIELD]
+ form.form_logics = [equalCondition]
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [fillInRadioButton('radioButtonOthers'), textFieldResponse],
+ form,
+ ).has(MOCK_TEXT_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [fillInRadioButton('Option 1'), textFieldResponse],
+ form,
+ ).has(MOCK_TEXT_FIELD._id),
+ ).toEqual(false)
+ })
+
+ it('should compute the correct visibility for radiobutton Others on serverside', () => {
+ // Arrange
+ const equalCondition = {
+ show: [MOCK_TEXT_FIELD._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '58169',
+ field: MOCK_RADIO_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 'Others',
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema
+
+ const textFieldResponse = makeResponse(
+ new ObjectId().toHexString(),
+ 'lorem',
+ )
+ form.form_fields = [MOCK_RADIO_FIELD, MOCK_TEXT_FIELD]
+ form.form_logics = [equalCondition]
+
+ // Act + Assert
+ expect(
+ getVisibleFieldIds(
+ [
+ makeResponse(MOCK_RADIO_FIELD._id, 'Others: School'),
+ textFieldResponse,
+ ],
+ form,
+ ).has(MOCK_TEXT_FIELD._id),
+ ).toEqual(true)
+
+ expect(
+ getVisibleFieldIds(
+ [makeResponse(MOCK_RADIO_FIELD._id, 'Option 1'), textFieldResponse],
+ form,
+ ).has(MOCK_TEXT_FIELD._id),
+ ).toEqual(false)
+ })
+ })
+
+ describe('visibility for circular logic', () => {
+ const FIELD_1 = makeField(new ObjectId().toHexString())
+ const FIELD_2 = makeField(new ObjectId().toHexString())
+ const VISIBLE_FIELD = makeField(new ObjectId().toHexString())
+ const MOCK_LOGIC_ID_1 = new ObjectId()
+ const MOCK_LOGIC_ID_2 = new ObjectId()
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = ({
+ _id: new ObjectId(),
+ form_logics: [
+ {
+ show: [FIELD_2._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '9577',
+ field: FIELD_1._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ ],
+ _id: MOCK_LOGIC_ID_1,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema,
+ {
+ show: [FIELD_1._id],
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '89906',
+ field: FIELD_2._id,
+ state: LogicConditionState.Equal,
+ value: 'Yes',
+ },
+ ],
+ _id: MOCK_LOGIC_ID_2,
+ logicType: LogicType.ShowFields,
+ } as IShowFieldsLogicSchema,
+ ],
+ } as unknown) as IFormDocument
+ })
+
+ it('should compute the correct visibility for circular logic where all fields are hidden', () => {
+ form.form_fields = [FIELD_1, FIELD_2]
+ for (const field1Response of ['Yes', 'No']) {
+ for (const field2Response of ['Yes', 'No']) {
+ const visibleFieldIds = getVisibleFieldIds(
+ [
+ makeResponse(FIELD_1._id, field1Response),
+ makeResponse(FIELD_2._id, field2Response),
+ ],
+ form,
+ )
+ expect(visibleFieldIds.has(FIELD_1._id)).toEqual(false)
+ expect(visibleFieldIds.has(FIELD_2._id)).toEqual(false)
+ }
+ }
+ })
+
+ it('should compute the correct visibility for circular logic with a mix of shown and hidden fields', () => {
+ form.form_fields = [FIELD_1, FIELD_2, VISIBLE_FIELD]
+ for (const field1Response of ['Yes', 'No']) {
+ for (const field2Response of ['Yes', 'No']) {
+ const visibleFieldIds = getVisibleFieldIds(
+ [
+ makeResponse(FIELD_1._id, field1Response),
+ makeResponse(FIELD_2._id, field2Response),
+ makeResponse(VISIBLE_FIELD._id, 'Yes'),
+ ],
+ form,
+ )
+ expect(visibleFieldIds.has(FIELD_1._id)).toEqual(false)
+ expect(visibleFieldIds.has(FIELD_2._id)).toEqual(false)
+ expect(visibleFieldIds.has(VISIBLE_FIELD._id)).toEqual(true)
+ }
+ }
+ })
+ })
+
+ describe('prevent submit for others value', () => {
+ const MOCK_LOGIC_ID = new ObjectId()
+ const MOCK_RADIO_FIELD = {
+ _id: new ObjectId(),
+ fieldType: BasicField.Radio,
+ fieldOptions: ['Option 1', 'Option 2'],
+ othersRadioButton: true,
+ } as IRadioFieldSchema
+ const MOCK_TEXT_FIELD = {
+ _id: new ObjectId(),
+ fieldType: BasicField.ShortText,
+ } as IShortTextFieldSchema
+
+ const fillInRadioButton = (fieldValue: string) =>
+ Object.assign({}, MOCK_RADIO_FIELD, { fieldValue, isVisible: true })
+
+ let form: IFormDocument
+
+ beforeEach(() => {
+ form = { _id: new ObjectId() } as IFormDocument
+ })
+
+ it('should correctly prevent submission for radiobutton Others on clientside', () => {
+ // Arrange
+ const textFieldResponse = Object.assign({}, MOCK_TEXT_FIELD, {
+ fieldValue: 'lorem',
+ })
+ form.form_fields = [MOCK_RADIO_FIELD, MOCK_TEXT_FIELD]
+ form.form_logics = [
+ {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '58169',
+ field: MOCK_RADIO_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 'Others',
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ } as IPreventSubmitLogicSchema,
+ ]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [fillInRadioButton('radioButtonOthers'), textFieldResponse],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+
+ expect(
+ getLogicUnitPreventingSubmit(
+ [fillInRadioButton('Option 1'), textFieldResponse],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+
+ it('should correctly prevent submission for radiobutton Others on serverside', () => {
+ // Arrange
+ const textFieldResponse = makeResponse(
+ new ObjectId().toHexString(),
+ 'lorem',
+ )
+ form.form_fields = [MOCK_RADIO_FIELD, MOCK_TEXT_FIELD]
+ form.form_logics = [
+ {
+ conditions: [
+ {
+ ifValueType: LogicIfValue.SingleSelect,
+ _id: '58169',
+ field: MOCK_RADIO_FIELD._id,
+ state: LogicConditionState.Equal,
+ value: 'Others',
+ },
+ ],
+ _id: MOCK_LOGIC_ID,
+ logicType: LogicType.PreventSubmit,
+ } as IPreventSubmitLogicSchema,
+ ]
+
+ // Act + Assert
+ expect(
+ getLogicUnitPreventingSubmit(
+ [
+ makeResponse(MOCK_RADIO_FIELD._id, 'Others: School'),
+ textFieldResponse,
+ ],
+ form,
+ ),
+ ).toEqual(form.form_logics[0])
+ expect(
+ getLogicUnitPreventingSubmit(
+ [makeResponse(MOCK_RADIO_FIELD._id, 'Option 1'), textFieldResponse],
+ form,
+ ),
+ ).toBeUndefined()
+ })
+ })
+})
From b619f7bb74071654add3bc40548a651cfc2e1e5c Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Thu, 8 Apr 2021 14:35:08 +0800
Subject: [PATCH 26/75] refactor: collapse encrypt preview submission
middleware (#1554)
---
.../encrypt-submissions.server.controller.js | 139 ----
.../__tests__/admin-form.controller.spec.ts | 719 +++++++++++++++++-
.../__tests__/admin-form.routes.spec.ts | 338 +++++++-
.../form/admin-form/admin-form.controller.ts | 148 +++-
.../form/admin-form/admin-form.routes.ts | 21 +
.../encrypt-submission.service.spec.ts | 47 +-
.../encrypt-submission.controller.ts | 5 +-
.../encrypt-submission.middleware.ts | 131 ++--
.../encrypt-submission.service.ts | 33 +-
.../encrypt-submission.types.ts | 39 +-
.../encrypt-submission.utils.ts | 2 +
.../verified-content.middleware.spec.ts | 200 -----
.../verified-content.middlewares.ts | 70 --
src/app/routes/admin-forms.server.routes.js | 76 --
src/app/routes/public-forms.server.routes.js | 61 +-
src/types/api/encrypt-submission.ts | 21 +
src/types/api/index.ts | 1 +
...rypt-submissions.server.controller.spec.js | 342 ---------
.../backend/helpers/generate-form-data.ts | 15 +-
19 files changed, 1387 insertions(+), 1021 deletions(-)
delete mode 100644 src/app/controllers/encrypt-submissions.server.controller.js
delete mode 100644 src/app/modules/verified-content/__tests__/verified-content.middleware.spec.ts
delete mode 100644 src/app/modules/verified-content/verified-content.middlewares.ts
create mode 100644 src/types/api/encrypt-submission.ts
delete mode 100644 tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js
diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js
deleted file mode 100644
index 9614791b86..0000000000
--- a/src/app/controllers/encrypt-submissions.server.controller.js
+++ /dev/null
@@ -1,139 +0,0 @@
-'use strict'
-const crypto = require('crypto')
-const { StatusCodes } = require('http-status-codes')
-
-const mongoose = require('mongoose')
-const {
- getEncryptSubmissionModel,
-} = require('../models/submission.server.model')
-const EncryptSubmission = getEncryptSubmissionModel(mongoose)
-
-const { createReqMeta } = require('../utils/request')
-const logger = require('../../config/logger').createLoggerWithLabel(module)
-const {
- aws: { attachmentS3Bucket, s3 },
-} = require('../../config/config')
-
-/**
- * @param {Error} err - the Error to report
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} submission - the Mongoose model instance
- * of the submission
- */
-function onEncryptSubmissionFailure(err, req, res, submission) {
- logger.error({
- message: 'Encrypt submission error',
- meta: {
- action: 'onEncryptSubmissionFailure',
- ...createReqMeta(req),
- },
- error: err,
- })
- return res.status(StatusCodes.BAD_REQUEST).json({
- message:
- 'Could not send submission. For assistance, please contact the person who asked you to fill in this form.',
- submissionId: submission._id,
- spcpSubmissionFailure: false,
- })
-}
-
-/**
- * @typedef AttachmentData
- * @type {Object} Object with keys as form field IDs
- */
-
-/**
- * @typedef EncryptedAttachment
- * @property {object} encryptedFile
- * @property {string} encryptedFile.submissionPublicKey
- * @property {string} encryptedFile.nonce
- * @property {string} encryptedFile.binary Attachment binary in base64
- *
- */
-
-/**
- * Uploads attachments to S3 and a saves new Submission to the database for encrypted forms.
- * @param {Object} req - Express request object
- * @param {Object} req.form - f
- * @param {Object} req.formData - the submission for the form
- * @param {AttachmentData} req.attachmentData - attachments for the form if any
- * @param {Object} res - Express response object
- * @param {Object} res.locals - Express response containing local variables scoped to the request
- * @param {Object} res.locals.verified - any signed+encrypted verified object from server.
- * @param {Object} next - the next expressjs callback, invoked once attachments
- * are processed
- */
-exports.saveResponseToDb = function (req, res, next) {
- const { form, formData, attachmentData } = req
- const logMeta = {
- action: 'saveResponseToDb',
- formId: form._id,
- ...createReqMeta(req),
- }
- const { verified } = res.locals
- let attachmentMetadata = new Map()
- let attachmentUploadPromises = []
-
- // Object.keys(attachmentData[fieldId].encryptedFile) [ 'submissionPublicKey', 'nonce', 'binary' ]
- for (let fieldId in attachmentData) {
- const individualAttachment = JSON.stringify(attachmentData[fieldId])
-
- const hashStr = crypto
- .createHash('sha256')
- .update(individualAttachment)
- .digest('hex')
-
- const uploadKey =
- form._id + '/' + crypto.randomBytes(20).toString('hex') + '/' + hashStr
-
- attachmentMetadata.set(fieldId, uploadKey)
-
- attachmentUploadPromises.push(
- s3
- .upload({
- Bucket: attachmentS3Bucket,
- Key: uploadKey,
- Body: Buffer.from(individualAttachment),
- })
- .promise()
- .catch((err) => {
- logger.error({
- message: 'Attachment upload error',
- meta: logMeta,
- error: err,
- })
- return onEncryptSubmissionFailure(err, req, res, submission)
- }),
- )
- }
-
- const submission = new EncryptSubmission({
- form: form._id,
- authType: form.authType,
- myInfoFields: form.getUniqueMyInfoAttrs(),
- encryptedContent: formData,
- verifiedContent: verified,
- attachmentMetadata,
- version: req.body.version,
- })
-
- Promise.all(attachmentUploadPromises)
- .then((_) => {
- return submission.save()
- })
- .then((savedSubmission) => {
- logger.info({
- message: 'Saved submission to MongoDB',
- meta: {
- ...logMeta,
- submissionId: savedSubmission._id,
- },
- })
- req.submission = savedSubmission
- return next()
- })
- .catch((err) => {
- return onEncryptSubmissionFailure(err, req, res, submission)
- })
-}
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
index 0808e5140a..150d700244 100644
--- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
@@ -1,7 +1,7 @@
import { PresignedPost } from 'aws-sdk/clients/s3'
import { ObjectId } from 'bson-ext'
import { assignIn, cloneDeep, merge } from 'lodash'
-import { errAsync, okAsync } from 'neverthrow'
+import { err, errAsync, ok, okAsync } from 'neverthrow'
import { PassThrough } from 'stream'
import { MockedObject } from 'ts-jest/dist/utils/testing'
import { mocked } from 'ts-jest/utils'
@@ -15,16 +15,27 @@ import {
} from 'src/app/modules/core/core.errors'
import * as FeedbackService from 'src/app/modules/feedback/feedback.service'
import { FeedbackResponse } from 'src/app/modules/feedback/feedback.types'
+import * as EncryptSubmissionService from 'src/app/modules/submission/encrypt-submission/encrypt-submission.service'
+import {
+ ConflictError,
+ InvalidEncodingError,
+ ProcessingError,
+ ResponseModeError,
+ ValidateFieldError,
+} from 'src/app/modules/submission/submission.errors'
import * as SubmissionService from 'src/app/modules/submission/submission.service'
import { MissingUserError } from 'src/app/modules/user/user.errors'
+import * as EncryptionUtils from 'src/app/utils/encryption'
import { EditFieldActions } from 'src/shared/constants'
import {
AuthType,
BasicField,
FormMetaView,
FormSettings,
+ IEncryptedSubmissionSchema,
IForm,
IFormSchema,
+ IPopulatedEncryptedForm,
IPopulatedForm,
IPopulatedUser,
IUserSchema,
@@ -32,8 +43,13 @@ import {
ResponseMode,
Status,
} from 'src/types'
+import { EncryptSubmissionDto } from 'src/types/api'
-import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data'
+import {
+ generateDefaultField,
+ generateNewSingleAnswerResponse,
+ generateUnprocessedSingleAnswerResponse,
+} from 'tests/unit/backend/helpers/generate-form-data'
import expressHandler from 'tests/unit/backend/helpers/jest-express'
import * as UserService from '../../../user/user.service'
@@ -63,6 +79,12 @@ jest.mock('src/app/modules/feedback/feedback.service')
const MockFeedbackService = mocked(FeedbackService)
jest.mock('src/app/modules/submission/submission.service')
const MockSubmissionService = mocked(SubmissionService)
+jest.mock(
+ 'src/app/modules/submission/encrypt-submission/encrypt-submission.service',
+)
+const MockEncryptSubmissionService = mocked(EncryptSubmissionService)
+jest.mock('src/app/utils/encryption')
+const MockEncryptionUtils = mocked(EncryptionUtils)
jest.mock('../admin-form.service')
const MockAdminFormService = mocked(AdminFormService)
jest.mock('../../../user/user.service')
@@ -4784,4 +4806,697 @@ describe('admin-form.controller', () => {
)
})
})
+
+ describe('handleEncryptPreviewSubmission', () => {
+ const MOCK_RESPONSES = [
+ generateUnprocessedSingleAnswerResponse(BasicField.Email),
+ ]
+ const MOCK_PARSED_RESPONSES = [
+ generateNewSingleAnswerResponse(BasicField.Email),
+ ]
+ const MOCK_ENCRYPTED_CONTENT = 'mockEncryptedContent'
+ const MOCK_VERSION = 1
+ const MOCK_SUBMISSION_BODY: EncryptSubmissionDto = {
+ responses: MOCK_RESPONSES,
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ version: MOCK_VERSION,
+ isPreview: false,
+ attachments: {
+ [new ObjectId().toHexString()]: {
+ encryptedFile: {
+ binary: '10101',
+ nonce: 'mockNonce',
+ submissionPublicKey: 'mockPublicKey',
+ },
+ },
+ },
+ }
+ const MOCK_USER_ID = new ObjectId().toHexString()
+ const MOCK_FORM_ID = new ObjectId().toHexString()
+ const MOCK_SUBMISSION_ID = new ObjectId().toHexString()
+ const MOCK_USER = {
+ _id: MOCK_USER_ID,
+ email: 'somerandom@example.com',
+ } as IPopulatedUser
+ const MOCK_FORM = {
+ admin: MOCK_USER,
+ _id: MOCK_FORM_ID,
+ title: 'mock title',
+ } as IPopulatedEncryptedForm
+ const MOCK_SUBMISSION = {
+ _id: MOCK_SUBMISSION_ID,
+ created: new Date(),
+ } as IEncryptedSubmissionSchema
+
+ beforeEach(() => {
+ MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER))
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValue(
+ okAsync(MOCK_FORM),
+ )
+ MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValue(
+ ok(MOCK_FORM),
+ )
+ MockEncryptionUtils.checkIsEncryptedEncoding.mockReturnValue(ok(true))
+ MockSubmissionService.getProcessedResponses.mockReturnValue(
+ ok(MOCK_PARSED_RESPONSES),
+ )
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave.mockReturnValue(
+ MOCK_SUBMISSION,
+ )
+ })
+
+ it('should call all services correctly when submission is valid', async () => {
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(MockEncryptionUtils.checkIsEncryptedEncoding).toHaveBeenCalledWith(
+ MOCK_ENCRYPTED_CONTENT,
+ )
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).toHaveBeenCalledWith({
+ form: MOCK_FORM,
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ verifiedContent: '',
+ version: MOCK_VERSION,
+ })
+ expect(MockSubmissionService.sendEmailConfirmations).toHaveBeenCalledWith(
+ {
+ form: MOCK_FORM,
+ parsedResponses: MOCK_PARSED_RESPONSES,
+ submission: MOCK_SUBMISSION,
+ },
+ )
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: 'Form submission successful.',
+ submissionId: MOCK_SUBMISSION_ID,
+ })
+ })
+
+ it('should return 500 when generic database error occurs while retrieving user', async () => {
+ MockUserService.getPopulatedUserById.mockReturnValueOnce(
+ errAsync(new DatabaseError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(
+ MockAuthService.getFormAfterPermissionChecks,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(500)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 422 when user is missing', async () => {
+ MockUserService.getPopulatedUserById.mockReturnValueOnce(
+ errAsync(new MissingUserError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(
+ MockAuthService.getFormAfterPermissionChecks,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(422)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 500 when generic database error occurs while retrieving form', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new DatabaseError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(500)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 404 when form is not found', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new FormNotFoundError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(404)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 410 when form has been archived', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new FormDeletedError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(410)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 403 when user does not have read permissions', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new ForbiddenFormError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(403)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when form is not encrypt mode', async () => {
+ MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValueOnce(
+ err(new ResponseModeError(ResponseMode.Email, ResponseMode.Encrypt)),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEncryptionUtils.checkIsEncryptedEncoding,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when encrypted content encoding is invalid', async () => {
+ MockEncryptionUtils.checkIsEncryptedEncoding.mockReturnValueOnce(
+ err(new InvalidEncodingError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(MockEncryptionUtils.checkIsEncryptedEncoding).toHaveBeenCalledWith(
+ MOCK_ENCRYPTED_CONTENT,
+ )
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when responses cannot be processed', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ProcessingError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(MockEncryptionUtils.checkIsEncryptedEncoding).toHaveBeenCalledWith(
+ MOCK_ENCRYPTED_CONTENT,
+ )
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 409 when form fields submitted are not updated', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ConflictError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(MockEncryptionUtils.checkIsEncryptedEncoding).toHaveBeenCalledWith(
+ MOCK_ENCRYPTED_CONTENT,
+ )
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(409)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when responses cannot be validated', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ValidateFieldError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEncryptPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEncryptSubmissionService.checkFormIsEncryptMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(MockEncryptionUtils.checkIsEncryptedEncoding).toHaveBeenCalledWith(
+ MOCK_ENCRYPTED_CONTENT,
+ )
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(
+ MockEncryptSubmissionService.createEncryptSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+ })
})
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
index 2bd52a224e..ab72a34bce 100644
--- a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { ObjectId } from 'bson-ext'
import { format, subDays } from 'date-fns'
-import { cloneDeep, times } from 'lodash'
+import { cloneDeep, omit, times } from 'lodash'
import mongoose from 'mongoose'
import { errAsync, okAsync } from 'neverthrow'
import SparkMD5 from 'spark-md5'
@@ -28,6 +28,7 @@ import { EditFieldActions, VALID_UPLOAD_FILE_TYPES } from 'src/shared/constants'
import {
BasicField,
IFormDocument,
+ IFormSchema,
IPopulatedEmailForm,
IPopulatedForm,
IUserSchema,
@@ -36,6 +37,7 @@ import {
SubmissionCursorData,
SubmissionType,
} from 'src/types'
+import { EncryptSubmissionDto } from 'src/types/api'
import {
createAuthedSession,
@@ -43,7 +45,10 @@ import {
} from 'tests/integration/helpers/express-auth'
import { setupApp } from 'tests/integration/helpers/express-setup'
import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate'
-import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data'
+import {
+ generateDefaultField,
+ generateUnprocessedSingleAnswerResponse,
+} from 'tests/unit/backend/helpers/generate-form-data'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data'
@@ -84,6 +89,335 @@ describe('admin-form.routes', () => {
})
afterAll(async () => await dbHandler.closeDatabase())
+ describe('POST /v2/submissions/encrypt/preview/:formId', () => {
+ const MOCK_FIELD_ID = new ObjectId().toHexString()
+ const MOCK_ATTACHMENT_FIELD_ID = new ObjectId().toHexString()
+ const MOCK_RESPONSE = omit(
+ generateUnprocessedSingleAnswerResponse(BasicField.Email, {
+ _id: MOCK_FIELD_ID,
+ answer: 'a@abc.com',
+ }),
+ 'question',
+ )
+ const MOCK_ENCRYPTED_CONTENT = `${'a'.repeat(44)};${'a'.repeat(
+ 32,
+ )}:${'a'.repeat(4)}`
+ const MOCK_VERSION = 1
+ const MOCK_SUBMISSION_BODY: EncryptSubmissionDto = {
+ responses: [MOCK_RESPONSE],
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ version: MOCK_VERSION,
+ isPreview: false,
+ attachments: {
+ [MOCK_ATTACHMENT_FIELD_ID]: {
+ encryptedFile: {
+ binary: '10101',
+ nonce: 'mockNonce',
+ submissionPublicKey: 'mockPublicKey',
+ },
+ },
+ },
+ }
+ let mockForm: IFormSchema
+
+ beforeEach(async () => {
+ mockForm = await EncryptFormModel.create({
+ title: 'mock form',
+ publicKey: 'some public key',
+ admin: defaultUser._id,
+ form_fields: [
+ generateDefaultField(BasicField.Email, { _id: MOCK_FIELD_ID }),
+ ],
+ })
+ })
+
+ it('should return 200 with submission ID when request is valid', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send(MOCK_SUBMISSION_BODY)
+
+ expect(response.body.message).toBe('Form submission successful.')
+ expect(mongoose.isValidObjectId(response.body.submissionId)).toBe(true)
+ expect(response.status).toBe(200)
+ })
+
+ it('should return 401 when user is not signed in', async () => {
+ await logoutSession(request)
+
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send(MOCK_SUBMISSION_BODY)
+
+ expect(response.status).toBe(401)
+ expect(response.body).toEqual({ message: 'User is unauthorized.' })
+ })
+
+ it('should return 400 when responses are not provided in body', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send(omit(MOCK_SUBMISSION_BODY, 'responses'))
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({ body: { key: 'responses' } }),
+ )
+ })
+
+ it('should return 400 when responses are missing _id field', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ responses: [omit(MOCK_RESPONSE, '_id')],
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'responses.0._id',
+ message: '"responses[0]._id" is required',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when responses are missing answer field', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ responses: [omit(MOCK_RESPONSE, 'answer')],
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'responses.0.answer',
+ message: '"responses[0].answer" is required',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when responses are missing fieldType', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ responses: [omit(MOCK_RESPONSE, 'fieldType')],
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'responses.0.fieldType',
+ message: '"responses[0].fieldType" is required',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when a fieldType is malformed', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ responses: [{ ...MOCK_RESPONSE, fieldType: 'malformed' }],
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'responses.0.fieldType',
+ message: expect.stringContaining(
+ '"responses[0].fieldType" must be one of ',
+ ),
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when encryptedContent is not provided in body', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send(omit(MOCK_SUBMISSION_BODY, 'encryptedContent'))
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'encryptedContent',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when version is not provided in body', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send(omit(MOCK_SUBMISSION_BODY, 'version'))
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'version',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when encryptedContent is malformed', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({ ...MOCK_SUBMISSION_BODY, encryptedContent: 'abc' })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: 'encryptedContent',
+ message: 'Invalid encryptedContent.',
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when attachment field ID is malformed', async () => {
+ const invalidKey = 'invalidFieldId'
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ attachments: {
+ [invalidKey]: {
+ encryptedFile: {
+ binary: '10101',
+ nonce: 'mockNonce',
+ submissionPublicKey: 'mockPublicKey',
+ },
+ },
+ },
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: `attachments.${invalidKey}`,
+ message: `"attachments.${invalidKey}" is not allowed`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when attachment is missing encryptedFile key', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ attachments: {
+ [MOCK_ATTACHMENT_FIELD_ID]: {},
+ },
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile`,
+ message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile" is required`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when attachment is missing binary', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ attachments: {
+ [MOCK_ATTACHMENT_FIELD_ID]: {
+ encryptedFile: {
+ // binary is missing
+ nonce: 'mockNonce',
+ submissionPublicKey: 'mockPublicKey',
+ },
+ },
+ },
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary`,
+ message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary" is required`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when attachment is missing nonce', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ attachments: {
+ [MOCK_ATTACHMENT_FIELD_ID]: {
+ encryptedFile: {
+ binary: '10101',
+ // nonce is missing
+ submissionPublicKey: 'mockPublicKey',
+ },
+ },
+ },
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce`,
+ message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce" is required`,
+ },
+ }),
+ )
+ })
+
+ it('should return 400 when attachment is missing public key', async () => {
+ const response = await request
+ .post(`/v2/submissions/encrypt/preview/${mockForm._id}`)
+ .send({
+ ...MOCK_SUBMISSION_BODY,
+ attachments: {
+ [MOCK_ATTACHMENT_FIELD_ID]: {
+ encryptedFile: {
+ binary: '10101',
+ nonce: 'mockNonce',
+ // missing public key
+ },
+ },
+ },
+ })
+
+ expect(response.status).toBe(400)
+ expect(response.body).toEqual(
+ buildCelebrateError({
+ body: {
+ key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey`,
+ message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey" is required`,
+ },
+ }),
+ )
+ })
+ })
+
describe('GET /adminform', () => {
it('should return 200 with empty array when user has no forms', async () => {
// Act
diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts
index 319204363d..9530af82d6 100644
--- a/src/app/modules/form/admin-form/admin-form.controller.ts
+++ b/src/app/modules/form/admin-form/admin-form.controller.ts
@@ -12,7 +12,12 @@ import {
IPopulatedForm,
WithForm,
} from '../../../../types'
-import { ErrorDto, SettingsUpdateDto } from '../../../../types/api'
+import {
+ EncryptSubmissionDto,
+ ErrorDto,
+ SettingsUpdateDto,
+} from '../../../../types/api'
+import { checkIsEncryptedEncoding } from '../../../utils/encryption'
import { createReqMeta } from '../../../utils/request'
import * as AuthService from '../../auth/auth.service'
import {
@@ -22,24 +27,14 @@ import {
DatabaseValidationError,
} from '../../core/core.errors'
import * as FeedbackService from '../../feedback/feedback.service'
+import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service'
+import { mapRouteError as mapEncryptSubmissionError } from '../../submission/encrypt-submission/encrypt-submission.utils'
import * as SubmissionService from '../../submission/submission.service'
import * as UserService from '../../user/user.service'
import { PrivateFormError } from '../form.errors'
import { EditFieldError } from './admin-form.errors'
-import {
- archiveForm,
- createForm,
- createPresignedPostUrlForImages,
- createPresignedPostUrlForLogos,
- duplicateForm,
- editFormFields,
- getDashboardForms,
- getMockSpcpLocals,
- transferFormOwnership,
- updateForm,
- updateFormSettings,
-} from './admin-form.service'
+import * as AdminFormService from './admin-form.service'
import {
DuplicateFormBody,
FormUpdateParams,
@@ -60,7 +55,7 @@ const logger = createLoggerWithLabel(module)
export const handleListDashboardForms: RequestHandler = async (req, res) => {
const authedUserId = (req.session as Express.AuthedSession).user._id
- return getDashboardForms(authedUserId)
+ return AdminFormService.getDashboardForms(authedUserId)
.map((dashboardView) => res.json(dashboardView))
.mapErr((error) => {
logger.error({
@@ -209,7 +204,11 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler<
)
// Step 3: Has write permissions, generate presigned POST URL.
.andThen(() =>
- createPresignedPostUrlForImages({ fileId, fileMd5Hash, fileType }),
+ AdminFormService.createPresignedPostUrlForImages({
+ fileId,
+ fileMd5Hash,
+ fileType,
+ }),
)
.map((presignedPostUrl) => res.json(presignedPostUrl))
.mapErr((error) => {
@@ -265,7 +264,11 @@ export const handleCreatePresignedPostUrlForLogos: RequestHandler<
)
// Step 3: Has write permissions, generate presigned POST URL.
.andThen(() =>
- createPresignedPostUrlForLogos({ fileId, fileMd5Hash, fileType }),
+ AdminFormService.createPresignedPostUrlForLogos({
+ fileId,
+ fileMd5Hash,
+ fileType,
+ }),
)
.map((presignedPostUrl) => res.json(presignedPostUrl))
.mapErr((error) => {
@@ -368,7 +371,7 @@ export const passThroughSpcp: RequestHandler = (req, res, next) => {
if ([AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(authType)) {
res.locals = {
...res.locals,
- ...getMockSpcpLocals(
+ ...AdminFormService.getMockSpcpLocals(
authType,
(req as WithForm).form.form_fields,
),
@@ -593,7 +596,7 @@ export const handleArchiveForm: RequestHandler<{ formId: string }> = async (
}),
)
// Step 3: Currently logged in user has permissions to archive form.
- .andThen((formToArchive) => archiveForm(formToArchive))
+ .andThen((formToArchive) => AdminFormService.archiveForm(formToArchive))
.map(() => res.json({ message: 'Form has been archived' }))
.mapErr((error) => {
logger.warn({
@@ -647,7 +650,11 @@ export const handleDuplicateAdminForm: RequestHandler<
})
.andThen((originalForm) =>
// Step 3: Duplicate form.
- duplicateForm(originalForm, userId, overrideParams),
+ AdminFormService.duplicateForm(
+ originalForm,
+ userId,
+ overrideParams,
+ ),
)
// Step 4: Retrieve dashboard view of duplicated form.
.map((duplicatedForm) => duplicatedForm.getDashboardView(user)),
@@ -755,7 +762,7 @@ export const handleCopyTemplateForm: RequestHandler<
// Step 2: Check if form is currently public.
AuthService.getFormIfPublic(formId).andThen((originalForm) =>
// Step 3: Duplicate form.
- duplicateForm(originalForm, userId, overrideParams)
+ AdminFormService.duplicateForm(originalForm, userId, overrideParams)
// Step 4: Retrieve dashboard view of duplicated form.
.map((duplicatedForm) => duplicatedForm.getDashboardView(user)),
),
@@ -822,7 +829,7 @@ export const handleTransferFormOwnership: RequestHandler<
)
// Step 3: User has permissions, transfer form ownership.
.andThen((retrievedForm) =>
- transferFormOwnership(retrievedForm, newOwnerEmail),
+ AdminFormService.transferFormOwnership(retrievedForm, newOwnerEmail),
)
// Success, return updated form.
.map((updatedPopulatedForm) => res.json({ form: updatedPopulatedForm }))
@@ -867,7 +874,9 @@ export const handleCreateForm: RequestHandler<
// Step 1: Retrieve currently logged in user.
UserService.findUserById(sessionUserId)
// Step 2: Create form with given params and set admin to logged in user.
- .andThen((user) => createForm({ ...formParams, admin: user._id }))
+ .andThen((user) =>
+ AdminFormService.createForm({ ...formParams, admin: user._id }),
+ )
.map((createdForm) => res.status(StatusCodes.OK).json(createdForm))
.mapErr((error) => {
logger.error({
@@ -933,8 +942,8 @@ export const handleUpdateForm: RequestHandler<
| DatabaseConflictError
| DatabasePayloadSizeError
> = editFormField
- ? editFormFields(retrievedForm, editFormField)
- : updateForm(retrievedForm, formUpdateParams)
+ ? AdminFormService.editFormFields(retrievedForm, editFormField)
+ : AdminFormService.updateForm(retrievedForm, formUpdateParams)
return updateFormResult
})
@@ -991,7 +1000,7 @@ export const handleUpdateSettings: RequestHandler<
}),
)
.andThen((retrievedForm) =>
- updateFormSettings(retrievedForm, settingsToPatch),
+ AdminFormService.updateFormSettings(retrievedForm, settingsToPatch),
)
.map((updatedSettings) => res.status(StatusCodes.OK).json(updatedSettings))
.mapErr((error) => {
@@ -1010,3 +1019,90 @@ export const handleUpdateSettings: RequestHandler<
return res.status(statusCode).json({ message: errorMessage })
})
}
+
+/**
+ * Handler for POST /v2/submissions/encrypt/preview/:formId.
+ * @security session
+ *
+ * @returns 200 with a mock submission ID
+ * @returns 400 when body is malformed; e.g. invalid plaintext responses or encoding for encrypted content
+ * @returns 403 when current user does not have read permissions to given form
+ * @returns 404 when given form ID does not exist
+ * @returns 410 when given form has been deleted
+ * @returns 500 when database error occurs
+ */
+export const handleEncryptPreviewSubmission: RequestHandler<
+ { formId: string },
+ { message: string; submissionId: string } | ErrorDto,
+ EncryptSubmissionDto
+> = async (req, res) => {
+ const { formId } = req.params
+ const sessionUserId = (req.session as Express.AuthedSession).user._id
+ // No need to process attachments as we don't do anything with them
+ const { encryptedContent, responses, version } = req.body
+ const logMeta = {
+ action: 'handleEncryptPreviewSubmission',
+ formId,
+ }
+
+ const formResult = await UserService.getPopulatedUserById(sessionUserId)
+ .andThen((user) =>
+ // Step 2: Retrieve form with write permission check.
+ AuthService.getFormAfterPermissionChecks({
+ user,
+ formId,
+ level: PermissionLevel.Read,
+ }),
+ )
+ .andThen(EncryptSubmissionService.checkFormIsEncryptMode)
+ if (formResult.isErr()) {
+ logger.error({
+ message: 'Error while retrieving form for preview submission',
+ meta: logMeta,
+ error: formResult.error,
+ })
+ const { errorMessage, statusCode } = mapEncryptSubmissionError(
+ formResult.error,
+ )
+ return res.status(statusCode).json({ message: errorMessage })
+ }
+ const form = formResult.value
+
+ const parsedResponsesResult = checkIsEncryptedEncoding(
+ encryptedContent,
+ ).andThen(() => SubmissionService.getProcessedResponses(form, responses))
+ if (parsedResponsesResult.isErr()) {
+ logger.error({
+ message: 'Error while parsing responses for preview submission',
+ meta: logMeta,
+ error: parsedResponsesResult.error,
+ })
+ const { errorMessage, statusCode } = mapEncryptSubmissionError(
+ parsedResponsesResult.error,
+ )
+ return res.status(statusCode).json({ message: errorMessage })
+ }
+ const parsedResponses = parsedResponsesResult.value
+
+ const submission = EncryptSubmissionService.createEncryptSubmissionWithoutSave(
+ {
+ form,
+ encryptedContent,
+ // Don't bother encrypting and signing mock variables for previews
+ verifiedContent: '',
+ version,
+ },
+ )
+
+ // Don't await on email confirmations
+ void SubmissionService.sendEmailConfirmations({
+ form,
+ parsedResponses,
+ submission,
+ })
+
+ return res.json({
+ message: 'Form submission successful.',
+ submissionId: submission._id,
+ })
+}
diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts
index 934d559a9f..99bab1eb59 100644
--- a/src/app/modules/form/admin-form/admin-form.routes.ts
+++ b/src/app/modules/form/admin-form/admin-form.routes.ts
@@ -10,6 +10,7 @@ import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants'
import { IForm, ResponseMode } from '../../../../types'
import { withUserAuthentication } from '../../auth/auth.middlewares'
import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller'
+import * as EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware'
import * as AdminFormController from './admin-form.controller'
import { DuplicateFormBody } from './admin-form.types'
@@ -495,3 +496,23 @@ AdminFormsRouter.post(
fileUploadValidator,
AdminFormController.handleCreatePresignedPostUrlForLogos,
)
+
+/**
+ * Submit an encrypt mode form in preview mode
+ * @route POST /v2/submissions/encrypt/preview/:formId([a-fA-F0-9]{24})
+ * @security session
+ *
+ * @returns 200 if submission was valid
+ * @returns 400 when error occurs while processing submission or submission is invalid
+ * @returns 403 when user does not have read permissions for form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.post(
+ '/v2/submissions/encrypt/preview/:formId([a-fA-F0-9]{24})',
+ withUserAuthentication,
+ EncryptSubmissionMiddleware.validateEncryptSubmissionParams,
+ AdminFormController.handleEncryptPreviewSubmission,
+)
diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts
index 0ba236987c..65fb082ccf 100644
--- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts
+++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts
@@ -13,13 +13,17 @@ import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-f
import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error'
import { aws } from 'src/config/config'
import {
+ IPopulatedEncryptedForm,
SubmissionCursorData,
SubmissionData,
SubmissionMetadata,
} from 'src/types'
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
+
import { SubmissionNotFoundError } from '../../submission.errors'
import {
+ createEncryptSubmissionWithoutSave,
getEncryptedSubmissionData,
getSubmissionCursor,
getSubmissionMetadata,
@@ -31,7 +35,48 @@ import {
const EncryptSubmission = getEncryptSubmissionModel(mongoose)
describe('encrypt-submission.service', () => {
- beforeEach(() => jest.restoreAllMocks())
+ beforeAll(async () => await dbHandler.connect())
+ beforeEach(async () => {
+ await dbHandler.clearDatabase()
+ jest.restoreAllMocks()
+ })
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ describe('createEncryptSubmissionWithoutSave', () => {
+ const MOCK_FORM = ({
+ admin: new ObjectId(),
+ _id: new ObjectId(),
+ title: 'mock title',
+ getUniqueMyInfoAttrs: () => [],
+ authType: 'NIL',
+ } as unknown) as IPopulatedEncryptedForm
+ const MOCK_ENCRYPTED_CONTENT = 'mockEncryptedContent'
+ const MOCK_VERIFIED_CONTENT = 'mockVerifiedContent'
+ const MOCK_VERSION = 1
+ const MOCK_ATTACHMENT_METADATA = new Map([['a', 'b']])
+
+ it('should create a new submission without saving it to the database', async () => {
+ const result = createEncryptSubmissionWithoutSave({
+ encryptedContent: MOCK_ENCRYPTED_CONTENT,
+ form: MOCK_FORM,
+ version: MOCK_VERSION,
+ attachmentMetadata: MOCK_ATTACHMENT_METADATA,
+ verifiedContent: MOCK_VERIFIED_CONTENT,
+ })
+ const foundInDatabase = await EncryptSubmission.findOne({
+ _id: result._id,
+ })
+
+ expect(result.encryptedContent).toBe(MOCK_ENCRYPTED_CONTENT)
+ expect(result.form).toEqual(MOCK_FORM._id)
+ expect(result.verifiedContent).toEqual(MOCK_VERIFIED_CONTENT)
+ expect(Object.fromEntries(result.attachmentMetadata!)).toEqual(
+ Object.fromEntries(MOCK_ATTACHMENT_METADATA),
+ )
+ expect(result.version).toEqual(MOCK_VERSION)
+ expect(foundInDatabase).toBeNull()
+ })
+ })
describe('getSubmissionCursor', () => {
it('should return cursor successfully when date range is not provided', async () => {
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
index 6f447ccf93..c640efbbb0 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
@@ -13,7 +13,7 @@ import {
ResWithUinFin,
SubmissionMetadataList,
} from '../../../../types'
-import { ErrorDto } from '../../../../types/api'
+import { EncryptSubmissionDto, ErrorDto } from '../../../../types/api'
import { getEncryptSubmissionModel } from '../../../models/submission.server.model'
import { CaptchaFactory } from '../../../services/captcha/captcha.factory'
import { checkIsEncryptedEncoding } from '../../../utils/encryption'
@@ -44,7 +44,6 @@ import {
transformAttachmentMetaStream,
uploadAttachments,
} from './encrypt-submission.service'
-import { EncryptSubmissionBody } from './encrypt-submission.types'
import {
createEncryptedSubmissionDto,
mapRouteError,
@@ -165,7 +164,7 @@ export const handleEncryptedSubmission: RequestHandler = async (req, res) => {
})
}
const processedResponses = processedResponsesResult.value
- delete (req.body as SetOptional).responses
+ delete (req.body as SetOptional).responses
// Checks if user is SPCP-authenticated before allowing submission
const { authType } = form
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts
index a7a6483f30..e569d2ad9d 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts
@@ -1,86 +1,53 @@
-import { RequestHandler } from 'express'
-import { SetOptional } from 'type-fest'
+import { celebrate, Joi, Segments } from 'celebrate'
-import { createReqMeta } from '../../../../app/utils/request'
-import { createLoggerWithLabel } from '../../../../config/logger'
-import { WithForm, WithParsedResponses } from '../../../../types'
-import { checkIsEncryptedEncoding } from '../../../utils/encryption'
-import { getProcessedResponses } from '../submission.service'
-
-import {
- EncryptSubmissionBody,
- EncryptSubmissionBodyAfterProcess,
- WithAttachmentsData,
- WithFormData,
-} from './encrypt-submission.types'
-import { mapRouteError } from './encrypt-submission.utils'
-
-const logger = createLoggerWithLabel(module)
+import { BasicField } from '../../../../types'
/**
- * Extracts relevant fields, injects questions, verifies visibility of field and validates answers
- * to produce req.body.parsedResponses
+ * Celebrate middleware for verifying shape of encrypted submission
*/
-
-export const validateAndProcessEncryptSubmission: RequestHandler<
- { formId: string },
- unknown,
- EncryptSubmissionBody
-> = (req, res, next) => {
- const { form } = req as WithForm
- const { encryptedContent, responses } = req.body
-
- // Step 1: Check whether submitted encryption is valid.
- return (
- checkIsEncryptedEncoding(encryptedContent)
- // Step 2: Encryption is valid, process given responses.
- .andThen(() => getProcessedResponses(form, responses))
- // If pass, then set parsedResponses and delete responses.
- .map((processedResponses) => {
- // eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;(req.body as WithParsedResponses<
- typeof req.body
- >).parsedResponses = processedResponses
- // Prevent downstream functions from using responses by deleting it.
- delete (req.body as SetOptional)
- .responses
- return next()
- })
- // If error, log and return res error.
- .mapErr((error) => {
- logger.error({
- message:
- 'Error validating and processing encrypt submission responses',
- meta: {
- action: 'validateAndProcessEncryptSubmission',
- ...createReqMeta(req),
- formId: form._id,
- },
- error,
- })
-
- const { statusCode, errorMessage } = mapRouteError(error)
- return res.status(statusCode).json({
- message: errorMessage,
- })
- })
- )
-}
-
-/**
- * Verify structure of encrypted response
- */
-
-export const prepareEncryptSubmission: RequestHandler<
- { formId: string },
- unknown,
- EncryptSubmissionBodyAfterProcess
-> = (req, res, next) => {
- // Step 1: Add req.body.encryptedContent to req.formData
- // eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;(req as WithFormData).formData = req.body.encryptedContent
- // Step 2: Add req.body.attachments to req.attachmentData
- ;(req as WithAttachmentsData).attachmentData =
- req.body.attachments || {}
- return next()
-}
+export const validateEncryptSubmissionParams = celebrate({
+ [Segments.BODY]: Joi.object({
+ responses: Joi.array()
+ .items(
+ Joi.object().keys({
+ _id: Joi.string().required(),
+ answer: Joi.string().allow('').required(),
+ fieldType: Joi.string()
+ .required()
+ .valid(...Object.values(BasicField)),
+ signature: Joi.string().allow(''),
+ }),
+ )
+ .required(),
+ encryptedContent: Joi.string()
+ .custom((value, helpers) => {
+ const parts = String(value).split(/;|:/)
+ if (
+ parts.length !== 3 ||
+ parts[0].length !== 44 || // public key
+ parts[1].length !== 32 || // nonce
+ !parts.every((part) => Joi.string().base64().validate(part))
+ ) {
+ return helpers.message({ custom: 'Invalid encryptedContent.' })
+ }
+ return value
+ }, 'encryptedContent')
+ .required(),
+ attachments: Joi.object()
+ .pattern(
+ /^[a-fA-F0-9]{24}$/,
+ Joi.object().keys({
+ encryptedFile: Joi.object()
+ .keys({
+ binary: Joi.string().required(),
+ nonce: Joi.string().required(),
+ submissionPublicKey: Joi.string().required(),
+ })
+ .required(),
+ }),
+ )
+ .optional(),
+ isPreview: Joi.boolean().required(),
+ version: Joi.number().required(),
+ }),
+})
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
index 583317314f..1784312fa9 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts
@@ -8,6 +8,7 @@ import { Transform } from 'stream'
import { aws as AwsConfig } from '../../../../config/config'
import { createLoggerWithLabel } from '../../../../config/logger'
import {
+ IEncryptedSubmissionSchema,
IPopulatedEncryptedForm,
IPopulatedForm,
ResponseMode,
@@ -31,7 +32,10 @@ import {
SubmissionNotFoundError,
} from '../submission.errors'
-import { AttachmentMetadata } from './encrypt-submission.types'
+import {
+ AttachmentMetadata,
+ SaveEncryptSubmissionParams,
+} from './encrypt-submission.types'
const logger = createLoggerWithLabel(module)
const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
@@ -348,3 +352,30 @@ export const checkFormIsEncryptMode = (
? ok(form)
: err(new ResponseModeError(ResponseMode.Encrypt, form.responseMode))
}
+
+/**
+ * Creates an encrypted submission without saving it to the database.
+ * @param form Document of the form being submitted
+ * @param encryptedContent Encrypted content of submission
+ * @param version Encryption version
+ * @param attachmentMetadata
+ * @param verifiedContent Verified content included in submission, e.g. SingPass ID
+ * @returns Encrypted submission document which has not been saved to database
+ */
+export const createEncryptSubmissionWithoutSave = ({
+ form,
+ encryptedContent,
+ version,
+ attachmentMetadata,
+ verifiedContent,
+}: SaveEncryptSubmissionParams): IEncryptedSubmissionSchema => {
+ return new EncryptSubmissionModel({
+ form: form._id,
+ authType: form.authType,
+ myInfoFields: form.getUniqueMyInfoAttrs(),
+ encryptedContent,
+ verifiedContent,
+ attachmentMetadata,
+ version,
+ })
+}
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
index ed468f7232..cda813af4f 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
@@ -1,38 +1,27 @@
-import { FieldResponse } from 'src/types'
-
+import { IPopulatedEncryptedForm } from '../../../../types'
+import { EncryptedAttachmentsDto } from '../../../../types/api'
import { ProcessedFieldResponse } from '../submission.types'
-export type EncryptSubmissionBody = {
- responses: FieldResponse[]
- encryptedContent: string
- attachments?: {
- encryptedFile?: {
- binary: string
- nonce: string
- submissionPublicKey: string
- }
- }
- isPreview: boolean
- version: number
-}
-
-type Attachments = {
- encryptedFile?: {
- binary: string
- nonce: string
- submissionPublicKey: string
- }
-}
export type EncryptSubmissionBodyAfterProcess = {
encryptedContent: string
- attachments?: Attachments
+ attachments?: EncryptedAttachmentsDto
isPreview: boolean
version: number
parsedResponses: ProcessedFieldResponse[]
}
-export type WithAttachmentsData = T & { attachmentData: Attachments }
+export type WithAttachmentsData = T & {
+ attachmentData: EncryptedAttachmentsDto
+}
export type WithFormData = T & { formData: string }
export type AttachmentMetadata = Map
+
+export type SaveEncryptSubmissionParams = {
+ form: IPopulatedEncryptedForm
+ encryptedContent: string
+ version: number
+ verifiedContent?: string
+ attachmentMetadata?: Map
+}
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
index 12c5fbc189..4117ae9edd 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts
@@ -4,6 +4,7 @@ import moment from 'moment-timezone'
import { createLoggerWithLabel } from '../../../../config/logger'
import { EncryptedSubmissionDto, SubmissionData } from '../../../../types'
import { MapRouteError } from '../../../../types/routing'
+import { MalformedVerifiedContentError } from '../../../modules/verified-content/verified-content.errors'
import {
CaptchaConnectionError,
MissingCaptchaError,
@@ -80,6 +81,7 @@ export const mapRouteError: MapRouteError = (
case MissingJwtError:
case VerifyJwtError:
case InvalidJwtError:
+ case MalformedVerifiedContentError:
return {
statusCode: StatusCodes.UNAUTHORIZED,
errorMessage:
diff --git a/src/app/modules/verified-content/__tests__/verified-content.middleware.spec.ts b/src/app/modules/verified-content/__tests__/verified-content.middleware.spec.ts
deleted file mode 100644
index ef70329243..0000000000
--- a/src/app/modules/verified-content/__tests__/verified-content.middleware.spec.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { err, ok } from 'neverthrow'
-import { mocked } from 'ts-jest/utils'
-
-import { FeatureNames } from 'src/config/feature-manager'
-import { AuthType, ResponseMode } from 'src/types'
-
-import expressHandler from 'tests/unit/backend/helpers/jest-express'
-
-import { MissingFeatureError } from '../../core/core.errors'
-import { MalformedVerifiedContentError } from '../verified-content.errors'
-import { VerifiedContentFactory } from '../verified-content.factory'
-import { encryptVerifiedSpcpFields } from '../verified-content.middlewares'
-import { CpVerifiedContent } from '../verified-content.types'
-
-jest.mock('../verified-content.factory')
-
-const MockedVerifiedContentFactory = mocked(VerifiedContentFactory)
-
-describe('verified-content.middlewares', () => {
- describe('encryptVerifiedSpcpFields', () => {
- beforeEach(() => jest.clearAllMocks())
-
- it('should call next with res.locals.verified assigned on success', async () => {
- // Arrange
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: {
- authType: AuthType.SP,
- responseMode: ResponseMode.Encrypt,
- publicKey: 'some public key',
- },
- })
- const mockRes = expressHandler.mockResponse({
- locals: { someKey: 'some mock data' },
- })
- const mockVerifiedContent: CpVerifiedContent = {
- cpUen: 'some uen',
- cpUid: 'some uid',
- }
- const expectedEncryptedContent = 'some encrypted content'
- const mockNext = jest.fn()
- MockedVerifiedContentFactory.getVerifiedContent.mockReturnValueOnce(
- ok(mockVerifiedContent),
- )
- MockedVerifiedContentFactory.encryptVerifiedContent.mockReturnValueOnce(
- ok(expectedEncryptedContent),
- )
-
- // Act
- await encryptVerifiedSpcpFields(mockReq, mockRes, mockNext)
-
- // Assert
- expect(mockRes.locals.verified).toEqual(expectedEncryptedContent)
- expect(mockNext).toBeCalled()
- expect(
- MockedVerifiedContentFactory.getVerifiedContent,
- ).toHaveBeenCalledWith({
- type: mockReq.form.authType,
- data: mockRes.locals,
- })
- expect(
- MockedVerifiedContentFactory.encryptVerifiedContent,
- ).toHaveBeenCalledWith({
- verifiedContent: mockVerifiedContent,
- formPublicKey: mockReq.form.publicKey,
- })
- expect(mockRes.status).not.toHaveBeenCalled()
- expect(mockRes.json).not.toHaveBeenCalled()
- })
-
- it('should return early if form is not SP/CP enabled', async () => {
- // Arrange
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: { authType: AuthType.NIL },
- })
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- // Act
- await encryptVerifiedSpcpFields(mockReq, mockRes, mockNext)
-
- // Assert
- expect(mockNext).toBeCalled()
- expect(mockRes.status).not.toBeCalled()
- expect(mockRes.json).not.toBeCalled()
- // Should not reach this step.
- expect(
- MockedVerifiedContentFactory.getVerifiedContent,
- ).not.toHaveBeenCalled()
- expect(
- MockedVerifiedContentFactory.encryptVerifiedContent,
- ).not.toHaveBeenCalled()
- })
-
- it('should return 422 if form is not encrypt mode', async () => {
- // Arrange
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- // SP form, but email mode form.
- form: { authType: AuthType.SP, responseMode: ResponseMode.Email },
- })
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- // Act
- await encryptVerifiedSpcpFields(mockReq, mockRes, mockNext)
-
- // Assert
- expect(mockNext).not.toBeCalled()
- expect(mockRes.status).toHaveBeenCalledWith(422)
- expect(mockRes.json).toHaveBeenCalledWith({
- message:
- 'Unable to encrypt verified SPCP fields on non storage mode forms',
- })
- // Should not reach this step.
- expect(
- MockedVerifiedContentFactory.getVerifiedContent,
- ).not.toHaveBeenCalled()
- expect(
- MockedVerifiedContentFactory.encryptVerifiedContent,
- ).not.toHaveBeenCalled()
- })
-
- it('should silently passthrough if verified content feature is not activated', async () => {
- // Arrange
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: { authType: AuthType.SP, responseMode: ResponseMode.Encrypt },
- })
- const mockRes = expressHandler.mockResponse({
- locals: 'some mock data',
- })
- const mockNext = jest.fn()
- MockedVerifiedContentFactory.getVerifiedContent.mockReturnValueOnce(
- err(new MissingFeatureError(FeatureNames.WebhookVerifiedContent)),
- )
-
- // Act
- await encryptVerifiedSpcpFields(mockReq, mockRes, mockNext)
-
- // Assert
- expect(mockNext).toBeCalled()
- expect(
- MockedVerifiedContentFactory.getVerifiedContent,
- ).toHaveBeenCalledWith({
- type: mockReq.form.authType,
- data: mockRes.locals,
- })
- expect(mockRes.status).not.toHaveBeenCalled()
- expect(mockRes.json).not.toHaveBeenCalled()
- expect(
- MockedVerifiedContentFactory.encryptVerifiedContent,
- ).not.toHaveBeenCalled()
- })
-
- it('should return 400 if verified content is malformed', async () => {
- // Arrange
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: {
- authType: AuthType.SP,
- responseMode: ResponseMode.Encrypt,
- publicKey: 'some public key',
- },
- })
- const mockRes = expressHandler.mockResponse({
- locals: 'some mock data',
- })
- const mockVerifiedContent: CpVerifiedContent = {
- cpUen: 'some uen',
- cpUid: 'some uid',
- }
- const mockNext = jest.fn()
- MockedVerifiedContentFactory.getVerifiedContent.mockReturnValueOnce(
- ok(mockVerifiedContent),
- )
- MockedVerifiedContentFactory.encryptVerifiedContent.mockReturnValueOnce(
- err(new MalformedVerifiedContentError()),
- )
-
- // Act
- await encryptVerifiedSpcpFields(mockReq, mockRes, mockNext)
-
- // Assert
- expect(mockNext).not.toBeCalled()
- expect(
- MockedVerifiedContentFactory.getVerifiedContent,
- ).toHaveBeenCalledWith({
- type: mockReq.form.authType,
- data: mockRes.locals,
- })
- expect(
- MockedVerifiedContentFactory.encryptVerifiedContent,
- ).toHaveBeenCalledWith({
- verifiedContent: mockVerifiedContent,
- formPublicKey: mockReq.form.publicKey,
- })
- expect(mockRes.status).toHaveBeenCalledWith(400)
- expect(mockRes.json).toHaveBeenCalledWith({
- message: 'Invalid data was found. Please submit again.',
- })
- })
- })
-})
diff --git a/src/app/modules/verified-content/verified-content.middlewares.ts b/src/app/modules/verified-content/verified-content.middlewares.ts
deleted file mode 100644
index 393d6d284e..0000000000
--- a/src/app/modules/verified-content/verified-content.middlewares.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { RequestHandler } from 'express'
-import { StatusCodes } from 'http-status-codes'
-
-import { createLoggerWithLabel } from '../../../config/logger'
-import { AuthType, WithForm } from '../../../types'
-import { createReqMeta } from '../../utils/request'
-import { MissingFeatureError } from '../core/core.errors'
-import { isFormEncryptMode } from '../form/form.utils'
-
-import { VerifiedContentFactory } from './verified-content.factory'
-
-const logger = createLoggerWithLabel(module)
-
-// TODO: Delete this middleware when this step is inlined into the controller.
-export const encryptVerifiedSpcpFields: RequestHandler = (req, res, next) => {
- const { form } = req as WithForm
-
- // Early return if this is not a Singpass/Corppass submission.
- if (form.authType !== AuthType.SP && form.authType !== AuthType.CP) {
- return next()
- }
-
- const logMeta = {
- action: 'encryptVerifiedSpcpFields',
- formId: form._id,
- ...createReqMeta(req),
- }
-
- if (!isFormEncryptMode(form)) {
- logger.error({
- message: 'encryptVerifiedSpcpFields called on non-encrypt mode form',
- meta: logMeta,
- })
- return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({
- message:
- 'Unable to encrypt verified SPCP fields on non storage mode forms',
- })
- }
-
- const encryptVerifiedContentResult = VerifiedContentFactory.getVerifiedContent(
- { type: form.authType, data: res.locals },
- ).andThen((verifiedContent) =>
- VerifiedContentFactory.encryptVerifiedContent({
- verifiedContent,
- formPublicKey: form.publicKey,
- }),
- )
-
- if (encryptVerifiedContentResult.isErr()) {
- const { error } = encryptVerifiedContentResult
- logger.error({
- message: 'Unable to encrypt verified content',
- meta: logMeta,
- error,
- })
-
- // Passthrough if feature is not activated.
- if (error instanceof MissingFeatureError) {
- return next()
- }
-
- return res
- .status(StatusCodes.BAD_REQUEST)
- .json({ message: 'Invalid data was found. Please submit again.' })
- }
-
- // No errors, set local variable to the encrypted string.
- res.locals.verified = encryptVerifiedContentResult.value
- return next()
-}
diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js
index b8a6146169..f97c85f1cd 100644
--- a/src/app/routes/admin-forms.server.routes.js
+++ b/src/app/routes/admin-forms.server.routes.js
@@ -15,10 +15,8 @@ const { withUserAuthentication } = require('../modules/auth/auth.middlewares')
const {
PermissionLevel,
} = require('../modules/form/admin-form/admin-form.types')
-const EncryptSubmissionMiddleware = require('../modules/submission/encrypt-submission/encrypt-submission.middleware')
const SpcpController = require('../modules/spcp/spcp.controller')
const { BasicField } = require('../../types')
-const VerifiedContentMiddleware = require('../modules/verified-content/verified-content.middlewares')
/**
* Authenticates logged in user, before retrieving non-archived form
@@ -88,78 +86,4 @@ module.exports = function (app) {
EmailSubmissionsMiddleware.sendAdminEmail,
SubmissionsMiddleware.sendEmailConfirmations,
)
-
- /**
- * On preview, submit a form response, and stores the encrypted contents. Optionally, an autoreply
- * confirming submission is sent back to the user, if an email address
- * was given. SMS autoreplies for mobile number fields are also sent if feature
- * is enabled.
- * Note that preview submissions are not saved to db
- * Note that spcp session is not verified, neither is myInfo data verified
- * Note that webhooks are not supported as they require an actual submission document to be created
- * Note that v2 endpoint accepts requests in content-type json, instead of content-type multi-part
- * @route POST /v2/submissions/encrypt/preview/{formId}
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @param {Array} response.body.required - contains only the auto-reply fields
- * @param {string} encryptedContent.body.required - contains the entire encrypted form submission
- * @consumes multipart/form-data
- * @produces application/json
- * @returns {SubmissionResponse.model} 200 - submission made
- * @returns {SubmissionResponse.model} 400 - submission has bad data and could not be processed
- * @security OTP
- */
- app.route('/v2/submissions/encrypt/preview/:formId([a-fA-F0-9]{24})').post(
- celebrate({
- [Segments.BODY]: Joi.object({
- responses: Joi.array()
- .items(
- Joi.object().keys({
- _id: Joi.string().required(),
- answer: Joi.string().allow('').required(),
- fieldType: Joi.string()
- .required()
- .valid(...Object.values(BasicField)),
- signature: Joi.string().allow(''),
- }),
- )
- .required(),
- encryptedContent: Joi.string()
- .custom((value, helpers) => {
- const parts = String(value).split(/;|:/)
- if (
- parts.length !== 3 ||
- parts[0].length !== 44 || // public key
- parts[1].length !== 32 || // nonce
- !parts.every((part) => Joi.string().base64().validate(part))
- ) {
- return helpers.error('Invalid encryptedContent.')
- }
- return value
- }, 'encryptedContent')
- .required(),
- attachments: Joi.object()
- .pattern(
- /^[a-fA-F0-9]{24}$/,
- Joi.object().keys({
- encryptedFile: Joi.object().keys({
- binary: Joi.string().required(),
- nonce: Joi.string().required(),
- submissionPublicKey: Joi.string().required(),
- }),
- }),
- )
- .optional(),
- isPreview: Joi.boolean().required(),
- version: Joi.number().required(),
- }),
- }),
- authActiveForm(PermissionLevel.Read),
- EncryptSubmissionMiddleware.validateAndProcessEncryptSubmission,
- AdminFormController.passThroughSpcp,
- VerifiedContentMiddleware.encryptVerifiedSpcpFields,
- EncryptSubmissionMiddleware.prepareEncryptSubmission,
- adminForms.passThroughSaveMetadataToDb,
- SubmissionsMiddleware.sendEmailConfirmations,
- )
}
diff --git a/src/app/routes/public-forms.server.routes.js b/src/app/routes/public-forms.server.routes.js
index 15b27cd048..2e4bf9cf81 100644
--- a/src/app/routes/public-forms.server.routes.js
+++ b/src/app/routes/public-forms.server.routes.js
@@ -12,8 +12,8 @@ const { limitRate } = require('../utils/limit-rate')
const { rateLimitConfig } = require('../../config/config')
const PublicFormController = require('../modules/form/public-form/public-form.controller')
const SpcpController = require('../modules/spcp/spcp.controller')
-const { BasicField } = require('../../types')
const EncryptSubmissionController = require('../modules/submission/encrypt-submission/encrypt-submission.controller')
+const EncryptSubmissionMiddleware = require('../modules/submission/encrypt-submission/encrypt-submission.middleware')
module.exports = function (app) {
/**
@@ -155,54 +155,13 @@ module.exports = function (app) {
* @returns {SubmissionResponse.model} 200 - submission made
* @returns {SubmissionResponse.model} 400 - submission has bad data and could not be processed
*/
- app.route('/v2/submissions/encrypt/:formId([a-fA-F0-9]{24})').post(
- limitRate({ max: rateLimitConfig.submissions }),
- CaptchaFactory.validateCaptchaParams,
- celebrate({
- body: Joi.object({
- responses: Joi.array()
- .items(
- Joi.object().keys({
- _id: Joi.string().required(),
- answer: Joi.string().allow('').required(),
- fieldType: Joi.string()
- .required()
- .valid(...Object.values(BasicField)),
- signature: Joi.string().allow(''),
- }),
- )
- .required(),
- encryptedContent: Joi.string()
- .custom((value, helpers) => {
- const parts = String(value).split(/;|:/)
- if (
- parts.length !== 3 ||
- parts[0].length !== 44 || // public key
- parts[1].length !== 32 || // nonce
- !parts.every((part) => Joi.string().base64().validate(part))
- ) {
- return helpers.error('Invalid encryptedContent.')
- }
- return value
- }, 'encryptedContent')
- .required(),
- attachments: Joi.object()
- .pattern(
- /^[a-fA-F0-9]{24}$/,
- Joi.object().keys({
- encryptedFile: Joi.object().keys({
- binary: Joi.string().required(),
- nonce: Joi.string().required(),
- submissionPublicKey: Joi.string().required(),
- }),
- }),
- )
- .optional(),
- isPreview: Joi.boolean().required(),
- version: Joi.number().required(),
- }),
- }),
- forms.formById,
- EncryptSubmissionController.handleEncryptedSubmission,
- )
+ app
+ .route('/v2/submissions/encrypt/:formId([a-fA-F0-9]{24})')
+ .post(
+ limitRate({ max: rateLimitConfig.submissions }),
+ CaptchaFactory.validateCaptchaParams,
+ EncryptSubmissionMiddleware.validateEncryptSubmissionParams,
+ forms.formById,
+ EncryptSubmissionController.handleEncryptedSubmission,
+ )
}
diff --git a/src/types/api/encrypt-submission.ts b/src/types/api/encrypt-submission.ts
new file mode 100644
index 0000000000..c4d2196e5c
--- /dev/null
+++ b/src/types/api/encrypt-submission.ts
@@ -0,0 +1,21 @@
+import { FieldResponse } from '../response'
+
+export type EncryptSubmissionDto = {
+ responses: FieldResponse[]
+ encryptedContent: string
+ attachments?: EncryptedAttachmentsDto
+ isPreview: boolean
+ version: number
+}
+
+export type EncryptedAttachmentsDto = {
+ [fieldId: string]: {
+ encryptedFile:
+ | {
+ binary: string
+ nonce: string
+ submissionPublicKey: string
+ }
+ | undefined
+ }
+}
diff --git a/src/types/api/index.ts b/src/types/api/index.ts
index 20d19312a1..7538f9f35f 100644
--- a/src/types/api/index.ts
+++ b/src/types/api/index.ts
@@ -2,3 +2,4 @@ export * from './core'
export * from './form'
export * from './billing'
export * from './examples'
+export * from './encrypt-submission'
diff --git a/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js b/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js
deleted file mode 100644
index ec919c6d61..0000000000
--- a/tests/unit/backend/controllers/encrypt-submissions.server.controller.spec.js
+++ /dev/null
@@ -1,342 +0,0 @@
-const { StatusCodes } = require('http-status-codes')
-const mongoose = require('mongoose')
-const express = require('express')
-const request = require('supertest')
-
-const dbHandler = require('../helpers/db-handler')
-const EncryptSubmissionsMiddleware = require('../../../../dist/backend/app/modules/submission/encrypt-submission/encrypt-submission.middleware')
-const VerifiedContentMiddleware = require('../../../../dist/backend/app/modules/verified-content/verified-content.middlewares')
-
-const User = dbHandler.makeModel('user.server.model', 'User')
-const Agency = dbHandler.makeModel('agency.server.model', 'Agency')
-const Form = dbHandler.makeModel('form.server.model', 'Form')
-const EncryptForm = mongoose.model('encrypt')
-
-describe('Encrypt Submissions Controller', () => {
- // Declare global variables
- // spec out controller such that calls to request are
- // directed through a callback to the request spy,
- // which will be destroyed and re-created for every test
- const Controller = spec(
- 'dist/backend/app/controllers/encrypt-submissions.server.controller',
- {
- mongoose: Object.assign(mongoose, { '@noCallThru': true }),
- },
- )
-
- beforeAll(async () => await dbHandler.connect())
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- describe('/save-submission', () => {
- describe('saveMetadataToDb', () => {
- let testForm
- let testAgency
- let testUser
- let testEncryptVersion = 234
- let testReqBody = { version: testEncryptVersion }
- let formData = ''
- const app = express()
- const endpointPath = '/save-submission'
-
- const originalConsoleError = console.error
-
- beforeAll(() => {
- // Stubbing console error to prevent appearing in stdout
- console.error = jasmine.createSpy()
-
- app.route(endpointPath).get(
- (req, res, next) => {
- req.form = testForm
- req.formData = formData
- req.body = testReqBody
- return next()
- },
- Controller.saveResponseToDb,
- (req, res) => res.status(200).send(req.submission),
- )
- })
-
- beforeEach((done) => {
- testAgency = new Agency({
- shortName: 'test',
- fullName: 'Test Agency',
- emailDomain: 'test.gov.sg',
- logo: 'test.png',
- })
- testAgency
- .save()
- .then(() => {
- testUser = new User({
- email: 'user@test.gov.sg',
- agency: testAgency._id,
- })
- return testUser.save()
- })
- .then(() => {
- testForm = new EncryptForm({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- admin: testUser._id,
- publicKey: 'publicKey',
- })
- return testForm.save()
- })
- .then(() => done())
- })
-
- afterAll(() => {
- console.error = originalConsoleError
- })
-
- it('saves encrypted responses to db', (done) => {
- formData = 'encryptedContent'
- request(app)
- .get(endpointPath)
- .expect(StatusCodes.OK)
- .then(({ body: submission }) => {
- expect(submission.form).toEqual(testForm._id.toString())
- expect(submission.authType).toEqual('NIL')
- expect(submission.myInfoFields).toEqual([])
- expect(submission.encryptedContent).toEqual(formData)
- expect(submission.version).toEqual(testEncryptVersion)
- })
- .then(done)
- .catch(done)
- })
- })
- })
-
- describe('v2/submissions/encrypt', () => {
- const formsg = require('@opengovsg/formsg-sdk')({ mode: 'test' })
- const {
- checkIsEncryptedEncoding,
- } = require('../../../../dist/backend/app/utils/encryption')
-
- const publicKey = 'gsyeH+Kl+daaV/GlPzn47tdw2BVeqnh9nhIxNXaKM2I='
- const responses = 'responses'
- const correctlyEncryptedContent = formsg.crypto.encrypt(
- responses,
- publicKey,
- )
- const wronglyEncryptedContent = 'abc'
-
- const endpointPath = '/v2/submissions/encrypt'
-
- let fixtures
-
- const injectFixtures = (req, res, next) => {
- Object.assign(req, fixtures)
- next()
- }
-
- const sendSubmissionBack = (req, res) => {
- res.status(200).send({
- body: req.body,
- })
- }
-
- describe('validateAndProcessEncryptSubmission', () => {
- const app = express()
-
- beforeAll(() => {
- app
- .route(endpointPath)
- .post(
- injectFixtures,
- EncryptSubmissionsMiddleware.validateAndProcessEncryptSubmission,
- sendSubmissionBack,
- )
- })
-
- it('parses submissions', (done) => {
- fixtures = {
- form: new Form({
- title: 'Test Form',
- authType: 'NIL',
- responseMode: 'encrypt',
- publicKey: publicKey,
- form_fields: [],
- }).toObject(),
- body: {
- encryptedContent: correctlyEncryptedContent,
- responses: [],
- },
- }
-
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .expect(
- JSON.stringify({
- body: {
- encryptedContent: correctlyEncryptedContent,
- parsedResponses: [],
- },
- }),
- )
- .end(done)
- })
-
- it('Throws 400 for incorrectly encrypted content', (done) => {
- fixtures = {
- body: {
- responses: [],
- encryptedContent: wronglyEncryptedContent,
- },
- form: new Form({
- title: 'Test Form',
- authType: 'NIL',
- responseMode: 'encrypt',
- publicKey: publicKey,
- form_fields: [],
- }).toObject(),
- }
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.BAD_REQUEST)
- .end(done)
- })
- })
-
- describe('prepareEncryptSubmission', () => {
- let fixtures
-
- const endpointPath = '/v2/submissions/encrypt'
- const injectFixtures = (req, res, next) => {
- Object.assign(req, fixtures.req)
- Object.assign(res, fixtures.res)
- next()
- }
- const sendSubmissionBack = (req, res) => {
- const submissionData = {
- body: req.body,
- formData: req.formData,
- verified: res.locals.verified,
- }
-
- // If does not exist, remove entirely from submissionData.
- if (!res.locals.verified) delete submissionData.verified
-
- res.status(200).send(submissionData)
- }
-
- const app = express()
-
- beforeAll(() => {
- app
- .route(endpointPath)
- .post(
- injectFixtures,
- VerifiedContentMiddleware.encryptVerifiedSpcpFields,
- EncryptSubmissionsMiddleware.prepareEncryptSubmission,
- sendSubmissionBack,
- )
- })
-
- it('Verifies correctly encrypted content without verified content', (done) => {
- fixtures = {
- req: {
- body: {
- encryptedContent: correctlyEncryptedContent,
- },
- form: new Form({
- title: 'Test Form',
- authType: 'NIL',
- responseMode: 'encrypt',
- publicKey: publicKey,
- form_fields: [],
- }).toObject(),
- },
- }
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .expect(
- JSON.stringify({
- body: { encryptedContent: correctlyEncryptedContent },
- formData: correctlyEncryptedContent,
- }),
- )
- .end(done)
- })
-
- it('should sign and encrypt local uinFin in SP forms when it exists', (done) => {
- fixtures = {
- req: {
- body: {
- encryptedContent: correctlyEncryptedContent,
- },
- form: new Form({
- title: 'Test Form',
- authType: 'SP',
- responseMode: 'encrypt',
- publicKey: publicKey,
- form_fields: [],
- }).toObject(),
- },
- res: {
- locals: {
- uinFin: 'SXXXXXXYZ',
- },
- },
- }
-
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .end((err, res) => {
- if (err) return done(err)
- expect(res.body.body).toEqual({
- encryptedContent: correctlyEncryptedContent,
- })
- expect(res.body.formData).toEqual(correctlyEncryptedContent)
- // Cannot get the exact verified string since it changes everytime.
- // Just be content that a string of the correct encoding was
- // returned
- expect(checkIsEncryptedEncoding(res.body.verified).value).toBe(true)
- return done()
- })
- })
-
- it('should sign and encrypt CP data in CP forms when it exists', (done) => {
- fixtures = {
- req: {
- body: {
- encryptedContent: correctlyEncryptedContent,
- },
- form: new Form({
- title: 'Test Form',
- authType: 'CP',
- responseMode: 'encrypt',
- publicKey: publicKey,
- form_fields: [],
- }).toObject(),
- },
- res: {
- locals: {
- uinFin: 'ABCDEFG',
- userInfo: 'SXXXXXXYZ',
- },
- },
- }
-
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .end((err, res) => {
- if (err) return done(err)
- expect(res.body.body).toEqual({
- encryptedContent: correctlyEncryptedContent,
- })
- expect(res.body.formData).toEqual(correctlyEncryptedContent)
- // Cannot get the exact verified string since it changes everytime.
- // Just be content that a string of the correct encoding was
- // returned
- expect(checkIsEncryptedEncoding(res.body.verified).value).toBe(true)
- return done()
- })
- })
- })
- })
-})
diff --git a/tests/unit/backend/helpers/generate-form-data.ts b/tests/unit/backend/helpers/generate-form-data.ts
index 9c407ef9ee..79a4175ab2 100644
--- a/tests/unit/backend/helpers/generate-form-data.ts
+++ b/tests/unit/backend/helpers/generate-form-data.ts
@@ -1,5 +1,6 @@
/* eslint-disable typesafe/no-throw-sync-func */
import { ObjectId } from 'bson'
+import { pick } from 'lodash'
import {
ProcessedAttachmentResponse,
@@ -49,7 +50,7 @@ export const generateDefaultField = (
| IRatingField
| IShortTextField
| ILongTextField
- >,
+ > & { _id?: string },
): IFieldSchema => {
const defaultParams = {
title: `test ${fieldType} field title`,
@@ -227,6 +228,18 @@ export const generateNewSingleAnswerResponse = (
}
}
+export const generateUnprocessedSingleAnswerResponse = (
+ fieldType: BasicField,
+ customParams?: Partial,
+): ISingleAnswerResponse => {
+ return pick(generateNewSingleAnswerResponse(fieldType, customParams), [
+ '_id',
+ 'question',
+ 'fieldType',
+ 'answer',
+ ])
+}
+
export const generateAttachmentResponse = (
field: IAttachmentFieldSchema,
filename = 'filename',
From 84d46475311f700c3c59b0c3f98400155433bfda Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Thu, 8 Apr 2021 15:56:38 +0800
Subject: [PATCH 27/75] refactor: collapse email preview submission middleware
(#1561)
---
.../admin-forms.server.controller.js | 66 -
.../authentication.server.controller.js | 89 -
.../__tests__/admin-form.controller.spec.ts | 1065 +++++++
.../__tests__/admin-form.routes.spec.ts | 452 +++
.../form/admin-form/admin-form.constants.ts | 5 +
.../form/admin-form/admin-form.controller.ts | 179 +-
.../form/admin-form/admin-form.routes.ts | 22 +
.../form/admin-form/admin-form.service.ts | 32 +-
src/app/modules/spcp/spcp.controller.ts | 35 +-
.../email-submission.service.spec.ts | 35 +
.../email-submission.controller.ts | 4 +-
.../email-submission.middleware.ts | 158 +-
.../email-submission.service.ts | 22 +
.../email-submission.types.ts | 10 +-
.../email-submission/email-submission.util.ts | 14 +-
.../encrypt-submission.types.ts | 6 -
.../submission/submission.middleware.ts | 49 -
src/app/routes/admin-forms.server.routes.js | 89 -
src/app/routes/index.js | 1 -
src/types/express.locals.ts | 33 +-
.../admin-forms.server.controller.spec.js | 188 --
.../authentication.server.controller.spec.js | 98 -
...mail-submissions.server.controller.spec.js | 2824 -----------------
23 files changed, 1785 insertions(+), 3691 deletions(-)
delete mode 100644 src/app/controllers/admin-forms.server.controller.js
delete mode 100755 src/app/controllers/authentication.server.controller.js
delete mode 100644 src/app/modules/submission/submission.middleware.ts
delete mode 100644 src/app/routes/admin-forms.server.routes.js
delete mode 100644 tests/unit/backend/controllers/admin-forms.server.controller.spec.js
delete mode 100644 tests/unit/backend/controllers/authentication.server.controller.spec.js
delete mode 100644 tests/unit/backend/controllers/email-submissions.server.controller.spec.js
diff --git a/src/app/controllers/admin-forms.server.controller.js b/src/app/controllers/admin-forms.server.controller.js
deleted file mode 100644
index 6ec33f04be..0000000000
--- a/src/app/controllers/admin-forms.server.controller.js
+++ /dev/null
@@ -1,66 +0,0 @@
-'use strict'
-
-/**
- * Module dependencies.
- */
-const mongoose = require('mongoose')
-const _ = require('lodash')
-const { StatusCodes } = require('http-status-codes')
-
-const getSubmissionModel = require('../models/submission.server.model').default
-
-// Export individual functions (i.e. create, delete)
-// and makeModule function that takes in connection object
-module.exports = _.assign({ makeModule }, makeModule(mongoose))
-
-/**
- * Packages functions in module that can accept a db connection
- * @param {Object} connection - DB connection instance
- * @return {Object} functions - admin controller functions
- */
-function makeModule(connection) {
- return {
- /**
- * Checks if form is active
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} next - Express next middleware function
- */
- isFormActive: function (req, res, next) {
- if (req.form.status === 'ARCHIVED') {
- return res.status(StatusCodes.NOT_FOUND).json({
- message: 'Form has been archived',
- })
- } else {
- return next()
- }
- },
- /**
- * Ensures form is encrypt mode
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} next - Express next middleware function
- */
- isFormEncryptMode: function (req, res, next) {
- if (req.form.responseMode !== 'encrypt') {
- return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({
- message: 'Form is not encrypt mode',
- })
- }
- return next()
- },
- /**
- * Pass through save new Submission object to db
- * Simply create and pass forward a submissions object w/o saving to db
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} next - the next expressjs callback
- */
- passThroughSaveMetadataToDb: function (req, res, next) {
- let Submission = getSubmissionModel(connection)
- let submission = new Submission({})
- req.submission = submission
- return next()
- },
- }
-}
diff --git a/src/app/controllers/authentication.server.controller.js b/src/app/controllers/authentication.server.controller.js
deleted file mode 100755
index 42c4993b4f..0000000000
--- a/src/app/controllers/authentication.server.controller.js
+++ /dev/null
@@ -1,89 +0,0 @@
-'use strict'
-
-/**
- * Module dependencies.
- */
-const { StatusCodes } = require('http-status-codes')
-const {
- PermissionLevel,
-} = require('../modules/form/admin-form/admin-form.types')
-const {
- assertHasReadPermissions,
- assertHasWritePermissions,
- assertHasDeletePermissions,
-} = require('../modules/form/admin-form/admin-form.utils')
-const { createReqMeta } = require('../utils/request')
-const logger = require('../../config/logger').createLoggerWithLabel(module)
-
-/**
- * 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,
- ...createReqMeta(req),
- },
- error: Error(msg),
- })
-}
-
-/**
- * Returns a middleware function that ensures that only users with the requiredPermission will pass.
- * @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.
- */
-exports.verifyPermission = (requiredPermission) =>
- /**
- * Middleware function that ensures only those with the required permission level will pass.
- * @param {Object} req - Express request object
- * @param {Object} req.form - The form object retrieved from the DB
- * @param {Object} req.session - The session info of the query
- * @param {Object} res - Express response object
- * @param {function} next - Next middleware function
- */
- (req, res, next) => {
- let result
- switch (requiredPermission) {
- case PermissionLevel.Read:
- result = assertHasReadPermissions(req.session.user, req.form)
- break
- case PermissionLevel.Write:
- result = assertHasWritePermissions(req.session.user, req.form)
- break
- case PermissionLevel.Delete:
- result = assertHasDeletePermissions(req.session.user, req.form)
- break
- default:
- logger.error({
- message:
- 'Unknown permission type encountered when verifying permissions',
- meta: {
- action: 'verifyPermission',
- ...createReqMeta(req),
- },
- })
- return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
- message: 'Unknown permission type',
- })
- }
-
- if (result.isErr()) {
- logUnauthorizedAccess(req, 'verifyPermission', requiredPermission)
- return res.status(StatusCodes.FORBIDDEN).json({
- message: result.error.message,
- })
- }
-
- // No error, pass to next function.
- return next()
- }
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
index 150d700244..d3dd7dbfbd 100644
--- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
@@ -15,16 +15,28 @@ import {
} from 'src/app/modules/core/core.errors'
import * as FeedbackService from 'src/app/modules/feedback/feedback.service'
import { FeedbackResponse } from 'src/app/modules/feedback/feedback.types'
+import {
+ AttachmentTooLargeError,
+ InvalidFileExtensionError,
+} from 'src/app/modules/submission/email-submission/email-submission.errors'
+import * as EmailSubmissionService from 'src/app/modules/submission/email-submission/email-submission.service'
+import * as EmailSubmissionUtil from 'src/app/modules/submission/email-submission/email-submission.util'
import * as EncryptSubmissionService from 'src/app/modules/submission/encrypt-submission/encrypt-submission.service'
import {
ConflictError,
InvalidEncodingError,
ProcessingError,
ResponseModeError,
+ SendEmailConfirmationError,
ValidateFieldError,
} from 'src/app/modules/submission/submission.errors'
import * as SubmissionService from 'src/app/modules/submission/submission.service'
import { MissingUserError } from 'src/app/modules/user/user.errors'
+import {
+ MailGenerationError,
+ MailSendError,
+} from 'src/app/services/mail/mail.errors'
+import MailService from 'src/app/services/mail/mail.service'
import * as EncryptionUtils from 'src/app/utils/encryption'
import { EditFieldActions } from 'src/shared/constants'
import {
@@ -32,9 +44,11 @@ import {
BasicField,
FormMetaView,
FormSettings,
+ IEmailSubmissionSchema,
IEncryptedSubmissionSchema,
IForm,
IFormSchema,
+ IPopulatedEmailForm,
IPopulatedEncryptedForm,
IPopulatedForm,
IPopulatedUser,
@@ -83,12 +97,18 @@ jest.mock(
'src/app/modules/submission/encrypt-submission/encrypt-submission.service',
)
const MockEncryptSubmissionService = mocked(EncryptSubmissionService)
+jest.mock(
+ 'src/app/modules/submission/email-submission/email-submission.service',
+)
+const MockEmailSubmissionService = mocked(EmailSubmissionService)
jest.mock('src/app/utils/encryption')
const MockEncryptionUtils = mocked(EncryptionUtils)
jest.mock('../admin-form.service')
const MockAdminFormService = mocked(AdminFormService)
jest.mock('../../../user/user.service')
const MockUserService = mocked(UserService)
+jest.mock('src/app/services/mail/mail.service')
+const MockMailService = mocked(MailService)
describe('admin-form.controller', () => {
beforeEach(() => jest.clearAllMocks())
@@ -4807,6 +4827,1051 @@ describe('admin-form.controller', () => {
})
})
+ describe('handleEmailPreviewSubmission', () => {
+ const MOCK_FIELD_ID = new ObjectId().toHexString()
+ const MOCK_RESPONSES = [
+ generateUnprocessedSingleAnswerResponse(BasicField.Email, {
+ _id: MOCK_FIELD_ID,
+ }),
+ ]
+ const MOCK_PARSED_RESPONSES = [
+ generateNewSingleAnswerResponse(BasicField.Email, { _id: MOCK_FIELD_ID }),
+ ]
+ const MOCK_USER_ID = new ObjectId().toHexString()
+ const MOCK_FORM_ID = new ObjectId().toHexString()
+ const MOCK_SUBMISSION_ID = new ObjectId().toHexString()
+ const MOCK_USER = {
+ _id: MOCK_USER_ID,
+ email: 'somerandom@example.com',
+ } as IPopulatedUser
+ const MOCK_FORM = {
+ admin: MOCK_USER,
+ _id: MOCK_FORM_ID,
+ title: 'mock title',
+ form_fields: [
+ generateDefaultField(BasicField.Email, {
+ _id: MOCK_FIELD_ID,
+ }),
+ ],
+ } as IPopulatedEmailForm
+ const MOCK_SUBMISSION = {
+ id: MOCK_SUBMISSION_ID,
+ _id: MOCK_SUBMISSION_ID,
+ created: new Date(),
+ } as IEmailSubmissionSchema
+ const MOCK_SUBMISSION_BODY = {
+ responses: MOCK_RESPONSES,
+ isPreview: false,
+ }
+ const MOCK_DATA_COLLATION_DATA = 'mockDataCollation'
+ const MOCK_FORM_DATA = 'mockFormData'
+ const MOCK_AUTOREPLY_DATA = 'mockAutoReply'
+
+ beforeEach(() => {
+ MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER))
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValue(
+ okAsync(MOCK_FORM),
+ )
+ MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValue(
+ ok(MOCK_FORM),
+ )
+ MockEmailSubmissionService.validateAttachments.mockReturnValue(
+ okAsync(true),
+ )
+ MockSubmissionService.getProcessedResponses.mockReturnValue(
+ ok(MOCK_PARSED_RESPONSES),
+ )
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave.mockReturnValue(
+ MOCK_SUBMISSION,
+ )
+ MockEmailSubmissionService.extractEmailAnswers.mockReturnValue([
+ MOCK_RESPONSES[0].answer,
+ ])
+ MockAdminFormService.extractMyInfoFieldIds.mockReturnValue([
+ MOCK_FIELD_ID,
+ ])
+ MockMailService.sendSubmissionToAdmin.mockReturnValue(okAsync(true))
+ MockSubmissionService.sendEmailConfirmations.mockReturnValue(
+ okAsync(true),
+ )
+ jest.spyOn(EmailSubmissionUtil, 'SubmissionEmailObj').mockReturnValue(({
+ dataCollationData: MOCK_DATA_COLLATION_DATA,
+ formData: MOCK_FORM_DATA,
+ autoReplyData: MOCK_AUTOREPLY_DATA,
+ } as unknown) as EmailSubmissionUtil.SubmissionEmailObj)
+ })
+
+ it('should call all services correctly when submission is valid', async () => {
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith(
+ MOCK_FORM.form_fields,
+ )
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).toHaveBeenCalledWith(MOCK_FORM, expect.any(String), expect.any(String))
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).toHaveBeenCalledWith(MOCK_PARSED_RESPONSES)
+ expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith({
+ replyToEmails: [MOCK_RESPONSES[0].answer],
+ form: MOCK_FORM,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ dataCollationData: MOCK_DATA_COLLATION_DATA,
+ formData: MOCK_FORM_DATA,
+ })
+ expect(MockSubmissionService.sendEmailConfirmations).toHaveBeenCalledWith(
+ {
+ form: MOCK_FORM,
+ parsedResponses: MOCK_PARSED_RESPONSES,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ autoReplyData: MOCK_AUTOREPLY_DATA,
+ },
+ )
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: 'Form submission successful.',
+ submissionId: MOCK_SUBMISSION_ID,
+ })
+ })
+
+ it('should return 500 when generic database error occurs while retrieving user', async () => {
+ MockUserService.getPopulatedUserById.mockReturnValueOnce(
+ errAsync(new DatabaseError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(
+ MockAuthService.getFormAfterPermissionChecks,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(500)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 422 when user is missing', async () => {
+ MockUserService.getPopulatedUserById.mockReturnValueOnce(
+ errAsync(new MissingUserError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(
+ MockAuthService.getFormAfterPermissionChecks,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(422)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 500 when generic database error occurs while retrieving form', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new DatabaseError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(500)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 404 when form is not found', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new FormNotFoundError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(404)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 410 when form has been archived', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new FormDeletedError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(410)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 403 when user does not have read permissions', async () => {
+ MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
+ errAsync(new ForbiddenFormError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(403)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when form is not email mode', async () => {
+ MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce(
+ err(new ResponseModeError(ResponseMode.Encrypt, ResponseMode.Email)),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).not.toHaveBeenCalled()
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when attachments are invalid', async () => {
+ MockEmailSubmissionService.validateAttachments.mockReturnValueOnce(
+ errAsync(new InvalidFileExtensionError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when attachments are too large', async () => {
+ MockEmailSubmissionService.validateAttachments.mockReturnValueOnce(
+ errAsync(new AttachmentTooLargeError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).not.toHaveBeenCalled()
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when responses cannot be processed', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ProcessingError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 409 when the submitted field IDs do not match the form field IDs', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ConflictError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(409)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when any answer is invalid', async () => {
+ MockSubmissionService.getProcessedResponses.mockReturnValueOnce(
+ err(new ValidateFieldError()),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).not.toHaveBeenCalled()
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).not.toHaveBeenCalled()
+ expect(MockMailService.sendSubmissionToAdmin).not.toHaveBeenCalled()
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when the submission email fails to be generated', async () => {
+ MockMailService.sendSubmissionToAdmin.mockReturnValueOnce(
+ errAsync(new MailGenerationError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith(
+ MOCK_FORM.form_fields,
+ )
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).toHaveBeenCalledWith(MOCK_FORM, expect.any(String), expect.any(String))
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).toHaveBeenCalledWith(MOCK_PARSED_RESPONSES)
+ expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith({
+ replyToEmails: [MOCK_RESPONSES[0].answer],
+ form: MOCK_FORM,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ dataCollationData: MOCK_DATA_COLLATION_DATA,
+ formData: MOCK_FORM_DATA,
+ })
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 400 when the submission email fails to be sent', async () => {
+ MockMailService.sendSubmissionToAdmin.mockReturnValueOnce(
+ errAsync(new MailSendError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith(
+ MOCK_FORM.form_fields,
+ )
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).toHaveBeenCalledWith(MOCK_FORM, expect.any(String), expect.any(String))
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).toHaveBeenCalledWith(MOCK_PARSED_RESPONSES)
+ expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith({
+ replyToEmails: [MOCK_RESPONSES[0].answer],
+ form: MOCK_FORM,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ dataCollationData: MOCK_DATA_COLLATION_DATA,
+ formData: MOCK_FORM_DATA,
+ })
+ expect(
+ MockSubmissionService.sendEmailConfirmations,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(400)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: expect.any(String),
+ })
+ })
+
+ it('should return 200 regardless of errors while sending email confirmations', async () => {
+ MockSubmissionService.sendEmailConfirmations.mockReturnValueOnce(
+ errAsync(new SendEmailConfirmationError('')),
+ )
+ const mockReq = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ body: MOCK_SUBMISSION_BODY,
+ session: {
+ user: {
+ _id: MOCK_USER_ID,
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ await AdminFormController.handleEmailPreviewSubmission(
+ mockReq,
+ mockRes,
+ jest.fn(),
+ )
+
+ expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith(
+ MOCK_USER_ID,
+ )
+ expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith(
+ {
+ user: MOCK_USER,
+ formId: MOCK_FORM_ID,
+ level: PermissionLevel.Read,
+ },
+ )
+ expect(
+ MockEmailSubmissionService.checkFormIsEmailMode,
+ ).toHaveBeenCalledWith(MOCK_FORM)
+ expect(
+ MockEmailSubmissionService.validateAttachments,
+ ).toHaveBeenCalledWith(MOCK_RESPONSES)
+ expect(MockSubmissionService.getProcessedResponses).toHaveBeenCalledWith(
+ MOCK_FORM,
+ MOCK_RESPONSES,
+ )
+ expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith(
+ MOCK_FORM.form_fields,
+ )
+ expect(
+ MockEmailSubmissionService.createEmailSubmissionWithoutSave,
+ ).toHaveBeenCalledWith(MOCK_FORM, expect.any(String), expect.any(String))
+ expect(
+ MockEmailSubmissionService.extractEmailAnswers,
+ ).toHaveBeenCalledWith(MOCK_PARSED_RESPONSES)
+ expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledWith({
+ replyToEmails: [MOCK_RESPONSES[0].answer],
+ form: MOCK_FORM,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ dataCollationData: MOCK_DATA_COLLATION_DATA,
+ formData: MOCK_FORM_DATA,
+ })
+ expect(MockSubmissionService.sendEmailConfirmations).toHaveBeenCalledWith(
+ {
+ form: MOCK_FORM,
+ parsedResponses: MOCK_PARSED_RESPONSES,
+ submission: MOCK_SUBMISSION,
+ attachments: [],
+ autoReplyData: MOCK_AUTOREPLY_DATA,
+ },
+ )
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: 'Form submission successful.',
+ submissionId: MOCK_SUBMISSION_ID,
+ })
+ })
+ })
+
describe('handleEncryptPreviewSubmission', () => {
const MOCK_RESPONSES = [
generateUnprocessedSingleAnswerResponse(BasicField.Email),
diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
index ab72a34bce..47bbf64f93 100644
--- a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
+++ b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts
@@ -21,12 +21,25 @@ import {
DatabaseError,
DatabasePayloadSizeError,
} from 'src/app/modules/core/core.errors'
+import {
+ MOCK_ATTACHMENT_FIELD,
+ MOCK_ATTACHMENT_RESPONSE,
+ MOCK_CHECKBOX_FIELD,
+ MOCK_CHECKBOX_RESPONSE,
+ MOCK_OPTIONAL_VERIFIED_FIELD,
+ MOCK_OPTIONAL_VERIFIED_RESPONSE,
+ MOCK_SECTION_FIELD,
+ MOCK_SECTION_RESPONSE,
+ MOCK_TEXT_FIELD,
+ MOCK_TEXTFIELD_RESPONSE,
+} from 'src/app/modules/submission/email-submission/__tests__/email-submission.test.constants'
import { saveSubmissionMetadata } from 'src/app/modules/submission/email-submission/email-submission.service'
import { SubmissionHash } from 'src/app/modules/submission/email-submission/email-submission.types'
import { aws } from 'src/config/config'
import { EditFieldActions, VALID_UPLOAD_FILE_TYPES } from 'src/shared/constants'
import {
BasicField,
+ IFieldSchema,
IFormDocument,
IFormSchema,
IPopulatedEmailForm,
@@ -58,6 +71,11 @@ import * as AdminFormService from '../admin-form.service'
// Prevent rate limiting.
jest.mock('src/app/utils/limit-rate')
+jest.mock('nodemailer', () => ({
+ createTransport: jest.fn().mockReturnValue({
+ sendMail: jest.fn().mockResolvedValue(true),
+ }),
+}))
const UserModel = getUserModel(mongoose)
const FormModel = getFormModel(mongoose)
@@ -89,6 +107,440 @@ describe('admin-form.routes', () => {
})
afterAll(async () => await dbHandler.closeDatabase())
+ describe('POST /v2/submissions/email/preview/:formId', () => {
+ const SUBMISSIONS_ENDPT_BASE = '/v2/submissions/email/preview'
+ it('should return 200 when submission is valid', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [MOCK_TEXT_FIELD],
+ admin: defaultUser._id,
+ },
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ // MOCK_RESPONSE contains all required keys
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [MOCK_TEXTFIELD_RESPONSE],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 200 when answer is empty string for optional field', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [
+ { ...MOCK_TEXT_FIELD, required: false } as IFieldSchema,
+ ],
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answer: '' }],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 200 when attachment response has filename and content', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [MOCK_ATTACHMENT_FIELD],
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [
+ {
+ ...MOCK_ATTACHMENT_RESPONSE,
+ content: MOCK_ATTACHMENT_RESPONSE.content.toString('binary'),
+ },
+ ],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 200 when response has isHeader key', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [MOCK_SECTION_FIELD],
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [{ ...MOCK_SECTION_RESPONSE, isHeader: true }],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 200 when signature is empty string for optional verified field', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [MOCK_OPTIONAL_VERIFIED_FIELD],
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [{ ...MOCK_OPTIONAL_VERIFIED_RESPONSE, signature: '' }],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 200 when response has answerArray and no answer', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ form_fields: [MOCK_CHECKBOX_FIELD],
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [MOCK_CHECKBOX_RESPONSE],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ message: 'Form submission successful.',
+ submissionId: expect.any(String),
+ })
+ })
+
+ it('should return 400 when isPreview key is missing', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ // Note missing isPreview
+ .field('body', JSON.stringify({ responses: [] }))
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when responses key is missing', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ // Note missing responses
+ .field('body', JSON.stringify({ isPreview: false }))
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when response is missing _id', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [omit(MOCK_TEXTFIELD_RESPONSE, '_id')],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when response is missing fieldType', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'fieldType')],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when response has invalid fieldType', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [
+ { ...MOCK_TEXTFIELD_RESPONSE, fieldType: 'definitelyInvalid' },
+ ],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when response is missing answer', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'answer')],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when response has both answer and answerArray', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answerArray: [] }],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when attachment response has filename but not content', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'content'],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+
+ it('should return 400 when attachment response has content but not filename', async () => {
+ const { form } = await dbHandler.insertEmailForm({
+ formOptions: {
+ hasCaptcha: false,
+ status: Status.Public,
+ admin: defaultUser._id,
+ },
+ // Avoid default mail domain so that user emails in the database don't conflict
+ mailDomain: 'test2.gov.sg',
+ })
+
+ const response = await request
+ .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}`)
+ .field(
+ 'body',
+ JSON.stringify({
+ isPreview: false,
+ responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'filename'],
+ }),
+ )
+ .query({ captchaResponse: 'null' })
+
+ expect(response.status).toBe(400)
+ expect(response.body.message).toEqual(
+ 'celebrate request validation failed',
+ )
+ })
+ })
+
describe('POST /v2/submissions/encrypt/preview/:formId', () => {
const MOCK_FIELD_ID = new ObjectId().toHexString()
const MOCK_ATTACHMENT_FIELD_ID = new ObjectId().toHexString()
diff --git a/src/app/modules/form/admin-form/admin-form.constants.ts b/src/app/modules/form/admin-form/admin-form.constants.ts
index a948520383..105c8c705c 100644
--- a/src/app/modules/form/admin-form/admin-form.constants.ts
+++ b/src/app/modules/form/admin-form/admin-form.constants.ts
@@ -2,3 +2,8 @@
* 900 seconds = 15 minutes. The expiry time for presigned POST URLs.
*/
export const PRESIGNED_POST_EXPIRY_SECS = 900
+
+// Values used for preview submissions
+export const PREVIEW_SINGPASS_UINFIN = 'S1234567A'
+export const PREVIEW_CORPPASS_UINFIN = '123456789A'
+export const PREVIEW_CORPPASS_UID = 'ABC'
diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts
index 9530af82d6..95516eb76b 100644
--- a/src/app/modules/form/admin-form/admin-form.controller.ts
+++ b/src/app/modules/form/admin-form/admin-form.controller.ts
@@ -1,4 +1,4 @@
-import { RequestHandler } from 'express'
+import { Request, RequestHandler } from 'express'
import { ParamsDictionary } from 'express-serve-static-core'
import { StatusCodes } from 'http-status-codes'
import JSONStream from 'JSONStream'
@@ -7,16 +7,17 @@ import { ResultAsync } from 'neverthrow'
import { createLoggerWithLabel } from '../../../../config/logger'
import {
AuthType,
+ FieldResponse,
FormSettings,
IForm,
IPopulatedForm,
- WithForm,
} from '../../../../types'
import {
EncryptSubmissionDto,
ErrorDto,
SettingsUpdateDto,
} from '../../../../types/api'
+import MailService from '../../../services/mail/mail.service'
import { checkIsEncryptedEncoding } from '../../../utils/encryption'
import { createReqMeta } from '../../../utils/request'
import * as AuthService from '../../auth/auth.service'
@@ -27,12 +28,27 @@ import {
DatabaseValidationError,
} from '../../core/core.errors'
import * as FeedbackService from '../../feedback/feedback.service'
+import {
+ createCorppassParsedResponses,
+ createSingpassParsedResponses,
+} from '../../spcp/spcp.util'
+import * as EmailSubmissionService from '../../submission/email-submission/email-submission.service'
+import {
+ mapAttachmentsFromResponses,
+ mapRouteError as mapEmailSubmissionError,
+ SubmissionEmailObj,
+} from '../../submission/email-submission/email-submission.util'
import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service'
import { mapRouteError as mapEncryptSubmissionError } from '../../submission/encrypt-submission/encrypt-submission.utils'
import * as SubmissionService from '../../submission/submission.service'
import * as UserService from '../../user/user.service'
import { PrivateFormError } from '../form.errors'
+import {
+ PREVIEW_CORPPASS_UID,
+ PREVIEW_CORPPASS_UINFIN,
+ PREVIEW_SINGPASS_UINFIN,
+} from './admin-form.constants'
import { EditFieldError } from './admin-form.errors'
import * as AdminFormService from './admin-form.service'
import {
@@ -360,26 +376,6 @@ export const handleCountFormSubmissions: RequestHandler<
})
}
-/**
- * Allow submission in preview without Spcp authentication by providing default values
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} next - the next expressjs callback
- */
-export const passThroughSpcp: RequestHandler = (req, res, next) => {
- const { authType } = (req as WithForm).form
- if ([AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(authType)) {
- res.locals = {
- ...res.locals,
- ...AdminFormService.getMockSpcpLocals(
- authType,
- (req as WithForm).form.form_fields,
- ),
- }
- }
- return next()
-}
-
/**
* Handler for GET /{formId}/adminform/feedback/count.
* @security session
@@ -1029,6 +1025,7 @@ export const handleUpdateSettings: RequestHandler<
* @returns 403 when current user does not have read permissions to given form
* @returns 404 when given form ID does not exist
* @returns 410 when given form has been deleted
+ * @returns 422 when user ID in session is not found in database
* @returns 500 when database error occurs
*/
export const handleEncryptPreviewSubmission: RequestHandler<
@@ -1106,3 +1103,141 @@ export const handleEncryptPreviewSubmission: RequestHandler<
submissionId: submission._id,
})
}
+
+/**
+ * Handler for POST /v2/submissions/encrypt/preview/:formId.
+ * @security session
+ *
+ * @returns 200 with a mock submission ID
+ * @returns 400 when body is malformed; e.g. invalid responses, or when admin email fails to be sent
+ * @returns 403 when current user does not have read permissions to given form
+ * @returns 404 when given form ID does not exist
+ * @returns 410 when given form has been deleted
+ * @returns 422 when user ID in session is not found in database
+ * @returns 500 when database error occurs
+ */
+export const handleEmailPreviewSubmission: RequestHandler<
+ { formId: string },
+ { message: string; submissionId?: string },
+ { responses: FieldResponse[]; isPreview: boolean },
+ { captchaResponse?: unknown }
+> = async (req, res) => {
+ const { formId } = req.params
+ const sessionUserId = (req.session as Express.AuthedSession).user._id
+ // No need to process attachments as we don't do anything with them
+ const { responses } = req.body
+ const logMeta = {
+ action: 'handleEmailPreviewSubmission',
+ formId,
+ ...createReqMeta(req as Request),
+ }
+
+ const formResult = await UserService.getPopulatedUserById(sessionUserId)
+ .andThen((user) =>
+ AuthService.getFormAfterPermissionChecks({
+ user,
+ formId,
+ level: PermissionLevel.Read,
+ }),
+ )
+ .andThen(EmailSubmissionService.checkFormIsEmailMode)
+ if (formResult.isErr()) {
+ logger.error({
+ message: 'Error while retrieving form for preview submission',
+ meta: logMeta,
+ error: formResult.error,
+ })
+ const { errorMessage, statusCode } = mapEmailSubmissionError(
+ formResult.error,
+ )
+ return res.status(statusCode).json({ message: errorMessage })
+ }
+ const form = formResult.value
+
+ const parsedResponsesResult = await EmailSubmissionService.validateAttachments(
+ responses,
+ ).andThen(() => SubmissionService.getProcessedResponses(form, responses))
+ if (parsedResponsesResult.isErr()) {
+ logger.error({
+ message: 'Error while parsing responses for preview submission',
+ meta: logMeta,
+ error: parsedResponsesResult.error,
+ })
+ const { errorMessage, statusCode } = mapEmailSubmissionError(
+ parsedResponsesResult.error,
+ )
+ return res.status(statusCode).json({ message: errorMessage })
+ }
+ const parsedResponses = parsedResponsesResult.value
+ const attachments = mapAttachmentsFromResponses(req.body.responses)
+
+ // Handle SingPass, CorpPass and MyInfo authentication and validation
+ if (form.authType === AuthType.SP || form.authType === AuthType.MyInfo) {
+ parsedResponses.push(
+ ...createSingpassParsedResponses(PREVIEW_SINGPASS_UINFIN),
+ )
+ } else if (form.authType === AuthType.CP) {
+ parsedResponses.push(
+ ...createCorppassParsedResponses(
+ PREVIEW_CORPPASS_UINFIN,
+ PREVIEW_CORPPASS_UID,
+ ),
+ )
+ }
+
+ const emailData = new SubmissionEmailObj(
+ parsedResponses,
+ // All MyInfo fields are verified in preview
+ new Set(AdminFormService.extractMyInfoFieldIds(form.form_fields)),
+ form.authType,
+ )
+ const submission = EmailSubmissionService.createEmailSubmissionWithoutSave(
+ form,
+ // Don't need to care about response hash or salt
+ '',
+ '',
+ )
+
+ const sendAdminEmailResult = await MailService.sendSubmissionToAdmin({
+ replyToEmails: EmailSubmissionService.extractEmailAnswers(parsedResponses),
+ form,
+ submission,
+ attachments,
+ dataCollationData: emailData.dataCollationData,
+ formData: emailData.formData,
+ })
+ if (sendAdminEmailResult.isErr()) {
+ logger.error({
+ message: 'Error sending submission to admin',
+ meta: logMeta,
+ error: sendAdminEmailResult.error,
+ })
+ const { statusCode, errorMessage } = mapEmailSubmissionError(
+ sendAdminEmailResult.error,
+ )
+ return res.status(statusCode).json({
+ message: errorMessage,
+ })
+ }
+
+ // Don't await on email confirmations, so submission is successful even if
+ // this fails
+ void SubmissionService.sendEmailConfirmations({
+ form,
+ parsedResponses,
+ submission,
+ attachments,
+ autoReplyData: emailData.autoReplyData,
+ }).mapErr((error) => {
+ logger.error({
+ message: 'Error while sending email confirmations',
+ meta: logMeta,
+ error,
+ })
+ })
+
+ return res.json({
+ message: 'Form submission successful.',
+ submissionId: submission.id,
+ })
+}
diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts
index 99bab1eb59..6ae229987a 100644
--- a/src/app/modules/form/admin-form/admin-form.routes.ts
+++ b/src/app/modules/form/admin-form/admin-form.routes.ts
@@ -9,6 +9,7 @@ import { Router } from 'express'
import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants'
import { IForm, ResponseMode } from '../../../../types'
import { withUserAuthentication } from '../../auth/auth.middlewares'
+import * as EmailSubmissionMiddleware from '../../submission/email-submission/email-submission.middleware'
import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller'
import * as EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware'
@@ -516,3 +517,24 @@ AdminFormsRouter.post(
EncryptSubmissionMiddleware.validateEncryptSubmissionParams,
AdminFormController.handleEncryptPreviewSubmission,
)
+
+/**
+ * Submit an email mode form in preview mode
+ * @route POST /v2/submissions/email/preview/:formId([a-fA-F0-9]{24})
+ * @security session
+ *
+ * @returns 200 if submission was valid
+ * @returns 400 when error occurs while processing submission or submission is invalid
+ * @returns 403 when user does not have read permissions for form
+ * @returns 404 when form cannot be found
+ * @returns 410 when form is archived
+ * @returns 422 when user in session cannot be retrieved from the database
+ * @returns 500 when database error occurs
+ */
+AdminFormsRouter.post(
+ '/v2/submissions/email/preview/:formId([a-fA-F0-9]{24})',
+ withUserAuthentication,
+ EmailSubmissionMiddleware.receiveEmailSubmission,
+ EmailSubmissionMiddleware.validateResponseParams,
+ AdminFormController.handleEmailPreviewSubmission,
+)
diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts
index 76cd9151da..cac0f76728 100644
--- a/src/app/modules/form/admin-form/admin-form.service.ts
+++ b/src/app/modules/form/admin-form/admin-form.service.ts
@@ -11,7 +11,6 @@ import {
VALID_UPLOAD_FILE_TYPES,
} from '../../../../shared/constants'
import {
- AuthType,
FormLogoState,
FormMetaView,
FormSettings,
@@ -21,7 +20,6 @@ import {
IFormSchema,
IPopulatedForm,
IUserSchema,
- SpcpLocals,
} from '../../../../types'
import { SettingsUpdateDto } from '../../../../types/api'
import getFormModel from '../../../models/form.server.model'
@@ -206,33 +204,19 @@ export const createPresignedPostUrlForLogos = (
return createPresignedPostUrl(AwsConfig.logoS3Bucket, uploadParams)
}
-export const getMockSpcpLocals = (
- authType: AuthType,
+/**
+ * Extracts IDs of MyInfo fields
+ * @param formFields
+ * @returns List of IDs of MyInfo fields
+ */
+export const extractMyInfoFieldIds = (
formFields: IFieldSchema[] | undefined,
-): SpcpLocals => {
- const myInfoFieldIds: string[] = formFields
+): string[] => {
+ return formFields
? formFields
.filter((field) => field.myInfo?.attr)
.map((field) => field._id.toString())
: []
- switch (authType) {
- case AuthType.MyInfo:
- return {
- uinFin: 'S1234567A',
- hashedFields: new Set(myInfoFieldIds),
- }
- case AuthType.SP:
- return {
- uinFin: 'S1234567A',
- }
- case AuthType.CP:
- return {
- uinFin: '123456789A',
- userInfo: 'ABC',
- }
- default:
- return {}
- }
}
/**
diff --git a/src/app/modules/spcp/spcp.controller.ts b/src/app/modules/spcp/spcp.controller.ts
index 6513df583d..42f7f57b3f 100644
--- a/src/app/modules/spcp/spcp.controller.ts
+++ b/src/app/modules/spcp/spcp.controller.ts
@@ -8,15 +8,10 @@ import { AuthType, WithForm } from '../../../types'
import { createReqMeta } from '../../utils/request'
import { BillingFactory } from '../billing/billing.factory'
import * as FormService from '../form/form.service'
-import { ProcessedFieldResponse } from '../submission/submission.types'
import { SpcpFactory } from './spcp.factory'
import { JwtName, LoginPageValidationResult } from './spcp.types'
-import {
- createCorppassParsedResponses,
- createSingpassParsedResponses,
- mapRouteError,
-} from './spcp.util'
+import { mapRouteError } from './spcp.util'
const logger = createLoggerWithLabel(module)
@@ -223,31 +218,3 @@ export const handleLogin: (
return res.redirect(destination)
})
}
-
-/**
- * Append additional verified responses(s) for SP and CP responses so that they show up in email response
- * @param req - Express request object
- * @param res - Express response object
- */
-export const appendVerifiedSPCPResponses: RequestHandler<
- ParamsDictionary,
- unknown,
- { parsedResponses: ProcessedFieldResponse[] }
-> = (req, res, next) => {
- const { form } = req as WithForm
- const { uinFin, userInfo } = res.locals
- switch (form.authType) {
- case AuthType.MyInfo:
- case AuthType.SP:
- req.body.parsedResponses.push(...createSingpassParsedResponses(uinFin))
- break
- case AuthType.CP:
- // Note that maskUidOnLastField() relies on the fact that userInfo is pushed in last to parsedResponses
- // TODO(#1104): Remove this comment after refactoring
- req.body.parsedResponses.push(
- ...createCorppassParsedResponses(uinFin, userInfo),
- )
- break
- }
- return next()
-}
diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts
index b069a378f5..ab8042a986 100644
--- a/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts
+++ b/src/app/modules/submission/email-submission/__tests__/email-submission.service.spec.ts
@@ -20,6 +20,7 @@ import {
generateNewAttachmentResponse,
generateNewSingleAnswerResponse,
} from 'tests/unit/backend/helpers/generate-form-data'
+import dbHandler from 'tests/unit/backend/helpers/jest-db'
import { ProcessedSingleAnswerResponse } from '../../submission.types'
import {
@@ -42,6 +43,40 @@ const MOCK_HASH = Buffer.from('mockHash')
const EmailSubmissionModel = getEmailSubmissionModel(mongoose)
describe('email-submission.service', () => {
+ beforeAll(async () => await dbHandler.connect())
+ afterEach(async () => await dbHandler.clearDatabase())
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ describe('createEmailSubmissionWithoutSave', () => {
+ const MOCK_EMAIL = 'a@abc.com'
+ const MOCK_RESPONSE_HASH = 'mockHash'
+ const MOCK_RESPONSE_SALT = 'mockSalt'
+ const MOCK_FORM = ({
+ admin: new ObjectId(),
+ _id: new ObjectId(),
+ title: 'mock title',
+ getUniqueMyInfoAttrs: () => [],
+ authType: 'NIL',
+ emails: [MOCK_EMAIL],
+ } as unknown) as IPopulatedEmailForm
+
+ it('should create a new submission without saving it to the database', async () => {
+ const result = EmailSubmissionService.createEmailSubmissionWithoutSave(
+ MOCK_FORM,
+ MOCK_RESPONSE_HASH,
+ MOCK_RESPONSE_SALT,
+ )
+ const foundInDatabase = await EmailSubmissionModel.findOne({
+ _id: result._id,
+ })
+
+ expect(result.form).toEqual(MOCK_FORM._id)
+ expect(result.responseHash).toEqual(MOCK_RESPONSE_HASH)
+ expect(result.responseSalt).toEqual(MOCK_RESPONSE_SALT)
+ expect(foundInDatabase).toBeNull()
+ })
+ })
+
describe('validateAttachments', () => {
it('should reject submissions when attachments are more than 7MB', async () => {
const processedResponse1 = generateNewAttachmentResponse({
diff --git a/src/app/modules/submission/email-submission/email-submission.controller.ts b/src/app/modules/submission/email-submission/email-submission.controller.ts
index 781b411532..248da53408 100644
--- a/src/app/modules/submission/email-submission/email-submission.controller.ts
+++ b/src/app/modules/submission/email-submission/email-submission.controller.ts
@@ -261,9 +261,7 @@ export const handleEmailSubmission: RequestHandler<
}).mapErr((error) => {
logger.error({
message: 'Error while sending email confirmations',
- meta: {
- action: 'sendEmailAutoReplies',
- },
+ meta: logMetaWithSubmission,
error,
})
})
diff --git a/src/app/modules/submission/email-submission/email-submission.middleware.ts b/src/app/modules/submission/email-submission/email-submission.middleware.ts
index 0aa07aa0a2..8c85e8acd6 100644
--- a/src/app/modules/submission/email-submission/email-submission.middleware.ts
+++ b/src/app/modules/submission/email-submission/email-submission.middleware.ts
@@ -1,61 +1,16 @@
import { celebrate, Joi } from 'celebrate'
import { RequestHandler } from 'express'
import { ParamsDictionary } from 'express-serve-static-core'
-import { Merge, SetOptional } from 'type-fest'
import { createLoggerWithLabel } from '../../../../config/logger'
-import {
- BasicField,
- FieldResponse,
- ResWithHashedFields,
- WithAttachments,
- WithEmailData,
- WithForm,
-} from '../../../../types'
-import MailService from '../../../services/mail/mail.service'
+import { BasicField, FieldResponse } from '../../../../types'
import { createReqMeta } from '../../../utils/request'
-import { getProcessedResponses } from '../submission.service'
-import { ProcessedFieldResponse } from '../submission.types'
import * as EmailSubmissionReceiver from './email-submission.receiver'
-import * as EmailSubmissionService from './email-submission.service'
-import { WithAdminEmailData } from './email-submission.types'
-import {
- mapAttachmentsFromResponses,
- mapRouteError,
- SubmissionEmailObj,
-} from './email-submission.util'
+import { mapRouteError } from './email-submission.util'
const logger = createLoggerWithLabel(module)
-/**
- * Construct autoReply data and data to send admin from responses submitted
- *
- * @param req - Express request object
- * @param res - Express response object
- * @param next - Express next middleware function
- */
-export const prepareEmailSubmission: RequestHandler<
- ParamsDictionary,
- unknown,
- { parsedResponses: ProcessedFieldResponse[] }
-> = (req, res, next) => {
- const hashedFields =
- (res as ResWithHashedFields).locals.hashedFields || new Set()
- const { form } = req as WithForm
- const emailData = new SubmissionEmailObj(
- req.body.parsedResponses,
- hashedFields,
- form.authType,
- )
- // eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;(req as WithEmailData).autoReplyData = emailData.autoReplyData
- ;(req as WithEmailData).dataCollationData =
- emailData.dataCollationData
- ;(req as WithEmailData).formData = emailData.formData
- return next()
-}
-
/**
* Parses multipart-form data request. Parsed attachments are
* placed into req.attachments and parsed fields are placed into
@@ -97,115 +52,6 @@ export const receiveEmailSubmission: RequestHandler<
})
}
-/**
- * Extracts relevant fields, injects questions, verifies visibility of field and validates answers
- * to produce req.body.parsedResponses
- *
- * @param req - Express request object
- * @param res - Express response object
- * @param next - Express next middleware function
- */
-export const validateEmailSubmission: RequestHandler<
- ParamsDictionary,
- { message: string },
- { responses: FieldResponse[] }
-> = async (req, res, next) => {
- const { form } = req as WithForm
-
- return EmailSubmissionService.validateAttachments(req.body.responses)
- .andThen(() => getProcessedResponses(form, req.body.responses))
- .map((parsedResponses) => {
- // Creates an array of attachments from the validated responses
- // TODO (#42): remove these types when merging middlewares into controller
- // eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;(req as WithAttachments<
- typeof req
- >).attachments = mapAttachmentsFromResponses(req.body.responses)
- ;(req.body as Merge<
- typeof req.body,
- { parsedResponses: ProcessedFieldResponse[] }
- >).parsedResponses = parsedResponses
- delete (req.body as SetOptional).responses // Prevent downstream functions from using responses by deleting it
- return next()
- })
- .mapErr((error) => {
- logger.error({
- message: 'Error processing responses',
- meta: {
- action: 'validateEmailSubmission',
- ...createReqMeta(req),
- formId: form._id,
- },
- error,
- })
- const { errorMessage, statusCode } = mapRouteError(error)
- return res.status(statusCode).json({
- message: errorMessage,
- })
- })
-}
-
-/**
- * Sends response email to admin
- * @param req - Express request object
- * @param req.form - form object from req
- * @param req.formData - the submission for the form
- * @param req.dataCollationData - data to be included in JSON section of email
- * @param req.submission - submission which was saved to database
- * @param req.attachments - submitted attachments, parsed by
- * exports.receiveSubmission
- * @param res - Express response object
- * @param next - the next expressjs callback, invoked once attachments
- * are processed
- */
-export const sendAdminEmail: RequestHandler<
- ParamsDictionary,
- { message: string; spcpSubmissionFailure: boolean },
- { parsedResponses: ProcessedFieldResponse[] }
-> = async (req, res, next) => {
- const {
- form,
- formData,
- dataCollationData,
- submission,
- attachments,
- } = req as WithAdminEmailData
- const logMeta = {
- action: 'sendAdminEmail',
- submissionId: submission.id,
- formId: form._id,
- ...createReqMeta(req),
- submissionHash: submission.responseHash,
- }
- logger.info({
- message: 'Sending admin mail',
- meta: logMeta,
- })
- return MailService.sendSubmissionToAdmin({
- replyToEmails: EmailSubmissionService.extractEmailAnswers(
- req.body.parsedResponses,
- ),
- form,
- submission,
- attachments,
- dataCollationData,
- formData,
- })
- .map(() => next())
- .mapErr((error) => {
- logger.error({
- message: 'Error sending submission to admin',
- meta: logMeta,
- error,
- })
- const { statusCode, errorMessage } = mapRouteError(error)
- return res.status(statusCode).json({
- message: errorMessage,
- spcpSubmissionFailure: false,
- })
- })
-}
-
/**
* Celebrate validation for the email submissions endpoint.
*/
diff --git a/src/app/modules/submission/email-submission/email-submission.service.ts b/src/app/modules/submission/email-submission/email-submission.service.ts
index 280544e7fc..90c3d30408 100644
--- a/src/app/modules/submission/email-submission/email-submission.service.ts
+++ b/src/app/modules/submission/email-submission/email-submission.service.ts
@@ -190,3 +190,25 @@ export const checkFormIsEmailMode = (
}
return err(new ResponseModeError(ResponseMode.Email, form.responseMode))
}
+
+/**
+ * Creates an email submission without saving it to the database.
+ * @param form Form document
+ * @param responseHash Hash of response
+ * @param responseSalt Salt used to hash response
+ * @returns Submission document which has not been saved to database
+ */
+export const createEmailSubmissionWithoutSave = (
+ form: IPopulatedEmailForm,
+ responseHash: string,
+ responseSalt: string,
+): IEmailSubmissionSchema => {
+ return new EmailSubmissionModel({
+ form: form._id,
+ authType: form.authType,
+ myInfoFields: form.getUniqueMyInfoAttrs(),
+ recipientEmails: transformEmails(form.emails),
+ responseHash,
+ responseSalt,
+ })
+}
diff --git a/src/app/modules/submission/email-submission/email-submission.types.ts b/src/app/modules/submission/email-submission/email-submission.types.ts
index 2d66820eae..f1cabbda9c 100644
--- a/src/app/modules/submission/email-submission/email-submission.types.ts
+++ b/src/app/modules/submission/email-submission/email-submission.types.ts
@@ -1,10 +1,4 @@
-import {
- BasicField,
- FieldResponse,
- IBaseResponse,
- WithEmailModeMetadata,
- WithSubmission,
-} from '../../../../types'
+import { BasicField, FieldResponse, IBaseResponse } from '../../../../types'
import { ProcessedResponse } from '../submission.types'
// When a response has been formatted for email, all answerArray
@@ -26,5 +20,3 @@ export interface SubmissionHash {
hash: string
salt: string
}
-
-export type WithAdminEmailData = WithEmailModeMetadata & WithSubmission
diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts
index f0f9a100cb..c327a93fa6 100644
--- a/src/app/modules/submission/email-submission/email-submission.util.ts
+++ b/src/app/modules/submission/email-submission/email-submission.util.ts
@@ -39,6 +39,7 @@ import {
MissingFeatureError,
} from '../../core/core.errors'
import {
+ ForbiddenFormError,
FormDeletedError,
FormNotFoundError,
PrivateFormError,
@@ -56,6 +57,7 @@ import {
MissingJwtError,
VerifyJwtError,
} from '../../spcp/spcp.errors'
+import { MissingUserError } from '../../user/user.errors'
import {
ConflictError,
ProcessingError,
@@ -373,6 +375,16 @@ export const mapRouteError: MapRouteError = (error) => {
errorMessage:
'Could not send submission. For assistance, please contact the person who asked you to fill in this form.',
}
+ case MissingUserError:
+ return {
+ statusCode: StatusCodes.UNPROCESSABLE_ENTITY,
+ errorMessage: 'You must be logged in to perform this action.',
+ }
+ case ForbiddenFormError:
+ return {
+ statusCode: StatusCodes.FORBIDDEN,
+ errorMessage: 'You do not have permission to perform this action.',
+ }
case FormNotFoundError:
return {
statusCode: StatusCodes.NOT_FOUND,
@@ -603,7 +615,7 @@ const maskUidOnLastField = (
): EmailRespondentConfirmationField[] => {
// Mask corppass UID and show only last 4 chars in autoreply to form filler
// This does not affect response email to form admin
- // Function assumes corppass UID is last in the autoReplyData array - see appendVerifiedSPCPResponses()
+ // Function assumes corppass UID is last in the autoReplyData array
// TODO(#1104): Refactor to move validation and construction of parsedResponses in class constructor
// This will allow for proper tagging of corppass UID field instead of checking field title and position
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
index cda813af4f..8feb259149 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
@@ -10,12 +10,6 @@ export type EncryptSubmissionBodyAfterProcess = {
parsedResponses: ProcessedFieldResponse[]
}
-export type WithAttachmentsData = T & {
- attachmentData: EncryptedAttachmentsDto
-}
-
-export type WithFormData = T & { formData: string }
-
export type AttachmentMetadata = Map
export type SaveEncryptSubmissionParams = {
diff --git a/src/app/modules/submission/submission.middleware.ts b/src/app/modules/submission/submission.middleware.ts
deleted file mode 100644
index 0acfc5da8e..0000000000
--- a/src/app/modules/submission/submission.middleware.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { RequestHandler } from 'express'
-import { ParamsDictionary } from 'express-serve-static-core'
-
-import { createLoggerWithLabel } from '../../../config/logger'
-import { WithAutoReplyEmailData } from '../../../types'
-
-import * as SubmissionService from './submission.service'
-import { ProcessedFieldResponse } from './submission.types'
-
-const logger = createLoggerWithLabel(module)
-
-/**
- * Sends email confirmations to form-fillers, for email fields which have
- * email confirmation enabled.
- * @param req Express request object
- * @param res Express response object
- */
-export const sendEmailConfirmations: RequestHandler<
- ParamsDictionary,
- unknown,
- { parsedResponses: ProcessedFieldResponse[] }
-> = async (req, res) => {
- const {
- form,
- attachments,
- autoReplyData,
- submission,
- } = req as WithAutoReplyEmailData
- // Return the reply early to the submitter
- res.json({
- message: 'Form submission successful.',
- submissionId: submission.id,
- })
- return SubmissionService.sendEmailConfirmations({
- form,
- parsedResponses: req.body.parsedResponses,
- submission,
- attachments,
- autoReplyData,
- }).mapErr((error) => {
- logger.error({
- message: 'Error while sending email confirmations',
- meta: {
- action: 'sendEmailAutoReplies',
- },
- error,
- })
- })
-}
diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js
deleted file mode 100644
index f97c85f1cd..0000000000
--- a/src/app/routes/admin-forms.server.routes.js
+++ /dev/null
@@ -1,89 +0,0 @@
-'use strict'
-
-/**
- * Module dependencies.
- */
-const { celebrate, Joi, Segments } = require('celebrate')
-
-let forms = require('../../app/controllers/forms.server.controller')
-let adminForms = require('../../app/controllers/admin-forms.server.controller')
-let auth = require('../../app/controllers/authentication.server.controller')
-const EmailSubmissionsMiddleware = require('../../app/modules/submission/email-submission/email-submission.middleware')
-const SubmissionsMiddleware = require('../../app/modules/submission/submission.middleware')
-const AdminFormController = require('../modules/form/admin-form/admin-form.controller')
-const { withUserAuthentication } = require('../modules/auth/auth.middlewares')
-const {
- PermissionLevel,
-} = require('../modules/form/admin-form/admin-form.types')
-const SpcpController = require('../modules/spcp/spcp.controller')
-const { BasicField } = require('../../types')
-
-/**
- * Authenticates logged in user, before retrieving non-archived form
- * and verifying read/write permissions.
- * @param {enum} requiredPermission
- */
-let authActiveForm = (requiredPermission) => [
- withUserAuthentication,
- forms.formById,
- adminForms.isFormActive,
- auth.verifyPermission(requiredPermission),
-]
-
-module.exports = function (app) {
- /**
- * 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
- * containing the submission back to the user, if an email address
- * was given. SMS autoreplies for mobile number fields are also sent if feature
- * is enabled.
- * Note that preview submissions are not saved to db
- * Note that spcp session is not verified, neither is myInfo data verified
- * @route POST /v2/submissions/email/preview/{formId}
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @param {Array} response.body.required - contains the entire form submission
- * @consumes multipart/form-data
- * @produces application/json
- * @returns {SubmissionResponse.model} 200 - submission made
- * @returns {SubmissionResponse.model} 400 - submission has bad data and could not be processed
- * @security OTP
- */
- app.route('/v2/submissions/email/preview/:formId([a-fA-F0-9]{24})').post(
- authActiveForm(PermissionLevel.Read),
- EmailSubmissionsMiddleware.receiveEmailSubmission,
- celebrate({
- [Segments.BODY]: Joi.object({
- responses: Joi.array()
- .items(
- Joi.object()
- .keys({
- _id: Joi.string().required(),
- question: Joi.string().required(),
- fieldType: Joi.string()
- .required()
- .valid(...Object.values(BasicField)),
- answer: Joi.string().allow(''),
- answerArray: Joi.array(),
- filename: Joi.string(),
- content: Joi.binary(),
- isHeader: Joi.boolean(),
- myInfo: Joi.object(),
- signature: Joi.string().allow(''),
- })
- .xor('answer', 'answerArray') // only answer or answerArray can be present at once
- .with('filename', 'content'), // if filename is present, content must be present
- )
- .required(),
- isPreview: Joi.boolean().required(),
- }),
- }),
- EmailSubmissionsMiddleware.validateEmailSubmission,
- AdminFormController.passThroughSpcp,
- SpcpController.appendVerifiedSPCPResponses,
- EmailSubmissionsMiddleware.prepareEmailSubmission,
- adminForms.passThroughSaveMetadataToDb,
- EmailSubmissionsMiddleware.sendAdminEmail,
- SubmissionsMiddleware.sendEmailConfirmations,
- )
-}
diff --git a/src/app/routes/index.js b/src/app/routes/index.js
index dfb783cbe9..08270e7337 100644
--- a/src/app/routes/index.js
+++ b/src/app/routes/index.js
@@ -1,5 +1,4 @@
module.exports = [
- require('./admin-forms.server.routes.js'),
require('./frontend.server.routes.js'),
require('./public-forms.server.routes.js'),
]
diff --git a/src/types/express.locals.ts b/src/types/express.locals.ts
index 51032e3d97..cdd69b5b92 100644
--- a/src/types/express.locals.ts
+++ b/src/types/express.locals.ts
@@ -4,9 +4,7 @@ import { LeanDocument } from 'mongoose'
import { ProcessedFieldResponse } from '../app/modules/submission/submission.types'
import { BasicField } from './field'
-import { IEmailFormSchema, IPopulatedForm } from './form'
-import { SpcpSession } from './spcp'
-import { ISubmissionSchema } from './submission'
+import { IPopulatedForm } from './form'
export type WithForm = T & {
form: IPopulatedForm
@@ -20,10 +18,6 @@ export type WithParsedResponses = T & {
parsedResponses: ProcessedFieldResponse[]
}
-export type ResWithSpcpSession = T & {
- locals: { spcpSession?: SpcpSession }
-}
-
export type ResWithUinFin = T & {
uinFin?: string
}
@@ -32,14 +26,6 @@ export type ResWithHashedFields = T & {
locals: { hashedFields?: Set }
}
-export type SpcpLocals =
- | {
- uinFin: string
- hashedFields?: Set
- }
- | { uinFin: string; userInfo: string }
- | { [key: string]: never } // empty object
-
export type EmailRespondentConfirmationField = {
question: string
answerTemplate: string[]
@@ -74,25 +60,8 @@ export interface EmailDataForOneField {
formData: EmailAdminDataField
}
-export type WithSubmission = T & { submission: ISubmissionSchema }
-
export interface IAttachmentInfo {
filename: string
content: Buffer
fieldId: string
}
-
-export type WithAttachments = T & { attachments: IAttachmentInfo[] }
-
-export type WithEmailData = T & EmailData
-
-export type WithEmailForm = T & { form: IEmailFormSchema }
-
-export type WithEmailModeMetadata = WithEmailData &
- WithAttachments &
- WithEmailForm
-
-export type WithAutoReplyEmailData = WithForm &
- WithSubmission &
- Partial> &
- Partial>
diff --git a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js
deleted file mode 100644
index 35fcf4792a..0000000000
--- a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js
+++ /dev/null
@@ -1,188 +0,0 @@
-const { StatusCodes } = require('http-status-codes')
-const mongoose = require('mongoose')
-
-const dbHandler = require('../helpers/db-handler')
-const Form = dbHandler.makeModel('form.server.model', 'Form')
-
-const Controller = spec(
- 'dist/backend/app/controllers/admin-forms.server.controller',
-).makeModule(mongoose)
-
-const NewController = require('../../../../dist/backend/app/modules/form/admin-form/admin-form.controller')
-const { ResponseMode } = require('../../../../dist/backend/types')
-
-describe('Admin-Forms Controller', () => {
- // Declare global variables
- let req
- let res
- let testForm
- let testUser
-
- beforeAll(async () => await dbHandler.connect())
- beforeEach(async () => {
- res = jasmine.createSpyObj('res', ['status', 'send', 'json'])
- const collections = await dbHandler.preloadCollections()
-
- testUser = collections.user
- testForm = collections.form
-
- req = {
- query: {},
- params: {},
- body: {},
- session: {
- user: {
- _id: testUser._id,
- email: testUser.email,
- },
- },
- headers: {},
- ip: '127.0.0.1',
- get: () => {},
- }
- })
-
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- describe('isFormActive', () => {
- it('should return 404 if form is archived', () => {
- let req = { form: { status: 'ARCHIVED' }, headers: {}, ip: '127.0.0.1' }
- res.status.and.callFake(function () {
- return this
- })
- res.send.and.callFake(function () {
- return this
- })
- Controller.isFormActive(req, res, () => {})
- expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND)
- })
-
- it('should pass on to the next middleware if not archived', () => {
- let req = { form: { status: 'PUBLIC' }, headers: {}, ip: '127.0.0.1' }
- let next = jasmine.createSpy()
- Controller.isFormActive(req, res, next)
- expect(next).toHaveBeenCalled()
- })
- })
-
- describe('isFormEncryptMode', () => {
- let next
- beforeEach(() => {
- req.form = testForm
- res.status.and.callFake(() => res)
- next = jasmine.createSpy()
- })
- it('should reject forms that are not encrypt mode', () => {
- req.form.responseMode = 'email'
- Controller.isFormEncryptMode(req, res, next)
- expect(next).not.toHaveBeenCalled()
- expect(res.status).toHaveBeenCalledWith(StatusCodes.UNPROCESSABLE_ENTITY)
- })
- it('should accept forms that are encrypt mode', () => {
- req.form.responseMode = 'encrypt'
- Controller.isFormEncryptMode(req, res, next)
- expect(next).toHaveBeenCalled()
- })
- })
-
- describe('create', () => {
- it('should successfully save a Form object with user defined fields', (done) => {
- let expected = {
- title: 'form_title',
- responseMode: ResponseMode.Email,
- emails: ['email@hello.gov.sg', 'user@byebye.gov.sg'],
- }
- req.body.form = _.cloneDeep(expected)
-
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(StatusCodes.OK)
- return res
- })
-
- // Check for user-defined fields
- res.json.and.callFake((args) => {
- let returnObj = args.toObject()
- expect(returnObj.title).toEqual(expected.title)
- expect(returnObj.emails).toEqual(expected.emails)
- expect(returnObj.admin.toString()).toEqual(
- req.session.user._id.toString(),
- )
- Form.findOne({ title: expected.title }, (err, foundForm) => {
- if (err || !foundForm) {
- done(err || new Error('Form not saved'))
- } else {
- done()
- }
- })
- })
- NewController.handleCreateForm(req, res)
- })
-
- it('should return 422 error when saving a Form object with invalid fields', (done) => {
- req.body.form = {
- title: 'bad_form',
- responseMode: ResponseMode.Email,
- emails: 'wrongemail.com',
- }
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(
- StatusCodes.UNPROCESSABLE_ENTITY,
- )
- done()
- return res
- })
- NewController.handleCreateForm(req, res)
- })
- })
-
- describe('update', () => {
- it('should successfully update a Form object with new fields', (done) => {
- let expected = {
- title: 'form_title2',
- startPage: {
- colorTheme: 'blue',
- estTimeTaken: 1,
- },
- permissionList: [],
- }
- req.params.formId = testForm._id
- req.body.form = _.cloneDeep(expected)
- // Check for user-defined fields
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(StatusCodes.OK)
- return res
- })
- res.json.and.callFake(() => {
- Form.findOne({ _id: testForm._id }, (err, updatedForm) => {
- expect(err).not.toBeTruthy()
- let updatedFormObj = updatedForm.toObject()
- expect(updatedFormObj.title).toEqual(expected.title)
- expect(updatedFormObj.startPage).toEqual(expected.startPage)
- done()
- })
- })
- NewController.handleUpdateForm(req, res)
- })
-
- it('should return 422 error when updating a Form object with invalid fields', (done) => {
- req.params.formId = testForm._id
- req.body.form = {
- title: 'form_title3',
- startPage: {
- colorTheme: 'wrong_color',
- estTimeTaken: 1,
- },
- permissionList: [],
- }
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(
- StatusCodes.UNPROCESSABLE_ENTITY,
- )
- done()
- return res
- })
- NewController.handleUpdateForm(req, res)
- })
- })
-})
diff --git a/tests/unit/backend/controllers/authentication.server.controller.spec.js b/tests/unit/backend/controllers/authentication.server.controller.spec.js
deleted file mode 100644
index add4854d9d..0000000000
--- a/tests/unit/backend/controllers/authentication.server.controller.spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-const { StatusCodes } = require('http-status-codes')
-const mongoose = require('mongoose')
-const {
- PermissionLevel,
-} = require('../../../../dist/backend/app/modules/form/admin-form/admin-form.types')
-
-const dbHandler = require('../helpers/db-handler')
-let roles = require('../helpers/roles')
-
-describe('Authentication Controller', () => {
- const TEST_OTP = '123456'
- const bcrypt = jasmine.createSpyObj('bcrypt', ['hash'])
- const mockSendNodeMail = jasmine.createSpy()
-
- const Controller = spec(
- 'dist/backend/app/controllers/authentication.server.controller',
- {
- mongoose: Object.assign(mongoose, { '@noCallThru': true }),
- '../utils/otp': {
- generateOtp: () => TEST_OTP,
- },
- '../services/mail/mail.service': {
- sendNodeMail: mockSendNodeMail,
- },
- bcrypt,
- },
- )
-
- let req
- let res
- let testForm
-
- beforeAll(async () => await dbHandler.connect())
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- beforeEach(async () => {
- req = {
- query: {},
- params: {},
- body: {},
- session: {
- user: {
- _id: mongoose.Types.ObjectId('000000000001'),
- email: 'test@test.gov.sg',
- },
- },
- headers: {},
- ip: '127.0.0.1',
- get: () => '',
- }
-
- res = jasmine.createSpyObj('res', ['status', 'send', 'json'])
- res.locals = {}
-
- const collections = await dbHandler.preloadCollections({
- userId: req.session.user._id,
- })
-
- // Insert test form before each test
- testForm = collections.form
- })
-
- describe('hasFormAdminAuthorization', () => {
- it('should authorize if session user is admin', () => {
- let next = jasmine.createSpy()
- // Populate admin with partial user object
- let testFormObj = testForm.toObject()
- testFormObj.admin = { _id: req.session.user._id }
- req.form = testFormObj
- Controller.verifyPermission(PermissionLevel.Delete)(req, res, next)
- expect(next).toHaveBeenCalled()
- })
- it('should authorize if session user is a collaborator', () => {
- let next = jasmine.createSpy()
- // Populate admin with partial user object
- let testFormObj = testForm.toObject()
- testFormObj.admin = { _id: mongoose.Types.ObjectId('000000000002') }
- testFormObj.permissionList.push(
- roles.collaborator(req.session.user.email),
- )
- req.form = testFormObj
- Controller.verifyPermission(PermissionLevel.Write)(req, res, next)
- expect(next).toHaveBeenCalled()
- })
- it('should not authorize if session user is not a collaborator nor admin', () => {
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN)
- return res
- })
- // Populate admin with partial user object
- let testFormObj = testForm.toObject()
- testFormObj.admin = { _id: mongoose.Types.ObjectId('000000000002') }
- req.form = testFormObj
- Controller.verifyPermission(PermissionLevel.Write)(req, res, () => {})
- })
- })
-})
diff --git a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js b/tests/unit/backend/controllers/email-submissions.server.controller.spec.js
deleted file mode 100644
index 6c07a38a4c..0000000000
--- a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js
+++ /dev/null
@@ -1,2824 +0,0 @@
-const { StatusCodes } = require('http-status-codes')
-const { times, omit } = require('lodash')
-const ejs = require('ejs')
-const { okAsync, errAsync } = require('neverthrow')
-const express = require('express')
-const request = require('supertest')
-const mongoose = require('mongoose')
-
-const dbHandler = require('../helpers/db-handler')
-
-const { ObjectID } = require('bson-ext')
-const MailService = require('../../../../dist/backend/app/services/mail/mail.service')
- .default
-const EmailSubmissionsMiddleware = require('../../../../dist/backend/app/modules/submission/email-submission/email-submission.middleware')
-const User = dbHandler.makeModel('user.server.model', 'User')
-const Agency = dbHandler.makeModel('agency.server.model', 'Agency')
-const Form = dbHandler.makeModel('form.server.model', 'Form')
-const Verification = dbHandler.makeModel(
- '../modules/verification/verification.model',
- 'Verification',
-)
-const vfnConstants = require('../../../../dist/backend/shared/util/verification')
-
-const {
- SPCPFieldTitle,
-} = require('../../../../dist/backend/types/field/fieldTypes')
-const {
- MailSendError,
- MailGenerationError,
-} = require('../../../../dist/backend/app/services/mail/mail.errors')
-
-describe('Email Submissions Controller', () => {
- // Declare global variables
- let sendSubmissionMailSpy
-
- // spec out controller such that calls to request are
- // directed through a callback to the request spy,
- // which will be destroyed and re-created for every test
- const spcpController = spec('dist/backend/app/modules/spcp/spcp.controller', {
- mongoose: Object.assign(mongoose, { '@noCallThru': true }),
- })
-
- beforeAll(async () => await dbHandler.connect())
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- describe('notifyParties', () => {
- const originalConsoleError = console.error
-
- let fixtures
-
- const endpointPath = '/send-admin-email'
- const injectFixtures = (req, res, next) => {
- Object.assign(req, fixtures)
- return next()
- }
-
- const app = express()
-
- // Set EJS as the template engine
- app.engine('server.view.html', ejs.__express)
-
- // Set views path and view engine
- app.set('view engine', 'server.view.html')
- app.set('views', './src/app/views')
-
- beforeAll(() => {
- console.error = jasmine.createSpy()
- app
- .route(endpointPath)
- .get(
- injectFixtures,
- EmailSubmissionsMiddleware.sendAdminEmail,
- (req, res) => res.status(200).send(),
- )
-
- sendSubmissionMailSpy = spyOn(MailService, 'sendSubmissionToAdmin')
- })
-
- afterAll(() => {
- console.error = originalConsoleError
- })
-
- afterEach(() => sendSubmissionMailSpy.calls.reset())
-
- beforeEach(() => {
- fixtures = {
- body: {
- parsedResponses: [],
- },
- replyToEmails: [],
- attachments: [
- {
- filename: 'file.txt',
- content: Buffer.alloc(5),
- },
- ],
- form: {
- title: 'Form Title',
- emails: ['test@test.gov.sg'],
- responseMode: 'email',
- },
- formData: [
- {
- question: 'foo',
- answerTemplate: ['bar'],
- },
- ],
- submission: {
- id: 1,
- created: Date.now(),
- },
- dataCollationData: [
- {
- question: 'Reference Number',
- answer: '123',
- },
- {
- question: 'Timestamp',
- answer: '1/2/3',
- },
- {
- question: 'foo',
- answer: 'bar',
- },
- ],
- }
- })
-
- it('sends mail with correct parameters', (done) => {
- // Arrange
- sendSubmissionMailSpy.and.callFake(() => okAsync(true))
-
- request(app)
- .get(endpointPath)
- .expect(StatusCodes.OK)
- .then(() => {
- const mailOptions = sendSubmissionMailSpy.calls.mostRecent().args[0]
- expect(mailOptions).toEqual(omit(fixtures, 'body'))
- })
- .then(done)
- .catch(done)
- })
-
- it('errors with 400 on send failure', (done) => {
- // Arrange
- sendSubmissionMailSpy.and.callFake(() => errAsync(new MailSendError()))
- // Trigger error by deleting recipient list
- delete fixtures.form.emails
- request(app)
- .get(endpointPath)
- .expect(StatusCodes.BAD_REQUEST)
- .then(done)
- .catch(done)
- })
-
- it('errors with 400 on generation failure', (done) => {
- // Arrange
- sendSubmissionMailSpy.and.callFake(() =>
- errAsync(new MailGenerationError()),
- )
- // Trigger error by deleting recipient list
- delete fixtures.form.emails
- request(app)
- .get(endpointPath)
- .expect(StatusCodes.BAD_REQUEST)
- .then(done)
- .catch(done)
- })
- })
-
- describe('receiveEmailSubmissionUsingBusBoy', () => {
- const endpointPath = '/v2/submissions/email'
- const sendSubmissionBack = (req, res) => {
- res.status(200).send({
- body: req.body,
- })
- }
-
- const app = express()
-
- const injectForm = (req, res, next) => {
- Object.assign(req, { form: { _id: 'formId' } })
- next()
- }
-
- beforeAll(() => {
- app
- .route(endpointPath)
- .post(
- injectForm,
- EmailSubmissionsMiddleware.receiveEmailSubmission,
- sendSubmissionBack,
- )
- })
-
- it('parses submissions without files', (done) => {
- const body = { responses: [] }
- request(app)
- .post(endpointPath)
- .field('body', JSON.stringify(body))
- .expect(StatusCodes.OK)
- .expect({ body })
- .end(done)
- })
-
- it('parses submissions with files', (done) => {
- const body = {
- responses: [
- {
- _id: 'receiveId',
- question: 'attachment question',
- fieldType: 'attachment',
- answer: 'govtech.jpg',
- },
- ],
- }
-
- const parsedBody = {
- responses: [
- {
- _id: 'receiveId',
- question: 'attachment question',
- fieldType: 'attachment',
- answer: 'govtech.jpg',
- filename: 'govtech.jpg',
- content: Buffer.alloc(1),
- },
- ],
- }
- request(app)
- .post(endpointPath)
- .field('body', JSON.stringify(body))
- .attach('govtech.jpg', Buffer.alloc(1), 'receiveId')
- .expect(StatusCodes.OK)
- .expect(JSON.stringify({ body: parsedBody }))
- .end(done)
- })
-
- it('changes duplicated file names', (done) => {
- const body = {
- responses: [
- {
- _id: 'attachment1',
- question: 'question 1',
- fieldType: 'attachment',
- answer: 'attachment.jpg',
- },
- {
- _id: 'attachment2',
- question: 'question 2',
- fieldType: 'attachment',
- answer: 'attachment.jpg',
- },
- ],
- }
- const parsedBody = {
- responses: [
- {
- _id: 'attachment1',
- question: 'question 1',
- fieldType: 'attachment',
- answer: '1-attachment.jpg',
- filename: '1-attachment.jpg',
- content: Buffer.alloc(1),
- },
- {
- _id: 'attachment2',
- question: 'question 2',
- fieldType: 'attachment',
- answer: 'attachment.jpg',
- filename: 'attachment.jpg',
- content: Buffer.alloc(1),
- },
- ],
- }
-
- request(app)
- .post(endpointPath)
- .field('body', JSON.stringify(body))
- .attach('attachment.jpg', Buffer.alloc(1), 'attachment1')
- .attach('attachment.jpg', Buffer.alloc(1), 'attachment2')
- .expect(StatusCodes.OK)
- .expect(JSON.stringify({ body: parsedBody }))
- .end(done)
- })
- })
-
- describe('validateSubmission', () => {
- let fixtures
-
- beforeEach(() => {
- fixtures = {
- form: new Form({
- title: 'Test Form',
- authType: 'NIL',
- responseMode: 'email',
- form_fields: [],
- }),
- body: {
- responses: [],
- },
- }
- })
-
- const endpointPath = '/v2/submissions/email'
- const injectFixtures = (req, res, next) => {
- Object.assign(req, fixtures)
- next()
- }
- const sendSubmissionBack = (req, res) => {
- res.status(200).send({
- body: req.body,
- attachments: req.attachments,
- })
- }
-
- const app = express()
-
- beforeAll(() => {
- app
- .route(endpointPath)
- .post(
- injectFixtures,
- EmailSubmissionsMiddleware.validateEmailSubmission,
- sendSubmissionBack,
- )
- })
-
- it('parses submissions without files', (done) => {
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .expect(
- JSON.stringify({
- body: {
- parsedResponses: [],
- },
- attachments: [],
- }),
- )
- .end(done)
- })
-
- it('parses submissions with attachments', (done) => {
- const requiredAttachmentId = new ObjectID()
- const optionalAttachmentId = new ObjectID()
-
- const validAttachmentName = 'valid.pdf'
-
- fixtures.form.form_fields.push({
- title: 'Attachment',
- required: true,
- fieldType: 'attachment',
- _id: requiredAttachmentId,
- attachmentSize: '1',
- })
-
- fixtures.form.form_fields.push({
- title: 'NotRequired',
- required: false,
- fieldType: 'attachment',
- _id: optionalAttachmentId,
- attachmentSize: '1',
- })
-
- fixtures.body.responses.push({
- _id: String(requiredAttachmentId),
- fieldType: 'attachment',
- answer: validAttachmentName,
- filename: validAttachmentName,
- content: Buffer.alloc(1),
- })
-
- fixtures.body.responses.push({
- _id: String(optionalAttachmentId),
- fieldType: 'attachment',
- answer: '',
- })
-
- const expectedResponses = []
-
- expectedResponses.push({
- _id: String(requiredAttachmentId),
- fieldType: 'attachment',
- answer: validAttachmentName,
- filename: validAttachmentName,
- content: Buffer.alloc(1),
- isVisible: true,
- question: 'Attachment',
- })
-
- expectedResponses.push({
- _id: String(optionalAttachmentId),
- fieldType: 'attachment',
- answer: '',
- isVisible: true,
- question: 'NotRequired',
- })
-
- request(app)
- .post(endpointPath)
- .expect(StatusCodes.OK)
- .expect(
- JSON.stringify({
- body: {
- parsedResponses: expectedResponses,
- },
- attachments: [
- {
- fieldId: String(requiredAttachmentId),
- filename: validAttachmentName,
- content: Buffer.alloc(1),
- },
- ],
- }),
- )
- .end(done)
- })
-
- it('returns 400 for attachments with invalid file exts', (done) => {
- fixtures.body.responses.push({
- title: 'Attachment',
- required: true,
- fieldType: 'attachment',
- _id: String(new ObjectID()),
- attachmentSize: '1',
- content: Buffer.alloc(1),
- filename: 'invalid.py',
- })
- request(app).post(endpointPath).expect(StatusCodes.BAD_REQUEST).end(done)
- })
-
- it('returns 400 for attachments beyond 7 million bytes', (done) => {
- fixtures.body.responses.push({
- title: 'Attachment',
- required: true,
- fieldType: 'attachment',
- _id: String(new ObjectID()),
- attachmentSize: '1',
- content: Buffer.alloc(3000000),
- filename: 'valid.jpg',
- })
- fixtures.body.responses.push({
- title: 'Attachment',
- required: true,
- fieldType: 'attachment',
- _id: String(new ObjectID()),
- attachmentSize: '1',
- content: Buffer.alloc(3000000),
- filename: 'valid.jpg',
- })
- fixtures.body.responses.push({
- title: 'Attachment',
- required: true,
- fieldType: 'attachment',
- _id: String(new ObjectID()),
- attachmentSize: '1',
- content: Buffer.alloc(3000000),
- filename: 'valid.jpg',
- })
- request(app).post(endpointPath).expect(StatusCodes.BAD_REQUEST).end(done)
- })
- })
-
- describe('prepareSubmissionForEmail', () => {
- let reqFixtures
- let resLocalFixtures
-
- beforeEach(() => {
- reqFixtures = {
- attachments: [],
- form: new Form({
- title: 'Test Form',
- authType: 'NIL',
- responseMode: 'email',
- }),
- body: { form_fields: [], responses: [] },
- }
-
- resLocalFixtures = {}
- })
-
- const endpointPath = '/v2/submissions/email'
- const injectFixtures = (req, res, next) => {
- Object.assign(req, reqFixtures)
- if (Object.entries(resLocalFixtures).length !== 0) {
- Object.assign(res, { locals: resLocalFixtures })
- }
- next()
- }
- const sendSubmissionBack = (req, res) => {
- res.status(200).send({
- formData: req.formData,
- autoReplyData: req.autoReplyData,
- dataCollationData: req.dataCollationData,
- attachments: req.attachments.map((b) => {
- b.content = b.content.toString('base64')
- return b
- }),
- })
- }
-
- const app = express()
-
- beforeAll(() => {
- app
- .route(endpointPath)
- .get(
- injectFixtures,
- EmailSubmissionsMiddleware.validateEmailSubmission,
- spcpController.appendVerifiedSPCPResponses,
- EmailSubmissionsMiddleware.prepareEmailSubmission,
- sendSubmissionBack,
- )
- })
-
- const prepareSubmissionThenCompare = (expected, done) => {
- request(app)
- .get(endpointPath)
- .expect(StatusCodes.OK)
- .then(({ body: { formData, autoReplyData, dataCollationData } }) => {
- expect(formData).withContext('Form Data').toEqual(expected.formData)
- expect(autoReplyData)
- .withContext('autoReplyData')
- .toEqual(expected.autoReplyData)
- expect(dataCollationData)
- .withContext('dataCollationData')
- .toEqual(expected.dataCollationData)
- })
- .then(done)
- .catch(done)
- }
-
- const expectStatusCodeError = (statusCode, done) => {
- request(app).get(endpointPath).expect(statusCode).then(done)
- }
- /**
- * Mock a field
- * @param {String} fieldId
- * @param {String} fieldType
- * @param {String} title
- * @param {Object} options other options that can be passed to a field in the field schema
- */
- const makeField = (fieldId, fieldType, title, options) => {
- return { _id: new ObjectID(fieldId), fieldType, title, ...options }
- }
- /**
- * Mock a response
- * @param {String} fieldId field id of the field that this response is meant for
- * @param {String} fieldType field type of the field that this response is meant for
- * @param {String} question
- * @param {String} answer
- * @param {Array} [answerArray] array of answers passed in for checkbox and table
- * @param {Boolean} [isExpectedToBeVisible] this boolean is used for computing expected output, and should match isVisible injected by server to pass the test
- */
- const makeResponse = (
- fieldId,
- fieldType,
- question,
- answer,
- answerArray = null,
- isExpectedToBeVisible = true,
- ) => {
- let response = {
- _id: String(fieldId),
- fieldType,
- question,
- answer,
- isExpectedToBeVisible,
- }
- if (answerArray) response.answerArray = answerArray
- return response
- }
-
- /**
- * Generate expected output for a table
- * @param {Object} field
- * @param {String} field.title
- * @param {String} field.fieldType
- * @param {Array} field.columns
- * @param {Object} response
- * @param {Array} response.answerArray
- * @param {Boolean} response.isExpectedToBeVisible
- */
- const getExpectedForTable = (field, response) => {
- const { answerArray, isExpectedToBeVisible } = response
- const { columns, title, fieldType } = field
- const columnTitles = columns.map(({ title }) => title)
- const autoReplyQuestion = `${title} (${columnTitles.join(', ')})`
- const question = `[table] ${autoReplyQuestion}`
- let expected = {
- autoReplyData: [],
- formData: [],
- dataCollationData: [],
- }
- for (let answer of answerArray) {
- answer = String(answer)
- const answerTemplate = answer.split('\n')
- if (isExpectedToBeVisible) {
- // Auto replies only show visible data
- expected.autoReplyData.push({
- question: autoReplyQuestion,
- answerTemplate,
- })
- }
- expected.dataCollationData.push({
- question,
- answer,
- })
- expected.formData.push({
- question,
- answerTemplate,
- answer,
- fieldType,
- })
- }
- return expected
- }
-
- /**
- * Generate expected output
- * @param {Array} fields
- * @param {Array} responses
- * @returns {Object} { autoReplyData: Array, formData: Array, dataCollationData: Array }
- */
- const getExpectedOutput = (fields, responses) => {
- let expected = {
- autoReplyData: [],
- formData: [],
- dataCollationData: [],
- }
- for (let i = 0; i < fields.length; i++) {
- const answer = String(responses[i].answer)
- const answerTemplate = answer.split('\n')
- if (fields[i].fieldType === 'table') {
- const expectedTable = getExpectedForTable(fields[i], responses[i])
- expected.autoReplyData.push(...expectedTable.autoReplyData)
- expected.dataCollationData.push(...expectedTable.dataCollationData)
- expected.formData.push(...expectedTable.formData)
- } else {
- let question = fields[i].title
- if (responses[i].isExpectedToBeVisible) {
- // Auto replies only show visible data
- expected.autoReplyData.push({
- question,
- answerTemplate,
- })
- }
- if (fields[i].fieldType !== 'section') {
- expected.dataCollationData.push({
- question,
- answer,
- })
- }
- expected.formData.push({
- question,
- answerTemplate,
- answer,
- fieldType: fields[i].fieldType,
- })
- }
- }
- return expected
- }
-
- it('maps SingPass attributes', (done) => {
- resLocalFixtures.uinFin = 'S1234567A'
- reqFixtures.form.authType = 'SP'
- const expectedFormData = [
- {
- question: SPCPFieldTitle.SpNric,
- answerTemplate: [resLocalFixtures.uinFin],
- answer: resLocalFixtures.uinFin,
- fieldType: 'nric',
- },
- ]
- const expectedAutoReplyData = [
- {
- question: SPCPFieldTitle.SpNric,
- answerTemplate: [resLocalFixtures.uinFin],
- },
- ]
- const expectedDataCollationData = [
- {
- question: SPCPFieldTitle.SpNric,
- answer: resLocalFixtures.uinFin,
- },
- ]
- const expected = {
- formData: expectedFormData,
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('maps CorpPass attributes', (done) => {
- resLocalFixtures.uinFin = '123456789K'
- resLocalFixtures.userInfo = 'S1234567A'
- const maskedCpUid = '*****567A'
- reqFixtures.form.authType = 'CP'
- const expectedFormData = [
- {
- question: SPCPFieldTitle.CpUen,
- answerTemplate: [resLocalFixtures.uinFin],
- answer: resLocalFixtures.uinFin,
- fieldType: 'textfield',
- },
- {
- question: SPCPFieldTitle.CpUid,
- answerTemplate: [resLocalFixtures.userInfo],
- answer: resLocalFixtures.userInfo,
- fieldType: 'nric',
- },
- ]
- const expectedAutoReplyData = [
- {
- question: SPCPFieldTitle.CpUen,
- answerTemplate: [resLocalFixtures.uinFin],
- },
- {
- question: SPCPFieldTitle.CpUid,
- answerTemplate: [maskedCpUid],
- },
- ]
- const expectedDataCollationData = [
- {
- question: SPCPFieldTitle.CpUen,
- answer: resLocalFixtures.uinFin,
- },
- {
- question: SPCPFieldTitle.CpUid,
- answer: resLocalFixtures.userInfo,
- },
- ]
- const expected = {
- formData: expectedFormData,
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('prefixes MyInfo fields with [MyInfo]', (done) => {
- const fieldId = new ObjectID()
-
- const attr = 'passportnumber'
- resLocalFixtures.hashedFields = new Set([fieldId.toHexString()])
- const responseField = {
- _id: String(fieldId),
- question: 'myinfo',
- fieldType: 'textfield',
- isHeader: false,
- answer: 'bar',
- myInfo: { attr },
- }
- reqFixtures.body.responses.push(responseField)
- reqFixtures.form.form_fields.push({
- _id: fieldId,
- title: 'myinfo',
- fieldType: 'textfield',
- required: true,
- myInfo: { attr },
- })
- const expectedDataCollationData = [
- {
- question: 'myinfo',
- answer: 'bar',
- },
- ]
- const expectedFormData = [
- {
- question: '[MyInfo] myinfo',
- answerTemplate: ['bar'],
- answer: 'bar',
- fieldType: 'textfield',
- },
- ]
- const expectedAutoReplyData = [
- {
- question: 'myinfo',
- answerTemplate: ['bar'],
- },
- ]
- const expected = {
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- formData: expectedFormData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('maps text if isVisible', (done) => {
- const fieldId = new ObjectID()
- reqFixtures.body.responses.push({
- _id: String(fieldId),
- question: 'regular',
- fieldType: 'textfield',
- isHeader: false,
- answer: 'foo',
- })
- reqFixtures.form.form_fields.push({
- _id: fieldId,
- title: 'regular',
- fieldType: 'textfield',
- })
- const expectedAutoReplyData = [
- {
- question: 'regular',
- answerTemplate: ['foo'],
- },
- ]
- const expectedDataCollationData = [
- {
- question: 'regular',
- answer: 'foo',
- },
- ]
- const expectedFormData = [
- {
- question: 'regular',
- answerTemplate: ['foo'],
- answer: 'foo',
- fieldType: 'textfield',
- },
- ]
- const expected = {
- formData: expectedFormData,
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('excludes field if isVisible is false for autoReplyData', (done) => {
- const nonVisibleField = {
- _id: new ObjectID(),
- title: 'not visible to autoReplyData',
- fieldType: 'textfield',
- }
- const yesNoField = {
- _id: new ObjectID(),
- title: 'Show textfield if this field is yes',
- fieldType: 'yes_no',
- }
- const nonVisibleResponse = {
- _id: String(nonVisibleField._id),
- question: nonVisibleField.title,
- fieldType: nonVisibleField.fieldType,
- isHeader: false,
- answer: '',
- }
- const yesNoResponse = {
- _id: String(yesNoField._id),
- question: yesNoField.title,
- fieldType: yesNoField.fieldType,
- isHeader: false,
- answer: 'No',
- }
-
- reqFixtures.body.responses.push(nonVisibleResponse)
- reqFixtures.body.responses.push(yesNoResponse)
- reqFixtures.form.form_fields.push(nonVisibleField)
- reqFixtures.form.form_fields.push(yesNoField)
- reqFixtures.form.form_logics.push({
- show: [nonVisibleField._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: yesNoField._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- })
- const expectedDataCollationData = [
- {
- question: nonVisibleField.title,
- answer: nonVisibleResponse.answer,
- },
- {
- question: yesNoField.title,
- answer: yesNoResponse.answer,
- },
- ]
- const expectedFormData = [
- {
- question: nonVisibleField.title,
- answerTemplate: [nonVisibleResponse.answer],
- answer: nonVisibleResponse.answer,
- fieldType: nonVisibleField.fieldType,
- },
- {
- question: yesNoField.title,
- answerTemplate: [yesNoResponse.answer],
- answer: yesNoResponse.answer,
- fieldType: yesNoField.fieldType,
- },
- ]
- const expectedAutoReplyData = [
- {
- question: yesNoField.title,
- answerTemplate: [yesNoResponse.answer],
- },
- ]
- const expected = {
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- formData: expectedFormData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('prefixes attachment fields with [attachment]', (done) => {
- const validAttachmentName = 'valid.pdf'
- const fieldId = new ObjectID()
- reqFixtures.body.responses.push({
- _id: String(fieldId),
- question: 'an attachment',
- fieldType: 'attachment',
- isHeader: false,
- answer: validAttachmentName,
- filename: validAttachmentName,
- content: Buffer.alloc(1),
- })
- reqFixtures.form.form_fields.push({
- _id: fieldId,
- title: 'an attachment',
- fieldType: 'attachment',
- attachmentSize: '1',
- })
- const expectedDataCollationData = [
- {
- question: '[attachment] an attachment',
- answer: validAttachmentName,
- },
- ]
- const expectedFormData = [
- {
- question: '[attachment] an attachment',
- answerTemplate: [validAttachmentName],
- answer: validAttachmentName,
- fieldType: 'attachment',
- },
- ]
- const expectedAutoReplyData = [
- {
- question: 'an attachment',
- answerTemplate: [validAttachmentName],
- },
- ]
- const expected = {
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- formData: expectedFormData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('prefixes table fields with [table]', (done) => {
- const fieldId = new ObjectID()
- reqFixtures.body.responses.push({
- _id: String(fieldId),
- question: 'a table',
- fieldType: 'table',
- isHeader: false,
- answerArray: [['', '']],
- })
- reqFixtures.form.form_fields.push({
- _id: fieldId,
- title: 'a table',
- fieldType: 'table',
- columns: [
- { title: 'Name', fieldType: 'textfield' },
- { title: 'Age', fieldType: 'textfield' },
- ],
- minimumRows: 1,
- })
- const expectedDataCollationData = [
- {
- question: '[table] a table (Name, Age)',
- answer: ',',
- },
- ]
- const expectedFormData = [
- {
- question: '[table] a table (Name, Age)',
- answerTemplate: [','],
- answer: ',',
- fieldType: 'table',
- },
- ]
- const expectedAutoReplyData = [
- {
- question: 'a table (Name, Age)',
- answerTemplate: [','],
- },
- ]
- const expected = {
- autoReplyData: expectedAutoReplyData,
- dataCollationData: expectedDataCollationData,
- formData: expectedFormData,
- }
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('selects only first response for each form field', (done) => {
- const fieldIds = times(15, () => String(new ObjectID()))
- const responseIds = fieldIds.map((id) => String(id))
-
- const fields = [
- makeField(fieldIds[0], 'section', 'Title for section'),
- makeField(fieldIds[1], 'radiobutton', 'Title for radiobutton', {
- fieldOptions: ['rb1', 'rb2'],
- }),
- makeField(fieldIds[2], 'dropdown', 'Title for dropdown', {
- fieldOptions: ['db1', 'db2'],
- }),
- makeField(fieldIds[3], 'email', 'Title for email', {
- autoReplyOptions: { hasAutoReply: false },
- }),
- makeField(fieldIds[4], 'table', 'Title for table', {
- minimumRows: 2,
- addMoreRows: false,
- maximumRows: 2,
- columns: [
- {
- title: 'Some dropdown',
- required: true,
- _id: '5ca5d548ddafb6c289893537',
- columnType: 'dropdown',
- fieldOptions: ['Option 1', 'Option 2'],
- },
- {
- title: 'Some textfield',
- required: true,
- _id: '5ca5d548ddafb6c289893538',
- columnType: 'textfield',
- },
- ],
- }),
- makeField(fieldIds[5], 'number', 'Title for number'),
- makeField(fieldIds[6], 'textfield', 'Title for textfield'),
- makeField(fieldIds[7], 'textarea', 'Title for textarea'),
- makeField(fieldIds[8], 'decimal', 'Title for decimal'),
- makeField(fieldIds[9], 'nric', 'Title for nric'),
- makeField(fieldIds[10], 'yes_no', 'Title for yes_no'),
- makeField(fieldIds[11], 'mobile', 'Title for mobile'),
- makeField(fieldIds[12], 'checkbox', 'Title for checkbox', {
- fieldOptions: ['cb1', 'cb2', 'cb3'],
- }),
- makeField(fieldIds[13], 'rating', 'Title for rating', {
- ratingOptions: { steps: 5, shape: 'Heart' },
- }),
- makeField(fieldIds[14], 'date', 'Title for date'),
- ]
- const responses = [
- makeResponse(responseIds[0], 'section', 'Title for section', ''),
- makeResponse(
- responseIds[1],
- 'radiobutton',
- 'Title for radiobutton',
- 'rb1',
- ),
- makeResponse(responseIds[2], 'dropdown', 'Title for dropdown', 'db1'),
- makeResponse(responseIds[3], 'email', 'Title for email', 'abc@abc.com'),
- makeResponse(
- responseIds[4],
- 'table',
- 'Title for table',
- 'Option 1, text 1',
- [
- ['Option 1', 'text 1'],
- ['Option 1', 'text 2'],
- ],
- ),
- makeResponse(responseIds[5], 'number', 'Title for number', '9000'),
- makeResponse(
- responseIds[6],
- 'textfield',
- 'Title for textfield',
- 'hola',
- ),
- makeResponse(responseIds[7], 'textarea', 'Title for textarea', 'ciao'),
- makeResponse(responseIds[8], 'decimal', 'Title for decimal', '10.1'),
- makeResponse(responseIds[9], 'nric', 'Title for nric', 'S9912345A'),
- makeResponse(responseIds[10], 'yes_no', 'Title for yes_no', 'Yes'),
- makeResponse(
- responseIds[11],
- 'mobile',
- 'Title for mobile',
- '+6583838383',
- ),
- makeResponse(
- responseIds[12],
- 'checkbox',
- 'Title for checkbox',
- 'cb1, cb2, cb3',
- ['cb1', 'cb2', 'cb3'],
- ),
- makeResponse(responseIds[13], 'rating', 'Title for rating', '5'),
- makeResponse(responseIds[14], 'date', 'Title for date', '15 Nov 2019'),
- ]
-
- const extra = [
- // Add extra responses
- makeResponse(responseIds[0], 'section', 'Title for section', ''),
- makeResponse(
- responseIds[1],
- 'radiobutton',
- 'Title for radiobutton',
- 'rb2',
- ),
- makeResponse(responseIds[2], 'dropdown', 'Title for dropdown', 'db2'),
- makeResponse(responseIds[3], 'email', 'Title for email', 'xyz@xyz.com'),
- makeResponse(
- responseIds[4],
- 'table',
- 'Title for table',
- 'Option 1, text 2',
- [
- ['Option 1', 'text 1'],
- ['Option 1', 'text 2'],
- ],
- ),
- makeResponse(
- responseIds[4],
- 'table',
- 'Title for table',
- 'Option 2, text 3',
- [
- ['Option 1', 'text 1'],
- ['Option 1', 'text 2'],
- ['Option 2', 'text 3'],
- ['Option 2', 'text 4'],
- ],
- ),
- makeResponse(
- responseIds[4],
- 'table',
- 'Title for table',
- 'Option 2, text 4',
- [
- ['Option 1', 'text 1'],
- ['Option 1', 'text 2'],
- ['Option 2', 'text 3'],
- ['Option 2', 'text 4'],
- ],
- ),
- makeResponse(responseIds[5], 'number', 'Title for number', '9999'),
- makeResponse(
- responseIds[6],
- 'textfield',
- 'Title for textfield',
- 'hello',
- ),
- makeResponse(
- responseIds[7],
- 'textarea',
- 'Title for textarea',
- 'byebye',
- ),
- makeResponse(responseIds[8], 'decimal', 'Title for decimal', '202.12'),
- makeResponse(responseIds[9], 'nric', 'Title for nric', 'S9634214D'),
- makeResponse(responseIds[10], 'yes_no', 'Title for yes_no', 'No'),
- makeResponse(
- responseIds[11],
- 'mobile',
- 'Title for mobile',
- '+6584848484',
- ),
- makeResponse(responseIds[12], 'checkbox', 'Title for checkbox', 'cb3', [
- 'cb3',
- ]),
- makeResponse(responseIds[13], 'rating', 'Title for rating', '1'),
- makeResponse(responseIds[14], 'date', 'Title for date', '15 Dec 2019'),
- ]
-
- const expected = getExpectedOutput(fields, responses)
- reqFixtures.form.form_fields = fields
- reqFixtures.body.responses = responses.concat(extra)
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('ignores statement and image fields in submission', (done) => {
- const fieldIds = times(4, () => new ObjectID())
- const responseIds = fieldIds.map((id) => String(id))
- const fields = [
- { _id: fieldIds[0], fieldType: 'section', title: 'Welcome to my form' },
- { _id: fieldIds[1], fieldType: 'statement', title: 'Hello there' },
- {
- _id: fieldIds[2],
- fieldType: 'image',
- title: 'Does image even have a title?',
- url: 'http://myimage.com/image.jpg',
- },
- { _id: fieldIds[3], fieldType: 'number', title: 'Lottery number' },
- ]
- const responses = [
- {
- _id: responseIds[0],
- fieldType: 'section',
- question: 'Welcome to my form',
- answer: '',
- },
- {
- _id: responseIds[1],
- fieldType: 'statement',
- question: 'Hello there',
- answer: '',
- },
- {
- _id: responseIds[2],
- fieldType: 'image',
- question: 'Does image even have a title?',
- answer: '',
- },
- {
- _id: responseIds[3],
- fieldType: 'number',
- question: 'Lottery number',
- answer: '37',
- },
- ]
- let expected = {
- autoReplyData: [],
- formData: [],
- dataCollationData: [],
- }
- for (let i = 0; i < fields.length; i++) {
- let { fieldType, title } = fields[i]
- let { answer } = responses[i]
- if (!['image', 'statement'].includes(fieldType)) {
- expected.autoReplyData.push({
- question: title,
- answerTemplate: String(answer).split('\n'),
- })
- if (fieldType !== 'section') {
- expected.dataCollationData.push({
- question: title,
- answer: String(answer),
- })
- }
- expected.formData.push({
- question: title,
- answerTemplate: String(answer).split('\n'),
- answer,
- fieldType,
- })
- }
- }
- reqFixtures.form.form_fields = fields
- reqFixtures.body.responses = responses
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('errors with 409 when some fields are not submitted', (done) => {
- const fields = [
- { _id: '1', fieldType: 'section', title: 'Welcome to my form' },
- { _id: '2', fieldType: 'number', title: 'Lottery number' },
- ]
- const responses = [
- {
- _id: '1',
- fieldType: 'section',
- question: 'Welcome to my form',
- answer: '',
- },
- ]
- reqFixtures.form.form_fields = fields
- reqFixtures.body.responses = responses
- request(app).get(endpointPath).expect(StatusCodes.CONFLICT).then(done)
- })
-
- describe('Logic', () => {
- describe('Single-select value', () => {
- const conditionField = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show text field if yes',
- )
- const logicField = makeField(new ObjectID(), 'textfield', 'Text field')
- const visField = makeField(new ObjectID(), 'nric', 'Nric field')
- const fields = [conditionField, logicField, visField]
- const conditions = [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: conditionField._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ]
- const _id = '5db00a15af2ffb29487d4eb1'
- const showFieldLogics = [
- {
- show: [logicField._id],
- conditions,
- _id,
- logicType: 'showFields',
- },
- ]
- const preventSubmitLogics = [
- {
- show: [],
- conditions,
- _id,
- preventSubmitMessage: 'test',
- logicType: 'preventSubmit',
- },
- ]
- const makeSingleSelectFixtures = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- conditionFieldVal,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? 'lorem' : '',
- null,
- expectLogicFieldVisible,
- ),
- makeResponse(
- visField._id,
- visField.fieldType,
- visField.title,
- 'S9912345A',
- ), // This field is always visible
- ]
- }
-
- const singleSelectSuccessTest = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- done,
- ) => {
- makeSingleSelectFixtures(
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
-
- it('hides logic fields for single select value', (done) => {
- // Does not fulfill condition, hence second field hidden
- singleSelectSuccessTest(showFieldLogics, 'No', false, done)
- })
-
- it('shows logic fields for single select value', (done) => {
- // Fulfills condition, hence second field shown
- singleSelectSuccessTest(showFieldLogics, 'Yes', true, done)
- })
-
- it('allows submission when not prevented by single select value', (done) => {
- // Does not fulfill condition, hence submission goes through
- singleSelectSuccessTest(preventSubmitLogics, 'No', true, done)
- })
-
- it('rejects submission prevented by single select value', (done) => {
- // Fulfills condition, hence submission rejected
- makeSingleSelectFixtures(preventSubmitLogics, 'Yes', true)
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('Number value', () => {
- const conditionField = makeField(
- new ObjectID(),
- 'number',
- 'Show text field if less than 10',
- )
- const logicField = makeField(new ObjectID(), 'textfield', 'Text field')
- const visField = makeField(new ObjectID(), 'nric', 'Nric field')
- const fields = [conditionField, logicField, visField]
- const conditions = [
- {
- ifValueType: 'number',
- _id: '58169',
- field: conditionField._id,
- state: 'is less than or equal to',
- value: 10,
- },
- ]
- const _id = '5db00a15af2ffb29487d4eb1'
- const showFieldLogics = [
- {
- show: [logicField._id],
- conditions,
- _id,
- logicType: 'showFields',
- },
- ]
- const preventSubmitLogics = [
- {
- show: [],
- conditions,
- _id,
- preventSubmitMessage: 'test',
- logicType: 'preventSubmit',
- },
- ]
- const makeNumberValueFixtures = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- conditionFieldVal,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? 'lorem' : '',
- null,
- expectLogicFieldVisible,
- ),
- makeResponse(
- visField._id,
- visField.fieldType,
- visField.title,
- 'S9912345A',
- ), // This field is always visible
- ]
- }
- const numberValueSuccessTest = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- done,
- ) => {
- makeNumberValueFixtures(
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
- it('hides logic fields for number value', (done) => {
- // Second field hidden
- numberValueSuccessTest(showFieldLogics, '11', false, done)
- })
-
- it('shows logic for number value', (done) => {
- // Second field shown
- numberValueSuccessTest(showFieldLogics, '9', true, done)
- })
-
- it('accepts submission not prevented by number value', (done) => {
- // Condition not fulfilled, form goes through
- numberValueSuccessTest(preventSubmitLogics, '11', true, done)
- })
-
- it('rejects submission prevented by number value', (done) => {
- makeNumberValueFixtures(preventSubmitLogics, '9', true)
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('Multi-select value', () => {
- const conditionField = makeField(
- new ObjectID(),
- 'dropdown',
- 'Show text field if value is Option 1 or Option 2',
- {
- fieldOptions: ['Option 1', 'Option 2', 'Option 3'],
- },
- )
- const logicField = makeField(new ObjectID(), 'textfield', 'Text field')
- const visField = makeField(new ObjectID(), 'nric', 'Nric field')
- const fields = [conditionField, logicField, visField]
- const conditions = [
- {
- ifValueType: 'multi-select',
- _id: '58169',
- field: conditionField._id,
- state: 'is either',
- value: ['Option 1', 'Option 2'],
- },
- ]
- const _id = '5db00a15af2ffb29487d4eb1'
- const showFieldLogics = [
- {
- show: [logicField._id],
- conditions,
- _id,
- logicType: 'showFields',
- },
- ]
- const preventSubmitLogics = [
- {
- show: [],
- conditions,
- _id,
- preventSubmitMessage: 'test',
- logicType: 'preventSubmit',
- },
- ]
- const makeMultiSelectFixtures = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- conditionFieldVal,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? 'lorem' : '',
- null,
- expectLogicFieldVisible,
- ),
- makeResponse(
- visField._id,
- visField.fieldType,
- visField.title,
- 'S9912345A',
- ), // This field is always visible
- ]
- }
- const multiSelectSuccessTest = (
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- done,
- ) => {
- makeMultiSelectFixtures(
- logics,
- conditionFieldVal,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
-
- it('hides logic fields for multi-select value', (done) => {
- multiSelectSuccessTest(showFieldLogics, 'Option 3', false, done)
- })
-
- it('shows logic for multi-select value', (done) => {
- multiSelectSuccessTest(showFieldLogics, 'Option 1', true, done)
- })
-
- it('allows submission not prevented by logic', (done) => {
- multiSelectSuccessTest(preventSubmitLogics, 'Option 3', true, done)
- })
-
- it('rejects submissions prevented by logic', (done) => {
- makeMultiSelectFixtures(preventSubmitLogics, 'Option 1', true)
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('supports multiple AND conditions', () => {
- const conditionField1 = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show text field if yes',
- )
- const conditionField2 = makeField(
- new ObjectID(),
- 'dropdown',
- 'Show text field if dropdown says Textfield',
- {
- fieldOptions: ['Textfield', 'Radiobutton', 'Email'],
- },
- )
- const logicField = makeField(new ObjectID(), 'textfield', 'Text field')
- const fields = [conditionField1, conditionField2, logicField]
- const conditions = [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- {
- ifValueType: 'single-select',
- _id: '45633',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Textfield',
- },
- ]
- const _id = '5df11ee1e6b6e7108a939c8a'
- const showFieldLogics = [
- {
- show: [logicField._id],
- conditions,
- _id,
- logicType: 'showFields',
- },
- ]
- const preventSubmitLogics = [
- {
- show: [logicField._id],
- conditions,
- _id,
- preventSubmitMessage: 'test',
- logicType: 'preventSubmit',
- },
- ]
- const makeMultiAndFixtures = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- conditionField1Val,
- ),
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- conditionField2Val,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? 'lorem' : '',
- null,
- expectLogicFieldVisible,
- ),
- ]
- }
- const multiAndSuccessTest = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- done,
- ) => {
- makeMultiAndFixtures(
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
-
- it('hides logic fields if any condition is not fulfilled', (done) => {
- multiAndSuccessTest(
- showFieldLogics,
- 'Yes',
- 'Radiobutton',
- false,
- done,
- )
- })
-
- it('shows logic fields if every condition is fulfilled', (done) => {
- multiAndSuccessTest(showFieldLogics, 'Yes', 'Textfield', true, done)
- })
-
- it('discards invalid logic when a condition is missing', (done) => {
- reqFixtures.form.form_fields = [conditionField2, logicField] // Missing conditionField1
- reqFixtures.form.form_logics = showFieldLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- 'Radiobutton',
- ), // Does not fulfill condition
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- 'lorem',
- null,
- true,
- ), // This field should be shown because logic has been discarded
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('accepts submissions not prevented by logic', (done) => {
- multiAndSuccessTest(
- preventSubmitLogics,
- 'Yes',
- 'Radiobutton',
- true,
- done,
- )
- })
-
- it('rejects submissions prevented by logic', (done) => {
- makeMultiAndFixtures(preventSubmitLogics, 'Yes', 'Textfield', true)
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('supports multiple OR conditions', () => {
- const conditionField1 = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show text field if yes',
- )
- const conditionField2 = makeField(
- new ObjectID(),
- 'dropdown',
- 'Show text field if dropdown says Textfield',
- {
- fieldOptions: ['Textfield', 'Radiobutton', 'Email'],
- },
- )
- const logicField = makeField(new ObjectID(), 'textfield', 'Text field')
- const fields = [conditionField1, conditionField2, logicField]
- const conditionses = [
- [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- [
- {
- ifValueType: 'single-select',
- _id: '89906',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Textfield',
- },
- ],
- ]
- const _ids = ['5df11ee1e6b6e7108a939c8a', '5df127a2e6b6e7108a939c90']
- const showFieldLogics = _.zipWith(
- conditionses,
- _ids,
- (conditions, _id) => {
- return {
- show: [logicField._id],
- conditions,
- _id,
- logicType: 'showFields',
- }
- },
- )
- const preventSubmitLogics = _.zipWith(
- conditionses,
- _ids,
- (conditions, _id) => {
- return { show: [], conditions, _id, logicType: 'preventSubmit' }
- },
- )
- const makeOrFixtures = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- conditionField1Val,
- ),
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- conditionField2Val,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? 'lorem' : '',
- null,
- expectLogicFieldVisible,
- ),
- ]
- }
- const orSuccessTest = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- done,
- ) => {
- makeOrFixtures(
- logics,
- conditionField1Val,
- conditionField2Val,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
-
- it('hides logic fields if every condition is not fulfilled', (done) => {
- orSuccessTest(showFieldLogics, 'No', 'Radiobutton', false, done)
- })
-
- it('shows logic fields if any condition is fulfilled', (done) => {
- orSuccessTest(showFieldLogics, 'Yes', 'Radiobutton', true, done)
- })
-
- it('accepts submission not prevented by logic', (done) => {
- orSuccessTest(preventSubmitLogics, 'No', 'Radiobutton', true, done)
- })
-
- it('rejects submission prevented by logic', (done) => {
- makeOrFixtures(preventSubmitLogics, 'Yes', 'Radiobutton', true)
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('supports multiple showable fields', () => {
- const conditionField = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show text field if yes',
- )
- const logicField1 = makeField(new ObjectID(), 'textfield', 'Text field')
- const logicField2 = makeField(
- new ObjectID(),
- 'textarea',
- 'Long text field',
- )
- const fields = [conditionField, logicField1, logicField2]
- const formLogics = [
- {
- show: [logicField1._id, logicField2._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: conditionField._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ]
- it('hides multiple logic fields when condition is not fulfilled', (done) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- 'No',
- ), // Does not fulfill condition
- makeResponse(
- logicField1._id,
- logicField1.fieldType,
- logicField1.title,
- '',
- null,
- false,
- ), // This field should be hidden
- makeResponse(
- logicField2._id,
- logicField2.fieldType,
- logicField2.title,
- '',
- null,
- false,
- ), // This field should be hidden
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('shows multiple logic fields when condition is fulfilled', (done) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- 'Yes',
- ), // Fulfills condition
- makeResponse(
- logicField1._id,
- logicField1.fieldType,
- logicField1.title,
- 'lorem',
- null,
- true,
- ), // This field should be shown
- makeResponse(
- logicField2._id,
- logicField2.fieldType,
- logicField2.title,
- 'ipsum',
- null,
- true,
- ), // This field should be shown
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('should hide unfulfilled logic field even when some of the logic fields are missing', (done) => {
- reqFixtures.form.form_fields = [conditionField, logicField1] // Missing logicField2
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- 'No',
- ), // Does not fulfill condition
- makeResponse(
- logicField1._id,
- logicField1.fieldType,
- logicField1.title,
- '',
- null,
- false,
- ), // This field should be hidden
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
-
- it('should show fulfilled logic field even when some of the logic fields are missing', (done) => {
- reqFixtures.form.form_fields = [conditionField, logicField1] // Missing logicField2
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField._id,
- conditionField.fieldType,
- conditionField.title,
- 'Yes',
- ), // Fulfills condition
- makeResponse(
- logicField1._id,
- logicField1.fieldType,
- logicField1.title,
- 'lorem',
- null,
- true,
- ), // This field should be shown
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
- })
-
- describe('supports chained logic', () => {
- const conditionField1 = makeField(
- new ObjectID(),
- 'rating',
- 'Show radio if rating is more than or equal 2',
- {
- ratingOptions: {
- steps: 5,
- shape: 'Heart',
- },
- },
- )
- const conditionField2 = makeField(
- new ObjectID(),
- 'radiobutton',
- 'Show date if radio is others',
- {
- fieldOptions: ['Option 1', 'Option 2'],
- othersRadioButton: true,
- },
- )
- const logicField = makeField(new ObjectID(), 'date', 'Date field')
- const fields = [conditionField1, conditionField2, logicField]
- const showFieldLogics = [
- {
- show: [conditionField2._id],
- _id: '5df12cf8e6b6e7108a939c99',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '57184',
- field: conditionField1._id,
- state: 'is more than or equal to',
- value: 2,
- },
- ],
- logicType: 'showFields',
- },
- {
- show: [logicField._id],
- _id: '5df12d0ee6b6e7108a939c9a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '48323',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- logicType: 'showFields',
- },
- ]
- const preventSubmitLogics = [
- {
- show: [conditionField2._id],
- _id: '5df12cf8e6b6e7108a939c99',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '57184',
- field: conditionField1._id,
- state: 'is more than or equal to',
- value: 2,
- },
- ],
- logicType: 'showFields',
- },
- {
- show: [],
- _id: '5df12d0ee6b6e7108a939c9a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '48323',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- logicType: 'preventSubmit',
- },
- ]
- const makeChainedFixtures = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectedField2Visible,
- expectLogicFieldVisible,
- ) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = logics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- conditionField1Val,
- ),
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- expectedField2Visible ? conditionField2Val : '',
- null,
- expectedField2Visible,
- ),
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- expectLogicFieldVisible ? '12 Dec 2019' : '',
- null,
- expectLogicFieldVisible,
- ),
- ]
- }
- const chainedSuccessTest = (
- logics,
- conditionField1Val,
- conditionField2Val,
- expectedField2Visible,
- expectLogicFieldVisible,
- done,
- ) => {
- makeChainedFixtures(
- logics,
- conditionField1Val,
- conditionField2Val,
- expectedField2Visible,
- expectLogicFieldVisible,
- )
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- }
- it('shows chained logic', (done) => {
- chainedSuccessTest(
- showFieldLogics,
- '2',
- 'Others: peas',
- true,
- true,
- done,
- )
- })
-
- it('hides chained logic', (done) => {
- chainedSuccessTest(
- showFieldLogics,
- '1',
- 'Others: peas',
- false,
- false,
- done,
- )
- })
-
- it('accepts submission not prevented by chained logic', (done) => {
- chainedSuccessTest(
- preventSubmitLogics,
- '2',
- 'Option 1',
- true,
- true,
- done,
- )
- })
-
- it('rejects submission prevented by chained logic', (done) => {
- makeChainedFixtures(
- preventSubmitLogics,
- '2',
- 'Others: peas',
- true,
- true,
- )
- expectStatusCodeError(StatusCodes.BAD_REQUEST, done)
- })
- })
-
- describe('supports logic regardless of field order', () => {
- const conditionField1 = makeField(
- new ObjectID(),
- 'rating',
- 'Show radio if rating is more than or equal 2',
- {
- ratingOptions: {
- steps: 5,
- shape: 'Heart',
- },
- },
- )
- const conditionField2 = makeField(
- new ObjectID(),
- 'radiobutton',
- 'Show date if radio is others',
- {
- fieldOptions: ['Option 1', 'Option 2'],
- othersRadioButton: true,
- },
- )
- const logicField = makeField(new ObjectID(), 'date', 'Date field')
- const fields = [conditionField1, logicField, conditionField2]
- const formLogics = [
- {
- show: [conditionField2._id],
- _id: '5df12cf8e6b6e7108a939c99',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '57184',
- field: conditionField1._id,
- state: 'is more than or equal to',
- value: 2,
- },
- ],
- logicType: 'showFields',
- },
- {
- show: [logicField._id],
- _id: '5df12d0ee6b6e7108a939c9a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '48323',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Others',
- },
- ],
- logicType: 'showFields',
- },
- ]
- it('shows logic regardless of field order', (done) => {
- reqFixtures.form.form_fields = fields
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- '2',
- ), // Fulfills condition
- makeResponse(
- logicField._id,
- logicField.fieldType,
- logicField.title,
- '12 Dec 2019',
- null,
- true,
- ), // This field should be shown
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- 'Others: peas',
- ), // Fulfills condition
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
- })
- describe('circular logic', () => {
- const conditionField1 = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show field 2 if yes',
- )
- const conditionField2 = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show field 3 if yes',
- )
- const conditionField3 = makeField(
- new ObjectID(),
- 'yes_no',
- 'Show field 1 if yes',
- )
- const visibleField = makeField(
- new ObjectID(),
- 'textfield',
- 'Text field',
- )
- const formLogics = [
- {
- show: [conditionField2._id],
- _id: '5df11ee1e6b6e7108a939c8a',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField1._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- logicType: 'showFields',
- },
- {
- show: [conditionField3._id],
- _id: '5df11ee1e6b6e7108a939c8b',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9577',
- field: conditionField2._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- logicType: 'showFields',
- },
- {
- show: [conditionField1._id],
- _id: '5df11ee1e6b6e7108a939c8c',
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '9578',
- field: conditionField3._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- logicType: 'showFields',
- },
- ]
- it('correctly parses circular logic where all fields are hidden', (done) => {
- reqFixtures.form.form_fields = [
- conditionField1,
- conditionField2,
- conditionField3,
- ]
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- '',
- null,
- false,
- ), // Circular, never shown
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- '',
- null,
- false,
- ), // Circular, never shown
- makeResponse(
- conditionField3._id,
- conditionField3.fieldType,
- conditionField3.title,
- '',
- null,
- false,
- ), // Circular, never shown
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
- it('correctly parses circular logic for a mix of hidden and shown fields', (done) => {
- reqFixtures.form.form_fields = [
- conditionField1,
- conditionField2,
- conditionField3,
- visibleField,
- ]
- reqFixtures.form.form_logics = formLogics
- reqFixtures.body.responses = [
- makeResponse(
- conditionField1._id,
- conditionField1.fieldType,
- conditionField1.title,
- '',
- null,
- false,
- ), // Circular, never shown
- makeResponse(
- conditionField2._id,
- conditionField2.fieldType,
- conditionField2.title,
- '',
- null,
- false,
- ), // Circular, never shown
- makeResponse(
- conditionField3._id,
- conditionField3.fieldType,
- conditionField3.title,
- '',
- null,
- false,
- ), // Circular, never shown
- makeResponse(
- visibleField._id,
- visibleField.fieldType,
- visibleField.title,
- 'lorem',
- ), // Always shown
- ]
- const expected = getExpectedOutput(
- reqFixtures.form.form_fields,
- reqFixtures.body.responses,
- )
- prepareSubmissionThenCompare(expected, done)
- })
- })
- })
- })
-
- describe('Verified fields', () => {
- let fixtures
- let testAgency, testUser, testForm
-
- const expireAt = new Date()
- expireAt.setTime(
- expireAt.getTime() + vfnConstants.TRANSACTION_EXPIRE_AFTER_SECONDS * 1000,
- ) // Expires 4 hours later
- const hasExpired = new Date()
- hasExpired.setTime(
- hasExpired.getTime() -
- vfnConstants.TRANSACTION_EXPIRE_AFTER_SECONDS * 2000,
- ) // Expired 2 days ago
-
- const endpointPath = '/submissions'
- const injectFixtures = (req, res, next) => {
- Object.assign(req, fixtures)
- next()
- }
- const sendSubmissionBack = (req, res) => {
- res.status(200).send({
- body: req.body,
- })
- }
-
- const app = express()
-
- const sendAndExpect = (status, expectedResponse = null) => {
- let send = request(app).post(endpointPath).expect(status)
- if (expectedResponse) {
- send = send.expect(expectedResponse)
- }
- return send
- }
-
- const createTransactionForForm = (form) => {
- return (expireAt, verifiableFields) => {
- let t = {
- formId: String(form._id),
- expireAt,
- }
- t.fields = verifiableFields.map((field, i) => {
- const {
- fieldType,
- hashCreatedAt,
- hashedOtp,
- signedData,
- hashRetries,
- } = field
- return {
- _id: form.form_fields[i]._id,
- fieldType,
- hashCreatedAt: hashCreatedAt === undefined ? null : hashCreatedAt,
- hashedOtp: hashedOtp === undefined ? null : hashedOtp,
- signedData: signedData === undefined ? null : signedData,
- hashRetries: hashRetries === undefined ? 0 : hashRetries,
- }
- })
- return Verification.create(t)
- }
- }
-
- beforeAll((done) => {
- app
- .route(endpointPath)
- .post(
- injectFixtures,
- EmailSubmissionsMiddleware.validateEmailSubmission,
- sendSubmissionBack,
- )
- testAgency = new Agency({
- shortName: 'govtest',
- fullName: 'Government Testing Agency',
- emailDomain: 'test.gov.sg',
- logo: '/invalid-path/test.jpg',
- })
- testAgency
- .save()
- .then(() => {
- return User.deleteMany({})
- })
- .then(() => {
- testUser = new User({
- email: 'test@test.gov.sg',
- agency: testAgency._id,
- })
- return testUser.save()
- })
- .then(done)
- })
-
- // Submission
- describe('No verified fields in form', () => {
- beforeEach((done) => {
- testForm = new Form({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- admin: testUser._id,
- responseMode: 'email',
- form_fields: [{ title: 'Email', fieldType: 'email' }],
- })
- testForm
- .save({ validateBeforeSave: false })
- .then(() => {
- fixtures = {
- form: testForm,
- body: {
- responses: [],
- },
- }
- })
- .then(done)
- })
- it('should allow submission if transaction does not exist for forms that do not contain any fields that have to be verified', (done) => {
- // No transaction created for testForm
- const field = testForm.form_fields[0]
- const response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: 'test@abc.com',
- }
- fixtures.body.responses.push(response)
- sendAndExpect(StatusCodes.OK, {
- body: {
- parsedResponses: [Object.assign(response, { isVisible: true })],
- },
- }).end(done)
- })
- })
- describe('Verified fields', () => {
- let createTransaction
- beforeAll((done) => {
- testForm = new Form({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- responseMode: 'email',
- admin: testUser._id,
- form_fields: [
- { title: 'Email', fieldType: 'email', isVerifiable: true },
- ],
- })
- testForm
- .save({ validateBeforeSave: false })
- .then(() => {
- createTransaction = createTransactionForForm(testForm)
- })
- .then(done)
- })
- beforeEach(() => {
- fixtures = {
- form: testForm,
- body: {
- responses: [],
- },
- }
- })
-
- describe('No transaction', () => {
- it('should prevent submission if transaction does not exist for a form containing fields that have to be verified', (done) => {
- const field = testForm.form_fields[0]
- const response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: 'test@abc.com',
- }
- fixtures.body.responses.push(response)
-
- sendAndExpect(StatusCodes.BAD_REQUEST).end(done)
- })
- })
-
- describe('Has transaction', () => {
- it('should prevent submission if transaction has expired for a form containing fields that have to be verified', (done) => {
- createTransaction(hasExpired, [
- {
- fieldType: 'email',
- hashCreatedAt: new Date(),
- hashedOtp: 'someHashValue',
- signedData: 'someData',
- },
- ]).then(() => {
- const field = testForm.form_fields[0]
- const response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: 'test@abc.com',
- signature: 'someData',
- }
- fixtures.body.responses.push(response)
-
- sendAndExpect(StatusCodes.BAD_REQUEST).end(done)
- })
- })
-
- it('should prevent submission if any of the transaction fields are not verified', (done) => {
- createTransaction(expireAt, [
- {
- fieldType: 'email',
- hashCreatedAt: null,
- hashedOtp: null,
- signedData: null,
- },
- ]).then(() => {
- const field = testForm.form_fields[0]
- const response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: 'test@abc.com',
- }
- fixtures.body.responses.push(response)
-
- sendAndExpect(StatusCodes.BAD_REQUEST).end(done)
- })
- })
-
- it('should allow submission if all of the transaction fields are verified', (done) => {
- const formsg = require('@opengovsg/formsg-sdk')({
- mode: 'test',
- verificationOptions: {
- secretKey: process.env.VERIFICATION_SECRET_KEY,
- },
- })
- const transactionId = mongoose.Types.ObjectId(
- '5e71ef8b19c1ed04b54cd5f9',
- )
-
- const field = testForm.form_fields[0]
- const formId = testForm._id
- let response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: 'test@abc.com',
- }
- const signature = formsg.verification.generateSignature({
- transactionId: String(transactionId),
- formId: String(formId),
- fieldId: response._id,
- answer: response.answer,
- })
- response.signature = signature
-
- createTransaction(expireAt, [
- {
- fieldType: 'email',
- hashCreatedAt: new Date(),
- hashedOtp: 'someHashValue',
- signedData: signature,
- },
- ]).then(() => {
- fixtures.body.responses.push(response)
- sendAndExpect(StatusCodes.OK).end(done)
- })
- })
- })
- })
-
- describe('Hidden and optional fields', () => {
- const expireAt = new Date()
- expireAt.setTime(expireAt.getTime() + 86400)
-
- beforeEach(() => {
- fixtures = {
- form: {},
- body: {
- responses: [],
- },
- }
- })
-
- const test = ({
- fieldValue,
- fieldIsRequired,
- fieldIsHidden,
- expectedStatus,
- done,
- }) => {
- const field = {
- _id: '5e719d5b62a2c4aa5d9789e2',
- title: 'Email',
- fieldType: 'email',
- isVerifiable: true,
- required: fieldIsRequired,
- }
- const yesNoField = {
- _id: '5e719d5b62a2c4aa5d9789e3',
- title: 'Show email if this field is yes',
- fieldType: 'yes_no',
- }
- let form = new Form({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- responseMode: 'email',
- admin: testUser._id,
- form_fields: [field, yesNoField],
- form_logics: [
- {
- show: [field._id],
- conditions: [
- {
- ifValueType: 'single-select',
- _id: '58169',
- field: yesNoField._id,
- state: 'is equals to',
- value: 'Yes',
- },
- ],
- _id: '5db00a15af2ffb29487d4eb1',
- logicType: 'showFields',
- },
- ],
- })
- form.save({ validateBeforeSave: false }).then(() => {
- const response = {
- _id: String(field._id),
- fieldType: field.fieldType,
- question: field.title,
- answer: fieldValue,
- }
- const yesNoResponse = {
- _id: yesNoField._id,
- question: yesNoField.title,
- fieldType: yesNoField.fieldType,
- answer: fieldIsHidden ? 'No' : 'Yes',
- }
- fixtures.form = form
- fixtures.body.responses.push(yesNoResponse)
- fixtures.body.responses.push(response)
- sendAndExpect(expectedStatus).end(done)
- })
- }
- it('should verify fields that are optional and filled in', (done) => {
- test({
- fieldValue: 'test@abc.com',
- fieldIsRequired: false,
- fieldIsHidden: false,
- expectedStatus: StatusCodes.BAD_REQUEST,
- done,
- })
- })
- it('should not verify fields that are optional and not filled in', (done) => {
- test({
- fieldValue: '',
- fieldIsRequired: false,
- fieldIsHidden: false,
- expectedStatus: StatusCodes.OK,
- done,
- })
- })
- it('should verify fields that are required and not hidden by logic', (done) => {
- test({
- fieldValue: 'test@abc.com',
- fieldIsRequired: true,
- fieldIsHidden: false,
- expectedStatus: StatusCodes.BAD_REQUEST,
- done,
- })
- })
-
- it('should not verify fields that are required and hidden by logic', (done) => {
- test({
- fieldValue: '',
- fieldIsRequired: true,
- fieldIsHidden: true,
- expectedStatus: StatusCodes.OK,
- done,
- })
- })
- })
- })
-})
From fa9d4bb85ecb62fa51b7d2210f65fc069fa5e5cf Mon Sep 17 00:00:00 2001
From: seaerchin <44049504+seaerchin@users.noreply.github.com>
Date: Thu, 8 Apr 2021 15:57:51 +0800
Subject: [PATCH 28/75] refactor: update getPublicForm endpoint to typescript
(#1398)
* refactor(modules/spcp): shifts get spcp session out into service
* refactor(modules/form): adds utility methods and refactored checkFormSubmission
* refactor(modules/form): refactors GET public forms end point from js to ts
* refactor(utils): adds utility type for all possible db errors
* fix(types/form): fixed the type of submissionLimit to allow for null
* refactor(modules/form): updated deactiveForm so that it uses never throw and adds logging
* refactor(modules/form): updated form limit checking to account for submission limit being null
* refactor(modules/form): adds logging to getSpcpSession
* refactor(modules/form): refactored handleGetPublicForm for clarity and code cleanliness
* refactor(publicformctrl): refactored handler for GET public forms to be cleaner and easier to read
* refactor(spcpsvc): added logging to extract JWT; updated typings for getSpcpSession
* revert(formsvc): removes retrievePublicForm (will be done in another PR) to limit scope
* style(public-form/controller): updated object to use variables
* style(services/types): updated documentation for functions and removed unneeded logging
* refactor(public-form/controller): refactored spcp flow so it's clearer
* refactor(public-form/controller): wip for myInfo
* refactor(myinfo): extracts myInfoCookiePayload to 2 types and adds utility function to differentiate
* refactor(public-form/controller): refactored myinfo chunk so it's neater
* test(form/service): fixes failing tests due to checkFormSubmissionLimitAndDeactivateForm
* refactor(myinfo): removed unneeded cookie type and updated extractSuccessfulCookie to reflect this
* docs(public-form/controller): adds docs to confusing sections of handlGetPublicForm
* test(public-form/controller/test): add test for database error when GET /getPublicForm
* test(public-form/controller/tests): add more tests
* test(public-form/controller/test): adds test for success cases
* refactor(myinfo): extracts out chunks form public-form controller into myinfo service
* refactor(public-form/controller): updated controller to use MyInfoFactory method
* test(public-form/controller/test): updated tests to use mocks
* fix(public-form/controller): fixed succesful 200 when auth using myInfo returning a private view
* test(public-form/controller/test): adds remaining tests for myInfo
* refactor(spcp): combined controller calls to spcp services into a single spcp call
* refactor(myinfo): refactored multiple controller calls to myInfo into a single call
* refactor(public-form): updated typings and refactored getPublicForm to be cleaner
* style(public-form/controller/test): updated comments in tests to use when
* refactor(form/service): updated deactiveForm to return the form itself; updated tests
* style(myinfo): extractMyInfoData renamed to fetchMyInfoData
* fix(public-form/controller): fixed bug where wrong extract cookie method is being called
* refactor(spcp/myinfo): removed extra logging in spcp; updated names in myinfo
* test(spcp/service/test): adds service tests
* test(spcp/service/test): adds unit tests for createFormWithSpcpSession
* test(myinfo.service): adds tests for createFormWithMyInfo
* feat(form): adds new errors and utility methods
* refactor(public-form): refactor to account for intarnet
* refactor(public-form/controller): added compatability for checking intranet access
* test(public-form/controller): adds tests for checking intranet; fixes old tests due to this addition
* style(spcp/service/test): changed naming to camelCase for variables
* fix(form.service): fixed intranet ip checking
* refactor(public-form): shifts utility method out into public form service
* test(public-form/controller): updates tests
* refactor(public-forms/server/routes): swaps to new controller for express middleware
* fix(myinfo/util): added cookie access check
* fix(form/service): adds error recovery for missing feature error when checking intranet access
* refactor(public-form/controller): tightened logic for myinfo error
* build(package.json): added ts-essential for UnreachableCaseException
* refactor(forms): added new type for intranet form
* refactor(spcp/service): updated service methods
* refactor(intranet/factory): updated isIntranetIp and factory signature typings
* refactor(myinfo): updated typings and factory methods to remove responsibility from service
* refactor(public-form/controller): wip
* refactor(form/service): remove result wrapping as only error is MissingFeatureError
* refactor(myinfo): updated typings and comments for myInfo
* refactor(publicform): updated handleGetPublicForm method and removed unused types
* refactor(public-form/service): removed unnused method
* fix(public-form/controller): changed returned form to be a publicForm
* test(myinfo/service): updated tests for myinfo service
* test(public-form/controller): updated tests to fit iwth refactor
* chore(intranet): removed unused variables
* test(intranet/service): fixes failing tests due to refactor
* refactor(app/utils): removed duplicate datatype in handle mongo errors
* style(form/service): updated logger message
* test(spcp/service): removed tests for deleted method; updated test for getSpcpSession
* refactor(public-form/controller): extracts logger meta property into a variable
* style(form/service): updated action property of logger meta
* docs(public-form/controller): updated comments for getPublicForm on conditions for myInfoError
* refactor(myinfo): deleted unused middleware; made fetchMyInfoPersonData private
* refactor(myinfo): changed fetchMyInfoPersonData to become a private field
* test(myinfo): fixed factory and service tests due to making myInfoPersonData private
* chore(webhook/service/test): fixed import
* chore(types/forms): removed unused type declaration
* style(form/service): chains calls together for clarity
* refactor(myinfo): refactored methods so that atomic operations are performed together
* test(myinfo): fixes tests for myinfo
* style(spcp): renamed service methods for clarity; removed unused error
* test(spcp): fixes spcp tests
* refactor(public-form): updated controller to account for myinfo/spcp refactoring
* test(public-form): updated tests
* fix(public-form/controller): fixed cp returning userInfo as ewll
---
package-lock.json | 6 +
package.json | 1 +
.../form/__tests__/form.service.spec.ts | 8 +-
src/app/modules/form/form.service.ts | 118 ++-
.../__tests__/public-form.controller.spec.ts | 754 +++++++++++++++++-
.../public-form/public-form.controller.ts | 162 +++-
.../form/public-form/public-form.routes.ts | 27 +
.../form/public-form/public-form.types.ts | 18 +
.../myinfo/__tests__/myinfo.factory.spec.ts | 30 +-
.../myinfo/__tests__/myinfo.service.spec.ts | 180 +++--
.../myinfo/__tests__/myinfo.test.constants.ts | 29 +-
src/app/modules/myinfo/myinfo.errors.ts | 9 +
src/app/modules/myinfo/myinfo.factory.ts | 42 +-
src/app/modules/myinfo/myinfo.middleware.ts | 101 ---
src/app/modules/myinfo/myinfo.service.ts | 170 ++--
src/app/modules/myinfo/myinfo.types.ts | 16 +-
src/app/modules/myinfo/myinfo.util.ts | 49 ++
.../spcp/__tests__/spcp.service.spec.ts | 180 ++++-
.../spcp/__tests__/spcp.test.constants.ts | 13 +
src/app/modules/spcp/spcp.factory.ts | 2 +
src/app/modules/spcp/spcp.service.ts | 26 +-
src/app/routes/public-forms.server.routes.js | 12 +-
.../__tests__/intranet.service.spec.ts | 4 +-
src/app/services/intranet/intranet.factory.ts | 9 +-
src/app/services/intranet/intranet.service.ts | 6 +-
src/app/utils/handle-mongo-error.ts | 21 +-
src/types/form.ts | 6 +-
27 files changed, 1674 insertions(+), 325 deletions(-)
create mode 100644 src/app/modules/form/public-form/public-form.routes.ts
delete mode 100644 src/app/modules/myinfo/myinfo.middleware.ts
diff --git a/package-lock.json b/package-lock.json
index 6ac8a54707..f2b03dc5c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23921,6 +23921,12 @@
"utf8-byte-length": "^1.0.1"
}
},
+ "ts-essentials": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.1.tgz",
+ "integrity": "sha512-8lwh3QJtIc1UWhkQtr9XuksXu3O0YQdEE5g79guDfhCaU1FWTDIEDZ1ZSx4HTHUmlJZ8L812j3BZQ4a0aOUkSA==",
+ "dev": true
+ },
"ts-jest": {
"version": "26.5.4",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.4.tgz",
diff --git a/package.json b/package.json
index d60b176cdd..79ae4eb9c8 100644
--- a/package.json
+++ b/package.json
@@ -247,6 +247,7 @@
"supertest-session": "^4.1.0",
"terser-webpack-plugin": "^1.2.3",
"testcafe": "^1.14.0",
+ "ts-essentials": "^7.0.1",
"ts-jest": "^26.5.4",
"ts-loader": "^7.0.5",
"ts-mock-imports": "^1.3.3",
diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts
index f4668bf567..20be4ce147 100644
--- a/src/app/modules/form/__tests__/form.service.spec.ts
+++ b/src/app/modules/form/__tests__/form.service.spec.ts
@@ -242,10 +242,10 @@ describe('FormService', () => {
)
// Assert
- expect(actual._unsafeUnwrap()).toEqual(true)
+ expect(actual._unsafeUnwrap()).toEqual(form)
})
- it('should let requests through when form has not reached submission limit', async () => {
+ it('should return the form when the submission limit is not reached', async () => {
// Arrange
const formParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, {
status: Status.Public,
@@ -268,11 +268,11 @@ describe('FormService', () => {
// Act
const actual = await FormService.checkFormSubmissionLimitAndDeactivateForm(
- form,
+ form as IPopulatedForm,
)
// Assert
- expect(actual._unsafeUnwrap()).toEqual(true)
+ expect(actual._unsafeUnwrap()).toEqual(validForm)
})
it('should not let requests through and deactivate form when form has reached submission limit', async () => {
diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts
index f4ddf91ed1..5ff5e62333 100644
--- a/src/app/modules/form/form.service.ts
+++ b/src/app/modules/form/form.service.ts
@@ -3,6 +3,7 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow'
import { createLoggerWithLabel } from '../../../config/logger'
import {
+ AuthType,
IEmailFormModel,
IEncryptedFormModel,
IFormSchema,
@@ -15,6 +16,7 @@ import getFormModel, {
getEncryptedFormModel,
} from '../../models/form.server.model'
import getSubmissionModel from '../../models/submission.server.model'
+import { IntranetFactory } from '../../services/intranet/intranet.factory'
import {
getMongoErrorMessage,
transformMongoError,
@@ -37,10 +39,42 @@ const EmailFormModel = getEmailFormModel(mongoose)
const EncryptedFormModel = getEncryptedFormModel(mongoose)
const SubmissionModel = getSubmissionModel(mongoose)
-export const deactivateForm = async (
+/**
+ * Deactivates a given form by its id
+ * @param formId the id of the form to deactivate
+ * @returns ok(true) if the form has been deactivated successfully
+ * @returns err(PossibleDatabaseError) if an error occurred while trying to deactivate the form
+ * @returns err(FormNotFoundError) if there is no form with the given formId
+ */
+export const deactivateForm = (
formId: string,
-): Promise => {
- return FormModel.deactivateById(formId)
+): ResultAsync => {
+ return ResultAsync.fromPromise(FormModel.deactivateById(formId), (error) => {
+ logger.error({
+ message: 'Error deactivating form by id',
+ meta: {
+ action: 'deactivateForm',
+ form: formId,
+ },
+ error,
+ })
+
+ return transformMongoError(error)
+ }).andThen((deactivatedForm) => {
+ if (!deactivatedForm) {
+ logger.error({
+ message:
+ 'Attempted to deactivate form that cannot be found in the database',
+ meta: {
+ action: 'deactivateForm',
+ form: formId,
+ },
+ })
+ return errAsync(new FormNotFoundError())
+ }
+ // Successfully deactivated.
+ return okAsync(deactivatedForm)
+ })
}
/**
@@ -118,9 +152,9 @@ export const retrieveFormById = (
* Method to ensure given form is available to the public.
* @param form the form to check
* @returns ok(true) if form is public
+ * @returns err(ApplicationError) if form has an invalid state
* @returns err(FormDeletedError) if form has been deleted
* @returns err(PrivateFormError) if form is private, the message will be the form inactive message
- * @returns err(ApplicationError) if form has an invalid state
*/
export const isFormPublic = (
form: IPopulatedForm,
@@ -128,7 +162,6 @@ export const isFormPublic = (
if (!form.status) {
return err(new ApplicationError())
}
-
switch (form.status) {
case Status.Public:
return ok(true)
@@ -142,20 +175,30 @@ export const isFormPublic = (
/**
* Method to check whether a form has reached submission limits, and deactivate the form if necessary
* @param form the form to check
- * @returns ok(true) if submission is allowed because the form has not reached limits
- * @returns ok(false) if submission is not allowed because the form has reached limits
+ * @returns ok(form) if submission is allowed because the form has not reached limits
+ * @returns err(PossibleDatabaseError) if an error occurred while querying the database for the specified form
+ * @returns err(FormNotFoundError) if the form has exceeded the submission limits but could not be found and deactivated
+ * @returns err(PrivateFormError) if the count of the form has been exceeded and the form has been deactivated
*/
export const checkFormSubmissionLimitAndDeactivateForm = (
form: IPopulatedForm,
-): ResultAsync => {
+): ResultAsync<
+ IPopulatedForm,
+ PossibleDatabaseError | PrivateFormError | FormNotFoundError
+> => {
const logMeta = {
action: 'checkFormSubmissionLimitAndDeactivateForm',
formId: form._id,
}
- if (form.submissionLimit === null) return okAsync(true)
+ const { submissionLimit } = form
+ const formId = String(form._id)
+ // Not using falsey check as submissionLimit === 0 can result in incorrectly
+ // returning form without any actions.
+ if (submissionLimit === null) return okAsync(form)
+
return ResultAsync.fromPromise(
SubmissionModel.countDocuments({
- form: form._id,
+ form: formId,
}).exec(),
(error) => {
logger.error({
@@ -165,21 +208,18 @@ export const checkFormSubmissionLimitAndDeactivateForm = (
})
return transformMongoError(error)
},
- ).andThen((count) => {
- if (count < form.submissionLimit) return okAsync(true)
+ ).andThen((currentCount) => {
+ // Limit has not been hit yet, passthrough.
+ if (currentCount < submissionLimit) return okAsync(form)
+
logger.info({
message: 'Form reached maximum submission count, deactivating.',
meta: logMeta,
})
- return ResultAsync.fromPromise(deactivateForm(form._id), (error) => {
- logger.error({
- message: 'Error while deactivating form',
- meta: logMeta,
- error,
- })
- return transformMongoError(error)
- }).andThen(() =>
- // Always return err because submission limit was exceeded
+
+ // Map success case back into error to display to client as form has been
+ // deactivated.
+ return deactivateForm(formId).andThen(() =>
errAsync(
new PrivateFormError(
'Submission made after form submission limit was reached',
@@ -200,3 +240,39 @@ export const getFormModelByResponseMode = (
return EncryptedFormModel
}
}
+
+/**
+ * Checks if a form is accessed from within intranet and sets the property accordingly
+ * @param ip The ip of the request
+ * @param publicFormView The form to check
+ * @returns ok(PublicFormView) if the form is accessed from the internet
+ * @returns err(ApplicationError) if an error occured while checking if the ip of the request is from the intranet
+ */
+export const checkIsIntranetFormAccess = (
+ ip: string,
+ form: IPopulatedForm,
+): boolean => {
+ return (
+ IntranetFactory.isIntranetIp(ip)
+ .andThen((isIntranetUser) => {
+ // Warn if form is being accessed from within intranet
+ // and the form has authentication set
+ if (
+ isIntranetUser &&
+ [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType)
+ ) {
+ logger.warn({
+ message:
+ 'Attempting to access SingPass, CorpPass or MyInfo form from intranet',
+ meta: {
+ action: 'checkIsIntranetFormAccess',
+ formId: form._id,
+ },
+ })
+ }
+ return ok(isIntranetUser)
+ })
+ // This is required becausing the factory can throw missing feature error on initialization
+ .unwrapOr(false)
+ )
+}
diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts
index cc3cf172fc..e18cce6cdd 100644
--- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts
+++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts
@@ -1,3 +1,4 @@
+import { IPersonResponse } from '@opengovsg/myinfo-gov-client'
import { ObjectId } from 'bson-ext'
import { merge } from 'lodash'
import mongoose from 'mongoose'
@@ -6,11 +7,35 @@ import querystring from 'querystring'
import { mocked } from 'ts-jest/utils'
import getFormFeedbackModel from 'src/app/models/form_feedback.server.model'
-import { DatabaseError } from 'src/app/modules/core/core.errors'
-import { IPopulatedForm } from 'src/types'
+import {
+ DatabaseError,
+ MissingFeatureError,
+} from 'src/app/modules/core/core.errors'
+import { MyInfoData } from 'src/app/modules/myinfo/myinfo.adapter'
+import {
+ MyInfoAuthTypeError,
+ MyInfoCookieAccessError,
+ MyInfoMissingAccessTokenError,
+ MyInfoNoESrvcIdError,
+} from 'src/app/modules/myinfo/myinfo.errors'
+import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types'
+import { JwtPayload } from 'src/app/modules/spcp/spcp.types'
+import { FeatureNames } from 'src/config/feature-manager/types'
+import {
+ AuthType,
+ IPopulatedForm,
+ IPopulatedUser,
+ MyInfoAttribute,
+ PublicForm,
+} from 'src/types'
import expressHandler from 'tests/unit/backend/helpers/jest-express'
+import * as AuthService from '../../../auth/auth.service'
+import { MyInfoCookieStateError } from '../../../myinfo/myinfo.errors'
+import { MyInfoFactory } from '../../../myinfo/myinfo.factory'
+import { MissingJwtError } from '../../../spcp/spcp.errors'
+import { SpcpFactory } from '../../../spcp/spcp.factory'
import {
FormDeletedError,
FormNotFoundError,
@@ -21,10 +46,17 @@ import * as PublicFormController from '../public-form.controller'
import * as PublicFormService from '../public-form.service'
import { Metatags } from '../public-form.types'
-jest.mock('../../form.service')
jest.mock('../public-form.service')
+jest.mock('../../form.service')
+jest.mock('../../../auth/auth.service')
+jest.mock('../../../spcp/spcp.factory')
+jest.mock('../../../myinfo/myinfo.factory')
+
const MockFormService = mocked(FormService)
const MockPublicFormService = mocked(PublicFormService)
+const MockAuthService = mocked(AuthService)
+const MockSpcpFactory = mocked(SpcpFactory, true)
+const MockMyInfoFactory = mocked(MyInfoFactory, true)
const FormFeedbackModel = getFormFeedbackModel(mongoose)
@@ -185,7 +217,7 @@ describe('public-form.controller', () => {
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Gone' })
})
- it('should return 500 when databse errors occur', async () => {
+ it('should return 500 when database errors occur', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
const mockErrorString = 'Form feedback could not be created'
@@ -389,4 +421,718 @@ describe('public-form.controller', () => {
expect(mockRes.redirect).toHaveBeenCalledWith(expectedRedirectPath)
})
})
+
+ describe('handleGetPublicForm', () => {
+ const MOCK_FORM_ID = new ObjectId().toHexString()
+ const MOCK_USER_ID = new ObjectId().toHexString()
+ const MOCK_USER = {
+ _id: MOCK_USER_ID,
+ email: 'randomrandomtest@example.com',
+ } as IPopulatedUser
+
+ const MOCK_SCRUBBED_FORM = ({
+ _id: MOCK_FORM_ID,
+ title: 'mock title',
+ admin: { _id: MOCK_USER_ID },
+ } as unknown) as PublicForm
+
+ const BASE_FORM = {
+ admin: MOCK_USER,
+ _id: MOCK_FORM_ID,
+ title: MOCK_SCRUBBED_FORM.title,
+ getUniqueMyInfoAttrs: jest.fn().mockReturnValue([MyInfoAttribute.Name]),
+ getPublicView: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM),
+ }
+
+ const MOCK_REQ = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ })
+
+ const MOCK_MYINFO_COOKIE = {
+ accessToken: 'cookie',
+ usedCount: 0,
+ state: MyInfoCookieState.Success,
+ }
+
+ let mockReqWithCookies: ReturnType
+
+ beforeEach(() => {
+ mockReqWithCookies = expressHandler.mockRequest({
+ params: {
+ formId: MOCK_FORM_ID,
+ },
+ others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } },
+ })
+ })
+
+ // Success
+ describe('valid form id', () => {
+ const MOCK_JWT_PAYLOAD: JwtPayload = {
+ userName: 'mock',
+ rememberMe: false,
+ }
+
+ beforeAll(() => {
+ MockFormService.checkIsIntranetFormAccess.mockReturnValue(false)
+ })
+
+ it('should return 200 when there is no AuthType on the request', async () => {
+ // Arrange
+ const MOCK_NIL_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.NIL,
+ } as unknown) as IPopulatedForm
+ const mockRes = expressHandler.mockResponse()
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_NIL_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_NIL_AUTH_FORM),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_NIL_AUTH_FORM.getPublicView(),
+ isIntranetUser: false,
+ })
+ })
+
+ it('should return 200 when client authenticates using SP', async () => {
+ // Arrange
+ const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName }
+ const MOCK_SP_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.SP,
+ } as unknown) as IPopulatedForm
+ const mockRes = expressHandler.mockResponse()
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_SP_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_SP_AUTH_FORM),
+ )
+ MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_SESSION),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_SP_AUTH_FORM.getPublicView(),
+ isIntranetUser: false,
+ spcpSession: MOCK_SPCP_SESSION,
+ })
+ })
+
+ it('should return 200 when client authenticates using CP', async () => {
+ // Arrange
+ const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName }
+ const MOCK_CP_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.CP,
+ } as unknown) as IPopulatedForm
+ const mockRes = expressHandler.mockResponse()
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_CP_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_CP_AUTH_FORM),
+ )
+ MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_SESSION),
+ )
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_CP_AUTH_FORM.getPublicView(),
+ isIntranetUser: false,
+ spcpSession: MOCK_SPCP_SESSION,
+ })
+ })
+
+ it('should return 200 when client authenticates using MyInfo', async () => {
+ // Arrange
+ const MOCK_MYINFO_AUTH_FORM = ({
+ ...BASE_FORM,
+ esrvcId: 'thing',
+ authType: AuthType.MyInfo,
+ toJSON: jest.fn().mockReturnValue(BASE_FORM),
+ } as unknown) as IPopulatedForm
+ const MOCK_MYINFO_DATA = new MyInfoData({
+ uinFin: 'i am a fish',
+ } as IPersonResponse)
+ const MOCK_SPCP_SESSION = { userName: MOCK_MYINFO_DATA.getUinFin() }
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ cookie: jest.fn().mockReturnThis(),
+ })
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_AUTH_FORM),
+ )
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_DATA),
+ )
+ MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce(
+ okAsync([]),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).not.toHaveBeenCalled()
+ expect(mockRes.cookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: { ...MOCK_MYINFO_AUTH_FORM.getPublicView(), form_fields: [] },
+ spcpSession: MOCK_SPCP_SESSION,
+ isIntranetUser: false,
+ })
+ })
+ })
+
+ // Errors
+ describe('errors in myInfo', () => {
+ const MOCK_MYINFO_FORM = ({
+ ...BASE_FORM,
+ toJSON: jest.fn().mockReturnThis(),
+ authType: AuthType.MyInfo,
+ } as unknown) as IPopulatedForm
+
+ // Setup because this gets invoked at the start of the controller to decide which branch to take
+ beforeAll(() => {
+ MockAuthService.getFormIfPublic.mockReturnValue(
+ okAsync(MOCK_MYINFO_FORM),
+ )
+
+ MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(false)
+
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue(
+ okAsync(MOCK_MYINFO_FORM),
+ )
+ })
+
+ it('should return 200 but the response should have cookies cleared with myInfoError set to undefined when the request has no cookie', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(new MyInfoMissingAccessTokenError()),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: undefined,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared with myInfoError set to undefined when the cookie has been used before', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(new MyInfoCookieAccessError()),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: undefined,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared and myInfoError when the cookie cannot be validated', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(new MyInfoCookieStateError()),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: true,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared and myInfoError if the form cannot be validated', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(new MyInfoAuthTypeError()),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ myInfoError: true,
+ isIntranetUser: false,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared and myInfoError when the form has no eservcId', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(new MyInfoNoESrvcIdError()),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: true,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared and myInfoError when the form could not be filled', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ errAsync(
+ new MissingFeatureError(
+ 'testing is the missing feature' as FeatureNames,
+ ),
+ ),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: true,
+ })
+ })
+
+ it('should return 200 but the response should have cookies cleared and myInfoError if a database error occurs while saving hashes', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const MOCK_MYINFO_DATA = new MyInfoData({
+ uinFin: 'i am a fish',
+ } as IPersonResponse)
+ const expected = new DatabaseError('fish error')
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ })
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_DATA),
+ )
+ MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce(
+ errAsync(expected),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_MYINFO_FORM.getPublicView(),
+ isIntranetUser: false,
+ myInfoError: true,
+ })
+ })
+ })
+
+ describe('errors in spcp', () => {
+ const MOCK_SPCP_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.SP,
+ } as unknown) as IPopulatedForm
+ it('should return 200 with the form but without a spcpSession when the JWT token could not be found', async () => {
+ // Arrange
+ // 1. Mock the response and calls
+ const mockRes = expressHandler.mockResponse()
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_FORM),
+ )
+ MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce(
+ errAsync(new MissingJwtError()),
+ )
+
+ // Act
+ // 2. GET the endpoint
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ // Status should be 200
+ // json object should only have form property
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_SPCP_FORM.getPublicView(),
+ isIntranetUser: false,
+ })
+ })
+ })
+
+ describe('errors in form retrieval', () => {
+ const MOCK_ERROR_STRING = 'mockingbird'
+
+ it('should return 500 when a database error occurs', async () => {
+ // Arrange
+ // 1. Mock the response
+ const mockRes = expressHandler.mockResponse()
+
+ // 2. Mock the call to retrieve the form
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ errAsync(new DatabaseError(MOCK_ERROR_STRING)),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ // 1. Check args of mocked services
+ expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith(
+ MOCK_FORM_ID,
+ )
+ // 2. Check that error is correct
+ expect(
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(500)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: MOCK_ERROR_STRING,
+ })
+ })
+
+ it('should return 404 when the form is not found', async () => {
+ // Arrange
+ // 1. Mock the response
+ const mockRes = expressHandler.mockResponse()
+ const MOCK_ERROR_STRING = 'Your form was eaten up by a monster'
+
+ // 2. Mock the call to retrieve the form
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ errAsync(new FormNotFoundError(MOCK_ERROR_STRING)),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ // 1. Check args of mocked services
+ expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith(
+ MOCK_FORM_ID,
+ )
+ // 2. Check that error is correct
+ expect(
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(404)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: MOCK_ERROR_STRING,
+ })
+ })
+
+ it('should return 404 when the form is private and not accessible by the public', async () => {
+ // Arrange
+ // 1. Mock the response
+ const mockRes = expressHandler.mockResponse()
+
+ // 2. Mock the call to retrieve the form
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ errAsync(new PrivateFormError(MOCK_ERROR_STRING, 'private form')),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ // 1. Check args of mocked services
+ expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith(
+ MOCK_FORM_ID,
+ )
+ // 2. Check that error is correct
+ expect(
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm,
+ ).not.toHaveBeenCalled()
+ expect(mockRes.status).toHaveBeenCalledWith(404)
+ expect(mockRes.json).toHaveBeenCalledWith({
+ message: MOCK_ERROR_STRING,
+ })
+ })
+ })
+
+ describe('errors in form access', () => {
+ const MOCK_SPCP_SESSION = {
+ userName: 'mock',
+ }
+
+ it('should return 200 with isIntranetUser set to false when a user accesses a form from outside intranet', async () => {
+ // Arrange
+ const MOCK_NIL_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.NIL,
+ } as unknown) as IPopulatedForm
+ const mockRes = expressHandler.mockResponse()
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_NIL_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_NIL_AUTH_FORM),
+ )
+ MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(false)
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_NIL_AUTH_FORM.getPublicView(),
+ isIntranetUser: false,
+ })
+ })
+
+ it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.SP form', async () => {
+ // Arrange
+ const MOCK_SP_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.SP,
+ } as unknown) as IPopulatedForm
+
+ const mockRes = expressHandler.mockResponse()
+
+ MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_SESSION),
+ )
+ MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true)
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_SP_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_SP_AUTH_FORM),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_SP_AUTH_FORM.getPublicView(),
+ spcpSession: MOCK_SPCP_SESSION,
+ isIntranetUser: true,
+ })
+ })
+
+ it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.CP form', async () => {
+ // Arrange
+ const MOCK_CP_AUTH_FORM = ({
+ ...BASE_FORM,
+ authType: AuthType.CP,
+ } as unknown) as IPopulatedForm
+
+ const mockRes = expressHandler.mockResponse()
+
+ MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true)
+ MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce(
+ okAsync(MOCK_SPCP_SESSION),
+ )
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_CP_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_CP_AUTH_FORM),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ MOCK_REQ,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: MOCK_CP_AUTH_FORM.getPublicView(),
+ spcpSession: MOCK_SPCP_SESSION,
+ isIntranetUser: true,
+ })
+ })
+
+ it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.MyInfo form', async () => {
+ // Arrange
+ const MOCK_MYINFO_AUTH_FORM = ({
+ ...BASE_FORM,
+ esrvcId: 'thing',
+ authType: AuthType.MyInfo,
+ toJSON: jest.fn().mockReturnValue(BASE_FORM),
+ } as unknown) as IPopulatedForm
+ const mockRes = expressHandler.mockResponse({
+ clearCookie: jest.fn().mockReturnThis(),
+ cookie: jest.fn().mockReturnThis(),
+ })
+
+ const MOCK_MYINFO_DATA = new MyInfoData({
+ uinFin: 'i am a fish',
+ } as IPersonResponse)
+
+ MockAuthService.getFormIfPublic.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_AUTH_FORM),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_AUTH_FORM),
+ )
+ MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true)
+ MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce(
+ okAsync(MOCK_MYINFO_DATA),
+ )
+ MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce(
+ okAsync([]),
+ )
+
+ // Act
+ await PublicFormController.handleGetPublicForm(
+ mockReqWithCookies,
+ mockRes,
+ jest.fn(),
+ )
+
+ // Assert
+ expect(mockRes.clearCookie).not.toHaveBeenCalled()
+ expect(mockRes.cookie).toHaveBeenCalled()
+ expect(mockRes.json).toHaveBeenCalledWith({
+ form: { ...MOCK_MYINFO_AUTH_FORM.getPublicView(), form_fields: [] },
+ spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() },
+ isIntranetUser: true,
+ })
+ })
+ })
+ })
})
diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts
index 474058a54c..90034261da 100644
--- a/src/app/modules/form/public-form/public-form.controller.ts
+++ b/src/app/modules/form/public-form/public-form.controller.ts
@@ -1,14 +1,31 @@
import { RequestHandler } from 'express'
import { StatusCodes } from 'http-status-codes'
import querystring from 'querystring'
+import { UnreachableCaseError } from 'ts-essentials'
import { createLoggerWithLabel } from '../../../../config/logger'
-import { createReqMeta } from '../../../utils/request'
+import { AuthType } from '../../../../types'
+import { ErrorDto } from '../../../../types/api'
+import { isMongoError } from '../../../utils/handle-mongo-error'
+import { createReqMeta, getRequestIp } from '../../../utils/request'
+import { getFormIfPublic } from '../../auth/auth.service'
+import {
+ MYINFO_COOKIE_NAME,
+ MYINFO_COOKIE_OPTIONS,
+} from '../../myinfo/myinfo.constants'
+import {
+ MyInfoCookieAccessError,
+ MyInfoMissingAccessTokenError,
+} from '../../myinfo/myinfo.errors'
+import { MyInfoFactory } from '../../myinfo/myinfo.factory'
+import { extractAndAssertMyInfoCookieValidity } from '../../myinfo/myinfo.util'
+import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors'
+import { SpcpFactory } from '../../spcp/spcp.factory'
import { PrivateFormError } from '../form.errors'
import * as FormService from '../form.service'
import * as PublicFormService from './public-form.service'
-import { RedirectParams } from './public-form.types'
+import { PublicFormViewDto, RedirectParams } from './public-form.types'
import { mapRouteError } from './public-form.utils'
const logger = createLoggerWithLabel(module)
@@ -161,3 +178,144 @@ export const handleRedirect: RequestHandler<
redirectPath,
})
}
+
+/**
+ * Handler for GET /:formId/publicform endpoint
+ * @returns 200 if the form exists
+ * @returns 404 if form with formId does not exist or is private
+ * @returns 410 if form has been archived
+ * @returns 500 if database error occurs or if the type of error is unknown
+ */
+export const handleGetPublicForm: RequestHandler<
+ { formId: string },
+ PublicFormViewDto | ErrorDto
+> = async (req, res) => {
+ const { formId } = req.params
+ const logMeta = {
+ action: 'handleGetPublicForm',
+ ...createReqMeta(req),
+ formId,
+ }
+
+ const formResult = await getFormIfPublic(formId).andThen((form) =>
+ FormService.checkFormSubmissionLimitAndDeactivateForm(form),
+ )
+
+ // Early return if form is not public or any error occurred.
+ if (formResult.isErr()) {
+ const { error } = formResult
+ // NOTE: Only log on possible database errors.
+ // This is because the other kinds of errors are expected errors and are not truly exceptional
+ if (isMongoError(error)) {
+ logger.error({
+ message: 'Error retrieving public form',
+ meta: logMeta,
+ error,
+ })
+ }
+ const { errorMessage, statusCode } = mapRouteError(error)
+ return res.status(statusCode).json({ message: errorMessage })
+ }
+
+ const form = formResult.value
+ const publicForm = form.getPublicView()
+ const { authType } = form
+ const isIntranetUser = FormService.checkIsIntranetFormAccess(
+ getRequestIp(req),
+ form,
+ )
+
+ switch (authType) {
+ case AuthType.NIL:
+ return res.json({ form: publicForm, isIntranetUser })
+ case AuthType.SP:
+ case AuthType.CP:
+ return SpcpFactory.extractJwtPayloadFromRequest(authType, req.cookies)
+ .map(({ userName }) =>
+ res.json({
+ form: publicForm,
+ isIntranetUser,
+ spcpSession: { userName },
+ }),
+ )
+ .mapErr((error) => {
+ // Report only relevant errors - verification failed for user here
+ if (
+ error instanceof VerifyJwtError ||
+ error instanceof InvalidJwtError
+ ) {
+ logger.error({
+ message: 'Error getting public form',
+ meta: logMeta,
+ error,
+ })
+ }
+ return res.json({ form: publicForm, isIntranetUser })
+ })
+ case AuthType.MyInfo: {
+ // Step 1. Fetch required data and fill the form based off data retrieved
+ return (
+ MyInfoFactory.getMyInfoDataForForm(form, req.cookies)
+ .andThen((myInfoData) => {
+ return MyInfoFactory.prefillAndSaveMyInfoFields(
+ form._id,
+ myInfoData,
+ form.toJSON().form_fields,
+ ).map((prefilledFields) => ({
+ prefilledFields,
+ spcpSession: { userName: myInfoData.getUinFin() },
+ }))
+ })
+ // Check if the user is signed in
+ .andThen(({ prefilledFields, spcpSession }) => {
+ return extractAndAssertMyInfoCookieValidity(req.cookies).map(
+ (myInfoCookie) => ({
+ prefilledFields,
+ spcpSession,
+ myInfoCookie,
+ }),
+ )
+ })
+ .map(({ myInfoCookie, prefilledFields, spcpSession }) => {
+ const updatedMyInfoCookie = {
+ ...myInfoCookie,
+ usedCount: myInfoCookie.usedCount + 1,
+ }
+ // Set the updated cookie accordingly and return the form back to the user
+ return res
+ .cookie(
+ MYINFO_COOKIE_NAME,
+ updatedMyInfoCookie,
+ MYINFO_COOKIE_OPTIONS,
+ )
+ .json({
+ spcpSession,
+ form: { ...publicForm, form_fields: prefilledFields },
+ isIntranetUser,
+ })
+ })
+ .mapErr((error) => {
+ // NOTE: If the user is not signed in or if the user refreshes the page while logged in, it is not an error.
+ // myInfoError is set to true only when the authentication provider rejects the user's attempt at auth
+ // or when there is a network or database error during the process of retrieval
+ const isMyInfoError = !(
+ error instanceof MyInfoCookieAccessError ||
+ error instanceof MyInfoMissingAccessTokenError
+ )
+ // No need for cookie if data could not be retrieved
+ // NOTE: If the user does not have any cookie, clearing the cookie still has the same result
+ return res
+ .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS)
+ .json({
+ form: publicForm,
+ // Setting to undefined ensures that the frontend does not get myInfoError if it is false
+ myInfoError: isMyInfoError || undefined,
+ isIntranetUser,
+ })
+ })
+ )
+ }
+ default:
+ return new UnreachableCaseError(authType)
+ }
+}
diff --git a/src/app/modules/form/public-form/public-form.routes.ts b/src/app/modules/form/public-form/public-form.routes.ts
new file mode 100644
index 0000000000..0d4fd71cc0
--- /dev/null
+++ b/src/app/modules/form/public-form/public-form.routes.ts
@@ -0,0 +1,27 @@
+import { Router } from 'express'
+
+import * as PublicFormController from './public-form.controller'
+
+export const PublicFormRouter = Router()
+
+/**
+ * Returns the specified form to the user, along with any
+ * identity information obtained from SingPass/CorpPass,
+ * and MyInfo details, if any.
+ *
+ * WARNING: TemperatureSG batch jobs rely on this endpoint to
+ * retrieve the master list of personnel for daily reporting.
+ * Please strictly ensure backwards compatibility.
+ *
+ * @route GET /{formId}/publicform
+ * @group forms - endpoints to serve forms
+ * @param {string} formId.path.required - the form id
+ * @consumes application/json
+ * @produces application/json
+ * @returns {string} 404 - form is not made public
+ * @returns {PublicForm.model} 200 - the form, and other information
+ */
+PublicFormRouter.get(
+ '/:formId([a-fA-F0-9]{24})/publicform',
+ PublicFormController.handleGetPublicForm,
+)
diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts
index 543318873c..6f970f46f6 100644
--- a/src/app/modules/form/public-form/public-form.types.ts
+++ b/src/app/modules/form/public-form/public-form.types.ts
@@ -1,5 +1,10 @@
import { ParamsDictionary } from 'express-serve-static-core'
+import { IFieldSchema, PublicForm } from 'src/types'
+
+import { SpcpSession } from '../../../../types/spcp'
+import { IPossiblyPrefilledField } from '../../myinfo/myinfo.types'
+
export type Metatags = {
title: string
description?: string
@@ -13,3 +18,16 @@ export type RedirectParams = ParamsDictionary & {
// TODO(#144): Rename Id to formId after all routes have been updated.
Id: string
}
+
+// NOTE: This is needed because PublicForm inherits from IFormDocument (where form_fields has type of IFieldSchema).
+// However, the form returned back to the client has form_field of two possible types
+interface PossiblyPrefilledPublicForm extends Omit {
+ form_fields: IPossiblyPrefilledField[] | IFieldSchema[]
+}
+
+export type PublicFormViewDto = {
+ form: PossiblyPrefilledPublicForm
+ spcpSession?: SpcpSession
+ isIntranetUser?: boolean
+ myInfoError?: true
+}
diff --git a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts
index 4812b81efc..932d5200ca 100644
--- a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts
+++ b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts
@@ -2,7 +2,7 @@ import { mocked } from 'ts-jest/utils'
import config from 'src/config/config'
import { ISpcpMyInfo } from 'src/config/feature-manager'
-import { Environment } from 'src/types'
+import { Environment, IPopulatedForm } from 'src/types'
import { MyInfoData } from '../myinfo.adapter'
import { createMyInfoFactory } from '../myinfo.factory'
@@ -45,12 +45,12 @@ describe('myinfo.factory', () => {
)
const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('')
const extractUinFinResult = MyInfoFactory.extractUinFin('')
- const fetchMyInfoPersonDataResult = await MyInfoFactory.fetchMyInfoPersonData(
- '',
- [],
- '',
+ const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm(
+ ({} as unknown) as IPopulatedForm,
+ {},
)
- const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields(
+ const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields(
+ '',
{} as MyInfoData,
[],
)
@@ -71,8 +71,8 @@ describe('myinfo.factory', () => {
expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error)
expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error)
expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error)
- expect(fetchMyInfoPersonDataResult._unsafeUnwrapErr()).toEqual(error)
- expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error)
+ expect(getMyInfoDataForFormResult._unsafeUnwrapErr()).toEqual(error)
+ expect(prefillAndSaveMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error)
expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
expect(checkMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
@@ -94,12 +94,12 @@ describe('myinfo.factory', () => {
)
const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('')
const extractUinFinResult = MyInfoFactory.extractUinFin('')
- const fetchMyInfoPersonDataResult = await MyInfoFactory.fetchMyInfoPersonData(
- '',
- [],
- '',
+ const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm(
+ ({} as unknown) as IPopulatedForm,
+ {},
)
- const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields(
+ const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields(
+ '',
{} as MyInfoData,
[],
)
@@ -120,8 +120,8 @@ describe('myinfo.factory', () => {
expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error)
expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error)
expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error)
- expect(fetchMyInfoPersonDataResult._unsafeUnwrapErr()).toEqual(error)
- expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error)
+ expect(getMyInfoDataForFormResult._unsafeUnwrapErr()).toEqual(error)
+ expect(prefillAndSaveMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error)
expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
expect(checkMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error)
diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts
index b433e47025..418ec67bac 100644
--- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts
+++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts
@@ -1,4 +1,5 @@
import bcrypt from 'bcrypt'
+import { ObjectId } from 'bson-ext'
import mongoose from 'mongoose'
import { mocked } from 'ts-jest/utils'
import { v4 as uuidv4 } from 'uuid'
@@ -10,17 +11,20 @@ import {
IFieldSchema,
IHashes,
IMyInfoHashSchema,
+ IPopulatedForm,
MyInfoAttribute,
} from 'src/types'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { DatabaseError } from '../../core/core.errors'
import { MyInfoData } from '../myinfo.adapter'
import { MYINFO_CONSENT_PAGE_PURPOSE } from '../myinfo.constants'
import {
MyInfoCircuitBreakerError,
MyInfoFetchError,
MyInfoInvalidAccessTokenError,
+ MyInfoMissingAccessTokenError,
MyInfoParseRelayStateError,
} from '../myinfo.errors'
import { IPossiblyPrefilledField, MyInfoRelayState } from '../myinfo.types'
@@ -35,11 +39,13 @@ import {
MOCK_HASHED_FIELD_IDS,
MOCK_HASHES,
MOCK_MYINFO_DATA,
+ MOCK_MYINFO_FORM,
MOCK_POPULATED_FORM_FIELDS,
MOCK_REDIRECT_URL,
MOCK_REQUESTED_ATTRS,
MOCK_RESPONSES,
MOCK_SERVICE_PARAMS,
+ MOCK_SUCCESSFUL_COOKIE,
MOCK_UINFIN,
} from './myinfo.test.constants'
@@ -183,75 +189,14 @@ describe('MyInfoService', () => {
})
})
- describe('fetchMyInfoPersonData', () => {
- beforeEach(() => {
- myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS)
- })
-
- it('should call MyInfoGovClient.getPerson with the correct parameters', async () => {
- const mockReturnedParams = {
- uinFin: MOCK_UINFIN,
- data: MOCK_MYINFO_DATA,
- }
- mockGetPerson.mockResolvedValueOnce(mockReturnedParams)
- const result = await myInfoService.fetchMyInfoPersonData(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS,
- MOCK_ESRVC_ID,
- )
-
- expect(mockGetPerson).toHaveBeenCalledWith(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute),
- MOCK_ESRVC_ID,
- )
- expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams))
- })
-
- it('should throw MyInfoFetchError when getPerson fails once', async () => {
- mockGetPerson.mockRejectedValueOnce(new Error())
- const result = await myInfoService.fetchMyInfoPersonData(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS,
- MOCK_ESRVC_ID,
- )
-
- expect(mockGetPerson).toHaveBeenCalledWith(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute),
- MOCK_ESRVC_ID,
- )
- expect(result._unsafeUnwrapErr()).toEqual(new MyInfoFetchError())
- })
-
- it('should throw MyInfoCircuitBreakerError when getPerson fails 5 times', async () => {
- mockGetPerson.mockRejectedValue(new Error())
- for (let i = 0; i < 5; i++) {
- await myInfoService.fetchMyInfoPersonData(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS,
- MOCK_ESRVC_ID,
- )
- }
- const result = await myInfoService.fetchMyInfoPersonData(
- MOCK_ACCESS_TOKEN,
- MOCK_REQUESTED_ATTRS,
- MOCK_ESRVC_ID,
- )
-
- // Last function call doesn't count as breaker is open, so expect 5 calls
- expect(mockGetPerson).toHaveBeenCalledTimes(5)
- expect(result._unsafeUnwrapErr()).toEqual(new MyInfoCircuitBreakerError())
- })
- })
-
- describe('prefillMyInfoFields', () => {
- it('should prefill fields correctly', () => {
+ describe('prefillAndSaveMyInfoFields', () => {
+ it('should prefill fields correctly', async () => {
const mockData = new MyInfoData({
data: MOCK_MYINFO_DATA,
uinFin: MOCK_UINFIN,
})
- const result = myInfoService.prefillMyInfoFields(
+ const result = await myInfoService.prefillAndSaveMyInfoFields(
+ new ObjectId().toHexString(),
mockData,
MOCK_FORM_FIELDS as IFieldSchema[],
)
@@ -313,7 +258,7 @@ describe('MyInfoService', () => {
MOCK_POPULATED_FORM_FIELDS as IPossiblyPrefilledField[],
)
expect(result._unsafeUnwrapErr()).toEqual(
- new Error('Failed to save MyInfo hashes to database'),
+ new DatabaseError('Failed to save MyInfo hashes to database'),
)
})
})
@@ -435,4 +380,107 @@ describe('MyInfoService', () => {
)
})
})
+
+ describe('getMyInfoDataForForm', () => {
+ // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls
+ beforeEach(() => {
+ myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS)
+ })
+
+ it('should return myInfo data when the provided form and cookie is valid', async () => {
+ // Arrange
+ const mockReturnedParams = {
+ uinFin: MOCK_UINFIN,
+ data: MOCK_MYINFO_DATA,
+ }
+
+ mockGetPerson.mockResolvedValueOnce(mockReturnedParams)
+
+ // Act
+ const result = await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE },
+ )
+
+ // Assert
+ expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams))
+ })
+
+ it('should call MyInfoGovClient.getPerson with the correct parameters', async () => {
+ // Arrange
+ const mockReturnedParams = {
+ uinFin: MOCK_UINFIN,
+ data: MOCK_MYINFO_DATA,
+ }
+ mockGetPerson.mockResolvedValueOnce(mockReturnedParams)
+
+ // Act
+ const result = await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE },
+ )
+
+ // Assert
+ expect(mockGetPerson).toHaveBeenCalledWith(
+ MOCK_ACCESS_TOKEN,
+ MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute),
+ MOCK_ESRVC_ID,
+ )
+ expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams))
+ })
+
+ it('should throw MyInfoFetchError when getPerson fails once', async () => {
+ // Arrange
+ mockGetPerson.mockRejectedValueOnce(new Error())
+
+ // Act
+ const result = await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE },
+ )
+
+ // Assert
+ expect(mockGetPerson).toHaveBeenCalledWith(
+ MOCK_ACCESS_TOKEN,
+ MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute),
+ MOCK_ESRVC_ID,
+ )
+ expect(result._unsafeUnwrapErr()).toEqual(new MyInfoFetchError())
+ })
+
+ it('should throw MyInfoCircuitBreakerError when getPerson fails 5 times', async () => {
+ // Arrange
+ mockGetPerson.mockRejectedValue(new Error())
+ for (let i = 0; i < 5; i++) {
+ await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE },
+ )
+ }
+
+ // Act
+ const result = await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE },
+ )
+
+ // Assert
+ // Last function call doesn't count as breaker is open, so expect 5 calls
+ expect(mockGetPerson).toHaveBeenCalledTimes(5)
+ expect(result._unsafeUnwrapErr()).toEqual(new MyInfoCircuitBreakerError())
+ })
+ it('should not validate the form if the cookie does not exist', async () => {
+ // Arrange
+ const expected = new MyInfoMissingAccessTokenError()
+
+ // Act
+ const result = await myInfoService.getMyInfoDataForForm(
+ MOCK_MYINFO_FORM as IPopulatedForm,
+ {},
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+ })
})
diff --git a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts
index 3ac62d4c4b..033b77af50 100644
--- a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts
+++ b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts
@@ -5,12 +5,16 @@ import {
MyInfoSource,
} from '@opengovsg/myinfo-gov-client'
import { ObjectId } from 'bson'
-import { merge, zipWith } from 'lodash'
+import { merge, omit, zipWith } from 'lodash'
import { ISpcpMyInfo } from 'src/config/feature-manager'
-import { Environment, IFormSchema, MyInfoAttribute } from 'src/types'
+import { AuthType, Environment, IFormSchema, MyInfoAttribute } from 'src/types'
-import { IMyInfoServiceConfig } from '../myinfo.types'
+import {
+ IMyInfoServiceConfig,
+ MyInfoCookieState,
+ MyInfoSuccessfulCookiePayload,
+} from '../myinfo.types'
export const MOCK_MYINFO_DATA = {
name: {
@@ -144,6 +148,23 @@ export const MOCK_SERVICE_PARAMS: IMyInfoServiceConfig = {
export const MOCK_MYINFO_FORM = ({
_id: MOCK_FORM_ID,
esrvcId: MOCK_ESRVC_ID,
- authType: 'MyInfo',
+ authType: AuthType.MyInfo,
+ admin: {
+ _id: new ObjectId().toHexString(),
+ agency: new ObjectId().toHexString(),
+ },
getUniqueMyInfoAttrs: () => MOCK_REQUESTED_ATTRS,
+ getPublicView: function () {
+ return omit(this, 'admin')
+ },
+ toJSON: function () {
+ return this
+ },
+ form_fields: [],
} as unknown) as IFormSchema
+
+export const MOCK_SUCCESSFUL_COOKIE: MyInfoSuccessfulCookiePayload = {
+ accessToken: MOCK_ACCESS_TOKEN,
+ usedCount: 0,
+ state: MyInfoCookieState.Success,
+}
diff --git a/src/app/modules/myinfo/myinfo.errors.ts b/src/app/modules/myinfo/myinfo.errors.ts
index 29f25c8c04..f149653498 100644
--- a/src/app/modules/myinfo/myinfo.errors.ts
+++ b/src/app/modules/myinfo/myinfo.errors.ts
@@ -103,3 +103,12 @@ export class MyInfoCookieStateError extends ApplicationError {
super(message)
}
}
+
+/**
+ * MyInfo cookie has been used more than once
+ */
+export class MyInfoCookieAccessError extends ApplicationError {
+ constructor(message = 'MyInfo cookie has already been used') {
+ super(message)
+ }
+}
diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts
index 9327f04d5f..d7f51b04b6 100644
--- a/src/app/modules/myinfo/myinfo.factory.ts
+++ b/src/app/modules/myinfo/myinfo.factory.ts
@@ -10,19 +10,24 @@ import {
IFieldSchema,
IHashes,
IMyInfoHashSchema,
- MyInfoAttribute,
+ IPopulatedForm,
} from '../../../types'
import { DatabaseError, MissingFeatureError } from '../core/core.errors'
import { ProcessedFieldResponse } from '../submission/submission.types'
import { MyInfoData } from './myinfo.adapter'
import {
+ MyInfoAuthTypeError,
MyInfoCircuitBreakerError,
+ MyInfoCookieAccessError,
+ MyInfoCookieStateError,
MyInfoFetchError,
MyInfoHashDidNotMatchError,
MyInfoHashingError,
MyInfoInvalidAccessTokenError,
+ MyInfoMissingAccessTokenError,
MyInfoMissingHashError,
+ MyInfoNoESrvcIdError,
MyInfoParseRelayStateError,
} from './myinfo.errors'
import { MyInfoService } from './myinfo.service'
@@ -39,24 +44,20 @@ interface IMyInfoFactory {
retrieveAccessToken: (
authCode: string,
) => ResultAsync
- fetchMyInfoPersonData: (
- accessToken: string,
- requestedAttributes: MyInfoAttribute[],
- singpassEserviceId: string,
- ) => ResultAsync<
- MyInfoData,
- MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError
- >
parseMyInfoRelayState: (
relayState: string,
) => Result<
MyInfoParsedRelayState,
MyInfoParseRelayStateError | MissingFeatureError
>
- prefillMyInfoFields: (
+ prefillAndSaveMyInfoFields: (
+ formId: string,
myInfoData: MyInfoData,
currFormFields: LeanDocument,
- ) => Result
+ ) => ResultAsync<
+ IPossiblyPrefilledField[],
+ MyInfoHashingError | DatabaseError
+ >
saveMyInfoHashes: (
uinFin: string,
formId: string,
@@ -82,6 +83,21 @@ interface IMyInfoFactory {
extractUinFin: (
accessToken: string,
) => Result
+
+ getMyInfoDataForForm: (
+ form: IPopulatedForm,
+ cookies: Record,
+ ) => ResultAsync<
+ MyInfoData,
+ | MyInfoMissingAccessTokenError
+ | MyInfoCookieStateError
+ | MyInfoNoESrvcIdError
+ | MyInfoAuthTypeError
+ | MyInfoCircuitBreakerError
+ | MyInfoFetchError
+ | MissingFeatureError
+ | MyInfoCookieAccessError
+ >
}
export const createMyInfoFactory = ({
@@ -92,14 +108,14 @@ export const createMyInfoFactory = ({
const error = new MissingFeatureError(FeatureNames.SpcpMyInfo)
return {
retrieveAccessToken: () => errAsync(error),
- fetchMyInfoPersonData: () => errAsync(error),
- prefillMyInfoFields: () => err(error),
+ prefillAndSaveMyInfoFields: () => errAsync(error),
saveMyInfoHashes: () => errAsync(error),
fetchMyInfoHashes: () => errAsync(error),
checkMyInfoHashes: () => errAsync(error),
createRedirectURL: () => err(error),
parseMyInfoRelayState: () => err(error),
extractUinFin: () => err(error),
+ getMyInfoDataForForm: () => errAsync(error),
}
}
return new MyInfoService({
diff --git a/src/app/modules/myinfo/myinfo.middleware.ts b/src/app/modules/myinfo/myinfo.middleware.ts
deleted file mode 100644
index 2741a4a211..0000000000
--- a/src/app/modules/myinfo/myinfo.middleware.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-// TODO (#144): move these into their respective controllers when
-// those controllers are being refactored.
-// A services module should not contain a controller.
-import { RequestHandler } from 'express'
-import { ParamsDictionary } from 'express-serve-static-core'
-
-import { createLoggerWithLabel } from '../../../config/logger'
-import { AuthType, WithForm, WithJsonForm } from '../../../types'
-import { createReqMeta } from '../../utils/request'
-
-import { MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS } from './myinfo.constants'
-import { MyInfoFactory } from './myinfo.factory'
-import { MyInfoCookiePayload, MyInfoCookieState } from './myinfo.types'
-import { extractMyInfoCookie, validateMyInfoForm } from './myinfo.util'
-
-const logger = createLoggerWithLabel(module)
-
-/**
- * Middleware for prefilling MyInfo values.
- * @returns next, always. If any error occurs, res.locals.myInfoError is set to true.
- */
-export const addMyInfo: RequestHandler = async (
- req,
- res,
- next,
-) => {
- // TODO (#42): add proper types here when migrating away from middleware pattern
- const formDocument = (req as WithForm).form
- const formJson = formDocument.toJSON()
- const myInfoCookieResult = extractMyInfoCookie(req.cookies)
-
- // No action needed if no cookie is present, this just means user is not signed in
- if (formDocument.authType !== AuthType.MyInfo || myInfoCookieResult.isErr())
- return next()
- const myInfoCookie = myInfoCookieResult.value
-
- // Error occurred while retrieving access token
- if (myInfoCookie.state !== MyInfoCookieState.Success) {
- res.locals.myInfoError = true
- // Important - clear the cookie so that user will not see error on refresh
- res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS)
- return next()
- }
-
- // Access token is already used
- if (myInfoCookie.usedCount > 0) {
- res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS)
- return next()
- }
-
- const requestedAttributes = (req as WithForm<
- typeof req
- >).form.getUniqueMyInfoAttrs()
- return validateMyInfoForm(formDocument)
- .asyncAndThen((form) =>
- MyInfoFactory.fetchMyInfoPersonData(
- myInfoCookie.accessToken,
- requestedAttributes,
- form.esrvcId,
- ),
- )
- .andThen((myInfoData) => {
- // Increment count in cookie
- const cookiePayload: MyInfoCookiePayload = {
- ...myInfoCookie,
- usedCount: myInfoCookie.usedCount + 1,
- }
- res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS)
- return MyInfoFactory.prefillMyInfoFields(
- myInfoData,
- formJson.form_fields,
- ).asyncAndThen((prefilledFields) => {
- formJson.form_fields = prefilledFields
- ;(req as WithJsonForm).form = formJson
- res.locals.spcpSession = { userName: myInfoData.getUinFin() }
- return MyInfoFactory.saveMyInfoHashes(
- myInfoData.getUinFin(),
- formDocument._id,
- prefilledFields,
- )
- })
- })
- .map(() => next())
- .mapErr((error) => {
- // No need for cookie if data could not be retrieved
- res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS)
- logger.error({
- message: error.message,
- meta: {
- action: 'addMyInfo',
- ...createReqMeta(req),
- formId: formDocument._id,
- esrvcId: formDocument.esrvcId,
- requestedAttributes,
- },
- error,
- })
- res.locals.myInfoError = true
- return next()
- })
-}
diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts
index 92291c1b23..99e5062269 100644
--- a/src/app/modules/myinfo/myinfo.service.ts
+++ b/src/app/modules/myinfo/myinfo.service.ts
@@ -16,9 +16,10 @@ import {
IFieldSchema,
IHashes,
IMyInfoHashSchema,
+ IPopulatedForm,
MyInfoAttribute,
} from '../../../types'
-import { DatabaseError } from '../core/core.errors'
+import { DatabaseError, MissingFeatureError } from '../core/core.errors'
import { ProcessedFieldResponse } from '../submission/submission.types'
import { internalAttrListToScopes, MyInfoData } from './myinfo.adapter'
@@ -28,12 +29,17 @@ import {
MYINFO_ROUTER_PREFIX,
} from './myinfo.constants'
import {
+ MyInfoAuthTypeError,
MyInfoCircuitBreakerError,
+ MyInfoCookieAccessError,
+ MyInfoCookieStateError,
MyInfoFetchError,
MyInfoHashDidNotMatchError,
MyInfoHashingError,
MyInfoInvalidAccessTokenError,
+ MyInfoMissingAccessTokenError,
MyInfoMissingHashError,
+ MyInfoNoESrvcIdError,
MyInfoParseRelayStateError,
} from './myinfo.errors'
import {
@@ -43,10 +49,13 @@ import {
MyInfoParsedRelayState,
} from './myinfo.types'
import {
+ assertMyInfoCookieUnused,
compareHashedValues,
createRelayState,
+ extractAndAssertMyInfoCookieValidity,
hashFieldValues,
isMyInfoRelayState,
+ validateMyInfoForm,
} from './myinfo.util'
import getMyInfoHashModel from './myinfo_hash.model'
@@ -86,6 +95,12 @@ export class MyInfoService {
*/
#spCookieMaxAge: number
+ #fetchMyInfoPersonData: (
+ accessToken: string,
+ requestedAttributes: MyInfoAttribute[],
+ singpassEserviceId: string,
+ ) => ResultAsync
+
/**
*
* @param myInfoConfig Environment variables including myInfoClientMode and myInfoKeyPath
@@ -118,6 +133,54 @@ export class MyInfoService {
this.#myInfoGovClient.getPerson(accessToken, attributes, eSrvcId),
BREAKER_PARAMS,
)
+
+ /**
+ * Fetches MyInfo person detail with given params.
+ * This function has circuit breaking built into it, and will throw an error
+ * if any recent usages of this function returned an error.
+ * @param params The params required to retrieve the data.
+ * @param params.uinFin The uin/fin of the person's data to retrieve.
+ * @param params.requestedAttributes The requested attributes to fetch.
+ * @param params.singpassEserviceId The eservice id of the form requesting the data.
+ * @returns the person object retrieved.
+ * @throws an error on fetch failure or if circuit breaker is in the opened state. Use {@link CircuitBreaker#isOurError} to determine if a rejection was a result of the circuit breaker or the action.
+ */
+ this.#fetchMyInfoPersonData = function (
+ accessToken: string,
+ requestedAttributes: MyInfoAttribute[],
+ singpassEserviceId: string,
+ ): ResultAsync {
+ return ResultAsync.fromPromise(
+ this.#myInfoPersonBreaker
+ .fire(
+ accessToken,
+ internalAttrListToScopes(requestedAttributes),
+ singpassEserviceId,
+ )
+ .then((response) => new MyInfoData(response)),
+ (error) => {
+ const logMeta = {
+ action: 'fetchMyInfoPersonData',
+ requestedAttributes,
+ }
+ if (CircuitBreaker.isOurError(error)) {
+ logger.error({
+ message: 'Circuit breaker tripped',
+ meta: logMeta,
+ error,
+ })
+ return new MyInfoCircuitBreakerError()
+ } else {
+ logger.error({
+ message: 'Error retrieving data from MyInfo',
+ meta: logMeta,
+ error,
+ })
+ return new MyInfoFetchError()
+ }
+ },
+ )
+ }
}
/**
@@ -218,64 +281,21 @@ export class MyInfoService {
)
}
- /**
- * Fetches MyInfo person detail with given params.
- * This function has circuit breaking built into it, and will throw an error
- * if any recent usages of this function returned an error.
- * @param params The params required to retrieve the data.
- * @param params.uinFin The uin/fin of the person's data to retrieve.
- * @param params.requestedAttributes The requested attributes to fetch.
- * @param params.singpassEserviceId The eservice id of the form requesting the data.
- * @returns the person object retrieved.
- * @throws an error on fetch failure or if circuit breaker is in the opened state. Use {@link CircuitBreaker#isOurError} to determine if a rejection was a result of the circuit breaker or the action.
- */
- fetchMyInfoPersonData(
- accessToken: string,
- requestedAttributes: MyInfoAttribute[],
- singpassEserviceId: string,
- ): ResultAsync {
- return ResultAsync.fromPromise(
- this.#myInfoPersonBreaker
- .fire(
- accessToken,
- internalAttrListToScopes(requestedAttributes),
- singpassEserviceId,
- )
- .then((response) => new MyInfoData(response)),
- (error) => {
- const logMeta = {
- action: 'fetchMyInfoPersonData',
- requestedAttributes,
- }
- if (CircuitBreaker.isOurError(error)) {
- logger.error({
- message: 'Circuit breaker tripped',
- meta: logMeta,
- error,
- })
- return new MyInfoCircuitBreakerError()
- } else {
- logger.error({
- message: 'Error retrieving data from MyInfo',
- meta: logMeta,
- error,
- })
- return new MyInfoFetchError()
- }
- },
- )
- }
-
/**
* Prefill given current form fields with given MyInfo data.
+ * Saves the has of the prefilled fields as well because the two operations are atomic and should not be separated
* @param myInfoData
* @param currFormFields
* @returns currFormFields with the MyInfo fields prefilled with data from myInfoData
*/
- prefillMyInfoFields(
+ prefillAndSaveMyInfoFields(
+ formId: string,
myInfoData: MyInfoData,
currFormFields: LeanDocument,
- ): Result {
+ ): ResultAsync<
+ IPossiblyPrefilledField[],
+ MyInfoHashingError | DatabaseError
+ > {
const prefilledFields = currFormFields.map((field) => {
if (!field.myInfo?.attr) return field
@@ -290,7 +310,11 @@ export class MyInfoService {
prefilledField.disabled = isReadOnly
return prefilledField
})
- return ok(prefilledFields)
+ return this.saveMyInfoHashes(
+ myInfoData.getUinFin(),
+ formId,
+ prefilledFields,
+ ).map(() => prefilledFields)
}
/**
@@ -451,4 +475,46 @@ export class MyInfoService {
},
)()
}
+
+ /**
+ * Gets myInfo data using the provided form and the cookies of the request
+ * @param form the form to validate
+ * @param cookies cookies of the request
+ * @returns ok(MyInfoData) if the form has been validated successfully
+ * @returns err(MyInfoMissingAccessTokenError) if no myInfoCookie was found on the request
+ * @returns err(MyInfoCookieStateError) if cookie was not successful
+ * @returns err(MyInfoCookieAccessError) if the cookie has already been used before
+ * @returns err(MyInfoNoESrvcIdError) if form has no eserviceId
+ * @returns err(MyInfoAuthTypeError) if the client was not authenticated using MyInfo
+ * @returns err(MyInfoCircuitBreakerError) if circuit breaker was active
+ * @returns err(MyInfoFetchError) if validated but the data could not be retrieved
+ * @returns err(MissingFeatureError) if using an outdated version that does not support myInfo
+ */
+ getMyInfoDataForForm(
+ form: IPopulatedForm,
+ cookies: Record,
+ ): ResultAsync<
+ MyInfoData,
+ | MyInfoMissingAccessTokenError
+ | MyInfoCookieStateError
+ | MyInfoNoESrvcIdError
+ | MyInfoAuthTypeError
+ | MyInfoCircuitBreakerError
+ | MyInfoFetchError
+ | MissingFeatureError
+ | MyInfoCookieAccessError
+ > {
+ const requestedAttributes = form.getUniqueMyInfoAttrs()
+ return extractAndAssertMyInfoCookieValidity(cookies)
+ .andThen((myInfoCookie) => assertMyInfoCookieUnused(myInfoCookie))
+ .asyncAndThen((cookiePayload) =>
+ validateMyInfoForm(form).asyncAndThen((form) =>
+ this.#fetchMyInfoPersonData(
+ cookiePayload.accessToken,
+ requestedAttributes,
+ form.esrvcId,
+ ),
+ ),
+ )
+ }
}
diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts
index d5d91c8a9f..2be487cad8 100644
--- a/src/app/modules/myinfo/myinfo.types.ts
+++ b/src/app/modules/myinfo/myinfo.types.ts
@@ -44,15 +44,15 @@ export enum MyInfoCookieState {
Error = 'error',
}
+export type MyInfoSuccessfulCookiePayload = {
+ accessToken: string
+ usedCount: number
+ state: MyInfoCookieState.Success
+}
+
export type MyInfoCookiePayload =
- | {
- accessToken: string
- usedCount: number
- state: MyInfoCookieState.Success
- }
- | {
- state: Exclude
- }
+ | MyInfoSuccessfulCookiePayload
+ | { state: Exclude }
/**
* The stringified properties included in the state sent to MyInfo.
diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts
index a77db98b0c..cf154b7274 100644
--- a/src/app/modules/myinfo/myinfo.util.ts
+++ b/src/app/modules/myinfo/myinfo.util.ts
@@ -29,6 +29,7 @@ import { ProcessedFieldResponse } from '../submission/submission.types'
import { MYINFO_COOKIE_NAME } from './myinfo.constants'
import {
MyInfoAuthTypeError,
+ MyInfoCookieAccessError,
MyInfoCookieStateError,
MyInfoHashDidNotMatchError,
MyInfoHashingError,
@@ -44,6 +45,7 @@ import {
MyInfoCookieState,
MyInfoHashPromises,
MyInfoRelayState,
+ MyInfoSuccessfulCookiePayload,
VisibleMyInfoResponse,
} from './myinfo.types'
@@ -346,6 +348,53 @@ export const extractMyInfoCookie = (
return err(new MyInfoMissingAccessTokenError())
}
+/**
+ * Asserts that myInfoCookie is in success state
+ * This function acts as a discriminator so that the type of the cookie is encoded in its type
+ * @param cookie the cookie to
+ * @returns ok(cookie) the successful myInfoCookie
+ * @returns err(cookie) the errored cookie
+ */
+export const assertMyInfoCookieSuccessState = (
+ cookie: MyInfoCookiePayload,
+): Result =>
+ cookie.state === MyInfoCookieState.Success
+ ? ok(cookie)
+ : err(new MyInfoCookieStateError())
+
+/**
+ * Extracts and asserts a successful myInfoCookie from a request's cookies
+ * @param cookies Cookies in a request
+ * @return ok(cookie) the successful myInfoCookie
+ * @return err(MyInfoMissingAccessTokenError) if myInfoCookie is not present on the request
+ * @return err(MyInfoCookieStateError) if the extracted myInfoCookie was in an error state
+ * @return err(MyInfoCookieAccessError) if the cookie has been accessed before
+ */
+export const extractAndAssertMyInfoCookieValidity = (
+ cookies: Record,
+): Result<
+ MyInfoSuccessfulCookiePayload,
+ | MyInfoCookieStateError
+ | MyInfoMissingAccessTokenError
+ | MyInfoCookieAccessError
+> =>
+ extractMyInfoCookie(cookies)
+ .andThen((cookiePayload) => assertMyInfoCookieSuccessState(cookiePayload))
+ .andThen((cookiePayload) => assertMyInfoCookieUnused(cookiePayload))
+
+/**
+ * Asserts that the myInfoCookie has not been used before
+ * @param myInfoCookie
+ * @returns ok(myInfoCookie) if the cookie has not been used before
+ * @returns err(MyInfoCookieAccessError) if the cookie has been used before
+ */
+export const assertMyInfoCookieUnused = (
+ myInfoCookie: MyInfoSuccessfulCookiePayload,
+): Result =>
+ myInfoCookie.usedCount <= 0
+ ? ok(myInfoCookie)
+ : err(new MyInfoCookieAccessError())
+
/**
* Extracts access token from a MyInfo cookie
* @param cookie Cookie from which access token should be extracted
diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts
index 3a96990ed0..43746bbcf4 100644
--- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts
+++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts
@@ -13,6 +13,7 @@ import dbHandler from 'tests/unit/backend/helpers/jest-db'
import {
CreateRedirectUrlError,
FetchLoginPageError,
+ InvalidJwtError,
InvalidOOBParamsError,
LoginPageValidationError,
MissingAttributesError,
@@ -226,7 +227,7 @@ describe('spcp.service', () => {
expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD)
})
- it('should return VerifyJwtError when SingPass JWT is invalid', async () => {
+ it('should return VerifyJwtError when SingPass JWT could not be verified', async () => {
const spcpService = new SpcpService(MOCK_PARAMS)
// Assumes that SP auth client was instantiated first
const mockClient = mocked(MockAuthClient.mock.instances[0], true)
@@ -237,6 +238,21 @@ describe('spcp.service', () => {
expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError())
})
+ it('should return InvalidJwtError when SP JWT has invalid shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[0], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {}))
+ const expected = new InvalidJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.SP)
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+
it('should return the correct payload for Corppass when JWT is valid', async () => {
const spcpService = new SpcpService(MOCK_PARAMS)
// Assumes that SP auth client was instantiated first
@@ -248,7 +264,7 @@ describe('spcp.service', () => {
expect(result._unsafeUnwrap()).toEqual(MOCK_CP_JWT_PAYLOAD)
})
- it('should return VerifyJwtError when CorpPass JWT is invalid', async () => {
+ it('should return VerifyJwtError when CorpPass JWT could not be verified', async () => {
const spcpService = new SpcpService(MOCK_PARAMS)
// Assumes that SP auth client was instantiated first
const mockClient = mocked(MockAuthClient.mock.instances[1], true)
@@ -258,6 +274,21 @@ describe('spcp.service', () => {
const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP)
expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError())
})
+
+ it('should return InvalidJwtError when CorpPass JWT has invalid shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[1], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {}))
+ const expected = new InvalidJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP)
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
})
describe('parseOOBParams', () => {
@@ -768,4 +799,149 @@ describe('spcp.service', () => {
expect(result._unsafeUnwrapErr()).toEqual(new MissingJwtError())
})
})
+
+ describe('extractJwtPayloadFromRequest', () => {
+ it('should return a SP JWT payload when there is a valid JWT in the request', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[0], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
+ cb(null, MOCK_SP_JWT_PAYLOAD),
+ )
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.SP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD)
+ })
+
+ it('should return a CP JWT payload when there is a valid JWT in the request', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that CP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[1], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
+ cb(null, MOCK_CP_JWT_PAYLOAD),
+ )
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.CP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrap()).toEqual(MOCK_CP_JWT_PAYLOAD)
+ })
+
+ it('should return MissingJwtError if there is no JWT when client authenticates using SP', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ const expected = new MissingJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.SP,
+ {},
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+
+ it('should return MissingJwtError when client authenticates using CP and there is no JWT', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ const expected = new MissingJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.CP,
+ {},
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+
+ it('should return InvalidJwtError when the client authenticates using SP and the JWT has wrong shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[0], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
+ cb(new Error(), null),
+ )
+ const expected = new VerifyJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.SP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+
+ it('should return VerifyJwtError when the client authenticates using CP and the JWT has wrong shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[1], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
+ cb(new Error(), null),
+ )
+ const expected = new VerifyJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.CP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+ it('should return InvalidJwtError when the client authenticates using SP and the JWT has invalid shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[0], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {}))
+ const expected = new InvalidJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.SP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+
+ it('should return InvalidJwtError when the client authenticates using CP and the JWT has invalid shape', async () => {
+ // Arrange
+ const spcpService = new SpcpService(MOCK_PARAMS)
+ // Assumes that SP auth client was instantiated first
+ const mockClient = mocked(MockAuthClient.mock.instances[1], true)
+ mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {}))
+ const expected = new InvalidJwtError()
+
+ // Act
+ const result = await spcpService.extractJwtPayloadFromRequest(
+ AuthType.CP,
+ MOCK_COOKIES,
+ )
+
+ // Assert
+ expect(result._unsafeUnwrapErr()).toEqual(expected)
+ })
+ })
})
diff --git a/src/app/modules/spcp/__tests__/spcp.test.constants.ts b/src/app/modules/spcp/__tests__/spcp.test.constants.ts
index 6a059fc214..2a036f4a12 100644
--- a/src/app/modules/spcp/__tests__/spcp.test.constants.ts
+++ b/src/app/modules/spcp/__tests__/spcp.test.constants.ts
@@ -1,6 +1,7 @@
import { MyInfoMode } from '@opengovsg/myinfo-gov-client'
import { ObjectId } from 'bson'
import crypto from 'crypto'
+import _ from 'lodash'
import { ISpcpMyInfo } from 'src/config/feature-manager'
import { ILoginSchema, IPopulatedForm } from 'src/types'
@@ -114,6 +115,7 @@ export const MOCK_SP_FORM = ({
_id: new ObjectId().toHexString(),
agency: new ObjectId().toHexString(),
},
+ getPublicView: () => _.omit(this, 'admin'),
} as unknown) as IPopulatedForm
export const MOCK_CP_FORM = ({
@@ -124,6 +126,17 @@ export const MOCK_CP_FORM = ({
_id: new ObjectId().toHexString(),
agency: new ObjectId().toHexString(),
},
+ getPublicView: () => _.omit(this, 'admin'),
+} as unknown) as IPopulatedForm
+
+export const MOCK_MYINFO_FORM = ({
+ authType: 'MyInfo',
+ title: 'Mock MyInfo form',
+ _id: new ObjectId().toHexString(),
+ admin: {
+ _id: new ObjectId().toHexString(),
+ agency: new ObjectId().toHexString(),
+ },
} as unknown) as IPopulatedForm
export const MOCK_LOGIN_DOC = {
diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts
index d7db1f322d..69cc2c031c 100644
--- a/src/app/modules/spcp/spcp.factory.ts
+++ b/src/app/modules/spcp/spcp.factory.ts
@@ -19,6 +19,7 @@ interface ISpcpFactory {
createJWT: SpcpService['createJWT']
createJWTPayload: SpcpService['createJWTPayload']
getCookieSettings: SpcpService['getCookieSettings']
+ extractJwtPayloadFromRequest: SpcpService['extractJwtPayloadFromRequest']
}
export const createSpcpFactory = ({
@@ -38,6 +39,7 @@ export const createSpcpFactory = ({
createJWT: () => err(error),
createJWTPayload: () => err(error),
getCookieSettings: () => ({}),
+ extractJwtPayloadFromRequest: () => errAsync(error),
}
}
return new SpcpService(props)
diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts
index aae6ae8b9e..74911cdbda 100644
--- a/src/app/modules/spcp/spcp.service.ts
+++ b/src/app/modules/spcp/spcp.service.ts
@@ -201,10 +201,7 @@ export class SpcpService {
): Result {
const jwtName = authType === AuthType.SP ? JwtName.SP : JwtName.CP
const cookie = cookies[jwtName]
- if (!cookie) {
- return err(new MissingJwtError())
- }
- return ok(cookie)
+ return cookie ? ok(cookie) : err(new MissingJwtError())
}
/**
@@ -394,4 +391,25 @@ export class SpcpService {
const spcpCookieDomain = this.#spcpProps.spcpCookieDomain
return spcpCookieDomain ? { domain: spcpCookieDomain, path: '/' } : {}
}
+
+ /**
+ * Gets the spcp session info from the auth, cookies
+ * @param authType The authentication type of the user
+ * @param cookies The spcp cookies set by the redirect
+ * @return ok(jwtPayload) if successful
+ * @return err(MissingJwtError) if the specified cookie for the authType (spcp) does not exist
+ * @return err(VerifyJwtError) if the jwt exists but could not be authenticated
+ * @return err(InvalidJwtError) if the jwt exists but the payload is invalid
+ */
+ extractJwtPayloadFromRequest(
+ authType: AuthType.SP | AuthType.CP,
+ cookies: SpcpCookies,
+ ): ResultAsync<
+ JwtPayload,
+ VerifyJwtError | InvalidJwtError | MissingJwtError
+ > {
+ return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) =>
+ this.extractJwtPayload(jwtResult, authType),
+ )
+ }
}
diff --git a/src/app/routes/public-forms.server.routes.js b/src/app/routes/public-forms.server.routes.js
index 2e4bf9cf81..b3ed749508 100644
--- a/src/app/routes/public-forms.server.routes.js
+++ b/src/app/routes/public-forms.server.routes.js
@@ -4,14 +4,11 @@
* Module dependencies.
*/
const forms = require('../../app/controllers/forms.server.controller')
-const publicForms = require('../modules/form/public-form/public-form.middlewares')
-const MyInfoMiddleware = require('../modules/myinfo/myinfo.middleware')
const { celebrate, Joi, Segments } = require('celebrate')
const { CaptchaFactory } = require('../services/captcha/captcha.factory')
const { limitRate } = require('../utils/limit-rate')
const { rateLimitConfig } = require('../../config/config')
const PublicFormController = require('../modules/form/public-form/public-form.controller')
-const SpcpController = require('../modules/spcp/spcp.controller')
const EncryptSubmissionController = require('../modules/submission/encrypt-submission/encrypt-submission.controller')
const EncryptSubmissionMiddleware = require('../modules/submission/encrypt-submission/encrypt-submission.middleware')
@@ -127,14 +124,7 @@ module.exports = function (app) {
*/
app
.route('/:formId([a-fA-F0-9]{24})/publicform')
- .get(
- forms.formById,
- publicForms.isFormPublicCheck,
- publicForms.checkFormSubmissionLimitAndDeactivate,
- SpcpController.addSpcpSessionInfo,
- MyInfoMiddleware.addMyInfo,
- forms.read(forms.REQUEST_TYPE.PUBLIC),
- )
+ .get(PublicFormController.handleGetPublicForm)
/**
* On preview, submit a form response, and stores the encrypted contents. Optionally, an autoreply
diff --git a/src/app/services/intranet/__tests__/intranet.service.spec.ts b/src/app/services/intranet/__tests__/intranet.service.spec.ts
index 654256d133..ed4db49f4c 100644
--- a/src/app/services/intranet/__tests__/intranet.service.spec.ts
+++ b/src/app/services/intranet/__tests__/intranet.service.spec.ts
@@ -31,7 +31,7 @@ describe('IntranetService', () => {
it('should return true when IP is in intranet IP list', () => {
const result = intranetService.isIntranetIp(MOCK_IP_LIST[0])
- expect(result._unsafeUnwrap()).toBe(true)
+ expect(result).toBe(true)
})
it('should return false when IP is not in intranet IP list', () => {
@@ -39,7 +39,7 @@ describe('IntranetService', () => {
const result = intranetService.isIntranetIp(ipNotInList)
- expect(result._unsafeUnwrap()).toBe(false)
+ expect(result).toBe(false)
})
})
})
diff --git a/src/app/services/intranet/intranet.factory.ts b/src/app/services/intranet/intranet.factory.ts
index 6b15b317a6..32860b2eea 100644
--- a/src/app/services/intranet/intranet.factory.ts
+++ b/src/app/services/intranet/intranet.factory.ts
@@ -1,4 +1,4 @@
-import { err } from 'neverthrow'
+import { err, ok, Result } from 'neverthrow'
import FeatureManager, {
FeatureNames,
@@ -9,7 +9,7 @@ import { MissingFeatureError } from '../../modules/core/core.errors'
import { IntranetService } from './intranet.service'
interface IIntranetFactory {
- isIntranetIp: IntranetService['isIntranetIp']
+ isIntranetIp: (ip: string) => Result
}
export const createIntranetFactory = ({
@@ -17,7 +17,10 @@ export const createIntranetFactory = ({
props,
}: RegisteredFeature): IIntranetFactory => {
if (isEnabled && props?.intranetIpListPath) {
- return new IntranetService(props)
+ const intranetService = new IntranetService(props)
+ return {
+ isIntranetIp: (ip: string) => ok(intranetService.isIntranetIp(ip)),
+ }
}
const error = new MissingFeatureError(FeatureNames.Intranet)
diff --git a/src/app/services/intranet/intranet.service.ts b/src/app/services/intranet/intranet.service.ts
index 56c1f00b97..91d957231b 100644
--- a/src/app/services/intranet/intranet.service.ts
+++ b/src/app/services/intranet/intranet.service.ts
@@ -1,9 +1,7 @@
import fs from 'fs'
-import { ok, Result } from 'neverthrow'
import { IIntranet } from '../../../config/feature-manager'
import { createLoggerWithLabel } from '../../../config/logger'
-import { ApplicationError } from '../../modules/core/core.errors'
const logger = createLoggerWithLabel(module)
@@ -42,7 +40,7 @@ export class IntranetService {
* @param ip IP address to check
* @returns Whether the IP address originated from the intranet
*/
- isIntranetIp(ip: string): Result {
- return ok(this.intranetIps.includes(ip))
+ isIntranetIp(ip: string): boolean {
+ return this.intranetIps.includes(ip)
}
}
diff --git a/src/app/utils/handle-mongo-error.ts b/src/app/utils/handle-mongo-error.ts
index cf3033402c..2ca25fcf43 100644
--- a/src/app/utils/handle-mongo-error.ts
+++ b/src/app/utils/handle-mongo-error.ts
@@ -6,6 +6,7 @@ import {
DatabaseError,
DatabasePayloadSizeError,
DatabaseValidationError,
+ PossibleDatabaseError,
} from '../modules/core/core.errors'
/**
@@ -66,13 +67,7 @@ export const getMongoErrorMessage = (
* @param error the error thrown by database operations
* @returns errors that extend from ApplicationError class
*/
-export const transformMongoError = (
- error: unknown,
-):
- | DatabaseError
- | DatabaseValidationError
- | DatabaseConflictError
- | DatabasePayloadSizeError => {
+export const transformMongoError = (error: unknown): PossibleDatabaseError => {
const errorMessage = getMongoErrorMessage(error)
if (!(error instanceof Error)) {
return new DatabaseError(errorMessage)
@@ -99,3 +94,15 @@ export const transformMongoError = (
return new DatabaseError(errorMessage)
}
+
+export const isMongoError = (error: Error): boolean => {
+ switch (error.constructor) {
+ case DatabaseConflictError:
+ case DatabaseError:
+ case DatabasePayloadSizeError:
+ case DatabaseValidationError:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/src/types/form.ts b/src/types/form.ts
index c2928fb977..e8964e443a 100644
--- a/src/types/form.ts
+++ b/src/types/form.ts
@@ -129,7 +129,7 @@ export interface IForm {
status?: Status
inactiveMessage?: string
- submissionLimit?: number
+ submissionLimit?: number | null
isListed?: boolean
esrvcId?: string
webhook?: Webhook
@@ -208,7 +208,9 @@ export interface IFormDocument extends IFormSchema {
authType: NonNullable
status: NonNullable
inactiveMessage: NonNullable
- submissionLimit: NonNullable
+ // NOTE: Due to the way creating a form works, creating a form without specifying submissionLimit will throw an error.
+ // Hence, using Exclude here over NonNullable.
+ submissionLimit: Exclude
isListed: NonNullable
form_fields: NonNullable
startPage: SetRequired, 'colorTheme'>
From 1ae669516fb5775ec7d2ddeaeac14af32cad4051 Mon Sep 17 00:00:00 2001
From: tshuli <63710093+tshuli@users.noreply.github.com>
Date: Thu, 8 Apr 2021 17:45:13 +0800
Subject: [PATCH 29/75] refactor: migrate frontend routes and google analytics
factory to ts (#1405)
---
src/app/factories/google-analytics.factory.js | 19 ------
.../frontend.server.controller.spec.ts | 14 ++---
.../google-analytics.factory.spec.ts | 55 ++++++++++++++++
.../frontend/frontend.server.controller.ts} | 62 ++++++++++++++-----
.../frontend/frontend.server.routes.ts | 49 +++++++++++++++
.../frontend/google-analytics.factory.ts | 43 +++++++++++++
src/app/routes/frontend.server.routes.js | 27 --------
src/app/routes/index.js | 5 +-
src/loaders/express/index.ts | 2 +
9 files changed, 203 insertions(+), 73 deletions(-)
delete mode 100644 src/app/factories/google-analytics.factory.js
rename src/app/{controllers => modules/frontend}/__tests__/frontend.server.controller.spec.ts (83%)
create mode 100644 src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts
rename src/app/{controllers/frontend.server.controller.js => modules/frontend/frontend.server.controller.ts} (56%)
create mode 100644 src/app/modules/frontend/frontend.server.routes.ts
create mode 100644 src/app/modules/frontend/google-analytics.factory.ts
delete mode 100644 src/app/routes/frontend.server.routes.js
diff --git a/src/app/factories/google-analytics.factory.js b/src/app/factories/google-analytics.factory.js
deleted file mode 100644
index 0e0a924481..0000000000
--- a/src/app/factories/google-analytics.factory.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const featureManager = require('../../config/feature-manager').default
-const frontend = require('../controllers/frontend.server.controller')
-const { StatusCodes } = require('http-status-codes')
-
-const googleAnalyticsFactory = ({ isEnabled }) => {
- if (isEnabled) {
- return {
- datalayer: frontend.datalayer,
- }
- } else {
- return {
- datalayer: (req, res) => {
- res.type('text/javascript').sendStatus(StatusCodes.OK)
- },
- }
- }
-}
-
-module.exports = googleAnalyticsFactory(featureManager.get('google-analytics'))
diff --git a/src/app/controllers/__tests__/frontend.server.controller.spec.ts b/src/app/modules/frontend/__tests__/frontend.server.controller.spec.ts
similarity index 83%
rename from src/app/controllers/__tests__/frontend.server.controller.spec.ts
rename to src/app/modules/frontend/__tests__/frontend.server.controller.spec.ts
index 5fdc2e0e37..05ce6016a2 100644
--- a/src/app/controllers/__tests__/frontend.server.controller.spec.ts
+++ b/src/app/modules/frontend/__tests__/frontend.server.controller.spec.ts
@@ -2,7 +2,7 @@ import { StatusCodes } from 'http-status-codes'
import expressHandler from 'tests/unit/backend/helpers/jest-express'
-import frontendServerController from '../frontend.server.controller'
+import * as FrontendServerController from '../frontend.server.controller'
describe('frontend.server.controller', () => {
afterEach(() => jest.clearAllMocks())
@@ -24,7 +24,7 @@ describe('frontend.server.controller', () => {
}
describe('datalayer', () => {
it('should return the correct response when the request is valid', () => {
- frontendServerController.datalayer(mockReq, mockRes)
+ FrontendServerController.addGoogleAnalyticsData(mockReq, mockRes)
expect(mockRes.send).toHaveBeenCalledWith(
expect.stringContaining("'app_name': 'xyz'"),
)
@@ -35,20 +35,20 @@ describe('frontend.server.controller', () => {
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.OK)
})
it('should return BAD_REQUEST if the request is not valid', () => {
- frontendServerController.datalayer(mockBadReq, mockRes)
+ FrontendServerController.addGoogleAnalyticsData(mockBadReq, mockRes)
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST)
})
})
describe('environment', () => {
it('should return the correct response when the request is valid', () => {
- frontendServerController.environment(mockReq, mockRes)
+ FrontendServerController.addEnvVarData(mockReq, mockRes)
expect(mockRes.send).toHaveBeenCalledWith('efg')
expect(mockRes.type).toHaveBeenCalledWith('text/javascript')
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.OK)
})
it('should return BAD_REQUEST if the request is not valid', () => {
- frontendServerController.environment(mockBadReq, mockRes)
+ FrontendServerController.addEnvVarData(mockBadReq, mockRes)
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST)
})
})
@@ -65,7 +65,7 @@ describe('frontend.server.controller', () => {
'window.location.hash = "#!/formId?fieldId1=abc&fieldId2=<>'"'
// Note this is different from mockReqModified.query.redirectPath as
// there are html-encoded characters
- frontendServerController.redirectLayer(mockReqModified, mockRes)
+ FrontendServerController.generateRedirectUrl(mockReqModified, mockRes)
expect(mockRes.send).toHaveBeenCalledWith(
expect.stringContaining(redirectString),
)
@@ -73,7 +73,7 @@ describe('frontend.server.controller', () => {
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.OK)
})
it('should return BAD_REQUEST if the request is not valid', () => {
- frontendServerController.redirectLayer(mockBadReq, mockRes)
+ FrontendServerController.generateRedirectUrl(mockBadReq, mockRes)
expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST)
})
})
diff --git a/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts b/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts
new file mode 100644
index 0000000000..a868f00c09
--- /dev/null
+++ b/src/app/modules/frontend/__tests__/google-analytics.factory.spec.ts
@@ -0,0 +1,55 @@
+import { StatusCodes } from 'http-status-codes'
+
+import { FeatureNames, RegisteredFeature } from 'src/config/feature-manager'
+
+import expressHandler from 'tests/unit/backend/helpers/jest-express'
+
+import { createGoogleAnalyticsFactory } from '../google-analytics.factory'
+
+describe('google-analytics.factory', () => {
+ afterEach(() => jest.clearAllMocks())
+ const mockReq = expressHandler.mockRequest({
+ others: {
+ app: {
+ locals: {
+ GATrackingID: 'abc',
+ appName: 'xyz',
+ environment: 'efg',
+ },
+ },
+ },
+ })
+ const mockRes = expressHandler.mockResponse()
+
+ it('should call res correctly if google-analytics feature is disabled', () => {
+ const MOCK_DISABLED_GA_FEATURE: RegisteredFeature = {
+ isEnabled: false,
+ }
+
+ const GoogleAnalyticsFactory = createGoogleAnalyticsFactory(
+ MOCK_DISABLED_GA_FEATURE,
+ )
+
+ GoogleAnalyticsFactory.addGoogleAnalyticsData(mockReq, mockRes)
+
+ expect(mockRes.type).toHaveBeenCalledWith('text/javascript')
+ expect(mockRes.sendStatus).toHaveBeenCalledWith(StatusCodes.OK)
+ expect(mockRes.send).not.toHaveBeenCalled()
+ })
+
+ it('should call res correctly if google-analytics feature is enabled', () => {
+ const MOCK_ENABLED_GA_FEATURE: RegisteredFeature = {
+ isEnabled: true,
+ }
+
+ const GoogleAnalyticsFactory = createGoogleAnalyticsFactory(
+ MOCK_ENABLED_GA_FEATURE,
+ )
+
+ GoogleAnalyticsFactory.addGoogleAnalyticsData(mockReq, mockRes)
+
+ expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('gtag'))
+ expect(mockRes.type).toHaveBeenCalledWith('text/javascript')
+ expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.OK)
+ })
+})
diff --git a/src/app/controllers/frontend.server.controller.js b/src/app/modules/frontend/frontend.server.controller.ts
similarity index 56%
rename from src/app/controllers/frontend.server.controller.js
rename to src/app/modules/frontend/frontend.server.controller.ts
index a89ba93c83..32f54e6a02 100644
--- a/src/app/controllers/frontend.server.controller.js
+++ b/src/app/modules/frontend/frontend.server.controller.ts
@@ -1,16 +1,24 @@
-'use strict'
+import ejs from 'ejs'
+import { RequestHandler } from 'express'
+import { ParamsDictionary } from 'express-serve-static-core'
+import { StatusCodes } from 'http-status-codes'
-const ejs = require('ejs')
-const { StatusCodes } = require('http-status-codes')
-const logger = require('../../config/logger').createLoggerWithLabel(module)
-const { createReqMeta } = require('../utils/request')
+import featureManager from '../../../config/feature-manager'
+import { createLoggerWithLabel } from '../../../config/logger'
+import { createReqMeta } from '../../utils/request'
+
+const logger = createLoggerWithLabel(module)
/**
- * Google Tag Manager initialisation Javascript code templated
- * with environment variables.
- * @returns {String} Templated Javascript code for the frontend
+ * Handler for GET /frontend/datalayer endpoint.
+ * @param req - Express request object
+ * @param res - Express response object
+ * @returns {String} Templated Javascript code for the frontend to initialise Google Tag Manager
*/
-module.exports.datalayer = function (req, res) {
+export const addGoogleAnalyticsData: RequestHandler<
+ ParamsDictionary,
+ string | { message: string }
+> = (req, res) => {
const js = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
@@ -40,10 +48,15 @@ module.exports.datalayer = function (req, res) {
}
/**
- * Custom Javascript code templated with environment variables.
- * @returns {String} Templated Javascript code for the frontend
+ * Handler for GET /frontend/environment endpoint.
+ * @param req - Express request object
+ * @param res - Express response object
+ * @returns {String} Templated Javascript code with environment variables for the frontend
*/
-module.exports.environment = function (req, res) {
+export const addEnvVarData: RequestHandler<
+ ParamsDictionary,
+ { message: string }
+> = (req, res) => {
try {
return res
.type('text/javascript')
@@ -65,10 +78,15 @@ module.exports.environment = function (req, res) {
}
/**
- * Custom Javascript code that redirects to specific form url
- * @returns {String} Templated Javascript code for the frontend
+ * Handler for GET /frontend/redirect endpoint.
+ * @param req - Express request object
+ * @param res - Express response object
+ * @returns {String} Templated Javascript code for the frontend that redirects to specific form url
*/
-module.exports.redirectLayer = function (req, res) {
+export const generateRedirectUrl: RequestHandler<
+ ParamsDictionary,
+ string | { message: string }
+> = (req, res) => {
const js = `
// Update hash to match form id
window.location.hash = "#!/<%= redirectPath%>"
@@ -79,7 +97,6 @@ module.exports.redirectLayer = function (req, res) {
// Prefer to replace just '&' instead of using <%- to output unescaped values into the template
// As this could potentially introduce security vulnerability
// See https://ejs.co/#docs for tags
-
try {
const ejsRendered = ejs.render(js, req.query).replace(/&/g, '&')
return res.type('text/javascript').status(StatusCodes.OK).send(ejsRendered)
@@ -97,3 +114,16 @@ module.exports.redirectLayer = function (req, res) {
})
}
}
+
+/**
+ * Handler for GET /frontend/features endpoint.
+ * @param req - Express request object
+ * @param res - Express response object
+ * @returns {String} Current featureManager states
+ */
+export const showFeaturesStates: RequestHandler<
+ unknown,
+ typeof featureManager.states
+> = (req, res) => {
+ return res.json(featureManager.states)
+}
diff --git a/src/app/modules/frontend/frontend.server.routes.ts b/src/app/modules/frontend/frontend.server.routes.ts
new file mode 100644
index 0000000000..0038ce44d0
--- /dev/null
+++ b/src/app/modules/frontend/frontend.server.routes.ts
@@ -0,0 +1,49 @@
+import { celebrate, Joi, Segments } from 'celebrate'
+import { Router } from 'express'
+
+import * as FrontendServerController from './frontend.server.controller'
+import { GoogleAnalyticsFactory } from './google-analytics.factory'
+
+export const FrontendRouter = Router()
+
+/**
+ * Generate the templated Javascript code for the frontend to initialise Google Tag Manager
+ * Code depends on whether googleAnalyticsFeature.isEnabled
+ * @route GET /frontend/datalayer
+ * @return 200 when code generation is successful
+ * @return 400 when code generation fails
+ */
+FrontendRouter.get('/datalayer', GoogleAnalyticsFactory.addGoogleAnalyticsData)
+
+/**
+ * Generate the templated Javascript code with environment variables for the frontend
+ * @route GET /frontend/environment
+ * @return 200 when code generation is successful
+ * @return 400 when code generation fails
+ */
+FrontendRouter.get('/environment', FrontendServerController.addEnvVarData)
+
+/**
+ * Generate a json of current activated features
+ * @route GET /frontend/features
+ * @return json with featureManager.states
+ */
+FrontendRouter.get('/features', FrontendServerController.showFeaturesStates)
+
+/**
+ * Generate the javascript code to redirect to the correct url
+ * @route GET /frontend/redirect
+ * @return 200 when redirect code is successful
+ * @return 400 when redirect code fails
+ */
+FrontendRouter.get(
+ '/redirect',
+ celebrate({
+ [Segments.QUERY]: {
+ redirectPath: Joi.string()
+ .regex(/^[a-fA-F0-9]{24}(\/(preview|template|use-template))?/)
+ .required(),
+ },
+ }),
+ FrontendServerController.generateRedirectUrl,
+)
diff --git a/src/app/modules/frontend/google-analytics.factory.ts b/src/app/modules/frontend/google-analytics.factory.ts
new file mode 100644
index 0000000000..1273dec4e6
--- /dev/null
+++ b/src/app/modules/frontend/google-analytics.factory.ts
@@ -0,0 +1,43 @@
+import { RequestHandler } from 'express'
+import { ParamsDictionary } from 'express-serve-static-core'
+import { StatusCodes } from 'http-status-codes'
+
+import FeatureManager, {
+ FeatureNames,
+ RegisteredFeature,
+} from '../../../config/feature-manager'
+
+import * as FrontendServerController from './frontend.server.controller'
+
+interface IGoogleAnalyticsFactory {
+ addGoogleAnalyticsData: RequestHandler<
+ ParamsDictionary,
+ string | { message: string }
+ >
+}
+
+const googleAnalyticsFeature = FeatureManager.get(FeatureNames.GoogleAnalytics)
+
+/**
+ * Factory function which returns the correct handler
+ * for /frontend/datalayer endpoint depending on googleAnalyticsFeature.isEnabled
+ * @param googleAnalyticsFeature
+ */
+export const createGoogleAnalyticsFactory = (
+ googleAnalyticsFeature: RegisteredFeature,
+): IGoogleAnalyticsFactory => {
+ if (!googleAnalyticsFeature.isEnabled) {
+ return {
+ addGoogleAnalyticsData: (req, res) => {
+ res.type('text/javascript').sendStatus(StatusCodes.OK)
+ },
+ }
+ }
+ return {
+ addGoogleAnalyticsData: FrontendServerController.addGoogleAnalyticsData,
+ }
+}
+
+export const GoogleAnalyticsFactory = createGoogleAnalyticsFactory(
+ googleAnalyticsFeature,
+)
diff --git a/src/app/routes/frontend.server.routes.js b/src/app/routes/frontend.server.routes.js
deleted file mode 100644
index e139ae225e..0000000000
--- a/src/app/routes/frontend.server.routes.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict'
-
-const frontendCtrl = require('../controllers/frontend.server.controller')
-const featureManager = require('../../config/feature-manager').default
-const googleAnalyticsFactory = require('../factories/google-analytics.factory')
-const { celebrate, Joi, Segments } = require('celebrate')
-
-module.exports = function (app) {
- app.route('/frontend/datalayer').get(googleAnalyticsFactory.datalayer)
-
- app.route('/frontend/environment').get(frontendCtrl.environment)
-
- app.route('/frontend/redirect').get(
- celebrate({
- [Segments.QUERY]: {
- redirectPath: Joi.string()
- .regex(/^[a-fA-F0-9]{24}(\/(preview|template|use-template))?/)
- .required(),
- },
- }),
- frontendCtrl.redirectLayer,
- )
-
- app.route('/frontend/features').get((req, res) => {
- res.json(featureManager.states)
- })
-}
diff --git a/src/app/routes/index.js b/src/app/routes/index.js
index 08270e7337..953d0f67c0 100644
--- a/src/app/routes/index.js
+++ b/src/app/routes/index.js
@@ -1,4 +1 @@
-module.exports = [
- require('./frontend.server.routes.js'),
- require('./public-forms.server.routes.js'),
-]
+module.exports = [require('./public-forms.server.routes.js')]
diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts
index 31129db757..170803036a 100644
--- a/src/loaders/express/index.ts
+++ b/src/loaders/express/index.ts
@@ -14,6 +14,7 @@ import { BillingRouter } from '../../app/modules/billing/billing.routes'
import { BounceRouter } from '../../app/modules/bounce/bounce.routes'
import { ExamplesRouter } from '../../app/modules/examples/examples.routes'
import { AdminFormsRouter } from '../../app/modules/form/admin-form/admin-form.routes'
+import { FrontendRouter } from '../../app/modules/frontend/frontend.server.routes'
import { HomeRouter } from '../../app/modules/home/home.routes'
import { MYINFO_ROUTER_PREFIX } from '../../app/modules/myinfo/myinfo.constants'
import { MyInfoRouter } from '../../app/modules/myinfo/myinfo.routes'
@@ -149,6 +150,7 @@ const loadExpressApp = async (connection: Connection) => {
})
app.use('/', HomeRouter)
+ app.use('/frontend', FrontendRouter)
app.use('/auth', AuthRouter)
app.use('/user', UserRouter)
app.use('/emailnotifications', BounceRouter)
From f32f7a62c7dc81bc3f9dc26f98135ebfacb88f07 Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Thu, 8 Apr 2021 19:15:08 +0800
Subject: [PATCH 30/75] feat: migrate EncryptSubmissionRouter to own router,
remove unused forms.server.controller, remove jasmine (#1592)
* feat: migrate storage mode submission endpoint to own router
* test(EncryptSubRoutes): use imported router instead of creating
* feat: delete unused forms.server.controller.js and tests
* chore: rip out jasmine
---
.travis.yml | 5 +-
package-lock.json | 42 -----
package.json | 12 +-
.../controllers/forms.server.controller.js | 154 ----------------
.../encrypt-submission.routes.spec.ts | 67 +------
.../encrypt-submission.routes.ts | 30 ++++
.../modules/submission/submission.routes.ts | 3 +
src/app/routes/public-forms.server.routes.js | 36 ----
tests/unit/backend/.eslintrc | 18 --
.../forms.server.controller.spec.js | 164 ------------------
tests/unit/backend/helpers/db-handler.js | 132 --------------
tests/unit/backend/helpers/index.js | 14 --
tests/unit/backend/helpers/reporter.js | 11 --
tests/unit/backend/helpers/roles.js | 6 -
tests/unit/backend/jasmine.json | 11 --
15 files changed, 44 insertions(+), 661 deletions(-)
delete mode 100644 src/app/controllers/forms.server.controller.js
create mode 100644 src/app/modules/submission/encrypt-submission/encrypt-submission.routes.ts
delete mode 100644 tests/unit/backend/.eslintrc
delete mode 100644 tests/unit/backend/controllers/forms.server.controller.spec.js
delete mode 100644 tests/unit/backend/helpers/db-handler.js
delete mode 100644 tests/unit/backend/helpers/index.js
delete mode 100644 tests/unit/backend/helpers/reporter.js
delete mode 100644 tests/unit/backend/helpers/roles.js
delete mode 100644 tests/unit/backend/jasmine.json
diff --git a/.travis.yml b/.travis.yml
index 188a48ad1b..d256a72259 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -36,17 +36,16 @@ jobs:
name: build
paths: .
- stage: Tests
- name: Javascript tests
+ name: Frontend tests
workspaces:
use: build
script:
- - npm run test-backend-jasmine
- npm run test-frontend
- name: Typescript tests
workspaces:
use: build
script:
- - npm run test-backend-jest
+ - npm run test-backend
after_success:
- cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
- name: End-to-end tests
diff --git a/package-lock.json b/package-lock.json
index f2b03dc5c7..095f804a6e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13769,48 +13769,6 @@
}
}
},
- "jasmine": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.7.0.tgz",
- "integrity": "sha512-wlzGQ+cIFzMEsI+wDqmOwvnjTvolLFwlcpYLCqSPPH0prOQaW3P+IzMhHYn934l1imNvw07oCyX+vGUv3wmtSQ==",
- "dev": true,
- "requires": {
- "glob": "^7.1.6",
- "jasmine-core": "~3.7.0"
- },
- "dependencies": {
- "jasmine-core": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.7.0.tgz",
- "integrity": "sha512-jmeRIgxS/abz8afk9NKJ1yP7n93aZdRaHDWtTHJBlYu6fhbzvErjiO3mMlSSrJefVk1a+26JlABrHS2iBH8Azw==",
- "dev": true
- }
- }
- },
- "jasmine-core": {
- "version": "3.7.1",
- "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.7.1.tgz",
- "integrity": "sha512-DH3oYDS/AUvvr22+xUBW62m1Xoy7tUlY1tsxKEJvl5JeJ7q8zd1K5bUwiOxdH+erj6l2vAMM3hV25Xs9/WrmuQ==",
- "dev": true
- },
- "jasmine-sinon": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/jasmine-sinon/-/jasmine-sinon-0.4.0.tgz",
- "integrity": "sha1-gECheaAa4DSbI0ruQ4wkGRI5rpg=",
- "dev": true,
- "requires": {
- "sinon": ">= 1.7.1"
- }
- },
- "jasmine-spec-reporter": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-6.0.0.tgz",
- "integrity": "sha512-MvTOVoMxDZAftQYBApIlSfKnGMzi9cj351nXeqtnZTuXffPlbONN31+Es7F+Ke4okUeQ2xISukt4U1npfzLVrQ==",
- "dev": true,
- "requires": {
- "colors": "1.4.0"
- }
- },
"jest": {
"version": "26.6.3",
"resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz",
diff --git a/package.json b/package.json
index 79ae4eb9c8..4376bf685b 100644
--- a/package.json
+++ b/package.json
@@ -16,11 +16,9 @@
"npm": "~6.4.0"
},
"scripts": {
- "test-backend-jest": "env-cmd -f tests/.test-full-env jest --coverage --maxWorkers=4",
- "test-backend-jest:watch": "env-cmd -f tests/.test-full-env jest --watch",
- "test-backend-jasmine": "env-cmd -f tests/.test-full-env --use-shell \"npm run download-binary && jasmine --config=tests/unit/backend/jasmine.json\"",
+ "test-backend": "env-cmd -f tests/.test-full-env jest --coverage --maxWorkers=4",
+ "test-backend:watch": "env-cmd -f tests/.test-full-env jest --watch",
"test-frontend": "jest --config=tests/unit/frontend/jest.config.js",
- "test-backend": "npm run test-backend-jasmine && npm run test-backend-jest",
"build": "npm run build-backend && npm run build-frontend",
"build-backend": "tsc -p tsconfig.build.json",
"build-frontend": "webpack --config webpack.prod.js",
@@ -29,7 +27,7 @@
"start": "node dist/backend/server.js",
"dev": "docker-compose up --build",
"docker-dev": "npm run build-frontend-dev:watch & ts-node-dev --respawn --transpile-only --inspect=0.0.0.0 --exit-child -- src/server.ts",
- "test": "npm run build-backend && npm run test-backend && npm run test-frontend",
+ "test": "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\" \"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",
@@ -219,10 +217,6 @@
"html-loader": "~0.5.5",
"htmlhint": "^0.14.2",
"husky": "^6.0.0",
- "jasmine": "^3.7.0",
- "jasmine-core": "^3.7.1",
- "jasmine-sinon": "^0.4.0",
- "jasmine-spec-reporter": "^6.0.0",
"jest": "^26.6.3",
"jest-extended": "^0.11.5",
"jest-mock-axios": "^4.3.0",
diff --git a/src/app/controllers/forms.server.controller.js b/src/app/controllers/forms.server.controller.js
deleted file mode 100644
index 32c1d8bbf7..0000000000
--- a/src/app/controllers/forms.server.controller.js
+++ /dev/null
@@ -1,154 +0,0 @@
-'use strict'
-
-/**
- * Module dependencies.
- */
-const mongoose = require('mongoose')
-const _ = require('lodash')
-const { StatusCodes } = require('http-status-codes')
-
-const { createReqMeta } = require('../utils/request')
-const logger = require('../../config/logger').createLoggerWithLabel(module)
-const getFormModel = require('../models/form.server.model').default
-const { IntranetFactory } = require('../services/intranet/intranet.factory')
-const { getRequestIp } = require('../utils/request')
-const { AuthType } = require('../../types')
-
-const Form = getFormModel(mongoose)
-
-/**
- * @typedef {string} RequestType
- */
-
-/**
- * @enum {RequestType}
- */
-const requestTypes = {
- ADMIN: 'ADMIN',
- PUBLIC: 'PUBLIC',
-}
-exports.REQUEST_TYPE = requestTypes
-
-const adminPublicFields = ['agency']
-const adminPrivateFields = ['email', 'betaFlags']
-const formPublicFields = [
- 'admin',
- 'authType',
- 'endPage',
- 'esrvcId',
- 'form_fields',
- 'form_logics',
- 'hasCaptcha',
- 'publicKey',
- 'startPage',
- 'status',
- 'title',
- '_id',
- 'responseMode',
-]
-
-/**
- * Shows the current form. If the form is for public use, more extensive scrubbing of admin details is carried out.
- * @param {RequestType} requestType - Whether this request is for admin use or public use of a form
- * @returns {function({Object}, {Object})} - A function that takes req, the express request object, and
- * res, the express response object.
- */
-exports.read = (requestType) =>
- /**
- * ! Note that this function should not call any mongoose functions on req.form as it is possibly already a plain JSON object.
- * Takes the form and replaces admin details with agency details, as well as scrubbing the form if the
- * request is not for admin purposes.
- * @param {Object} req - Express request object
- * @param {Object} req.form - The form from the DB that was retrieved from a previous middleware function
- * @param {Object} res - Express response object
- */
- (req, res) => {
- let form = req.form
- let spcpSession = res.locals.spcpSession || {}
- let myInfoError = res.locals.myInfoError
-
- // Remove sensitive admin details
- const adminFields = adminPublicFields.concat(
- requestType === requestTypes.ADMIN && adminPrivateFields,
- )
- form.admin = _.pick(form.admin, adminFields)
-
- // For non-admin forms, we have more extensive scrubbing of irrelevant fields
- if (requestType !== requestTypes.ADMIN) {
- form = _.pick(form, formPublicFields)
- }
-
- const isIntranetResult = IntranetFactory.isIntranetIp(getRequestIp(req))
- let isIntranetUser = false
- if (isIntranetResult.isOk()) {
- isIntranetUser = isIntranetResult.value
- }
-
- // SP, CP and MyInfo are not available on intranet
- if (
- isIntranetUser &&
- [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType)
- ) {
- logger.warn({
- message:
- 'Attempting to access SingPass, CorpPass or MyInfo form from intranet',
- meta: {
- action: 'read',
- formId: form._id,
- },
- })
- }
-
- return res.json({
- form,
- spcpSession,
- myInfoError,
- isIntranetUser,
- })
- }
-
-/**
- * Form middleware used to set form in the request after
- * grabbing it from MongoDB
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @param {Object} next - Express next middleware function
- * @param {Object} id - Form ID
- * @return {Void}
- */
-exports.formById = async function (req, res, next) {
- let id = req.params && req.params.formId
-
- if (!mongoose.Types.ObjectId.isValid(id)) {
- return res.status(StatusCodes.BAD_REQUEST).json({
- message: 'Form URL is invalid.',
- })
- }
- try {
- const form = await Form.getFullFormById(id)
- if (!form) {
- return res.status(StatusCodes.NOT_FOUND).json({
- message: "Oops! We can't find the form you're looking for.",
- })
- } else {
- // Remove sensitive information from User object
- if (!form.admin) {
- return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
- message: 'Server error.',
- })
- }
- req.form = form
- return next()
- }
- } catch (err) {
- logger.error({
- message: 'Error retrieving form from database',
- meta: {
- action: 'formById',
- ...createReqMeta(req),
- },
- error: err,
- })
- return next(err)
- }
-}
diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.routes.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.routes.spec.ts
index b153537bf7..d251979576 100644
--- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.routes.spec.ts
+++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.routes.spec.ts
@@ -1,23 +1,18 @@
import SPCPAuthClient from '@opengovsg/spcp-auth-client'
-import { celebrate, Joi } from 'celebrate'
-import { Router } from 'express'
import session, { Session } from 'supertest-session'
import { mocked } from 'ts-jest/utils'
-import * as FormController from 'src/app/controllers/forms.server.controller'
-import * as EncryptSubmissionController from 'src/app/modules/submission/encrypt-submission/encrypt-submission.controller'
-import { CaptchaFactory } from 'src/app/services/captcha/captcha.factory'
-import { AuthType, BasicField, Status } from 'src/types'
+import { AuthType, Status } from 'src/types'
import { setupApp } from 'tests/integration/helpers/express-setup'
import dbHandler from 'tests/unit/backend/helpers/jest-db'
+import { EncryptSubmissionRouter } from '../encrypt-submission.routes'
+
jest.mock('@opengovsg/spcp-auth-client')
const MockAuthClient = mocked(SPCPAuthClient, true)
-// TODO (#149): Import router instead of creating it here
const SUBMISSIONS_ENDPT_BASE = '/v2/submissions/encrypt'
-const SUBMISSIONS_ENDPT = `${SUBMISSIONS_ENDPT_BASE}/:formId([a-fA-F0-9]{24})`
const MOCK_ENCRYPTED_CONTENT = `${'a'.repeat(44)};${'a'.repeat(
32,
@@ -29,61 +24,11 @@ const MOCK_SUBMISSION_BODY = {
version: 1,
}
-// TODO (#149): Import router instead of creating it here
-const EncryptSubmissionsRouter = Router()
-EncryptSubmissionsRouter.post(
- SUBMISSIONS_ENDPT,
- CaptchaFactory.validateCaptchaParams,
- celebrate({
- body: Joi.object({
- responses: Joi.array()
- .items(
- Joi.object().keys({
- _id: Joi.string().required(),
- answer: Joi.string().allow('').required(),
- fieldType: Joi.string()
- .required()
- .valid(...Object.values(BasicField)),
- signature: Joi.string().allow(''),
- }),
- )
- .required(),
- encryptedContent: Joi.string()
- .custom((value, helpers) => {
- const parts = String(value).split(/;|:/)
- if (
- parts.length !== 3 ||
- parts[0].length !== 44 || // public key
- parts[1].length !== 32 || // nonce
- !parts.every((part) => Joi.string().base64().validate(part))
- ) {
- return helpers.error('Invalid encryptedContent.')
- }
- return value
- }, 'encryptedContent')
- .required(),
- attachments: Joi.object()
- .pattern(
- /^[a-fA-F0-9]{24}$/,
- Joi.object().keys({
- encryptedFile: Joi.object().keys({
- binary: Joi.string().required(),
- nonce: Joi.string().required(),
- submissionPublicKey: Joi.string().required(),
- }),
- }),
- )
- .optional(),
- isPreview: Joi.boolean().required(),
- version: Joi.number().required(),
- }),
- }),
- FormController.formById,
- EncryptSubmissionController.handleEncryptedSubmission,
+const EncryptSubmissionsApp = setupApp(
+ SUBMISSIONS_ENDPT_BASE,
+ EncryptSubmissionRouter,
)
-const EncryptSubmissionsApp = setupApp('/', EncryptSubmissionsRouter)
-
describe('encrypt-submission.routes', () => {
let request: Session
const mockSpClient = mocked(MockAuthClient.mock.instances[0], true)
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.routes.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.routes.ts
new file mode 100644
index 0000000000..f9a6a80fbe
--- /dev/null
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.routes.ts
@@ -0,0 +1,30 @@
+import { Router } from 'express'
+
+import { rateLimitConfig } from '../../../../config/config'
+import { CaptchaFactory } from '../../../services/captcha/captcha.factory'
+import { limitRate } from '../../../utils/limit-rate'
+
+import * as EncryptSubmissionController from './encrypt-submission.controller'
+import * as EncryptSubmissionMiddleware from './encrypt-submission.middleware'
+
+export const EncryptSubmissionRouter = Router()
+
+/**
+ * Submit a form response, submit a form response, and stores the encrypted
+ * contents.
+ * Optionally, an autoreply confirming submission is sent back to the user, if
+ * an email address was given. SMS autoreplies for mobile number fields are also
+ * sent if the feature is enabled.
+ * @route POST /v2/submissions/encrypt/:formId
+ * @param response.body.required - contains the entire form submission
+ * @param captchaResponse.query - contains the reCAPTCHA response artifact, if any
+ * @returns 200 - submission made
+ * @returns 400 - submission has bad data and could not be processed
+ */
+EncryptSubmissionRouter.post(
+ '/:formId([a-fA-F0-9]{24})',
+ limitRate({ max: rateLimitConfig.submissions }),
+ CaptchaFactory.validateCaptchaParams,
+ EncryptSubmissionMiddleware.validateEncryptSubmissionParams,
+ EncryptSubmissionController.handleEncryptedSubmission,
+)
diff --git a/src/app/modules/submission/submission.routes.ts b/src/app/modules/submission/submission.routes.ts
index 6c45a90c9f..45f3baa271 100644
--- a/src/app/modules/submission/submission.routes.ts
+++ b/src/app/modules/submission/submission.routes.ts
@@ -1,6 +1,9 @@
import { Router } from 'express'
import { EmailSubmissionRouter } from './email-submission/email-submission.routes'
+import { EncryptSubmissionRouter } from './encrypt-submission/encrypt-submission.routes'
export const SubmissionRouter = Router()
+
SubmissionRouter.use('/email', EmailSubmissionRouter)
+SubmissionRouter.use('/encrypt', EncryptSubmissionRouter)
diff --git a/src/app/routes/public-forms.server.routes.js b/src/app/routes/public-forms.server.routes.js
index b3ed749508..d4731d0d9a 100644
--- a/src/app/routes/public-forms.server.routes.js
+++ b/src/app/routes/public-forms.server.routes.js
@@ -3,15 +3,8 @@
/**
* Module dependencies.
*/
-const forms = require('../../app/controllers/forms.server.controller')
const { celebrate, Joi, Segments } = require('celebrate')
-const { CaptchaFactory } = require('../services/captcha/captcha.factory')
-const { limitRate } = require('../utils/limit-rate')
-const { rateLimitConfig } = require('../../config/config')
const PublicFormController = require('../modules/form/public-form/public-form.controller')
-const EncryptSubmissionController = require('../modules/submission/encrypt-submission/encrypt-submission.controller')
-const EncryptSubmissionMiddleware = require('../modules/submission/encrypt-submission/encrypt-submission.middleware')
-
module.exports = function (app) {
/**
* Redirect a form to the main index, with the specified path
@@ -125,33 +118,4 @@ module.exports = function (app) {
app
.route('/:formId([a-fA-F0-9]{24})/publicform')
.get(PublicFormController.handleGetPublicForm)
-
- /**
- * On preview, submit a form response, and stores the encrypted contents. Optionally, an autoreply
- * confirming submission is sent back to the user, if an email address
- * was given. SMS autoreplies for mobile number fields are also sent if feature
- * is enabled.
- * Note that v2 endpoint no longer accepts body.captchaResponse
- * Note that v2 endpoint accepts requests in content-type json, instead of content-type multi-part
- * Note that v2 endpoint now requires body.version
- * @route POST /v2/submissions/encrypt/{formId}
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @param {string} response.body.required - contains the entire form submission
- * @param {string} captchaResponse.query - contains the reCAPTCHA response artifact, if any
- * @param {string} encryptedContent.body.required - contains the entire encrypted form submission
- * @consumes multipart/form-data
- * @produces application/json
- * @returns {SubmissionResponse.model} 200 - submission made
- * @returns {SubmissionResponse.model} 400 - submission has bad data and could not be processed
- */
- app
- .route('/v2/submissions/encrypt/:formId([a-fA-F0-9]{24})')
- .post(
- limitRate({ max: rateLimitConfig.submissions }),
- CaptchaFactory.validateCaptchaParams,
- EncryptSubmissionMiddleware.validateEncryptSubmissionParams,
- forms.formById,
- EncryptSubmissionController.handleEncryptedSubmission,
- )
}
diff --git a/tests/unit/backend/.eslintrc b/tests/unit/backend/.eslintrc
deleted file mode 100644
index 8e33c17583..0000000000
--- a/tests/unit/backend/.eslintrc
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "env": {
- "jasmine": true
- },
- "globals": {
- "expect": true,
- "sinon": true,
- "spec": true,
- "expectAsync": true
- },
- "overrides": [
- {
- "files": ["*.ts"],
- "env": { "jest": true, "jasmine": false },
- "extends": ["plugin:jest/recommended"]
- }
- ]
-}
diff --git a/tests/unit/backend/controllers/forms.server.controller.spec.js b/tests/unit/backend/controllers/forms.server.controller.spec.js
deleted file mode 100644
index ac0769282c..0000000000
--- a/tests/unit/backend/controllers/forms.server.controller.spec.js
+++ /dev/null
@@ -1,164 +0,0 @@
-const { StatusCodes } = require('http-status-codes')
-const mongoose = require('mongoose')
-const { ObjectId } = require('bson-ext')
-
-const dbHandler = require('../helpers/db-handler')
-const Form = dbHandler.makeModel('form.server.model', 'Form')
-
-const Controller = spec(
- 'dist/backend/app/controllers/forms.server.controller',
- {
- mongoose: Object.assign(mongoose, { '@noCallThru': true }),
- },
-)
-
-describe('Form Controller', () => {
- // Declare global variables
- let req
- let res
- let testForm
-
- beforeAll(async () => await dbHandler.connect())
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- beforeEach(async () => {
- res = jasmine.createSpyObj('res', ['status', 'send', 'json'])
-
- const { form, user } = await dbHandler.preloadCollections()
- testForm = form
- req = {
- query: {},
- params: {},
- body: {},
- session: {
- user: {
- _id: user._id,
- email: user.email,
- },
- },
- headers: {},
- ip: '127.0.0.1',
- get: () => this.ip,
- }
- })
-
- describe('read (admin)', () => {
- it('should send response with required args', () => {
- req.form = testForm.toJSON()
- res.locals = {
- spcpSession: {},
- myInfoError: {},
- }
- // Expected form should not contain sensitive user info
- let expectedForm = testForm.toJSON()
- expectedForm.admin = _.pick(testForm.admin, [
- 'agency',
- 'email',
- 'betaFlags',
- ])
- Controller.read(Controller.REQUEST_TYPE.ADMIN)(req, res)
- expect(res.json).toHaveBeenCalledWith({
- form: expectedForm,
- spcpSession: res.locals.spcpSession,
- myInfoError: res.locals.myInfoError,
- isIntranetUser: false,
- })
- })
- })
-
- describe('read (public)', () => {
- it('should send response with required args', () => {
- req.form = testForm.toJSON()
- res.locals = {
- spcpSession: {},
- myInfoError: {},
- }
-
- // Expected form should not contain sensitive user info
- let expectedForm = testForm.toJSON()
- expectedForm.admin = _.pick(testForm.admin, ['agency'])
- // Expected form for public use should not contain certain things that are included in admin forms
- expectedForm = _.pick(expectedForm, [
- 'admin',
- 'authType',
- 'endPage',
- 'esrvcId',
- 'form_fields',
- 'form_logics',
- 'hasCaptcha',
- 'publicKey',
- 'startPage',
- 'status',
- 'title',
- '_id',
- 'responseMode',
- ])
-
- Controller.read(Controller.REQUEST_TYPE.PUBLIC)(req, res)
- expect(res.json).toHaveBeenCalledWith({
- form: expectedForm,
- spcpSession: res.locals.spcpSession,
- myInfoError: res.locals.myInfoError,
- isIntranetUser: false,
- })
- })
- })
-
- describe('formById', () => {
- it('should return a 400 error if form id is invalid', () => {
- let id = 'invalid_id'
- res.status.and.callFake(() => {
- return res
- })
- Controller.formById(req, res, null, id)
- expect(res.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST)
- })
-
- it('should return a 404 error if form id is not found', (done) => {
- req.params.formId = mongoose.Types.ObjectId()
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND)
- done()
- return res
- })
- Controller.formById(req, res, null)
- })
-
- it('should return a 500 error if admin is not a valid user', (done) => {
- let invalidForm = new Form({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- admin: new ObjectId(), // Random admin id
- })
- req.params.formId = invalidForm._id
- res.status.and.callFake(() => {
- expect(res.status).toHaveBeenCalledWith(
- StatusCodes.INTERNAL_SERVER_ERROR,
- )
- done()
- return res
- })
-
- // Disable validation since invalid admin id will prevent document from
- // being saved if validation is turned on.
- // This test is for backwards compatibility before form creation had the
- // user validation stage.
- invalidForm.save({ validateBeforeSave: false }).then(() => {
- Controller.formById(req, res, null)
- })
- })
-
- it('should populate form and pass on to next middleware if valid', (done) => {
- req.params.formId = testForm._id
- res.status.and.callFake((_args) => {
- return res
- })
- let next = jasmine.createSpy().and.callFake(() => {
- expect(next).toHaveBeenCalled()
- done()
- })
- Controller.formById(req, res, next)
- })
- })
-})
diff --git a/tests/unit/backend/helpers/db-handler.js b/tests/unit/backend/helpers/db-handler.js
deleted file mode 100644
index 1e6b3fb539..0000000000
--- a/tests/unit/backend/helpers/db-handler.js
+++ /dev/null
@@ -1,132 +0,0 @@
-const mongoose = require('mongoose')
-const { MongoMemoryServer } = require('mongodb-memory-server-core')
-const { ObjectID } = require('bson-ext')
-
-if (!process.env.MONGO_BINARY_VERSION) {
- console.error('Environment var MONGO_BINARY_VERSION is missing')
- process.exit(1)
-}
-const mongod = new MongoMemoryServer({
- binary: { version: String(process.env.MONGO_BINARY_VERSION) },
-})
-
-/**
- * Connect to the in-memory database.
- */
-const connect = async () => {
- const uri = await mongod.getConnectionString()
-
- const mongooseOpts = {
- useNewUrlParser: true,
- useUnifiedTopology: true,
- }
-
- await mongoose.connect(uri, mongooseOpts)
-}
-
-/**
- * Drop database, close the connection and stop mongod.
- */
-const closeDatabase = async () => {
- await mongoose.connection.dropDatabase()
- await mongoose.connection.close()
- await mongod.stop()
-}
-
-/**
- * Remove all the data for all db collections.
- */
-const clearDatabase = async () => {
- const collections = mongoose.connection.collections
-
- for (const key in collections) {
- const collection = collections[key]
- await collection.deleteMany()
- }
-}
-
-// TODO: Remove this function and all references once all schemas are using the
-// default mongoose instance.
-/**
- * Creates Mongoose model.
- * @param {string} modelFilename Name of file which exports model in app/models
- * @param {string} modelName Name of exported model
- */
-const makeModel = (modelFilename, modelName) => {
- if (modelName !== undefined && modelName !== null) {
- // check if model has already been compiled
- try {
- return mongoose.connection.model(modelName)
- } catch (error) {
- if (error.name !== 'MissingSchemaError') {
- console.error(error)
- }
- // else fail silently as we will create the model
- }
- }
-
- // Need this try catch block as some schemas may have been converted to
- // TypeScript and use default exports instead, or does not require a
- // connection
- try {
- return require(`../../../../dist/backend/app/models/${modelFilename}`)(
- mongoose,
- )
- } catch (e) {
- try {
- return require(`../../../../dist/backend/app/models/${modelFilename}`).default(
- mongoose,
- )
- } catch (e) {
- return require(`../../../../dist/backend/app/models/${modelFilename}`)
- .default
- }
- }
-}
-
-const preloadCollections = async (
- { userId, saveForm } = { saveForm: true },
-) => {
- const Agency = makeModel('agency.server.model', 'Agency')
- const User = makeModel('user.server.model', 'User')
- const Form = makeModel('form.server.model', 'Form')
-
- const adminId = userId ? new ObjectID(userId) : new ObjectID()
-
- const agency = new Agency({
- shortName: 'govtest',
- fullName: 'Government Testing Agency',
- emailDomain: 'test.gov.sg',
- logo: '/invalid-path/test.jpg',
- })
- const user = new User({
- email: 'test@test.gov.sg',
- _id: adminId,
- agency: agency._id,
- })
- const form = new Form({
- title: 'Test Form',
- emails: 'test@test.gov.sg',
- admin: user._id,
- })
-
- await agency.save()
- await user.save()
- if (saveForm) {
- await form.save()
- }
-
- return {
- agency,
- user,
- form,
- }
-}
-
-module.exports = {
- connect,
- closeDatabase,
- clearDatabase,
- makeModel,
- preloadCollections,
-}
diff --git a/tests/unit/backend/helpers/index.js b/tests/unit/backend/helpers/index.js
deleted file mode 100644
index 443b80bbac..0000000000
--- a/tests/unit/backend/helpers/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Increase upper bound for time-out so tests don't fail on travis
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 100 * 1000
-
-const proxyquire = require('proxyquire')
-
-global.spec = (path, proxy) => {
- let fullPath = `${process.env.PWD}/${path}`
- if (proxy) {
- return proxyquire(fullPath, proxy)
- }
- return require(fullPath)
-}
-
-global._ = require('lodash')
diff --git a/tests/unit/backend/helpers/reporter.js b/tests/unit/backend/helpers/reporter.js
deleted file mode 100644
index 0f180055a3..0000000000
--- a/tests/unit/backend/helpers/reporter.js
+++ /dev/null
@@ -1,11 +0,0 @@
-const { SpecReporter } = require('jasmine-spec-reporter')
-// remove default reporter logs
-jasmine.getEnv().clearReporters()
-// add jasmine-spec-reporter
-jasmine.getEnv().addReporter(
- new SpecReporter({
- spec: {
- displayPending: true,
- },
- }),
-)
diff --git a/tests/unit/backend/helpers/roles.js b/tests/unit/backend/helpers/roles.js
deleted file mode 100644
index 3fab2c7373..0000000000
--- a/tests/unit/backend/helpers/roles.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// Used for creating permissionList for test files more succinctly
-let collabPermissions = { write: true }
-
-module.exports.collaborator = (email) => {
- return Object.assign({ email }, collabPermissions)
-}
diff --git a/tests/unit/backend/jasmine.json b/tests/unit/backend/jasmine.json
deleted file mode 100644
index 2b706accb4..0000000000
--- a/tests/unit/backend/jasmine.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "spec_dir": "tests/unit/backend",
- "spec_files": [
- "**/*[sS]pec.js"
- ],
- "helpers": [
- "helpers/**/*.js"
- ],
- "stopSpecOnExpectationFailure": false,
- "random": true
-}
\ No newline at end of file
From 9c96a320d21ba0d69c494b9977bcda7fabc005be Mon Sep 17 00:00:00 2001
From: Kar Rui Lau
Date: Thu, 8 Apr 2021 22:27:17 +0800
Subject: [PATCH 31/75] fix: clear current worker pool on download (#1590)
this prevents messages from being posted to dead workers
---
src/public/modules/forms/services/submissions.client.factory.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/public/modules/forms/services/submissions.client.factory.js b/src/public/modules/forms/services/submissions.client.factory.js
index 815560dcff..744005024f 100644
--- a/src/public/modules/forms/services/submissions.client.factory.js
+++ b/src/public/modules/forms/services/submissions.client.factory.js
@@ -274,6 +274,8 @@ function SubmissionsFactory(
downloadAttachments,
secretKey,
) {
+ // Clear current worker pool.
+ workerPool = []
// Creates a new AbortController for every request
downloadAbortController = new AbortController()
From d3ee8c0150f9a9697f32c16c3f78b4f4bed0b66f Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Fri, 9 Apr 2021 08:47:57 +0800
Subject: [PATCH 32/75] refactor: convert Bounce module to use neverthrow
(#1591)
* ref: re-implement service with neverthrow
* ref: update controller to neverthrow implementation
* ref: avoid calling MailService when no recipients
* test: update service tests
* test: update controller tests
* chore: remove unnecessary eslint-disable
---
.../__tests__/bounce.controller.spec.ts | 154 ++++-----
.../bounce/__tests__/bounce.service.spec.ts | 194 +++++++----
src/app/modules/bounce/bounce.controller.ts | 184 +++++-----
src/app/modules/bounce/bounce.errors.ts | 63 ++++
src/app/modules/bounce/bounce.service.ts | 320 ++++++++++++------
src/app/modules/bounce/bounce.util.ts | 43 +--
.../bounce.types.ts => user/user.types.ts} | 5 -
src/app/modules/user/user.utils.ts | 14 +
8 files changed, 584 insertions(+), 393 deletions(-)
create mode 100644 src/app/modules/bounce/bounce.errors.ts
rename src/app/modules/{bounce/bounce.types.ts => user/user.types.ts} (59%)
diff --git a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts
index 75244e8e47..85e91c8e29 100644
--- a/src/app/modules/bounce/__tests__/bounce.controller.spec.ts
+++ b/src/app/modules/bounce/__tests__/bounce.controller.spec.ts
@@ -1,9 +1,10 @@
import { ObjectId } from 'bson'
import mongoose from 'mongoose'
-import { errAsync, okAsync } from 'neverthrow'
+import { errAsync, ok, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'
import getFormModel from 'src/app/models/form.server.model'
+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 * as FormService from 'src/app/modules/form/form.service'
@@ -19,6 +20,7 @@ import dbHandler from 'tests/unit/backend/helpers/jest-db'
import expressHandler from 'tests/unit/backend/helpers/jest-express'
import { DatabaseError } from '../../core/core.errors'
+import { InvalidNotificationError } from '../bounce.errors'
const Bounce = getBounceModel(mongoose)
const FormModel = getFormModel(mongoose)
@@ -39,8 +41,6 @@ jest.doMock('mongoose', () => ({
VersionError: MockVersionError,
},
}))
-// eslint-disable-next-line import/first
-import { handleSns } from 'src/app/modules/bounce/bounce.controller'
const MOCK_NOTIFICATION = { notificationType: 'Bounce' } as IEmailNotification
const MOCK_REQ = expressHandler.mockRequest({
@@ -100,49 +100,39 @@ describe('handleSns', () => {
beforeEach(() => {
jest.resetAllMocks()
// Default mocks
- MockBounceService.isValidSnsRequest.mockResolvedValue(true)
+ MockBounceService.validateSnsRequest.mockReturnValue(okAsync(true))
+ MockBounceService.safeParseNotification.mockReturnValue(
+ ok(MOCK_NOTIFICATION),
+ )
MockBounceService.extractEmailType.mockReturnValue(EmailType.AdminResponse)
- MockBounceService.getUpdatedBounceDoc.mockResolvedValue(mockBounceDoc)
+ MockBounceService.getUpdatedBounceDoc.mockReturnValue(
+ okAsync(mockBounceDoc),
+ )
MockFormService.retrieveFullFormById.mockResolvedValue(okAsync(mockForm))
mockBounceDoc.isCriticalBounce.mockReturnValue(true)
- MockBounceService.getEditorsWithContactNumbers.mockResolvedValue(
- MOCK_CONTACTS,
+ MockBounceService.getEditorsWithContactNumbers.mockReturnValue(
+ okAsync(MOCK_CONTACTS),
)
mockBounceDoc.hasNotified.mockReturnValue(false)
- MockBounceService.notifyAdminsOfBounce.mockResolvedValue({
- emailRecipients: MOCK_EMAIL_RECIPIENTS,
- smsRecipients: MOCK_CONTACTS,
- })
+ MockBounceService.sendEmailBounceNotification.mockReturnValue(
+ okAsync(MOCK_EMAIL_RECIPIENTS),
+ )
+ MockBounceService.sendSmsBounceNotification.mockReturnValue(
+ okAsync(MOCK_CONTACTS),
+ )
+ MockBounceService.saveBounceDoc.mockReturnValue(okAsync(mockBounceDoc))
// Note that this is true to simulate permanent bounce
mockBounceDoc.areAllPermanentBounces.mockReturnValue(true)
})
afterEach(async () => await dbHandler.clearDatabase())
- 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,
+ it('should return 401 when requests are invalid', async () => {
+ MockBounceService.validateSnsRequest.mockReturnValueOnce(
+ errAsync(new InvalidNotificationError()),
)
- expect(MockBounceService.logEmailNotification).not.toHaveBeenCalled()
- expect(MockFormService.retrieveFullFormById).not.toHaveBeenCalled()
- expect(
- MockBounceService.getEditorsWithContactNumbers,
- ).not.toHaveBeenCalled()
- expect(MockBounceService.notifyAdminsOfBounce).not.toHaveBeenCalled()
- expect(MockFormService.deactivateForm).not.toHaveBeenCalled()
- expect(MockBounceService.notifyAdminsOfDeactivation).not.toHaveBeenCalled()
- expect(MockBounceService.logCriticalBounce).not.toHaveBeenCalled()
- expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(403)
- })
-
- 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(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).not.toHaveBeenCalled()
@@ -150,11 +140,12 @@ describe('handleSns', () => {
expect(
MockBounceService.getEditorsWithContactNumbers,
).not.toHaveBeenCalled()
- expect(MockBounceService.notifyAdminsOfBounce).not.toHaveBeenCalled()
+ expect(MockBounceService.sendEmailBounceNotification).not.toHaveBeenCalled()
+ expect(MockBounceService.sendSmsBounceNotification).not.toHaveBeenCalled()
expect(MockFormService.deactivateForm).not.toHaveBeenCalled()
expect(MockBounceService.notifyAdminsOfDeactivation).not.toHaveBeenCalled()
expect(MockBounceService.logCriticalBounce).not.toHaveBeenCalled()
- expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(400)
+ expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(401)
})
it('should call services correctly for permanent critical bounces', async () => {
@@ -162,7 +153,7 @@ describe('handleSns', () => {
// to mocks are needed
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -181,7 +172,11 @@ describe('handleSns', () => {
expect(MockBounceService.getEditorsWithContactNumbers).toHaveBeenCalledWith(
mockForm,
)
- expect(MockBounceService.notifyAdminsOfBounce).toHaveBeenCalledWith(
+ expect(MockBounceService.sendEmailBounceNotification).toHaveBeenCalledWith(
+ mockBounceDoc,
+ mockForm,
+ )
+ expect(MockBounceService.sendSmsBounceNotification).toHaveBeenCalledWith(
mockBounceDoc,
mockForm,
MOCK_CONTACTS,
@@ -205,7 +200,7 @@ describe('handleSns', () => {
autoSmsRecipients: MOCK_CONTACTS,
hasDeactivated: true,
})
- expect(mockBounceDoc.save).toHaveBeenCalled()
+ expect(MockBounceService.saveBounceDoc).toHaveBeenCalledWith(mockBounceDoc)
expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(200)
})
@@ -215,7 +210,7 @@ describe('handleSns', () => {
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -234,7 +229,11 @@ describe('handleSns', () => {
expect(MockBounceService.getEditorsWithContactNumbers).toHaveBeenCalledWith(
mockForm,
)
- expect(MockBounceService.notifyAdminsOfBounce).toHaveBeenCalledWith(
+ expect(MockBounceService.sendEmailBounceNotification).toHaveBeenCalledWith(
+ mockBounceDoc,
+ mockForm,
+ )
+ expect(MockBounceService.sendSmsBounceNotification).toHaveBeenCalledWith(
mockBounceDoc,
mockForm,
MOCK_CONTACTS,
@@ -254,16 +253,16 @@ describe('handleSns', () => {
autoSmsRecipients: MOCK_CONTACTS,
hasDeactivated: false,
})
- expect(mockBounceDoc.save).toHaveBeenCalled()
+ expect(MockBounceService.saveBounceDoc).toHaveBeenCalledWith(mockBounceDoc)
expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(200)
})
- it('should return 400 when errors are thrown in getUpdatedBounceDoc', async () => {
- MockBounceService.getUpdatedBounceDoc.mockImplementationOnce(() => {
- throw new Error()
- })
+ it('should return 200 even when errors are thrown in getUpdatedBounceDoc', async () => {
+ MockBounceService.getUpdatedBounceDoc.mockReturnValueOnce(
+ errAsync(new DatabaseError()),
+ )
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -275,7 +274,7 @@ describe('handleSns', () => {
expect(MockBounceService.getUpdatedBounceDoc).toHaveBeenCalledWith(
MOCK_NOTIFICATION,
)
- expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(400)
+ expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(200)
})
it('should return 500 early if error occurs while retrieving form', async () => {
@@ -285,7 +284,7 @@ describe('handleSns', () => {
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -303,19 +302,20 @@ describe('handleSns', () => {
expect(
MockBounceService.getEditorsWithContactNumbers,
).not.toHaveBeenCalled()
- expect(MockBounceService.notifyAdminsOfBounce).not.toHaveBeenCalled()
+ expect(MockBounceService.sendEmailBounceNotification).not.toHaveBeenCalled()
+ expect(MockBounceService.sendSmsBounceNotification).not.toHaveBeenCalled()
expect(MockBounceService.notifyAdminsOfDeactivation).not.toHaveBeenCalled()
expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(500)
})
- it('should return 400 when errors are thrown in deactivateForm', async () => {
- MockFormService.deactivateForm.mockImplementationOnce(() => {
- throw new Error()
- })
+ it('should return 200 even when errors are returned from deactivateForm', async () => {
+ MockFormService.deactivateForm.mockReturnValueOnce(
+ errAsync(new DatabaseError()),
+ )
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -338,41 +338,7 @@ describe('handleSns', () => {
expect(MockFormService.deactivateForm).toHaveBeenCalledWith(
mockBounceDoc.formId,
)
- expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(400)
- })
-
- it('should return 400 when errors are thrown in notifyAdminsOfBounce', async () => {
- MockBounceService.notifyAdminsOfBounce.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(MockFormService.retrieveFullFormById).toHaveBeenCalledWith(
- mockBounceDoc.formId,
- )
- expect(mockBounceDoc.isCriticalBounce).toHaveBeenCalled()
- expect(MockBounceService.getEditorsWithContactNumbers).toHaveBeenCalledWith(
- mockForm,
- )
- expect(MockBounceService.notifyAdminsOfBounce).toHaveBeenCalledWith(
- mockBounceDoc,
- mockForm,
- MOCK_CONTACTS,
- )
- expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(400)
+ expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(200)
})
it('should return 200 when VersionErrors are thrown', async () => {
@@ -382,7 +348,7 @@ describe('handleSns', () => {
await handleSns(MOCK_REQ, MOCK_RES, jest.fn())
- expect(MockBounceService.isValidSnsRequest).toHaveBeenCalledWith(
+ expect(MockBounceService.validateSnsRequest).toHaveBeenCalledWith(
MOCK_REQ.body,
)
expect(MockBounceService.logEmailNotification).toHaveBeenCalledWith(
@@ -405,7 +371,11 @@ describe('handleSns', () => {
expect(MockFormService.deactivateForm).toHaveBeenCalledWith(
mockBounceDoc.formId,
)
- expect(MockBounceService.notifyAdminsOfBounce).toHaveBeenCalledWith(
+ expect(MockBounceService.sendEmailBounceNotification).toHaveBeenCalledWith(
+ mockBounceDoc,
+ mockForm,
+ )
+ expect(MockBounceService.sendSmsBounceNotification).toHaveBeenCalledWith(
mockBounceDoc,
mockForm,
MOCK_CONTACTS,
@@ -421,7 +391,7 @@ describe('handleSns', () => {
autoSmsRecipients: MOCK_CONTACTS,
hasDeactivated: true,
})
- expect(mockBounceDoc.save).toHaveBeenCalled()
+ expect(MockBounceService.saveBounceDoc).toHaveBeenCalledWith(mockBounceDoc)
expect(MOCK_RES.sendStatus).toHaveBeenCalledWith(200)
})
})
diff --git a/src/app/modules/bounce/__tests__/bounce.service.spec.ts b/src/app/modules/bounce/__tests__/bounce.service.spec.ts
index f29fb5306f..8e5e773660 100644
--- a/src/app/modules/bounce/__tests__/bounce.service.spec.ts
+++ b/src/app/modules/bounce/__tests__/bounce.service.spec.ts
@@ -25,7 +25,7 @@ import dbHandler from 'tests/unit/backend/helpers/jest-db'
import getMockLogger from 'tests/unit/backend/helpers/jest-logger'
import { DatabaseError } from '../../core/core.errors'
-import { UserWithContactNumber } from '../bounce.types'
+import { UserWithContactNumber } from '../../user/user.types'
import { makeBounceNotification, MOCK_SNS_BODY } from './bounce-test-helpers'
@@ -52,21 +52,17 @@ MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger)
// Import modules which depend on config last so that mocks get imported correctly
import getBounceModel from 'src/app/modules/bounce/bounce.model'
-import {
- extractEmailType,
- getEditorsWithContactNumbers,
- getUpdatedBounceDoc,
- isValidSnsRequest,
- logCriticalBounce,
- logEmailNotification,
- notifyAdminsOfBounce,
- notifyAdminsOfDeactivation,
-} from 'src/app/modules/bounce/bounce.service'
+import * as BounceService from 'src/app/modules/bounce/bounce.service'
import {
InvalidNumberError,
SmsSendError,
} from 'src/app/services/sms/sms.errors'
+import {
+ InvalidNotificationError,
+ MissingEmailHeadersError,
+} from '../bounce.errors'
+
const Form = getFormModel(mongoose)
const Bounce = getBounceModel(mongoose)
@@ -87,6 +83,10 @@ const MOCK_SUBMISSION_ID = new ObjectId()
describe('BounceService', () => {
beforeAll(async () => await dbHandler.connect())
+ beforeEach(() => {
+ MockMailService.sendBounceNotification.mockReturnValue(okAsync(true))
+ })
+
afterEach(async () => {
await dbHandler.clearDatabase()
jest.clearAllMocks()
@@ -99,7 +99,9 @@ describe('BounceService', () => {
const notification = makeBounceNotification({
emailType: EmailType.AdminResponse,
})
- expect(extractEmailType(notification)).toBe(EmailType.AdminResponse)
+ expect(BounceService.extractEmailType(notification)).toBe(
+ EmailType.AdminResponse,
+ )
})
})
@@ -112,7 +114,7 @@ describe('BounceService', () => {
await dbHandler.clearDatabase()
})
- it('should return null when there is no form ID', async () => {
+ it('should return MissingEmailHeadersError when there is no form ID', async () => {
const notification = makeBounceNotification()
const header = notification.mail.headers.find(
(header) => header.name === EMAIL_HEADERS.formId,
@@ -121,8 +123,8 @@ describe('BounceService', () => {
if (header) {
header.value = ''
}
- const result = await getUpdatedBounceDoc(notification)
- expect(result).toBeNull()
+ const result = await BounceService.getUpdatedBounceDoc(notification)
+ expect(result._unsafeUnwrapErr()).toEqual(new MissingEmailHeadersError())
})
it('should call updateBounceInfo if the document exists', async () => {
@@ -133,8 +135,8 @@ describe('BounceService', () => {
const notification = makeBounceNotification({
formId: MOCK_FORM_ID,
})
- const result = await getUpdatedBounceDoc(notification)
- expect(result?.toObject()).toEqual(
+ const result = await BounceService.getUpdatedBounceDoc(notification)
+ expect(result._unsafeUnwrap().toObject()).toEqual(
bounceDoc.updateBounceInfo(notification).toObject(),
)
})
@@ -144,8 +146,8 @@ describe('BounceService', () => {
const notification = makeBounceNotification({
formId: MOCK_FORM_ID,
})
- const result = await getUpdatedBounceDoc(notification)
- const actual = pick(result?.toObject(), [
+ const result = await BounceService.getUpdatedBounceDoc(notification)
+ const actual = pick(result._unsafeUnwrap().toObject(), [
'formId',
'bounces',
'hasAutoEmailed',
@@ -178,7 +180,7 @@ describe('BounceService', () => {
bounceType: BounceType.Transient,
emailType: EmailType.EmailConfirmation,
})
- logEmailNotification(notification)
+ BounceService.logEmailNotification(notification)
expect(mockLogger.info).not.toHaveBeenCalled()
expect(mockLogger.warn).not.toHaveBeenCalled()
expect(mockShortTermLogger.info).toHaveBeenCalledWith(notification)
@@ -195,7 +197,7 @@ describe('BounceService', () => {
bounceType: BounceType.Transient,
emailType: EmailType.AdminResponse,
})
- logEmailNotification(notification)
+ BounceService.logEmailNotification(notification)
expect(mockLogger.info).toHaveBeenCalledWith({
message: 'Email notification',
meta: {
@@ -218,7 +220,7 @@ describe('BounceService', () => {
bounceType: BounceType.Transient,
emailType: EmailType.LoginOtp,
})
- logEmailNotification(notification)
+ BounceService.logEmailNotification(notification)
expect(mockLogger.info).toHaveBeenCalledWith({
message: 'Email notification',
meta: {
@@ -241,7 +243,7 @@ describe('BounceService', () => {
bounceType: BounceType.Transient,
emailType: EmailType.AdminBounce,
})
- logEmailNotification(notification)
+ BounceService.logEmailNotification(notification)
expect(mockLogger.info).toHaveBeenCalledWith({
message: 'Email notification',
meta: {
@@ -264,14 +266,14 @@ describe('BounceService', () => {
bounceType: BounceType.Transient,
emailType: EmailType.VerificationOtp,
})
- logEmailNotification(notification)
+ BounceService.logEmailNotification(notification)
expect(mockLogger.info).not.toHaveBeenCalled()
expect(mockLogger.warn).not.toHaveBeenCalled()
expect(mockShortTermLogger.info).toHaveBeenCalledWith(notification)
})
})
- describe('notifyAdminsOfBounce', () => {
+ describe('sendEmailBounceNotification', () => {
const MOCK_FORM_TITLE = 'FormTitle'
let testUser: IUserSchema
@@ -282,10 +284,6 @@ describe('BounceService', () => {
testUser = user
})
- beforeEach(async () => {
- jest.resetAllMocks()
- })
-
it('should auto-email when admin is not email recipient', async () => {
const form = (await new Form({
admin: testUser._id,
@@ -300,7 +298,10 @@ describe('BounceService', () => {
],
})
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [])
+ const notifiedRecipients = await BounceService.sendEmailBounceNotification(
+ bounceDoc,
+ form,
+ )
expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({
emailRecipients: [testUser.email],
@@ -309,7 +310,7 @@ describe('BounceService', () => {
formTitle: form.title,
formId: form._id,
})
- expect(notifiedRecipients.emailRecipients).toEqual([testUser.email])
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([testUser.email])
})
it('should auto-email when any collaborator is not email recipient', async () => {
@@ -328,7 +329,10 @@ describe('BounceService', () => {
],
})
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [])
+ const notifiedRecipients = await BounceService.sendEmailBounceNotification(
+ bounceDoc,
+ form,
+ )
expect(MockMailService.sendBounceNotification).toHaveBeenCalledWith({
emailRecipients: [collabEmail],
@@ -337,7 +341,7 @@ describe('BounceService', () => {
formTitle: form.title,
formId: form._id,
})
- expect(notifiedRecipients.emailRecipients).toEqual([collabEmail])
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([collabEmail])
})
it('should not auto-email when admin is email recipient', async () => {
@@ -354,10 +358,13 @@ describe('BounceService', () => {
],
})
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [])
+ const notifiedRecipients = await BounceService.sendEmailBounceNotification(
+ bounceDoc,
+ form,
+ )
expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled()
- expect(notifiedRecipients.emailRecipients).toEqual([])
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([])
})
it('should not auto-email when all collabs are email recipients', async () => {
@@ -377,10 +384,29 @@ describe('BounceService', () => {
],
})
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [])
+ const notifiedRecipients = await BounceService.sendEmailBounceNotification(
+ bounceDoc,
+ form,
+ )
expect(MockMailService.sendBounceNotification).not.toHaveBeenCalled()
- expect(notifiedRecipients.emailRecipients).toEqual([])
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([])
+ })
+ })
+
+ describe('sendSmsBounceNotification', () => {
+ const MOCK_FORM_TITLE = 'FormTitle'
+ let testUser: IUserSchema
+
+ beforeEach(async () => {
+ const { user } = await dbHandler.insertFormCollectionReqs({
+ userId: MOCK_ADMIN_ID,
+ })
+ testUser = user
+ })
+
+ beforeEach(async () => {
+ jest.resetAllMocks()
})
it('should send text for all SMS recipients and return successful ones', async () => {
@@ -396,10 +422,11 @@ describe('BounceService', () => {
})
MockSmsFactory.sendBouncedSubmissionSms.mockReturnValue(okAsync(true))
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [
- MOCK_CONTACT,
- MOCK_CONTACT_2,
- ])
+ const notifiedRecipients = await BounceService.sendSmsBounceNotification(
+ bounceDoc,
+ form,
+ [MOCK_CONTACT, MOCK_CONTACT_2],
+ )
expect(MockSmsFactory.sendBouncedSubmissionSms).toHaveBeenCalledTimes(2)
expect(MockSmsFactory.sendBouncedSubmissionSms).toHaveBeenCalledWith({
@@ -418,7 +445,7 @@ describe('BounceService', () => {
recipient: MOCK_CONTACT_2.contact,
recipientEmail: MOCK_CONTACT_2.email,
})
- expect(notifiedRecipients.smsRecipients).toEqual([
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([
MOCK_CONTACT,
MOCK_CONTACT_2,
])
@@ -439,10 +466,11 @@ describe('BounceService', () => {
.mockReturnValueOnce(okAsync(true))
.mockReturnValueOnce(errAsync(new InvalidNumberError()))
- const notifiedRecipients = await notifyAdminsOfBounce(bounceDoc, form, [
- MOCK_CONTACT,
- MOCK_CONTACT_2,
- ])
+ const notifiedRecipients = await BounceService.sendSmsBounceNotification(
+ bounceDoc,
+ form,
+ [MOCK_CONTACT, MOCK_CONTACT_2],
+ )
expect(MockSmsFactory.sendBouncedSubmissionSms).toHaveBeenCalledTimes(2)
expect(MockSmsFactory.sendBouncedSubmissionSms).toHaveBeenCalledWith({
@@ -461,7 +489,7 @@ describe('BounceService', () => {
recipient: MOCK_CONTACT_2.contact,
recipientEmail: MOCK_CONTACT_2.email,
})
- expect(notifiedRecipients.smsRecipients).toEqual([MOCK_CONTACT])
+ expect(notifiedRecipients._unsafeUnwrap()).toEqual([MOCK_CONTACT])
})
})
@@ -488,7 +516,7 @@ describe('BounceService', () => {
})
const autoEmailRecipients = [MOCK_EMAIL, MOCK_EMAIL_2]
const autoSmsRecipients = [MOCK_CONTACT, MOCK_CONTACT_2]
- logCriticalBounce({
+ BounceService.logCriticalBounce({
bounceDoc,
notification: snsInfo,
autoEmailRecipients,
@@ -533,7 +561,7 @@ describe('BounceService', () => {
})
const autoEmailRecipients: string[] = []
const autoSmsRecipients = [MOCK_CONTACT, MOCK_CONTACT_2]
- logCriticalBounce({
+ BounceService.logCriticalBounce({
bounceDoc,
notification: snsInfo,
autoEmailRecipients,
@@ -578,7 +606,7 @@ describe('BounceService', () => {
})
const autoEmailRecipients: string[] = []
const autoSmsRecipients = [MOCK_CONTACT, MOCK_CONTACT_2]
- logCriticalBounce({
+ BounceService.logCriticalBounce({
bounceDoc,
notification: snsInfo,
autoEmailRecipients,
@@ -620,7 +648,7 @@ describe('BounceService', () => {
})
const autoEmailRecipients: string[] = []
const autoSmsRecipients: UserWithContactNumber[] = []
- logCriticalBounce({
+ BounceService.logCriticalBounce({
bounceDoc,
notification: snsInfo,
autoEmailRecipients,
@@ -662,7 +690,7 @@ describe('BounceService', () => {
})
const autoEmailRecipients: string[] = []
const autoSmsRecipients: UserWithContactNumber[] = []
- logCriticalBounce({
+ BounceService.logCriticalBounce({
bounceDoc,
notification: snsInfo,
autoEmailRecipients,
@@ -690,7 +718,7 @@ describe('BounceService', () => {
})
})
- describe('isValidSnsRequest', () => {
+ describe('validateSnsRequest', () => {
const keys = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
@@ -712,30 +740,43 @@ describe('BounceService', () => {
})
})
- it('should gracefully reject when input is empty', () => {
- return expect(isValidSnsRequest(undefined!)).resolves.toBe(false)
+ it('should gracefully reject when input is empty', async () => {
+ const result = await BounceService.validateSnsRequest(undefined!)
+
+ expect(result._unsafeUnwrapErr()).toEqual(new InvalidNotificationError())
})
- it('should reject requests when their structure is invalid', () => {
+ it('should reject requests when their structure is invalid', async () => {
const invalidBody = omit(cloneDeep(body), 'Type') as ISnsNotification
- return expect(isValidSnsRequest(invalidBody)).resolves.toBe(false)
+
+ const result = await BounceService.validateSnsRequest(invalidBody)
+
+ expect(result._unsafeUnwrapErr()).toEqual(new InvalidNotificationError())
})
- it('should reject requests when their certificate URL is invalid', () => {
+ it('should reject requests when their certificate URL is invalid', async () => {
body.SigningCertURL = 'http://www.example.com'
- return expect(isValidSnsRequest(body)).resolves.toBe(false)
+
+ const result = await BounceService.validateSnsRequest(body)
+
+ expect(result._unsafeUnwrapErr()).toEqual(new InvalidNotificationError())
})
- it('should reject requests when their signature version is invalid', () => {
+ it('should reject requests when their signature version is invalid', async () => {
body.SignatureVersion = 'wrongSignatureVersion'
- return expect(isValidSnsRequest(body)).resolves.toBe(false)
+
+ const result = await BounceService.validateSnsRequest(body)
+
+ expect(result._unsafeUnwrapErr()).toEqual(new InvalidNotificationError())
})
- it('should reject requests when their signature is invalid', () => {
- return expect(isValidSnsRequest(body)).resolves.toBe(false)
+ it('should reject requests when their signature is invalid', async () => {
+ const result = await BounceService.validateSnsRequest(body)
+
+ expect(result._unsafeUnwrapErr()).toEqual(new InvalidNotificationError())
})
- it('should accept when requests are valid', () => {
+ it('should accept when requests are valid', async () => {
const signer = crypto.createSign('RSA-SHA1')
const baseString =
dedent`Message
@@ -751,7 +792,10 @@ describe('BounceService', () => {
` + '\n'
signer.write(baseString)
body.Signature = signer.sign(keys.privateKey, 'base64')
- return expect(isValidSnsRequest(body)).resolves.toBe(true)
+
+ const result = await BounceService.validateSnsRequest(body)
+
+ expect(result._unsafeUnwrap()).toBe(true)
})
})
@@ -785,13 +829,13 @@ describe('BounceService', () => {
okAsync([MOCK_CONTACT]),
)
- const result = await getEditorsWithContactNumbers(form)
+ const result = await BounceService.getEditorsWithContactNumbers(form)
expect(MockUserService.findContactsForEmails).toHaveBeenCalledWith([
form.admin.email,
MOCK_EMAIL,
])
- expect(result).toEqual([MOCK_CONTACT])
+ expect(result._unsafeUnwrap()).toEqual([MOCK_CONTACT])
})
it('should filter out collaborators without contact numbers', async () => {
@@ -809,13 +853,13 @@ describe('BounceService', () => {
okAsync([omit(MOCK_CONTACT, 'contact'), MOCK_CONTACT_2]),
)
- const result = await getEditorsWithContactNumbers(form)
+ const result = await BounceService.getEditorsWithContactNumbers(form)
expect(MockUserService.findContactsForEmails).toHaveBeenCalledWith([
form.admin.email,
MOCK_EMAIL,
])
- expect(result).toEqual([MOCK_CONTACT_2])
+ expect(result._unsafeUnwrap()).toEqual([MOCK_CONTACT_2])
})
it('should return empty array when UserService returns error', async () => {
@@ -833,13 +877,13 @@ describe('BounceService', () => {
errAsync(new DatabaseError()),
)
- const result = await getEditorsWithContactNumbers(form)
+ const result = await BounceService.getEditorsWithContactNumbers(form)
expect(MockUserService.findContactsForEmails).toHaveBeenCalledWith([
form.admin.email,
MOCK_EMAIL,
])
- expect(result).toEqual([])
+ expect(result._unsafeUnwrap()).toEqual([])
})
})
@@ -867,12 +911,12 @@ describe('BounceService', () => {
.execPopulate()) as IPopulatedForm
MockSmsFactory.sendFormDeactivatedSms.mockReturnValue(okAsync(true))
- const result = await notifyAdminsOfDeactivation(form, [
+ const result = await BounceService.notifyAdminsOfDeactivation(form, [
MOCK_CONTACT,
MOCK_CONTACT_2,
])
- expect(result).toEqual(true)
+ expect(result._unsafeUnwrap()).toEqual(true)
expect(MockSmsFactory.sendFormDeactivatedSms).toHaveBeenCalledTimes(2)
expect(MockSmsFactory.sendFormDeactivatedSms).toHaveBeenCalledWith({
adminEmail: form.admin.email,
@@ -903,12 +947,12 @@ describe('BounceService', () => {
.mockReturnValueOnce(okAsync(true))
.mockReturnValueOnce(errAsync(new SmsSendError()))
- const result = await notifyAdminsOfDeactivation(form, [
+ const result = await BounceService.notifyAdminsOfDeactivation(form, [
MOCK_CONTACT,
MOCK_CONTACT_2,
])
- expect(result).toEqual(true)
+ expect(result._unsafeUnwrap()).toEqual(true)
expect(MockSmsFactory.sendFormDeactivatedSms).toHaveBeenCalledTimes(2)
expect(MockSmsFactory.sendFormDeactivatedSms).toHaveBeenCalledWith({
adminEmail: form.admin.email,
diff --git a/src/app/modules/bounce/bounce.controller.ts b/src/app/modules/bounce/bounce.controller.ts
index 160cbff392..074be5ff54 100644
--- a/src/app/modules/bounce/bounce.controller.ts
+++ b/src/app/modules/bounce/bounce.controller.ts
@@ -1,121 +1,123 @@
import { RequestHandler } from 'express'
-import { ParamsDictionary } from 'express-serve-static-core'
import { StatusCodes } from 'http-status-codes'
-import mongoose from 'mongoose'
import { createLoggerWithLabel } from '../../../config/logger'
-import { IEmailNotification, ISnsNotification } from '../../../types'
+import { ISnsNotification } from '../../../types'
import { EmailType } from '../../services/mail/mail.constants'
+import { DatabaseConflictError } from '../core/core.errors'
import * as FormService from '../form/form.service'
import * as BounceService from './bounce.service'
-import { AdminNotificationResult } from './bounce.types'
const logger = createLoggerWithLabel(module)
+
/**
* Validates that a request came from Amazon SNS, then updates the Bounce
* collection. Also informs form admins and collaborators if their form responses
- * bounced.
+ * bounced. Note that the response code is meaningless as it goes back to AWS.
* @param req Express request object
* @param res - Express response object
*/
export const handleSns: RequestHandler<
- ParamsDictionary,
+ unknown,
never,
ISnsNotification
> = async (req, res) => {
- // Since this function is for a public endpoint, catch all possible errors
- // 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 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 formResult = await FormService.retrieveFullFormById(bounceDoc.formId)
- if (formResult.isErr()) {
- // Either database error occurred or the formId saved in the bounce collection
- // doesn't exist, so something went wrong.
- logger.error({
- message: 'Failed to retrieve form corresponding to bounced formId',
- meta: {
- action: 'handleSns',
- formId: bounceDoc.formId,
- },
- })
- return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR)
- }
- const form = formResult.value
-
- if (bounceDoc.isCriticalBounce()) {
- // Get contact numbers
- const possibleSmsRecipients = await BounceService.getEditorsWithContactNumbers(
- form,
- )
-
- // Notify admin and collaborators
- let notificationRecipients: AdminNotificationResult = {
- emailRecipients: [],
- smsRecipients: [],
- }
- if (!bounceDoc.hasNotified()) {
- notificationRecipients = await BounceService.notifyAdminsOfBounce(
- bounceDoc,
- form,
- possibleSmsRecipients,
- )
- bounceDoc.setNotificationState(
- notificationRecipients.emailRecipients,
- notificationRecipients.smsRecipients,
- )
- }
-
- // Deactivate if all bounces are permanent
- const shouldDeactivate = bounceDoc.areAllPermanentBounces()
- if (shouldDeactivate) {
- await FormService.deactivateForm(bounceDoc.formId)
- await BounceService.notifyAdminsOfDeactivation(
- form,
- possibleSmsRecipients,
- )
- }
+ const notificationResult = await BounceService.validateSnsRequest(
+ req.body,
+ ).andThen(() => BounceService.safeParseNotification(req.body.Message))
+ if (notificationResult.isErr()) {
+ logger.warn({
+ message: 'Unable to parse email notification request',
+ meta: {
+ action: 'handleSns',
+ },
+ error: notificationResult.error,
+ })
+ return res.sendStatus(StatusCodes.UNAUTHORIZED)
+ }
+ const notification = notificationResult.value
- // Important log message for user follow-ups
- BounceService.logCriticalBounce({
- bounceDoc,
- notification,
- autoEmailRecipients: notificationRecipients.emailRecipients,
- autoSmsRecipients: notificationRecipients.smsRecipients,
- hasDeactivated: shouldDeactivate,
- })
- }
- await bounceDoc.save()
+ BounceService.logEmailNotification(notification)
+ // If not admin response, no more action to be taken
+ if (
+ BounceService.extractEmailType(notification) !== EmailType.AdminResponse
+ ) {
return res.sendStatus(StatusCodes.OK)
- } catch (err) {
+ }
+
+ const bounceDocResult = await BounceService.getUpdatedBounceDoc(notification)
+ if (bounceDocResult.isErr()) {
logger.warn({
- message: 'Error updating bounces',
+ message: 'Error while retrieving or creating new bounce doc',
meta: {
action: 'handleSns',
},
- error: err,
+ error: bounceDocResult.error,
})
- // Accept the risk that there might be concurrency problems
- // when multiple server instances try to access the same
- // document, due to notifications arriving asynchronously.
- if (err instanceof mongoose.Error.VersionError) {
- return res.sendStatus(StatusCodes.OK)
+ return res.sendStatus(StatusCodes.OK)
+ }
+ const bounceDoc = bounceDocResult.value
+
+ const formResult = await FormService.retrieveFullFormById(bounceDoc.formId)
+ if (formResult.isErr()) {
+ // Either database error occurred or the formId saved in the bounce collection
+ // doesn't exist, so something went wrong.
+ logger.error({
+ message: 'Failed to retrieve form corresponding to bounced formId',
+ meta: {
+ action: 'handleSns',
+ formId: bounceDoc.formId,
+ },
+ })
+ return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR)
+ }
+ const form = formResult.value
+
+ if (bounceDoc.isCriticalBounce()) {
+ // Send notifications and deactivate form on best-effort basis, ignore errors
+ const possibleSmsRecipients = await BounceService.getEditorsWithContactNumbers(
+ form,
+ ).unwrapOr([])
+ const emailRecipients = await BounceService.sendEmailBounceNotification(
+ bounceDoc,
+ form,
+ ).unwrapOr([])
+ const smsRecipients = await BounceService.sendSmsBounceNotification(
+ bounceDoc,
+ form,
+ possibleSmsRecipients,
+ ).unwrapOr([])
+ bounceDoc.setNotificationState(emailRecipients, smsRecipients)
+
+ const shouldDeactivate = bounceDoc.areAllPermanentBounces()
+ if (shouldDeactivate) {
+ await FormService.deactivateForm(bounceDoc.formId)
+ await BounceService.notifyAdminsOfDeactivation(
+ form,
+ possibleSmsRecipients,
+ )
}
- // Malformed request, could not be parsed
- return res.sendStatus(StatusCodes.BAD_REQUEST)
+
+ // Important log message for user follow-ups
+ BounceService.logCriticalBounce({
+ bounceDoc,
+ notification,
+ autoEmailRecipients: emailRecipients,
+ autoSmsRecipients: smsRecipients,
+ hasDeactivated: shouldDeactivate,
+ })
}
+
+ return BounceService.saveBounceDoc(bounceDoc)
+ .map(() => res.sendStatus(StatusCodes.OK))
+ .mapErr((error) => {
+ // Accept the risk that there might be concurrency problems
+ // when multiple server instances try to access the same
+ // document, due to notifications arriving asynchronously.
+ if (error instanceof DatabaseConflictError)
+ return res.sendStatus(StatusCodes.OK)
+ // Otherwise internal database error
+ return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR)
+ })
}
diff --git a/src/app/modules/bounce/bounce.errors.ts b/src/app/modules/bounce/bounce.errors.ts
new file mode 100644
index 0000000000..e97ef45168
--- /dev/null
+++ b/src/app/modules/bounce/bounce.errors.ts
@@ -0,0 +1,63 @@
+import { InvalidNumberError, SmsSendError } from '../../services/sms/sms.errors'
+import { ApplicationError } from '../core/core.errors'
+
+/**
+ * Error while retrieving the public key from the certificate URL provided
+ * in the SNS notification body.
+ */
+export class RetrieveAwsCertError extends ApplicationError {
+ constructor(message = 'Error while retrieving AWS signing public key') {
+ super(message)
+ }
+}
+
+/**
+ * Unexpected shape of request body.
+ */
+export class InvalidNotificationError extends ApplicationError {
+ constructor(message = 'Notification from AWS could not be validated') {
+ super(message)
+ }
+}
+
+/**
+ * Error while sending bounce-related notification to form admins via SMS.
+ */
+export class SendBounceSmsNotificationError extends ApplicationError {
+ meta: {
+ contact: string
+ originalError: SmsSendError | InvalidNumberError
+ }
+
+ constructor(
+ originalError: SmsSendError | InvalidNumberError,
+ contact: string,
+ message = 'Error while sending bounce notification via SMS',
+ ) {
+ super(message)
+ this.meta = {
+ contact,
+ originalError,
+ }
+ }
+}
+
+/**
+ * Email headers are missing the custom headers containing form and submission IDs.
+ */
+export class MissingEmailHeadersError extends ApplicationError {
+ constructor(
+ message = 'Email is missing custom header containing form or submission ID',
+ ) {
+ super(message)
+ }
+}
+
+/**
+ * Error while parsing notification
+ */
+export class ParseNotificationError extends ApplicationError {
+ constructor(message = 'Could not parse SNS notification') {
+ super(message)
+ }
+}
diff --git a/src/app/modules/bounce/bounce.service.ts b/src/app/modules/bounce/bounce.service.ts
index 93a9418dc9..78d9173bb6 100644
--- a/src/app/modules/bounce/bounce.service.ts
+++ b/src/app/modules/bounce/bounce.service.ts
@@ -2,6 +2,13 @@ import axios from 'axios'
import crypto from 'crypto'
import { difference, isEmpty } from 'lodash'
import mongoose from 'mongoose'
+import {
+ combineWithAllErrors,
+ errAsync,
+ okAsync,
+ Result,
+ ResultAsync,
+} from 'neverthrow'
import {
createCloudWatchLogger,
@@ -17,14 +24,24 @@ import {
import { EMAIL_HEADERS, EmailType } from '../../services/mail/mail.constants'
import MailService from '../../services/mail/mail.service'
import { SmsFactory } from '../../services/sms/sms.factory'
+import { transformMongoError } from '../../utils/handle-mongo-error'
+import { hasProp } from '../../utils/has-prop'
+import { PossibleDatabaseError } from '../core/core.errors'
import { getCollabEmailsWithPermission } from '../form/form.utils'
import * as UserService from '../user/user.service'
+import { UserWithContactNumber } from '../user/user.types'
+import { isUserWithContactNumber } from '../user/user.utils'
+import {
+ InvalidNotificationError,
+ MissingEmailHeadersError,
+ ParseNotificationError,
+ RetrieveAwsCertError,
+ SendBounceSmsNotificationError,
+} from './bounce.errors'
import getBounceModel from './bounce.model'
-import { AdminNotificationResult, UserWithContactNumber } from './bounce.types'
import {
extractHeader,
- extractSmsErrors,
extractSuccessfulSmsRecipients,
isBounceNotification,
} from './bounce.util'
@@ -54,8 +71,8 @@ const AWS_HOSTNAME = '.amazonaws.com'
* @param body body from Express request object
* @returns true if all required keys are present
*/
-const hasRequiredKeys = (body: any): body is ISnsNotification => {
- return !isEmpty(body) && snsKeys.every((keyObj) => body[keyObj.key])
+const hasRequiredKeys = (body: unknown): body is ISnsNotification => {
+ return !isEmpty(body) && snsKeys.every((keyObj) => hasProp(body, keyObj.key))
}
/**
@@ -96,13 +113,29 @@ const getSnsBasestring = (body: ISnsNotification): string => {
* @param body body from Express request object
* @returns true if signature is valid
*/
-const isValidSnsSignature = async (
+const isValidSnsSignature = (
body: ISnsNotification,
-): Promise => {
- const { data: cert } = await axios.get(body.SigningCertURL)
- const verifier = crypto.createVerify('RSA-SHA1')
- verifier.update(getSnsBasestring(body), 'utf8')
- return verifier.verify(cert, body.Signature, 'base64')
+): ResultAsync => {
+ return ResultAsync.fromPromise(
+ axios.get(body.SigningCertURL),
+ (error) => {
+ logger.warn({
+ message: 'Error while retrieving AWS signing certificate',
+ meta: {
+ action: 'isValidSnsSignature',
+ signingCertUrl: body.SigningCertURL,
+ },
+ error,
+ })
+ return new RetrieveAwsCertError()
+ },
+ ).andThen(({ data: cert }) => {
+ const verifier = crypto.createVerify('RSA-SHA1')
+ verifier.update(getSnsBasestring(body), 'utf8')
+ return verifier.verify(cert, body.Signature, 'base64')
+ ? okAsync(true)
+ : errAsync(new InvalidNotificationError())
+ })
}
/**
@@ -111,15 +144,16 @@ const isValidSnsSignature = async (
* @param body Body of Express request object
* @returns true if request shape and signature are valid
*/
-export const isValidSnsRequest = async (
+export const validateSnsRequest = (
body: ISnsNotification,
-): Promise => {
- const isValid =
- hasRequiredKeys(body) &&
- body.SignatureVersion === '1' && // We only check for SHA1-RSA signatures
- isValidCertUrl(body.SigningCertURL) &&
- (await isValidSnsSignature(body))
- return isValid
+): ResultAsync => {
+ if (
+ !hasRequiredKeys(body) ||
+ body.SignatureVersion !== '1' ||
+ !isValidCertUrl(body.SigningCertURL)
+ )
+ return errAsync(new InvalidNotificationError())
+ return isValidSnsSignature(body)
}
/**
@@ -197,35 +231,63 @@ const computeValidEmails = (
}
/**
- * Notifies admin and collaborators via email and SMS that response was lost.
+ * Notifies admin and collaborators via email that response was lost.
* @param bounceDoc Document from Bounce collection
* @param form Form corresponding to the formId from bounceDoc
- * @param possibleSmsRecipients Contact details of recipients to attempt to SMS
- * @returns contact details for email and SMSes which were successfully sent. Note that
- * this doesn't mean the emails and SMSes were received, only that they were delivered
+ * @returns contact details for emails which were successfully sent. Note that
+ * this doesn't mean the emails were received, only that they were delivered
* to the mail server/carrier.
*/
-export const notifyAdminsOfBounce = async (
+export const sendEmailBounceNotification = (
bounceDoc: IBounceSchema,
form: IPopulatedForm,
- possibleSmsRecipients: UserWithContactNumber[],
-): Promise => {
+ // Returns no errors. If emails fail, returns
+ // empty array as list of recipients.
+): ResultAsync => {
// Email all collaborators
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,
+ if (emailRecipients.length === 0) return okAsync([])
+ return MailService.sendBounceNotification({
+ emailRecipients,
+ bouncedRecipients: bounceDoc.getEmails(),
+ bounceType: bounceDoc.areAllPermanentBounces()
+ ? BounceType.Permanent
+ : BounceType.Transient,
+ formTitle: form.title,
+ formId: bounceDoc.formId,
+ })
+ .map(() => emailRecipients)
+ .orElse((error) => {
+ // Log error, then return empty array as email was sent
+ logger.warn({
+ message: 'Failed to send some bounce notification emails',
+ meta: {
+ action: 'notifyAdminOfBounce',
+ formId: form._id,
+ },
+ error,
+ })
+ return okAsync([])
})
- }
+}
- // Sms given recipients
- const smsPromises = possibleSmsRecipients.map((recipient) =>
+/**
+ * Notifies admin and collaborators via SMS that response was lost.
+ * @param bounceDoc Document from Bounce collection
+ * @param form Form corresponding to the formId from bounceDoc
+ * @param possibleSmsRecipients Contact details of recipients to attempt to SMS
+ * @returns contact details for SMSes which were successfully sent. Note that
+ * this doesn't mean and SMSes were received, only that they were delivered
+ * to the carrier.
+ */
+export const sendSmsBounceNotification = (
+ bounceDoc: IBounceSchema,
+ form: IPopulatedForm,
+ possibleSmsRecipients: UserWithContactNumber[],
+ // Returns no errors. If SMSes fail, returns
+ // empty array as list of recipients.
+): ResultAsync => {
+ const smsResults = possibleSmsRecipients.map((recipient) =>
SmsFactory.sendBouncedSubmissionSms({
adminEmail: form.admin.email,
adminId: form.admin._id,
@@ -233,26 +295,30 @@ export const notifyAdminsOfBounce = async (
formTitle: form.title,
recipient: recipient.contact,
recipientEmail: recipient.email,
- }),
+ })
+ .map(() => recipient)
+ .mapErr(
+ (error) => new SendBounceSmsNotificationError(error, recipient.contact),
+ ),
)
-
- // neverthrow#combine is not used since we do not want to short circuit on the first error.
- const smsResults = await Promise.all(smsPromises)
- const successfulSmsRecipients = extractSuccessfulSmsRecipients(
- smsResults,
- possibleSmsRecipients,
+ return (
+ combineWithAllErrors(smsResults)
+ // All succeeded
+ .map(() => possibleSmsRecipients)
+ .orElse((errors) => {
+ logger.warn({
+ message: 'Failed to send some bounce notification SMSes',
+ meta: {
+ action: 'notifyAdminOfBounce',
+ formId: form._id,
+ errors,
+ },
+ })
+ return okAsync(
+ extractSuccessfulSmsRecipients(errors, possibleSmsRecipients),
+ )
+ })
)
- if (successfulSmsRecipients.length < possibleSmsRecipients.length) {
- logger.warn({
- message: 'Failed to send some bounce notification SMSes',
- meta: {
- action: 'notifyAdminOfBounce',
- formId: form._id,
- reasons: extractSmsErrors(smsResults),
- },
- })
- }
- return { emailRecipients, smsRecipients: successfulSmsRecipients }
}
/**
@@ -290,15 +356,31 @@ export const logEmailNotification = (
* @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 getUpdatedBounceDoc = async (
+export const getUpdatedBounceDoc = (
notification: IEmailNotification,
-): Promise => {
+): ResultAsync<
+ IBounceSchema,
+ MissingEmailHeadersError | PossibleDatabaseError
+> => {
const formId = extractHeader(notification, EMAIL_HEADERS.formId)
- if (!formId) return null
- const oldBounces = await Bounce.findOne({ formId })
- return oldBounces
- ? oldBounces.updateBounceInfo(notification)
- : Bounce.fromSnsNotification(notification, formId)
+ if (!formId) return errAsync(new MissingEmailHeadersError())
+ return ResultAsync.fromPromise(Bounce.findOne({ formId }).exec(), (error) => {
+ logger.error({
+ message: 'Error while retrieving Bounce document',
+ meta: {
+ action: 'getUpdatedBounceDoc',
+ formId,
+ },
+ })
+ return transformMongoError(error)
+ }).map((bounceDoc) => {
+ // Doc already exists for this form, so update it with latest info
+ if (bounceDoc) {
+ return bounceDoc.updateBounceInfo(notification)
+ }
+ // Create new doc from scratch
+ return Bounce.fromSnsNotification(notification, formId)
+ })
}
/**
@@ -319,30 +401,27 @@ export const extractEmailType = (
* @returns The contact details, filtered for the emails which have verified
* contact numbers in the database
*/
-export const getEditorsWithContactNumbers = async (
+export const getEditorsWithContactNumbers = (
form: IPopulatedForm,
-): Promise => {
+ // Never return an error. If database query fails, return empty array.
+): ResultAsync => {
const possibleEditors = [
form.admin.email,
...getCollabEmailsWithPermission(form.permissionList, true),
]
- const smsRecipientsResult = await UserService.findContactsForEmails(
- possibleEditors,
- )
- if (smsRecipientsResult.isOk()) {
- return smsRecipientsResult.value.filter(
- (r) => !!r.contact,
- ) as UserWithContactNumber[]
- } else {
- logger.warn({
- message: 'Failed to retrieve contact numbers for form editors',
- meta: {
- action: 'getEditorsWithContactNumbers',
- formId: form._id,
- },
+ return UserService.findContactsForEmails(possibleEditors)
+ .map((editors) => editors.filter(isUserWithContactNumber))
+ .orElse((error) => {
+ logger.warn({
+ message: 'Failed to retrieve contact numbers for form editors',
+ meta: {
+ action: 'getEditorsWithContactNumbers',
+ formId: form._id,
+ },
+ error,
+ })
+ return okAsync([])
})
- return []
- }
}
/**
@@ -352,11 +431,12 @@ export const getEditorsWithContactNumbers = async (
* @param possibleSmsRecipients Recipients to attempt to notify
* @returns true regardless of the outcome
*/
-export const notifyAdminsOfDeactivation = async (
+export const notifyAdminsOfDeactivation = (
form: IPopulatedForm,
possibleSmsRecipients: UserWithContactNumber[],
-): Promise => {
- const smsPromises = possibleSmsRecipients.map((recipient) =>
+ // Best-effort attempt to send SMSes, don't propagate error upwards
+): ResultAsync => {
+ const smsResults = possibleSmsRecipients.map((recipient) =>
SmsFactory.sendFormDeactivatedSms({
adminEmail: form.admin.email,
adminId: form.admin._id,
@@ -366,19 +446,65 @@ export const notifyAdminsOfDeactivation = async (
recipientEmail: recipient.email,
}),
)
-
- // neverthrow#combine is not used since we do not want to short circuit on the first error.
- const smsResults = await Promise.all(smsPromises)
- const smsErrors = extractSmsErrors(smsResults)
- if (smsErrors.length > 0) {
- logger.warn({
- message: 'Failed to send some form deactivation notification SMSes',
- meta: {
- action: 'notifyAdminsOfDeactivation',
- formId: form._id,
- reasons: smsErrors,
- },
+ return combineWithAllErrors(smsResults)
+ .map(() => true as const)
+ .orElse((errors) => {
+ logger.warn({
+ message: 'Failed to send some form deactivation notification SMSes',
+ meta: {
+ action: 'notifyAdminsOfDeactivation',
+ formId: form._id,
+ errors,
+ },
+ })
+ return okAsync(true)
})
- }
- return true
+}
+
+/**
+ * Saves a document to the database.
+ * @param bounceDoc Bounce document
+ * @returns The saved document
+ */
+export const saveBounceDoc = (
+ bounceDoc: IBounceSchema,
+): ResultAsync => {
+ return ResultAsync.fromPromise(bounceDoc.save(), (error) => {
+ // Accept the risk that there might be concurrency problems
+ // when multiple server instances try to access the same
+ // document, due to notifications arriving asynchronously.
+ // Hence avoid logging so logs do not get polluted
+ if (!(error instanceof mongoose.Error.VersionError)) {
+ logger.warn({
+ message: 'Error while saving Bounce document',
+ meta: {
+ action: 'saveBounceDoc',
+ formId: bounceDoc.formId,
+ },
+ })
+ }
+ return transformMongoError(error)
+ })
+}
+
+/**
+ * Safely parses an SNS notification.
+ * @param message Content of notification
+ */
+export const safeParseNotification = (
+ message: string,
+): Result => {
+ return Result.fromThrowable(
+ () => JSON.parse(message),
+ (error) => {
+ logger.warn({
+ message: 'Unable to parse SNS notification',
+ meta: {
+ action: 'safeParseNotification',
+ },
+ error,
+ })
+ return new ParseNotificationError()
+ },
+ )()
}
diff --git a/src/app/modules/bounce/bounce.util.ts b/src/app/modules/bounce/bounce.util.ts
index fb88898427..f378275c6f 100644
--- a/src/app/modules/bounce/bounce.util.ts
+++ b/src/app/modules/bounce/bounce.util.ts
@@ -1,13 +1,12 @@
-import { Result } from 'neverthrow'
-
import {
IBounceNotification,
IDeliveryNotification,
IEmailNotification,
} from '../../../types'
-import { InvalidNumberError, SmsSendError } from '../../services/sms/sms.errors'
+import { UserWithContactNumber } from '../user/user.types'
+
+import { SendBounceSmsNotificationError } from './bounce.errors'
-import { UserWithContactNumber } from './bounce.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).
@@ -70,39 +69,17 @@ export const isDeliveryNotification = (
/**
* Filters the given SMS recipients to only those which were sent succesfully
- * @param smsResults Array of sms results
- * @param smsRecipients Recipients who were SMSed. This array must correspond
- * exactly to smsResults, i.e. the result at smsResults[i] corresponds
- * to the result of the attempt to SMS smsRecipients[i]
+ * @param smsErrors Array of sms errors
+ * @param smsRecipients Recipients who were SMSed
* @returns the contact details of SMSes sent successfully
*/
export const extractSuccessfulSmsRecipients = (
- smsResults: Result[],
+ smsErrors: SendBounceSmsNotificationError[],
smsRecipients: UserWithContactNumber[],
): UserWithContactNumber[] => {
- return smsResults.reduce((acc, result, index) => {
- if (result.isOk()) {
- acc.push(smsRecipients[index])
- }
- return acc
- }, [])
-}
-
-/**
- * Extracts the errors from results of attempting to send SMSes
- * @param smsResults Array of Promise.allSettled results
- * @returns Array of errors
- */
-export const extractSmsErrors = (
- smsResults: Result[],
-): (SmsSendError | InvalidNumberError)[] => {
- return smsResults.reduce<(SmsSendError | InvalidNumberError)[]>(
- (acc, result) => {
- if (result.isErr()) {
- acc.push(result.error)
- }
- return acc
- },
- [],
+ // Get recipients which errored
+ const failedRecipients = smsErrors.map((error) => error.meta.contact)
+ return smsRecipients.filter(
+ (recipient) => !failedRecipients.includes(recipient.contact),
)
}
diff --git a/src/app/modules/bounce/bounce.types.ts b/src/app/modules/user/user.types.ts
similarity index 59%
rename from src/app/modules/bounce/bounce.types.ts
rename to src/app/modules/user/user.types.ts
index 0246f4c1e1..8800101055 100644
--- a/src/app/modules/bounce/bounce.types.ts
+++ b/src/app/modules/user/user.types.ts
@@ -3,8 +3,3 @@ import { SetRequired } from 'type-fest'
import { UserContactView } from '../../../types'
export type UserWithContactNumber = SetRequired
-
-export interface AdminNotificationResult {
- emailRecipients: string[]
- smsRecipients: UserWithContactNumber[]
-}
diff --git a/src/app/modules/user/user.utils.ts b/src/app/modules/user/user.utils.ts
index 5b09487b5c..d9026359e6 100644
--- a/src/app/modules/user/user.utils.ts
+++ b/src/app/modules/user/user.utils.ts
@@ -1,12 +1,14 @@
import { StatusCodes } from 'http-status-codes'
import { createLoggerWithLabel } from '../../../config/logger'
+import { UserContactView } from '../../../types'
import * as SmsErrors from '../../services/sms/sms.errors'
import { HashingError } from '../../utils/hash'
import * as CoreErrors from '../core/core.errors'
import { ErrorResponseData } from '../core/core.types'
import * as UserErrors from './user.errors'
+import { UserWithContactNumber } from './user.types'
const logger = createLoggerWithLabel(module)
/**
@@ -58,3 +60,15 @@ export const mapRouteError = (
}
}
}
+
+/**
+ * Checks for presence of contact number in a user's contact
+ * details. Type guard.
+ * @param userDetails Contact view of user
+ * @returns True if user has a contact number
+ */
+export const isUserWithContactNumber = (
+ userDetails: UserContactView,
+): userDetails is UserWithContactNumber => {
+ return !!userDetails.contact
+}
From 685277b9f3a105862c032df1c8a1b7660ac7b5fc Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Fri, 9 Apr 2021 08:48:25 +0800
Subject: [PATCH 33/75] refactor: migrate public routes to TypeScript (#1595)
* ref: move all PublicForm routes to TS
* docs: update Travis build stage name
* docs: update JSdocs for publicform routes
---
.travis.yml | 2 +-
.../form/public-form/public-form.routes.ts | 77 +++++++++--
src/app/routes/index.js | 1 -
src/app/routes/public-forms.server.routes.js | 121 ------------------
src/loaders/express/index.ts | 8 +-
5 files changed, 71 insertions(+), 138 deletions(-)
delete mode 100644 src/app/routes/index.js
delete mode 100644 src/app/routes/public-forms.server.routes.js
diff --git a/.travis.yml b/.travis.yml
index d256a72259..3188c6cb8d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -41,7 +41,7 @@ jobs:
use: build
script:
- npm run test-frontend
- - name: Typescript tests
+ - name: Backend tests
workspaces:
use: build
script:
diff --git a/src/app/modules/form/public-form/public-form.routes.ts b/src/app/modules/form/public-form/public-form.routes.ts
index 0d4fd71cc0..17d8b76227 100644
--- a/src/app/modules/form/public-form/public-form.routes.ts
+++ b/src/app/modules/form/public-form/public-form.routes.ts
@@ -1,3 +1,4 @@
+import { celebrate, Joi, Segments } from 'celebrate'
import { Router } from 'express'
import * as PublicFormController from './public-form.controller'
@@ -6,22 +7,80 @@ export const PublicFormRouter = Router()
/**
* Returns the specified form to the user, along with any
- * identity information obtained from SingPass/CorpPass,
- * and MyInfo details, if any.
+ * identify information obtained from Singpass/Corppass/MyInfo.
*
* WARNING: TemperatureSG batch jobs rely on this endpoint to
* retrieve the master list of personnel for daily reporting.
* Please strictly ensure backwards compatibility.
+ * @route GET /:formId/publicform
*
- * @route GET /{formId}/publicform
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @consumes application/json
- * @produces application/json
- * @returns {string} 404 - form is not made public
- * @returns {PublicForm.model} 200 - the form, and other information
+ * @returns 200 with form when form exists and is public
+ * @returns 404 when form is private or form with given ID does not exist
+ * @returns 410 when form is archived
+ * @returns 500 when database error occurs
*/
PublicFormRouter.get(
'/:formId([a-fA-F0-9]{24})/publicform',
PublicFormController.handleGetPublicForm,
)
+
+/**
+ * Redirect a form to the main index, with the specified path
+ * suffixed with a hashbang (`/#!`).
+ * @route GET /{Id}
+ * @route GET /{Id}/embed
+ * @route GET /{Id}/preview
+ * @route GET /{Id}/template
+ * @route GET /{Id}/use-template
+ * @route GET /forms/:agency/{Id}
+ * @route GET /forms/:agency/{Id}/embed
+ * @route GET /forms/:agency/{Id}/preview
+ * @route GET /forms/:agency/{Id}/template
+ * @route GET /forms/:agency/{Id}/use-template
+ * @group forms - endpoints to serve forms
+ * @returns 302 - redirects the user to the specified form,
+ * through the main index, with the form ID specified as a hashbang path
+ */
+PublicFormRouter.get(
+ '/:Id([a-fA-F0-9]{24})/:state(preview|template|use-template)?',
+ PublicFormController.handleRedirect,
+)
+
+PublicFormRouter.get(
+ '/:Id([a-fA-F0-9]{24})/embed',
+ PublicFormController.handleRedirect,
+)
+
+PublicFormRouter.get(
+ '/forms/:agency/:Id([a-fA-F0-9]{24})/:state(preview|template|use-template)?',
+ PublicFormController.handleRedirect,
+)
+
+PublicFormRouter.get(
+ '/forms/:agency/:Id([a-fA-F0-9]{24})/embed',
+ PublicFormController.handleRedirect,
+)
+
+/**
+ * Send feedback for a public form
+ * @route POST /:formId/feedback
+ * @returns 200 if feedback was successfully saved
+ * @returns 400 if form feedback was malformed and hence cannot be saved
+ * @returns 404 if form with formId does not exist or is private
+ * @returns 410 if form has been archived
+ * @returns 500 if database error occurs
+ */
+PublicFormRouter.post(
+ '/:formId([a-fA-F0-9]{24})/feedback',
+ celebrate({
+ [Segments.BODY]: Joi.object()
+ .keys({
+ rating: Joi.number().min(1).max(5).cast('string').required(),
+ comment: Joi.string().allow('').required(),
+ })
+ // Allow other keys for backwards compability as frontend might put
+ // extra keys in the body.
+ .unknown(true),
+ }),
+ PublicFormController.handleSubmitFeedback,
+)
diff --git a/src/app/routes/index.js b/src/app/routes/index.js
deleted file mode 100644
index 953d0f67c0..0000000000
--- a/src/app/routes/index.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = [require('./public-forms.server.routes.js')]
diff --git a/src/app/routes/public-forms.server.routes.js b/src/app/routes/public-forms.server.routes.js
deleted file mode 100644
index d4731d0d9a..0000000000
--- a/src/app/routes/public-forms.server.routes.js
+++ /dev/null
@@ -1,121 +0,0 @@
-'use strict'
-
-/**
- * Module dependencies.
- */
-const { celebrate, Joi, Segments } = require('celebrate')
-const PublicFormController = require('../modules/form/public-form/public-form.controller')
-module.exports = function (app) {
- /**
- * Redirect a form to the main index, with the specified path
- * suffixed with a hashbang (`/#!`)
- * parameter Id is used instead of formId as formById middleware is not needed
- * @route GET /{Id}
- * @route GET /{Id}/preview
- * @route GET /{Id}/embed
- * @route GET /{Id}/template
- * @route GET /{Id}/use-template
- * @group forms - endpoints to serve forms
- * @param {string} Id.path.required - the form id
- * @produces text/html
- * @returns {string} 302 - redirects the user to the specified form,
- * through the main index, with the form id specified as a hashbang path
- */
- app
- .route('/:Id([a-fA-F0-9]{24})/:state(preview|template|use-template)?')
- .get(PublicFormController.handleRedirect)
-
- // TODO: Remove this embed endpoint
- app
- .route('/:Id([a-fA-F0-9]{24})/embed')
- .get(PublicFormController.handleRedirect)
-
- /**
- * Redirect a form to the main index, with the specified path
- * suffixed with a hashbang (`/#!`). /forms/:agency is added for backward compatibility.
- * parameter Id is used instead of formId as formById middleware is not needed
- * TODO: Remove once all form links being shared do not have /forms/:agency
- * @route GET /forms/:agency/{Id}
- * @route GET /forms/:agency/{Id}/preview
- * @route GET /forms/:agency/{Id}/embed
- * @route GET /forms/:agency/{Id}/template
- * @route GET /{Id}/use-template
- * @group forms - endpoints to serve forms
- * @param {string} Id.path.required - the form id
- * @produces text/html
- * @returns {string} 302 - redirects the user to the specified form,
- * through the main index, with the form id specified as a hashbang path
- */
- app
- .route(
- '/forms/:agency/:Id([a-fA-F0-9]{24})/:state(preview|template|use-template)?',
- )
- .get(PublicFormController.handleRedirect)
-
- // TODO: Remove this embed endpoint
- app
- .route('/forms/:agency/:Id([a-fA-F0-9]{24})/embed')
- .get(PublicFormController.handleRedirect)
-
- /**
- * @typedef Feedback
- * @property {number} rating.required - the user's rating of the form
- * @property {string} comment - any comments the user might have
- */
-
- /**
- * Send feedback for a public form
- * @route POST /:formId/feedback
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @param {Feedback.model} feedback.body.required - the user's feedback
- * @consumes application/json
- * @produces application/json
- * @returns 200 if feedback was successfully saved
- * @returns 400 if form feedback was malformed and hence cannot be saved
- * @returns 404 if form with formId does not exist or is private
- * @returns 410 if form has been archived
- * @returns 500 if database error occurs
- */
- app.route('/:formId([a-fA-F0-9]{24})/feedback').post(
- celebrate({
- [Segments.BODY]: Joi.object()
- .keys({
- rating: Joi.number().min(1).max(5).cast('string').required(),
- comment: Joi.string().allow('').required(),
- })
- // Allow other keys for backwards compability as frontend might put
- // extra keys in the body.
- .unknown(true),
- }),
- PublicFormController.handleSubmitFeedback,
- )
-
- /**
- * @typedef PublicForm
- * @property {object} form.required - the form
- * @property {object} spcpSession - contains identity information from SingPass/CorpPass
- * @property {boolean} myInfoError - indicates if there was any errors while accessing MyInfo
- */
-
- /**
- * Returns the specified form to the user, along with any
- * identity information obtained from SingPass/CorpPass,
- * and MyInfo details, if any.
- *
- * WARNING: TemperatureSG batch jobs rely on this endpoint to
- * retrieve the master list of personnel for daily reporting.
- * Please strictly ensure backwards compatibility.
- *
- * @route GET /{formId}/publicform
- * @group forms - endpoints to serve forms
- * @param {string} formId.path.required - the form id
- * @consumes application/json
- * @produces application/json
- * @returns {string} 404 - form is not made public
- * @returns {PublicForm.model} 200 - the form, and other information
- */
- app
- .route('/:formId([a-fA-F0-9]{24})/publicform')
- .get(PublicFormController.handleGetPublicForm)
-}
diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts
index 170803036a..1acdd06314 100644
--- a/src/loaders/express/index.ts
+++ b/src/loaders/express/index.ts
@@ -14,6 +14,7 @@ import { BillingRouter } from '../../app/modules/billing/billing.routes'
import { BounceRouter } from '../../app/modules/bounce/bounce.routes'
import { ExamplesRouter } from '../../app/modules/examples/examples.routes'
import { AdminFormsRouter } from '../../app/modules/form/admin-form/admin-form.routes'
+import { PublicFormRouter } from '../../app/modules/form/public-form/public-form.routes'
import { FrontendRouter } from '../../app/modules/frontend/frontend.server.routes'
import { HomeRouter } from '../../app/modules/home/home.routes'
import { MYINFO_ROUTER_PREFIX } from '../../app/modules/myinfo/myinfo.constants'
@@ -26,7 +27,6 @@ import {
import { SubmissionRouter } from '../../app/modules/submission/submission.routes'
import UserRouter from '../../app/modules/user/user.routes'
import { VfnRouter } from '../../app/modules/verification/verification.routes'
-import apiRoutes from '../../app/routes'
import { ApiRouter } from '../../app/routes/api'
import * as IntranetMiddleware from '../../app/services/intranet/intranet.middleware'
import config from '../../config/config'
@@ -144,11 +144,6 @@ const loadExpressApp = async (connection: Connection) => {
// Log intranet usage
app.use(IntranetMiddleware.logIntranetUsage)
- // Mount all API endpoints
- apiRoutes.forEach(function (routeFunction) {
- routeFunction(app)
- })
-
app.use('/', HomeRouter)
app.use('/frontend', FrontendRouter)
app.use('/auth', AuthRouter)
@@ -167,6 +162,7 @@ const loadExpressApp = async (connection: Connection) => {
// Use constant for registered routes with MyInfo servers
app.use(MYINFO_ROUTER_PREFIX, MyInfoRouter)
app.use(AdminFormsRouter)
+ app.use(PublicFormRouter)
// New routes in preparation for API refactor.
app.use('/api', ApiRouter)
From 437e16e746e3f18d080f5c4ef6d22ab7bb9ec2bc Mon Sep 17 00:00:00 2001
From: Antariksh Mahajan
Date: Fri, 9 Apr 2021 09:36:03 +0800
Subject: [PATCH 34/75] refactor: remove typecasts and non-null assertions
(#1596)
* ref: remove type casts where possible
* ref: remove unused middleware and dependencies
* ref: improve typing of JWT payload
* ref: remove non-null assertions
* ref: rename express.locals.ts
* ref: remove final non-null assertion
* chore: enforce no non-null assertion lint rule
* chore: titlecase Singpass and Corppass
* ref: use switch in extractJwtPayloadFromRequest
* ref: use switch in submissions controllers
---
.eslintrc | 5 +-
src/app/modules/examples/examples.service.ts | 4 -
.../__tests__/public-form.middlewares.spec.ts | 218 ------------------
.../public-form/public-form.middlewares.ts | 54 -----
src/app/modules/myinfo/myinfo.util.ts | 23 +-
.../spcp/__tests__/spcp.factory.spec.ts | 18 +-
.../spcp/__tests__/spcp.service.spec.ts | 16 +-
src/app/modules/spcp/spcp.controller.ts | 38 +--
src/app/modules/spcp/spcp.factory.ts | 6 +-
src/app/modules/spcp/spcp.service.ts | 71 ++++--
src/app/modules/spcp/spcp.types.ts | 11 +-
src/app/modules/spcp/spcp.util.ts | 54 +++--
.../email-submission.controller.ts | 136 ++++++-----
.../encrypt-submission.controller.ts | 112 ++++-----
src/app/utils/encryption.ts | 5 -
src/app/utils/limit-rate.ts | 3 +-
src/config/config.ts | 5 +-
src/config/logger.ts | 49 +---
src/loaders/mongoose.ts | 3 +-
src/shared/util/logic.ts | 12 +-
.../{express.locals.ts => email_mode_data.ts} | 26 ---
src/types/index.ts | 2 +-
22 files changed, 293 insertions(+), 578 deletions(-)
delete mode 100644 src/app/modules/form/public-form/__tests__/public-form.middlewares.spec.ts
delete mode 100644 src/app/modules/form/public-form/public-form.middlewares.ts
rename src/types/{express.locals.ts => email_mode_data.ts} (59%)
diff --git a/.eslintrc b/.eslintrc
index f69d51124f..87b14ecf6e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -66,9 +66,10 @@
{ "files": ["*.spec.ts"], "extends": ["plugin:jest/recommended"] },
{
"files": ["*.ts", "*.js"],
- "excludedFiles": ["**/*.spec.ts", "**/.spec.js"],
+ "excludedFiles": ["**/*.spec.ts", "**/.spec.js", "**/__tests__/**/*.ts"],
"rules": {
- "typesafe/no-await-without-trycatch": "warn"
+ "typesafe/no-await-without-trycatch": "warn",
+ "@typescript-eslint/no-non-null-assertion": "error"
}
}
],
diff --git a/src/app/modules/examples/examples.service.ts b/src/app/modules/examples/examples.service.ts
index b4d4ef62c3..3ca8f7ba31 100644
--- a/src/app/modules/examples/examples.service.ts
+++ b/src/app/modules/examples/examples.service.ts
@@ -157,10 +157,6 @@ const execExamplesQuery = (
): ResultAsync => {
return ResultAsync.fromPromise(
queryBuilder
- // TODO(#42): Missing type in native typescript, waiting on upstream fixes.
- // Tracking at https://github.com/Automattic/mongoose/issues/9714.
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
.append(
selectAndProjectCardInfo(/* limit= */ PAGE_SIZE, /* offset= */ offset),
)
diff --git a/src/app/modules/form/public-form/__tests__/public-form.middlewares.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.middlewares.spec.ts
deleted file mode 100644
index 2d2d9fa9df..0000000000
--- a/src/app/modules/form/public-form/__tests__/public-form.middlewares.spec.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { ObjectId } from 'bson-ext'
-import { Request } from 'express'
-import { StatusCodes } from 'http-status-codes'
-import { merge, times } from 'lodash'
-import mongoose from 'mongoose'
-
-import expressHandler from 'tests/unit/backend/helpers/jest-express'
-
-import dbHandler from '../../../../../../tests/unit/backend/helpers/jest-db'
-import {
- IEncryptedSubmissionSchema,
- ResponseMode,
- Status,
- SubmissionType,
- WithForm,
-} from '../../../../../types'
-import getFormModel from '../../../../models/form.server.model'
-import getSubmissionModel from '../../../../models/submission.server.model'
-import {
- checkFormSubmissionLimitAndDeactivate,
- isFormPublicCheck,
-} from '../public-form.middlewares'
-
-const FormModel = getFormModel(mongoose)
-const SubmissionModel = getSubmissionModel(mongoose)
-
-const MOCK_ADMIN_OBJ_ID = new ObjectId()
-
-const MOCK_FORM_PARAMS = {
- title: 'Test Form',
- admin: String(MOCK_ADMIN_OBJ_ID),
-}
-const MOCK_ENCRYPTED_FORM_PARAMS = {
- ...MOCK_FORM_PARAMS,
- publicKey: 'mockPublicKey',
- responseMode: ResponseMode.Encrypt,
-}
-
-describe('public-form.middlewares', () => {
- describe('checkFormSubmissionLimitAndDeactivate', () => {
- beforeAll(async () => await dbHandler.connect())
- beforeEach(async () => {
- await dbHandler.insertFormCollectionReqs({
- userId: MOCK_ADMIN_OBJ_ID,
- })
- })
- afterEach(async () => await dbHandler.clearDatabase())
- afterAll(async () => await dbHandler.closeDatabase())
-
- it('should let requests through when form has no submission limit', async () => {
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: { submissionLimit: null },
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- await checkFormSubmissionLimitAndDeactivate(mockReq, mockRes, mockNext)
-
- expect(mockNext).toBeCalled()
- expect(mockRes.sendStatus).not.toBeCalled()
- expect(mockRes.status).not.toBeCalled()
- expect(mockRes.json).not.toBeCalled()
- })
-
- it('should let requests through when form has not reached submission limit', async () => {
- const formParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, {
- status: Status.Public,
- submissionLimit: 10,
- })
- const validForm = new FormModel(formParams)
- const form = await validForm.save()
-
- const submissionPromises = times(5, () =>
- SubmissionModel.create({
- form: form._id,
- myInfoFields: [],
- submissionType: SubmissionType.Encrypt,
- encryptedContent: 'mockEncryptedContent',
- version: 1,
- created: new Date('2020-01-01'),
- }),
- )
- await Promise.all(submissionPromises)
-
- const title = 'My private form'
- const inactiveMessage = 'The form is not available.'
-
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: {
- _id: form._id.toHexString(),
- submissionLimit: 10,
- title,
- inactiveMessage,
- },
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- await checkFormSubmissionLimitAndDeactivate(mockReq, mockRes, mockNext)
-
- expect(mockNext).toBeCalled()
- expect(mockRes.sendStatus).not.toBeCalled()
- expect(mockRes.status).not.toBeCalled()
- expect(mockRes.json).not.toBeCalled()
- })
-
- it('should not let requests through and deactivate form when form has reached submission limit', async () => {
- const formParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, {
- status: Status.Public,
- submissionLimit: 5,
- })
- const validForm = new FormModel(formParams)
- const form = await validForm.save()
-
- const submissionPromises = times(5, () =>
- SubmissionModel.create({
- form: form._id,
- myInfoFields: [],
- submissionType: SubmissionType.Encrypt,
- encryptedContent: 'mockEncryptedContent',
- version: 1,
- created: new Date('2020-01-01'),
- }),
- )
- await Promise.all(submissionPromises)
-
- const title = 'My private form'
- const inactiveMessage = 'The form is not available.'
-
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: {
- _id: form._id.toHexString(),
- submissionLimit: 5,
- title,
- inactiveMessage,
- },
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- await checkFormSubmissionLimitAndDeactivate(mockReq, mockRes, mockNext)
-
- expect(mockNext).not.toBeCalled()
- expect(mockRes.json).toBeCalledWith({
- message: inactiveMessage,
- isPageFound: true,
- formTitle: title,
- })
- expect(mockRes.status).toBeCalledWith(StatusCodes.NOT_FOUND)
-
- const updated = await FormModel.findById(form._id)
- expect(updated!.status).toBe('PRIVATE')
- })
- })
-
- describe('isFormPublicCheck', () => {
- it('should call next middleware function if form is public', () => {
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form: { status: Status.Public },
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- isFormPublicCheck(mockReq, mockRes, mockNext)
-
- expect(mockNext).toBeCalled()
- expect(mockRes.sendStatus).not.toBeCalled()
- expect(mockRes.status).not.toBeCalled()
- expect(mockRes.json).not.toBeCalled()
- })
-
- it('should return HTTP 404 Not Found if form is private', () => {
- const title = 'My private form'
- const inactiveMessage = 'The form is not available.'
- const form = {
- status: Status.Private,
- title,
- inactiveMessage,
- }
-
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form,
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- isFormPublicCheck(mockReq, mockRes, mockNext)
-
- expect(mockNext).not.toBeCalled()
- expect(mockRes.sendStatus).not.toBeCalled()
- expect(mockRes.status).toBeCalledWith(StatusCodes.NOT_FOUND)
- expect(mockRes.json).toBeCalledWith({
- message: inactiveMessage,
- isPageFound: true,
- formTitle: title,
- })
- })
-
- it('should return HTTP 410 Gone if form is archived', () => {
- const form = {
- status: Status.Archived,
- }
-
- const mockReq = Object.assign(expressHandler.mockRequest(), {
- form,
- }) as WithForm
- const mockRes = expressHandler.mockResponse()
- const mockNext = jest.fn()
-
- isFormPublicCheck(mockReq, mockRes, mockNext)
-
- expect(mockNext).not.toBeCalled()
- expect(mockRes.sendStatus).toBeCalledWith(StatusCodes.GONE)
- expect(mockRes.status).not.toBeCalled()
- expect(mockRes.json).not.toBeCalledWith()
- })
- })
-})
diff --git a/src/app/modules/form/public-form/public-form.middlewares.ts b/src/app/modules/form/public-form/public-form.middlewares.ts
deleted file mode 100644
index a47ee97a98..0000000000
--- a/src/app/modules/form/public-form/public-form.middlewares.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { RequestHandler } from 'express'
-import { StatusCodes } from 'http-status-codes'
-
-import { WithForm } from '../../../../types'
-import { FormDeletedError } from '../form.errors'
-import {
- checkFormSubmissionLimitAndDeactivateForm,
- isFormPublic,
-} from '../form.service'
-
-/**
- * Express middleware function that checks if a form has exceeded its submission limits before allowing
- * downstream middleware to handle the request. Otherwise, it returns a HTTP 404.
- */
-export const checkFormSubmissionLimitAndDeactivate: RequestHandler = async (
- req,
- res,
- next,
-) => {
- const { form } = req as WithForm
- const formResult = await checkFormSubmissionLimitAndDeactivateForm(form)
- if (formResult.isErr()) {
- return res.status(StatusCodes.NOT_FOUND).json({
- message: form.inactiveMessage,
- isPageFound: true, // Flag to prevent default 404 subtext ("please check link") from showing
- formTitle: form.title,
- })
- }
-
- return next()
-}
-
-/**
- * Express middleware function that checks if a form attached to the Express request handler is public.
- * before allowing downstream middleware to handle the request. Otherwise, it returns a HTTP 404 if the
- * form is private, or HTTP 410 if the form has been archived.
- * @param req - Express request object
- * @param res - Express response object
- * @param next - Express next middleware function
- */
-export const isFormPublicCheck: RequestHandler = (req, res, next) => {
- const { form } = req as WithForm
- return isFormPublic(form)
- .map(() => next())
- .mapErr((error) => {
- return error instanceof FormDeletedError
- ? res.sendStatus(StatusCodes.GONE)
- : res.status(StatusCodes.NOT_FOUND).json({
- message: form.inactiveMessage,
- isPageFound: true, // Flag to prevent default 404 subtext ("please check link") from showing
- formTitle: form.title,
- })
- })
-}
diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts
index cf154b7274..4f5ef51a33 100644
--- a/src/app/modules/myinfo/myinfo.util.ts
+++ b/src/app/modules/myinfo/myinfo.util.ts
@@ -83,16 +83,6 @@ const hasMyInfoAnswer = (
return !!field.isVisible && !!field.myInfo?.attr
}
-const filterFieldsWithHashes = (
- responses: ProcessedFieldResponse[],
- hashes: IHashes,
-): VisibleMyInfoResponse[] => {
- // Filter twice to get types to cooperate
- return responses
- .filter(hasMyInfoAnswer)
- .filter((response) => !!hashes[response.myInfo.attr])
-}
-
const transformAnswer = (field: VisibleMyInfoResponse): string => {
const answer = field.answer
return field.fieldType === BasicField.Date
@@ -117,14 +107,15 @@ export const compareHashedValues = (
responses: ProcessedFieldResponse[],
hashes: IHashes,
): MyInfoComparePromises => {
- // Filter responses to only those fields with a corresponding hash
- const fieldsWithHashes = filterFieldsWithHashes(responses, hashes)
// Map MyInfoAttribute to response
const myInfoResponsesMap: MyInfoComparePromises = new Map()
- fieldsWithHashes.forEach((field) => {
- const attr = field.myInfo.attr
- // Already checked that hashes contains this attr
- myInfoResponsesMap.set(field._id, compareSingleHash(hashes[attr]!, field))
+ responses.forEach((field) => {
+ if (hasMyInfoAnswer(field)) {
+ const hash = hashes[field.myInfo.attr]
+ if (hash) {
+ myInfoResponsesMap.set(field._id, compareSingleHash(hash, field))
+ }
+ }
})
return myInfoResponsesMap
}
diff --git a/src/app/modules/spcp/__tests__/spcp.factory.spec.ts b/src/app/modules/spcp/__tests__/spcp.factory.spec.ts
index 2cab38f5c4..7d8903e1dc 100644
--- a/src/app/modules/spcp/__tests__/spcp.factory.spec.ts
+++ b/src/app/modules/spcp/__tests__/spcp.factory.spec.ts
@@ -27,9 +27,11 @@ describe('spcp.factory', () => {
)
const fetchLoginPageResult = await SpcpFactory.fetchLoginPage('')
const validateLoginPageResult = SpcpFactory.validateLoginPage('')
- const extractJwtPayloadResult = await SpcpFactory.extractJwtPayload(
+ const extractSingpassJwtPayloadResult = await SpcpFactory.extractSingpassJwtPayload(
+ '',
+ )
+ const extractCorppassJwtPayloadResult = await SpcpFactory.extractCorppassJwtPayload(
'',
- AuthType.SP,
)
const parseOOBParamsResult = SpcpFactory.parseOOBParams('', '', AuthType.SP)
const getSpcpAttributesResult = await SpcpFactory.getSpcpAttributes(
@@ -51,7 +53,8 @@ describe('spcp.factory', () => {
expect(createRedirectUrlResult._unsafeUnwrapErr()).toEqual(error)
expect(fetchLoginPageResult._unsafeUnwrapErr()).toEqual(error)
expect(validateLoginPageResult._unsafeUnwrapErr()).toEqual(error)
- expect(extractJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
+ expect(extractSingpassJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
+ expect(extractCorppassJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
expect(parseOOBParamsResult._unsafeUnwrapErr()).toEqual(error)
expect(getSpcpAttributesResult._unsafeUnwrapErr()).toEqual(error)
expect(createJWTResult._unsafeUnwrapErr()).toEqual(error)
@@ -74,9 +77,11 @@ describe('spcp.factory', () => {
)
const fetchLoginPageResult = await SpcpFactory.fetchLoginPage('')
const validateLoginPageResult = SpcpFactory.validateLoginPage('')
- const extractJwtPayloadResult = await SpcpFactory.extractJwtPayload(
+ const extractSingpassJwtPayloadResult = await SpcpFactory.extractSingpassJwtPayload(
+ '',
+ )
+ const extractCorppassJwtPayloadResult = await SpcpFactory.extractCorppassJwtPayload(
'',
- AuthType.SP,
)
const parseOOBParamsResult = SpcpFactory.parseOOBParams('', '', AuthType.SP)
const getSpcpAttributesResult = await SpcpFactory.getSpcpAttributes(
@@ -98,7 +103,8 @@ describe('spcp.factory', () => {
expect(createRedirectUrlResult._unsafeUnwrapErr()).toEqual(error)
expect(fetchLoginPageResult._unsafeUnwrapErr()).toEqual(error)
expect(validateLoginPageResult._unsafeUnwrapErr()).toEqual(error)
- expect(extractJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
+ expect(extractSingpassJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
+ expect(extractCorppassJwtPayloadResult._unsafeUnwrapErr()).toEqual(error)
expect(parseOOBParamsResult._unsafeUnwrapErr()).toEqual(error)
expect(getSpcpAttributesResult._unsafeUnwrapErr()).toEqual(error)
expect(createJWTResult._unsafeUnwrapErr()).toEqual(error)
diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts
index 43746bbcf4..dc20f58a5f 100644
--- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts
+++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts
@@ -215,7 +215,7 @@ describe('spcp.service', () => {
})
})
- describe('extractJwtPayload', () => {
+ describe('extractSingpassJwtPayload', () => {
it('should return the correct payload for Singpass when JWT is valid', async () => {
const spcpService = new SpcpService(MOCK_PARAMS)
// Assumes that SP auth client was instantiated first
@@ -223,7 +223,7 @@ describe('spcp.service', () => {
mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
cb(null, MOCK_SP_JWT_PAYLOAD),
)
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.SP)
+ const result = await spcpService.extractSingpassJwtPayload(MOCK_JWT)
expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD)
})
@@ -234,7 +234,7 @@ describe('spcp.service', () => {
mockClient.verifyJWT.mockImplementationOnce((_jwt, cb) =>
cb(new Error(), null),
)
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.SP)
+ const result = await spcpService.extractSingpassJwtPayload(MOCK_JWT)
expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError())
})
@@ -247,12 +247,14 @@ describe('spcp.service', () => {
const expected = new InvalidJwtError()
// Act
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.SP)
+ const result = await spcpService.extractSingpassJwtPayload(MOCK_JWT)
// Assert
expect(result._unsafeUnwrapErr()).toEqual(expected)
})
+ })
+ describe('extractCorppassJwtPayload', () => {
it('should return the correct payload for Corppass when JWT is valid', async () => {
const spcpService = new SpcpService(MOCK_PARAMS)
// Assumes that SP auth client was instantiated first
@@ -260,7 +262,7 @@ describe('spcp.service', () => {
mockClient.verifyJWT.mockImplementationOnce((jwt, cb) =>
cb(null, MOCK_CP_JWT_PAYLOAD),
)
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP)
+ const result = await spcpService.extractCorppassJwtPayload(MOCK_JWT)
expect(result._unsafeUnwrap()).toEqual(MOCK_CP_JWT_PAYLOAD)
})
@@ -271,7 +273,7 @@ describe('spcp.service', () => {
mockClient.verifyJWT.mockImplementationOnce((_jwt, cb) =>
cb(new Error(), null),
)
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP)
+ const result = await spcpService.extractCorppassJwtPayload(MOCK_JWT)
expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError())
})
@@ -284,7 +286,7 @@ describe('spcp.service', () => {
const expected = new InvalidJwtError()
// Act
- const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP)
+ const result = await spcpService.extractCorppassJwtPayload(MOCK_JWT)
// Assert
expect(result._unsafeUnwrapErr()).toEqual(expected)
diff --git a/src/app/modules/spcp/spcp.controller.ts b/src/app/modules/spcp/spcp.controller.ts
index 42f7f57b3f..77f7c6c082 100644
--- a/src/app/modules/spcp/spcp.controller.ts
+++ b/src/app/modules/spcp/spcp.controller.ts
@@ -4,7 +4,7 @@ import { StatusCodes } from 'http-status-codes'
import config from '../../../config/config'
import { createLoggerWithLabel } from '../../../config/logger'
-import { AuthType, WithForm } from '../../../types'
+import { AuthType } from '../../../types'
import { createReqMeta } from '../../utils/request'
import { BillingFactory } from '../billing/billing.factory'
import * as FormService from '../form/form.service'
@@ -90,42 +90,6 @@ export const handleValidate: RequestHandler<
})
}
-/**
- * Adds session to returned JSON if form-filler is SPCP Authenticated
- * @param req - Express request object
- * @param res - Express response object
- * @param next - Express next middleware function
- */
-export const addSpcpSessionInfo: RequestHandler = async (
- req,
- res,
- next,
-) => {
- const { authType } = (req as WithForm).form
- if (authType !== AuthType.SP && authType !== AuthType.CP) return next()
-
- const jwtResult = SpcpFactory.extractJwt(req.cookies, authType)
- // No action needed if JWT is missing, just means user is not logged in
- if (jwtResult.isErr()) return next()
-
- return SpcpFactory.extractJwtPayload(jwtResult.value, authType)
- .map(({ userName }) => {
- res.locals.spcpSession = { userName }
- return next()
- })
- .mapErr((error) => {
- logger.error({
- message: 'Failed to verify JWT with auth client',
- meta: {
- action: 'addSpcpSessionInfo',
- ...createReqMeta(req),
- },
- error,
- })
- return next()
- })
-}
-
/**
* Higher-order function which returns an Express handler to handle Singpass
* and Corppass login requests.
diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts
index 69cc2c031c..5df9aaf191 100644
--- a/src/app/modules/spcp/spcp.factory.ts
+++ b/src/app/modules/spcp/spcp.factory.ts
@@ -13,7 +13,8 @@ interface ISpcpFactory {
fetchLoginPage: SpcpService['fetchLoginPage']
validateLoginPage: SpcpService['validateLoginPage']
extractJwt: SpcpService['extractJwt']
- extractJwtPayload: SpcpService['extractJwtPayload']
+ extractSingpassJwtPayload: SpcpService['extractSingpassJwtPayload']
+ extractCorppassJwtPayload: SpcpService['extractCorppassJwtPayload']
parseOOBParams: SpcpService['parseOOBParams']
getSpcpAttributes: SpcpService['getSpcpAttributes']
createJWT: SpcpService['createJWT']
@@ -32,7 +33,8 @@ export const createSpcpFactory = ({
createRedirectUrl: () => err(error),
fetchLoginPage: () => errAsync(error),
validateLoginPage: () => err(error),
- extractJwtPayload: () => errAsync(error),
+ extractSingpassJwtPayload: () => errAsync(error),
+ extractCorppassJwtPayload: () => errAsync(error),
extractJwt: () => err(error),
parseOOBParams: () => err(error),
getSpcpAttributes: () => errAsync(error),
diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts
index 74911cdbda..81e353c2a0 100644
--- a/src/app/modules/spcp/spcp.service.ts
+++ b/src/app/modules/spcp/spcp.service.ts
@@ -22,11 +22,13 @@ import {
} from './spcp.errors'
import {
CorppassAttributes,
+ CorppassJwtPayload,
JwtName,
JwtPayload,
LoginPageValidationResult,
ParsedSpcpParams,
SingpassAttributes,
+ SingpassJwtPayload,
SpcpCookies,
SpcpDomainSettings,
} from './spcp.types'
@@ -34,7 +36,8 @@ import {
extractFormId,
getAttributesPromise,
getSubstringBetween,
- isJwtPayload,
+ isCorppassJwtPayload,
+ isSingpassJwtPayload,
isValidAuthenticationQuery,
verifyJwtPromise,
} from './spcp.util'
@@ -205,19 +208,16 @@ export class SpcpService {
}
/**
- * Verifies a JWT and extracts its payload.
+ * Verifies a Singpass JWT and extracts its payload.
* @param jwt The contents of the JWT cookie
- * @param authType 'SP' or 'CP'
*/
- extractJwtPayload(
+ extractSingpassJwtPayload(
jwt: string,
- authType: AuthType.SP | AuthType.CP,
- ): ResultAsync {
+ ): ResultAsync