diff --git a/.github/mergify.yml b/.github/mergify.yml index 6670e8edcc..09c26823d9 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -2,7 +2,6 @@ pull_request_rules: - name: Approve and merge non-major dependency upgrades conditions: - 'author=dependabot[bot]' - - '#check-failure=0' - 'title~=bump [^\s]+ from ([\d]+)\..+ to \1\.' actions: review: diff --git a/CHANGELOG.md b/CHANGELOG.md index c143cba688..47251c591d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,66 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v5.8.0](https://github.com/opengovsg/FormSG/compare/v5.7.1...v5.8.0) + +- feat: update form guide links to go links [`#1750`](https://github.com/opengovsg/FormSG/pull/1750) +- chore(mergify): remove 0 check failure condition [`#1748`](https://github.com/opengovsg/FormSG/pull/1748) +- fix(deps): bump aws-sdk from 2.892.0 to 2.893.0 [`#1745`](https://github.com/opengovsg/FormSG/pull/1745) +- feat: show highlights only if prefill is provided in url [`#1742`](https://github.com/opengovsg/FormSG/pull/1742) +- fix: clone field to save before removing myinfo field info [`#1741`](https://github.com/opengovsg/FormSG/pull/1741) +- fix: convert form field responses to field class [`#1739`](https://github.com/opengovsg/FormSG/pull/1739) +- refactor(test): integration and unit tests for redirect [`#1728`](https://github.com/opengovsg/FormSG/pull/1728) +- feat: prefill mainstream launch [`#1702`](https://github.com/opengovsg/FormSG/pull/1702) +- fix: update MyInfo field count correctly, show correct error [`#1738`](https://github.com/opengovsg/FormSG/pull/1738) +- refactor: use shared DateSelectedValidation enum instead of DATE_VALIDATION_OPTIONS object [`#1724`](https://github.com/opengovsg/FormSG/pull/1724) +- feat(api-refactor): implement specific reorder field api [`#1726`](https://github.com/opengovsg/FormSG/pull/1726) +- chore(deps-dev): bump @types/jest from 26.0.22 to 26.0.23 [`#1735`](https://github.com/opengovsg/FormSG/pull/1735) +- chore(deps-dev): bump eslint-plugin-jest from 24.3.5 to 24.3.6 [`#1734`](https://github.com/opengovsg/FormSG/pull/1734) +- feat(client): log client form reCAPTCHA failure to GA [`#1684`](https://github.com/opengovsg/FormSG/pull/1684) +- feat(spcp): raise sp cookie max age to 3 hrs [`#1727`](https://github.com/opengovsg/FormSG/pull/1727) +- feat(api): collapse spcp/myinfo redirect endpoint to new endpoint [`#1672`](https://github.com/opengovsg/FormSG/pull/1672) +- feat: Add a limit for the number of MyInfo fields that can be added [`#1664`](https://github.com/opengovsg/FormSG/pull/1664) +- build(deps): bump mockpass and spcp-auth-client [`#1723`](https://github.com/opengovsg/FormSG/pull/1723) +- fix: show correct error message for e-service ID validation [`#1722`](https://github.com/opengovsg/FormSG/pull/1722) +- fix(deps): bump @sentry/browser from 6.3.0 to 6.3.1 [`#1719`](https://github.com/opengovsg/FormSG/pull/1719) +- chore(deps-dev): bump eslint-config-prettier from 8.2.0 to 8.3.0 [`#1716`](https://github.com/opengovsg/FormSG/pull/1716) +- chore(deps-dev): bump stylelint-config-standard from 21.0.0 to 22.0.0 [`#1720`](https://github.com/opengovsg/FormSG/pull/1720) +- fix(deps): bump @sentry/integrations from 6.3.0 to 6.3.1 [`#1718`](https://github.com/opengovsg/FormSG/pull/1718) +- chore(deps-dev): bump eslint from 7.24.0 to 7.25.0 [`#1717`](https://github.com/opengovsg/FormSG/pull/1717) +- chore(deps-dev): bump stylelint from 13.12.0 to 13.13.0 [`#1715`](https://github.com/opengovsg/FormSG/pull/1715) +- fix(deps): bump aws-sdk from 2.890.0 to 2.892.0 [`#1714`](https://github.com/opengovsg/FormSG/pull/1714) +- fix(deps): bump twilio from 3.60.0 to 3.61.0 [`#1711`](https://github.com/opengovsg/FormSG/pull/1711) +- fix(deps): bump aws-sdk from 2.889.0 to 2.890.0 [`#1710`](https://github.com/opengovsg/FormSG/pull/1710) +- chore(deps-dev): bump core-js from 3.10.2 to 3.11.0 [`#1709`](https://github.com/opengovsg/FormSG/pull/1709) +- chore: merge v5.7.1 into develop [`#1707`](https://github.com/opengovsg/FormSG/pull/1707) +- chore(admin-forms-routes): remove duplicate logic route endpoint [`#1703`](https://github.com/opengovsg/FormSG/pull/1703) +- fix(mongoose): use official discriminator definitions [`#1704`](https://github.com/opengovsg/FormSG/pull/1704) +- fix: sync email field state between hasAllowedEmailDomains and allowedEmailDomains [`#1697`](https://github.com/opengovsg/FormSG/pull/1697) +- refactor(preview-api): duplicate adminform presign endpoints for /api/v3 [`#1644`](https://github.com/opengovsg/FormSG/pull/1644) +- fix: remove verified prefix on blank verified fields [`#1701`](https://github.com/opengovsg/FormSG/pull/1701) +- feat(webhooks): streamline webhook response data [`#1696`](https://github.com/opengovsg/FormSG/pull/1696) +- fix(deps): bump @babel/runtime from 7.13.16 to 7.13.17 [`#1700`](https://github.com/opengovsg/FormSG/pull/1700) +- refactor: Extract delete logic endpoint [`#1586`](https://github.com/opengovsg/FormSG/pull/1586) +- refactor(preview-api): duplicate adminform preview endpoints for /api/v3 [`#1643`](https://github.com/opengovsg/FormSG/pull/1643) +- feat(api-refactor): implement specific create field api [`#1671`](https://github.com/opengovsg/FormSG/pull/1671) +- feat(api-refactor): add specific API for updating of single form field [`#1640`](https://github.com/opengovsg/FormSG/pull/1640) +- fix(deps): bump @sentry/integrations from 6.2.5 to 6.3.0 [`#1690`](https://github.com/opengovsg/FormSG/pull/1690) +- chore(deps-dev): bump @babel/core from 7.13.15 to 7.13.16 [`#1692`](https://github.com/opengovsg/FormSG/pull/1692) +- fix(deps): bump aws-sdk from 2.888.0 to 2.889.0 [`#1691`](https://github.com/opengovsg/FormSG/pull/1691) +- fix(deps): bump @babel/runtime from 7.13.10 to 7.13.16 [`#1689`](https://github.com/opengovsg/FormSG/pull/1689) +- fix(deps): bump validator from 13.5.2 to 13.6.0 [`#1688`](https://github.com/opengovsg/FormSG/pull/1688) +- fix(deps): bump @sentry/browser from 6.2.5 to 6.3.0 [`#1687`](https://github.com/opengovsg/FormSG/pull/1687) +- fix(deps): bump fp-ts from 2.10.3 to 2.10.4 [`#1686`](https://github.com/opengovsg/FormSG/pull/1686) +- refactor: shard public forms router [`#1669`](https://github.com/opengovsg/FormSG/pull/1669) +- feat(admin-forms): implement retrieval of form settings [`#1633`](https://github.com/opengovsg/FormSG/pull/1633) +- refactor: migrate submissions metadata [`#1651`](https://github.com/opengovsg/FormSG/pull/1651) +- build: merge release 5.7.0 into develop [`#1681`](https://github.com/opengovsg/FormSG/pull/1681) + #### [v5.7.1](https://github.com/opengovsg/FormSG/compare/v5.7.0...v5.7.1) +> 22 April 2021 + +- chore: bump version to v5.7.1 [`5d977c2`](https://github.com/opengovsg/FormSG/commit/5d977c20663cfa9ecae91bdec5509241f8a7550d) - fix: correctly check Singpass title for error [`25a5ab5`](https://github.com/opengovsg/FormSG/commit/25a5ab527231b4bee04edf64a0f2f3782f1f5f3c) #### [v5.7.0](https://github.com/opengovsg/FormSG/compare/v5.6.1...v5.7.0) @@ -50,15 +108,15 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). > 13 April 2021 +- fix: call correct user update emergency contact endpoint [`#1631`](https://github.com/opengovsg/FormSG/pull/1631) +- chore: bump version to v5.6.0 [`c4724a9`](https://github.com/opengovsg/FormSG/commit/c4724a9f64c6dd5cb134dd46ceae8ab6a736dbec) - test(AdminFormRoutes): test for equal start and end dates validation [`2d81ff4`](https://github.com/opengovsg/FormSG/commit/2d81ff4a4573a70a698596f5ac2ec1a4c5139b27) - chore: bump version to v5.6.1 [`c640dd1`](https://github.com/opengovsg/FormSG/commit/c640dd1e8c219f5293f36fff18f57881ae1ded73) -- fix: use Joi.date.min() instead of Joi.date.greater() for date range [`864561c`](https://github.com/opengovsg/FormSG/commit/864561c129bebd24cfd7dca2851106066141a780) #### [v5.6.0](https://github.com/opengovsg/FormSG/compare/v5.5.1...v5.6.0) > 13 April 2021 -- fix: call correct user update emergency contact endpoint [`#1631`](https://github.com/opengovsg/FormSG/pull/1631) - fix(deps): bump winston-cloudwatch from 2.5.1 to 2.5.2 [`#1626`](https://github.com/opengovsg/FormSG/pull/1626) - chore(deps-dev): bump @typescript-eslint/parser from 4.21.0 to 4.22.0 [`#1627`](https://github.com/opengovsg/FormSG/pull/1627) - chore(deps-dev): bump date-fns from 2.20.1 to 2.20.2 [`#1628`](https://github.com/opengovsg/FormSG/pull/1628) @@ -123,7 +181,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore(deps-dev): bump @typescript-eslint/eslint-plugin [`#1556`](https://github.com/opengovsg/FormSG/pull/1556) - fix(deps): bump @opengovsg/spcp-auth-client from 1.4.4 to 1.4.5 [`#1555`](https://github.com/opengovsg/FormSG/pull/1555) - refactor(corppass-ui): make ui changes for corppass [`#1533`](https://github.com/opengovsg/FormSG/pull/1533) -- chore: bump version to v5.6.0 [`c4724a9`](https://github.com/opengovsg/FormSG/commit/c4724a9f64c6dd5cb134dd46ceae8ab6a736dbec) +- chore: bump version to v5.6.0 [`bf1874d`](https://github.com/opengovsg/FormSG/commit/bf1874d1aedec79f416cf0db3eec02eb1bcf017b) #### [v5.5.1](https://github.com/opengovsg/FormSG/compare/v5.5.0...v5.5.1) @@ -297,7 +355,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release 5.1.0 into develop [`#1341`](https://github.com/opengovsg/FormSG/pull/1341) - chore: bump version to 5.2.0 [`0eca821`](https://github.com/opengovsg/FormSG/commit/0eca8214f75345c76915ee8c14f342316dd1f190) -### [v5.1.0](https://github.com/opengovsg/FormSG/compare/v4.59.1...v5.1.0) +#### [v5.1.0](https://github.com/opengovsg/FormSG/compare/v5.0.1...v5.1.0) > 10 March 2021 @@ -375,11 +433,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - style: update end page styling [`#1231`](https://github.com/opengovsg/FormSG/pull/1231) - feat: migrate to MyInfo V3 [`#1175`](https://github.com/opengovsg/FormSG/pull/1175) - chore: merge v5.0.1 into develop [`#1241`](https://github.com/opengovsg/FormSG/pull/1241) -- fix: format workpass status correctly, preview submissions, copy changes [`#1237`](https://github.com/opengovsg/FormSG/pull/1237) -- feat: update editable fields and clear cookie upon submission [`#1232`](https://github.com/opengovsg/FormSG/pull/1232) -- style: update end page styling [`#1231`](https://github.com/opengovsg/FormSG/pull/1231) -- feat: migrate to MyInfo V3 [`#1175`](https://github.com/opengovsg/FormSG/pull/1175) -- chore: merge v4.59.1 into develop [`#1227`](https://github.com/opengovsg/FormSG/pull/1227) - build: release v4.59.1 hotfix [`#1226`](https://github.com/opengovsg/FormSG/pull/1226) - build: release v4.59.0 [`#1220`](https://github.com/opengovsg/FormSG/pull/1220) - build: Release 4.58.2 - hotfix msgSrvcName validation [`#1199`](https://github.com/opengovsg/FormSG/pull/1199) @@ -435,18 +488,39 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore: bump version to v5.0.2 [`256d772`](https://github.com/opengovsg/FormSG/commit/256d772f35d69a535a8062e36a6b2c1e5609784e) - chore: bump version to 5.0.4 [`9a422f8`](https://github.com/opengovsg/FormSG/commit/9a422f8fceb2a608f4983306376b54bd79cb8f77) -#### [v4.59.1](https://github.com/opengovsg/FormSG/compare/v4.59.0...v4.59.1) +#### [v5.0.1](https://github.com/opengovsg/FormSG/compare/v5.0.0...v5.0.1) -> 23 February 2021 +> 25 February 2021 + +- chore: bump version to 5.0.1 [`ea7ddcb`](https://github.com/opengovsg/FormSG/commit/ea7ddcbe030fbe3f1bbd9cb384f40a9558106896) +- fix: convert res status to uppercase [`8f4aeaf`](https://github.com/opengovsg/FormSG/commit/8f4aeaf76af02cdfe776c1671633909831e2fc5a) + +### [v5.0.0](https://github.com/opengovsg/FormSG/compare/v4.59.1...v5.0.0) +> 24 February 2021 + +- fix: format workpass status correctly, preview submissions, copy changes [`#1237`](https://github.com/opengovsg/FormSG/pull/1237) +- feat: update editable fields and clear cookie upon submission [`#1232`](https://github.com/opengovsg/FormSG/pull/1232) +- style: update end page styling [`#1231`](https://github.com/opengovsg/FormSG/pull/1231) +- feat: migrate to MyInfo V3 [`#1175`](https://github.com/opengovsg/FormSG/pull/1175) +- chore: merge v4.59.1 into develop [`#1227`](https://github.com/opengovsg/FormSG/pull/1227) - feat: remove updateFormValidator [`92f3f75`](https://github.com/opengovsg/FormSG/commit/92f3f75bc760f32bdb495e27eb880d53f1562093) +- chore: bump version to 5.0.0 [`fb11aee`](https://github.com/opengovsg/FormSG/commit/fb11aee0d19214d824a350c02a3e2a04f89ea726) - chore: bump version to v4.59.1 [`a712594`](https://github.com/opengovsg/FormSG/commit/a712594146e294a01cea13e429e4ed03109a1f70) -#### [v4.59.0](https://github.com/opengovsg/FormSG/compare/v4.58.2...v4.59.0) +#### [v4.59.1](https://github.com/opengovsg/FormSG/compare/v4.59.0...v4.59.1) > 23 February 2021 - fix: add _id key in permissionList object for updateForm validator [`#1224`](https://github.com/opengovsg/FormSG/pull/1224) +- feat: remove updateFormValidator [`45894b8`](https://github.com/opengovsg/FormSG/commit/45894b89ac36acb6f3a4d2cc22a05bdcdab28925) +- chore: bump version to v4.59.0 [`21bee76`](https://github.com/opengovsg/FormSG/commit/21bee768bb40e9eae57fe25b8a3c7b2ea3ccc130) +- chore: bump version to v4.59.1 [`25c46e7`](https://github.com/opengovsg/FormSG/commit/25c46e77aa1f2d2e109ffae1972645ebb9fd48ac) + +#### [v4.59.0](https://github.com/opengovsg/FormSG/compare/v4.58.2...v4.59.0) + +> 23 February 2021 + - chore: use formsg-sdk beta release [`#1219`](https://github.com/opengovsg/FormSG/pull/1219) - chore(deps-dev): bump csv-parse from 4.15.1 to 4.15.3 [`#1213`](https://github.com/opengovsg/FormSG/pull/1213) - refactor: move addLogin method to Billing module [`#1195`](https://github.com/opengovsg/FormSG/pull/1195) @@ -503,7 +577,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump nodemailer from 6.4.17 to 6.4.18 [`#1143`](https://github.com/opengovsg/FormSG/pull/1143) - fix(deps): bump @sentry/integrations from 5.30.0 to 6.1.0 [`#1142`](https://github.com/opengovsg/FormSG/pull/1142) - fix(deps): bump libphonenumber-js from 1.9.8 to 1.9.11 [`#1138`](https://github.com/opengovsg/FormSG/pull/1138) -- chore: bump version to v4.59.0 [`21bee76`](https://github.com/opengovsg/FormSG/commit/21bee768bb40e9eae57fe25b8a3c7b2ea3ccc130) +- chore: bump version to v4.59.0 [`53bd033`](https://github.com/opengovsg/FormSG/commit/53bd03318be09e83dc549d93515830e3b7efc365) #### [v4.58.2](https://github.com/opengovsg/FormSG/compare/v4.58.1...v4.58.2) diff --git a/package-lock.json b/package-lock.json index fb0fd8a1ce..739d4ccbf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "FormSG", - "version": "5.7.1", + "version": "5.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -20,20 +20,20 @@ "dev": true }, "@babel/core": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.13.15.tgz", - "integrity": "sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.13.16.tgz", + "integrity": "sha512-sXHpixBiWWFti0AV2Zq7avpTasr6sIAu7Y396c608541qAU2ui4a193m0KSQmfPSKFZLnQ3cvlKDOm3XkuXm3Q==", "dev": true, "requires": { "@babel/code-frame": "^7.12.13", - "@babel/generator": "^7.13.9", - "@babel/helper-compilation-targets": "^7.13.13", + "@babel/generator": "^7.13.16", + "@babel/helper-compilation-targets": "^7.13.16", "@babel/helper-module-transforms": "^7.13.14", - "@babel/helpers": "^7.13.10", - "@babel/parser": "^7.13.15", + "@babel/helpers": "^7.13.16", + "@babel/parser": "^7.13.16", "@babel/template": "^7.12.13", "@babel/traverse": "^7.13.15", - "@babel/types": "^7.13.14", + "@babel/types": "^7.13.16", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -58,23 +58,23 @@ "dev": true }, "@babel/generator": { - "version": "7.13.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz", - "integrity": "sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.16.tgz", + "integrity": "sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg==", "dev": true, "requires": { - "@babel/types": "^7.13.0", + "@babel/types": "^7.13.16", "jsesc": "^2.5.1", "source-map": "^0.5.0" } }, "@babel/helper-compilation-targets": { - "version": "7.13.13", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz", - "integrity": "sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.16.tgz", + "integrity": "sha512-3gmkYIrpqsLlieFwjkGgLaSHmhnvlAYzZLlYVjlW+QwI+1zE17kGxuJGmIqDQdYp56XdmGeD+Bswx0UTyG18xA==", "dev": true, "requires": { - "@babel/compat-data": "^7.13.12", + "@babel/compat-data": "^7.13.15", "@babel/helper-validator-option": "^7.12.17", "browserslist": "^4.14.5", "semver": "^6.3.0" @@ -100,70 +100,6 @@ "@babel/types": "^7.12.13" } }, - "@babel/helper-member-expression-to-functions": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz", - "integrity": "sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-module-imports": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz", - "integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-module-transforms": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.13.14.tgz", - "integrity": "sha512-QuU/OJ0iAOSIatyVZmfqB0lbkVP0kDRiKj34xy+QNsnVZi/PA6BoSoreeqnxxa9EHFAIL0R9XOaAR/G9WlIy5g==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.13.12", - "@babel/helper-replace-supers": "^7.13.12", - "@babel/helper-simple-access": "^7.13.12", - "@babel/helper-split-export-declaration": "^7.12.13", - "@babel/helper-validator-identifier": "^7.12.11", - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.13.13", - "@babel/types": "^7.13.14" - } - }, - "@babel/helper-optimise-call-expression": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", - "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", - "dev": true, - "requires": { - "@babel/types": "^7.12.13" - } - }, - "@babel/helper-replace-supers": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz", - "integrity": "sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==", - "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.13.12", - "@babel/helper-optimise-call-expression": "^7.12.13", - "@babel/traverse": "^7.13.0", - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-simple-access": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz", - "integrity": "sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, "@babel/helper-split-export-declaration": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", @@ -191,9 +127,9 @@ } }, "@babel/parser": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz", - "integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.16.tgz", + "integrity": "sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw==", "dev": true }, "@babel/template": { @@ -224,33 +160,38 @@ } }, "@babel/types": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz", - "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.16.tgz", + "integrity": "sha512-7enM8Wxhrl1hB1+k6+xO6RmxpNkaveRWkdpyii8DkrLWRgr0l3x29/SEuhTIkP+ynHsU/Hpjn8Evd/axv/ll6Q==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.12.11", - "lodash": "^4.17.19", "to-fast-properties": "^2.0.0" } }, "browserslist": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.3.tgz", - "integrity": "sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.4.tgz", + "integrity": "sha512-d7rCxYV8I9kj41RH8UKYnvDYCRENUlHRgyXy/Rhr/1BaeLGfiCptEdFE8MIrvGfWbBFNjVYx76SQWvNX1j+/cQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001181", - "colorette": "^1.2.1", - "electron-to-chromium": "^1.3.649", + "caniuse-lite": "^1.0.30001208", + "colorette": "^1.2.2", + "electron-to-chromium": "^1.3.712", "escalade": "^3.1.1", - "node-releases": "^1.1.70" + "node-releases": "^1.1.71" } }, "caniuse-lite": { - "version": "1.0.30001208", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz", - "integrity": "sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA==", + "version": "1.0.30001214", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001214.tgz", + "integrity": "sha512-O2/SCpuaU3eASWVaesQirZv1MSjUNOvmugaD8zNSJqw6Vv5SGwoOpA9LJs3pNPfM745nxqPvfZY3MQKY4AKHYg==", + "dev": true + }, + "colorette": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", + "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", "dev": true }, "debug": { @@ -263,9 +204,9 @@ } }, "electron-to-chromium": { - "version": "1.3.711", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.711.tgz", - "integrity": "sha512-XbklBVCDiUeho0PZQCjC25Ha6uBwqqJeyDhPLwLwfWRAo4x+FZFsmu1pPPkXT+B4MQMQoQULfyaMltDopfeiHQ==", + "version": "1.3.717", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.717.tgz", + "integrity": "sha512-OfzVPIqD1MkJ7fX+yTl2nKyOE4FReeVfMCzzxQS+Kp43hZYwHwThlGP+EGIZRXJsxCM7dqo8Y65NOX/HP12iXQ==", "dev": true }, "json5": { @@ -1418,14 +1359,14 @@ } }, "@babel/helpers": { - "version": "7.13.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.13.10.tgz", - "integrity": "sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.13.16.tgz", + "integrity": "sha512-x5otxUaLpdWHl02P4L94wBU+2BJXBkvO+6d6uzQ+xD9/h2hTSAwA5O8QV8GqKx/l8i+VYmKKQg9e2QGTa2Wu3Q==", "dev": true, "requires": { "@babel/template": "^7.12.13", - "@babel/traverse": "^7.13.0", - "@babel/types": "^7.13.0" + "@babel/traverse": "^7.13.15", + "@babel/types": "^7.13.16" }, "dependencies": { "@babel/code-frame": { @@ -1438,12 +1379,12 @@ } }, "@babel/generator": { - "version": "7.13.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz", - "integrity": "sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.16.tgz", + "integrity": "sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg==", "dev": true, "requires": { - "@babel/types": "^7.13.0", + "@babel/types": "^7.13.16", "jsesc": "^2.5.1", "source-map": "^0.5.0" } @@ -1495,9 +1436,9 @@ } }, "@babel/parser": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz", - "integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.16.tgz", + "integrity": "sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw==", "dev": true }, "@babel/template": { @@ -1528,13 +1469,12 @@ } }, "@babel/types": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz", - "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.16.tgz", + "integrity": "sha512-7enM8Wxhrl1hB1+k6+xO6RmxpNkaveRWkdpyii8DkrLWRgr0l3x29/SEuhTIkP+ynHsU/Hpjn8Evd/axv/ll6Q==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.12.11", - "lodash": "^4.17.19", "to-fast-properties": "^2.0.0" } }, @@ -3656,9 +3596,9 @@ } }, "@babel/runtime": { - "version": "7.13.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", - "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "version": "7.13.17", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.17.tgz", + "integrity": "sha512-NCdgJEelPTSh+FEFylhnP1ylq848l1z9t9N0j1Lfbcw0+KXGjsTvUmkxy+voLLXB5SOKMbLLx4jxYliGrYQseA==", "requires": { "regenerator-runtime": "^0.13.4" } @@ -4528,9 +4468,9 @@ } }, "@opengovsg/mockpass": { - "version": "2.6.8", - "resolved": "https://registry.npmjs.org/@opengovsg/mockpass/-/mockpass-2.6.8.tgz", - "integrity": "sha512-cwBDzJ/LviQ2rbDyaYwVf839BuGDOTm4Pjeuhs0+3yg+VMFyXprT/MTvGYcBMCivhtHw+MZGGXFr+cJpvdnoLw==", + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/@opengovsg/mockpass/-/mockpass-2.6.9.tgz", + "integrity": "sha512-ZgReHxNN7NXrqgrQhopTX04zEGR49418tnBU7zNi0zkhw28JSrKgukclJwZaPMi5DIc3EtHhJm0+MR7gjOEIsw==", "dev": true, "requires": { "base-64": "^1.0.0", @@ -4542,12 +4482,12 @@ "lodash": "^4.17.11", "moment": "^2.24.0", "morgan": "^1.9.1", - "mustache": "^4.0.0", + "mustache": "^4.2.0", "node-jose": "^2.0.0", "uuid": "^8.0.0", - "xml-crypto": "^2.1.1", - "xml-encryption": "^1.2.3", - "xmldom": "^0.5.0", + "xml-crypto": "^2.1.2", + "xml-encryption": "^1.2.4", + "xmldom": "^0.6.0", "xpath": "0.0.32" } }, @@ -4575,102 +4515,102 @@ "integrity": "sha512-YqR6GIsum9K7Cg6wOTxwJnKP+KDOxbZ9dnQE2/M47vP0ynXyTadvwflGBukzJ/MhzrS2R6buNhFjFnVJRXJinw==" }, "@opengovsg/spcp-auth-client": { - "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==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@opengovsg/spcp-auth-client/-/spcp-auth-client-1.4.6.tgz", + "integrity": "sha512-vW6kxwt7kGKuQytip1HGghibVvePgwvtPzv2n5JqqqBFHMkLed26ZrvModjrROjDJecFMydDm6zIB5xMVnHrXQ==", "requires": { "base-64": "^1.0.0", "jsonwebtoken": "^8.3.0", "lodash": "^4.17.21", "request": "^2.88.2", - "xml-crypto": "^2.1.0", - "xml-encryption": "^1.2.3", + "xml-crypto": "^2.1.2", + "xml-encryption": "^1.2.4", "xml2json-light": "^1.0.6", - "xmldom": "^0.5.0", + "xmldom": "^0.6.0", "xpath": "0.0.32" } }, "@sentry/browser": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.2.5.tgz", - "integrity": "sha512-nlvaE+D7oaj4MxoY9ikw+krQDOjftnDYJQnOwOraXPk7KYM6YwmkakLuE+x/AkaH3FQVTQF330VAa9d6SWETlA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.3.1.tgz", + "integrity": "sha512-Ri4tYsyuJIeLQnvQUqbpGzailUYpbjFSYM0+yEM63gPsjiXdg+W8yKHluA6cs6FLWVN3oWfwHW7Kd61echlGuw==", "requires": { - "@sentry/core": "6.2.5", - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/core": "6.3.1", + "@sentry/types": "6.3.1", + "@sentry/utils": "6.3.1", "tslib": "^1.9.3" } }, "@sentry/core": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.2.5.tgz", - "integrity": "sha512-I+AkgIFO6sDUoHQticP6I27TT3L+i6TUS03in3IEtpBcSeP2jyhlxI8l/wdA7gsBqUPdQ4GHOOaNgtFIcr8qag==", - "requires": { - "@sentry/hub": "6.2.5", - "@sentry/minimal": "6.2.5", - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.3.1.tgz", + "integrity": "sha512-aVuvVbaehGeN86jZlLDGGkhEtprdOtB6lvYLfGy40Dj1Tkh2mGWE550QsRXAXAqYvQzIYwQR23r6m3o8FujgVg==", + "requires": { + "@sentry/hub": "6.3.1", + "@sentry/minimal": "6.3.1", + "@sentry/types": "6.3.1", + "@sentry/utils": "6.3.1", "tslib": "^1.9.3" } }, "@sentry/hub": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.2.5.tgz", - "integrity": "sha512-YlEFdEhcfqpl2HC+/dWXBsBJEljyMzFS7LRRjCk8QANcOdp9PhwQjwebUB4/ulOBjHPP2WZk7fBBd/IKDasTUg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.3.1.tgz", + "integrity": "sha512-2er+OeVlsdVZkhl9kXQAANwgjwoCdM1etK2iFuhzX8xkMaJlAuZLyQInv2U1BbXBlIfWjvzRM8B95hCWvVrR3Q==", "requires": { - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/types": "6.3.1", + "@sentry/utils": "6.3.1", "tslib": "^1.9.3" } }, "@sentry/integrations": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.2.5.tgz", - "integrity": "sha512-4LOgO8lSeGaRV4w1Y03YWtTqrZdm56ciD7k0GLhv+PcFLpiu0exsS1XSs/9vET5LB5GtIgBTeJNNbxVFvvmv8g==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.3.1.tgz", + "integrity": "sha512-fB0+CmU2L2VJ8WyI33t060lxpBNAoh092jzMGEnnfPKTVMxnscjFrISzrWXQZs/OoR6q8Yo/+pZAT5gWA0dDOQ==", "requires": { - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/types": "6.3.1", + "@sentry/utils": "6.3.1", "localforage": "^1.8.1", "tslib": "^1.9.3" }, "dependencies": { "@sentry/types": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.5.tgz", - "integrity": "sha512-1Sux6CLYrV9bETMsGP/HuLFLouwKoX93CWzG8BjMueW+Di0OGxZphYjXrGuDs8xO8bAKEVGCHgVQdcB2jevS0w==" + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.3.1.tgz", + "integrity": "sha512-BEBn8JX1yaooCAuonbaMci9z0RjwwMbQ3Eny/eyDdd+rjXprZCZaStZnCvSThbNBqAJ8YaUqY2YBMnEwJxarAw==" }, "@sentry/utils": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.2.5.tgz", - "integrity": "sha512-fJoLUZHrd5MPylV1dT4qL74yNFDl1Ur/dab+pKNSyvnHPnbZ/LRM7aJ8VaRY/A7ZdpRowU+E14e/Yeem2c6gtQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-cdtl/QWC9FtinAuW3w8QfvSfh/Q9ui5vwvjzVHiS1ga/U38edi2XX+cttY39ZYwz0SQG99cE10GOIhd1p7/mAA==", "requires": { - "@sentry/types": "6.2.5", + "@sentry/types": "6.3.1", "tslib": "^1.9.3" } } } }, "@sentry/minimal": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.2.5.tgz", - "integrity": "sha512-RKP4Qx3p7Cv0oX1cPKAkNVFYM7p2k1t32cNk1+rrVQS4hwlJ7Eg6m6fsqsO+85jd6Ne/FnyYsfo9cDD3ImTlWQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.3.1.tgz", + "integrity": "sha512-0eN9S7HvXsCQEjX/qXHTMgvSb3mwrnZEWS9Qz/Bz5ig9pEGXKgJ1om5NTTHVHhXqd3wFCjdvIo6slufLHoCtSw==", "requires": { - "@sentry/hub": "6.2.5", - "@sentry/types": "6.2.5", + "@sentry/hub": "6.3.1", + "@sentry/types": "6.3.1", "tslib": "^1.9.3" } }, "@sentry/types": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.5.tgz", - "integrity": "sha512-1Sux6CLYrV9bETMsGP/HuLFLouwKoX93CWzG8BjMueW+Di0OGxZphYjXrGuDs8xO8bAKEVGCHgVQdcB2jevS0w==" + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.3.1.tgz", + "integrity": "sha512-BEBn8JX1yaooCAuonbaMci9z0RjwwMbQ3Eny/eyDdd+rjXprZCZaStZnCvSThbNBqAJ8YaUqY2YBMnEwJxarAw==" }, "@sentry/utils": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.2.5.tgz", - "integrity": "sha512-fJoLUZHrd5MPylV1dT4qL74yNFDl1Ur/dab+pKNSyvnHPnbZ/LRM7aJ8VaRY/A7ZdpRowU+E14e/Yeem2c6gtQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-cdtl/QWC9FtinAuW3w8QfvSfh/Q9ui5vwvjzVHiS1ga/U38edi2XX+cttY39ZYwz0SQG99cE10GOIhd1p7/mAA==", "requires": { - "@sentry/types": "6.2.5", + "@sentry/types": "6.3.1", "tslib": "^1.9.3" } }, @@ -5048,9 +4988,9 @@ } }, "@types/jest": { - "version": "26.0.22", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.22.tgz", - "integrity": "sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==", + "version": "26.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", + "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", "dev": true, "requires": { "jest-diff": "^26.0.0", @@ -5458,15 +5398,15 @@ } }, "@typescript-eslint/experimental-utils": { - "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==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz", + "integrity": "sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg==", "dev": true, "requires": { "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.21.0", - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/typescript-estree": "4.21.0", + "@typescript-eslint/scope-manager": "4.22.0", + "@typescript-eslint/types": "4.22.0", + "@typescript-eslint/typescript-estree": "4.22.0", "eslint-scope": "^5.0.0", "eslint-utils": "^2.0.0" } @@ -5566,29 +5506,29 @@ } }, "@typescript-eslint/scope-manager": { - "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==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz", + "integrity": "sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/visitor-keys": "4.21.0" + "@typescript-eslint/types": "4.22.0", + "@typescript-eslint/visitor-keys": "4.22.0" } }, "@typescript-eslint/types": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz", - "integrity": "sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.22.0.tgz", + "integrity": "sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA==", "dev": true }, "@typescript-eslint/typescript-estree": { - "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==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz", + "integrity": "sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", - "@typescript-eslint/visitor-keys": "4.21.0", + "@typescript-eslint/types": "4.22.0", + "@typescript-eslint/visitor-keys": "4.22.0", "debug": "^4.1.1", "globby": "^11.0.1", "is-glob": "^4.0.1", @@ -5632,12 +5572,12 @@ } }, "@typescript-eslint/visitor-keys": { - "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==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz", + "integrity": "sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw==", "dev": true, "requires": { - "@typescript-eslint/types": "4.21.0", + "@typescript-eslint/types": "4.22.0", "eslint-visitor-keys": "^2.0.0" }, "dependencies": { @@ -6434,9 +6374,9 @@ "integrity": "sha512-24q5Rh3bno7ldoyCq99d6hpnLI+PAMocdeVaaGt/5BTQMprvDwQToHfNnruqN11odCHZZIQbRBw+nZo1lTCH9g==" }, "aws-sdk": { - "version": "2.888.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.888.0.tgz", - "integrity": "sha512-9Rg14eneXnrs5Wh5FL42qGEXf7QaqaV/gMHU9SfvAA0SEM390QnwVjCSKF5YAReWjSuJriKJTDiodMI39J+Nrg==", + "version": "2.893.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.893.0.tgz", + "integrity": "sha512-yebi0KfJSyYcaYF+sDJ6Wq7OLq/8VZZiP7mN3fYfE3XZQV9MUYHMITqyeVxKG8BMvzkYfr6gpx1XZOJAYzfvJQ==", "requires": { "buffer": "4.9.2", "events": "1.1.1", @@ -8792,9 +8732,9 @@ } }, "core-js": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.10.2.tgz", - "integrity": "sha512-W+2oVYeNghuBr3yTzZFQ5rfmjZtYB/Ubg87R5YOmlGrIb+Uw9f7qjUbhsj+/EkXhcV7eOD3jiM4+sgraX3FZUw==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.11.0.tgz", + "integrity": "sha512-bd79DPpx+1Ilh9+30aT5O1sgpQd4Ttg8oqkqi51ZzhedMM1omD2e6IOF48Z/DzDCZ2svp49tN/3vneTK6ZBkXw==", "dev": true }, "core-js-compat": { @@ -10550,9 +10490,9 @@ } }, "eslint": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.24.0.tgz", - "integrity": "sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.25.0.tgz", + "integrity": "sha512-TVpSovpvCNpLURIScDRB6g5CYu/ZFq9GfX2hLNIV4dSBKxIWojeDODvYl3t0k0VtMxYeR8OXPCFE5+oHMlGfhw==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", @@ -10640,9 +10580,9 @@ "dev": true }, "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", "dev": true, "requires": { "ansi-styles": "^4.1.0", @@ -10869,9 +10809,9 @@ } }, "eslint-config-prettier": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.2.0.tgz", - "integrity": "sha512-dWV9EVeSo2qodOPi1iBYU/x6F6diHv8uujxbxr77xExs3zTAlNXvVZKiyLsQGNz7yPV2K49JY5WjPzNIuDc2Bw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", + "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", "dev": true }, "eslint-import-resolver-node": { @@ -11028,9 +10968,9 @@ } }, "eslint-plugin-jest": { - "version": "24.3.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.5.tgz", - "integrity": "sha512-XG4rtxYDuJykuqhsOqokYIR84/C8pRihRtEpVskYLbIIKGwPNW2ySxdctuVzETZE+MbF/e7wmsnbNVpzM0rDug==", + "version": "24.3.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.3.6.tgz", + "integrity": "sha512-WOVH4TIaBLIeCX576rLcOgjNXqP+jNlCiEmRgFTfQtJ52DpwnIQKAVGlGPAN7CZ33bW6eNfHD6s8ZbEUTQubJg==", "dev": true, "requires": { "@typescript-eslint/experimental-utils": "^4.0.1" @@ -12037,9 +11977,9 @@ "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, "fp-ts": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.10.3.tgz", - "integrity": "sha512-Lq9XweGms3tAmCh1AAxBG+1PfBY1zKQ3kD52q3Db6SgoA4xIUKLFZQBhmuZ7fCGmhUPZF32rlSX2/QBP0VMdjg==" + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.10.4.tgz", + "integrity": "sha512-vMTB5zNc9PnE20q145PNbkiL9P9WegwmKVOFloi/NfHnPdAlcob6I3AKqlH/9u3k3/M/GOftZhcJdBrb+NtnDA==" }, "fragment-cache": { "version": "0.2.1", @@ -13809,6 +13749,12 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, "is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", @@ -16363,6 +16309,12 @@ "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz", "integrity": "sha1-ywcE1Hq3F4n/oN6Ll92Sb7iLE7E=" }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", + "dev": true + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -16420,6 +16372,12 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -16727,9 +16685,9 @@ "dev": true }, "map-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz", - "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.2.1.tgz", + "integrity": "sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==", "dev": true }, "map-visit": { @@ -16854,9 +16812,9 @@ }, "dependencies": { "hosted-git-info": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", - "integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", + "integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -16872,21 +16830,31 @@ } }, "normalize-package-data": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.0.tgz", - "integrity": "sha512-6lUjEI0d3v6kFrtgA/lOx4zHCWULXsFNIjHolnZCKCTLA6m/G625cdn3O7eNmT0iD3jfo6HZ9cdImGZwf21prw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz", + "integrity": "sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==", "dev": true, "requires": { - "hosted-git-info": "^3.0.6", - "resolve": "^1.17.0", - "semver": "^7.3.2", + "hosted-git-info": "^4.0.1", + "resolve": "^1.20.0", + "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, "semver": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", - "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -16905,9 +16873,9 @@ "dev": true }, "yargs-parser": { - "version": "20.2.6", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.6.tgz", - "integrity": "sha512-AP1+fQIWSM/sMiET8fyayjx/J+JmTPt2Mr0FkrgqB4todtfa53sOsrSAcIrJRD5XS20bKUwaDIuMkWKCEiQLKA==", + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", "dev": true } } @@ -17598,14 +17566,14 @@ "integrity": "sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE=" }, "mongoose": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.12.3.tgz", - "integrity": "sha512-frsSR9yeldaRpSUeTegXCSB0Tu5UGq8sHuHBuEV31Jk3COyxlKFQPL7UsdMhxPUCmk74FpOYSmNwxhWBEqgzQg==", + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.12.5.tgz", + "integrity": "sha512-VVoqiELZcoI2HhHDuPpfN3qmExrtIeXSWNb1nihf4w1SJoWGXilU/g2cQgeeSMc2vAHSZd5Nv2sNPvbZHFw+pg==", "requires": { "@types/mongodb": "^3.5.27", "bson": "^1.1.4", "kareem": "2.3.2", - "mongodb": "3.6.5", + "mongodb": "3.6.6", "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.8.3", "mquery": "3.2.5", @@ -17622,14 +17590,14 @@ "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==" }, "mongodb": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.5.tgz", - "integrity": "sha512-mQlYKw1iGbvJJejcPuyTaytq0xxlYbIoVDm2FODR+OHxyEiMR021vc32bTvamgBjCswsD54XIRwhg3yBaWqJjg==", + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.6.tgz", + "integrity": "sha512-WlirMiuV1UPbej5JeCMqE93JRfZ/ZzqE7nJTwP85XzjAF4rRSeq2bGCb1cjfoHLOF06+HxADaPGqT0g3SbVT1w==", "requires": { "bl": "^2.2.1", "bson": "^1.1.4", "denque": "^1.4.1", - "require_optional": "^1.0.1", + "optional-require": "^1.0.2", "safe-buffer": "^5.1.2", "saslprep": "^1.0.0" } @@ -17766,9 +17734,9 @@ } }, "mustache": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.1.0.tgz", - "integrity": "sha512-0FsgP/WVq4mKyjolIyX+Z9Bd+3WS8GOwoUTyKXT5cTYMGeauNTi2HPCwERqseC1IHAy0Z7MDZnJBfjabd4O8GQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "dev": true }, "nan": { @@ -18432,6 +18400,11 @@ "last-call-webpack-plugin": "^3.0.0" } }, + "optional-require": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", + "integrity": "sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==" + }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -21112,9 +21085,9 @@ }, "dependencies": { "object-inspect": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", - "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.2.tgz", + "integrity": "sha512-gz58rdPpadwztRrPjZE9DZLOABUpTGdcANUgOwBFO1C+HZZhePoP83M65WGDmbpwFYJSWqavbl4SgDn4k8RYTA==" } } }, @@ -21949,15 +21922,15 @@ } }, "stylelint": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-13.12.0.tgz", - "integrity": "sha512-P8O1xDy41B7O7iXaSlW+UuFbE5+ZWQDb61ndGDxKIt36fMH50DtlQTbwLpFLf8DikceTAb3r6nPrRv30wBlzXw==", + "version": "13.13.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-13.13.0.tgz", + "integrity": "sha512-jvkM1iuH88vAvjdKPwPm6abiMP2/D/1chbfb+4GVONddOOskHuCXc0loyrLdxO1AwwH6jdnjYskkTKHQD7cXwQ==", "dev": true, "requires": { "@stylelint/postcss-css-in-js": "^0.37.2", "@stylelint/postcss-markdown": "^0.36.2", "autoprefixer": "^9.8.6", - "balanced-match": "^1.0.0", + "balanced-match": "^2.0.0", "chalk": "^4.1.0", "cosmiconfig": "^7.0.0", "debug": "^4.3.1", @@ -21967,7 +21940,7 @@ "file-entry-cache": "^6.0.1", "get-stdin": "^8.0.0", "global-modules": "^2.0.0", - "globby": "^11.0.2", + "globby": "^11.0.3", "globjoin": "^0.1.4", "html-tags": "^3.1.0", "ignore": "^5.1.8", @@ -21975,10 +21948,10 @@ "imurmurhash": "^0.1.4", "known-css-properties": "^0.21.0", "lodash": "^4.17.21", - "log-symbols": "^4.0.0", + "log-symbols": "^4.1.0", "mathml-tag-names": "^2.1.3", "meow": "^9.0.0", - "micromatch": "^4.0.2", + "micromatch": "^4.0.4", "normalize-selector": "^0.2.0", "postcss": "^7.0.35", "postcss-html": "^0.36.0", @@ -21988,7 +21961,7 @@ "postcss-safe-parser": "^4.0.2", "postcss-sass": "^0.4.4", "postcss-scss": "^2.1.1", - "postcss-selector-parser": "^6.0.4", + "postcss-selector-parser": "^6.0.5", "postcss-syntax": "^0.36.2", "postcss-value-parser": "^4.1.0", "resolve-from": "^5.0.0", @@ -21999,11 +21972,23 @@ "style-search": "^0.1.0", "sugarss": "^2.0.0", "svg-tags": "^1.0.0", - "table": "^6.0.7", - "v8-compile-cache": "^2.2.0", + "table": "^6.5.1", + "v8-compile-cache": "^2.3.0", "write-file-atomic": "^3.0.3" }, "dependencies": { + "ajv": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.1.0.tgz", + "integrity": "sha512-B/Sk2Ix7A36fs/ZkuGLIR86EdjbgR6fsAcbx9lOP/QBSXujDNbVmIS/U4Itz5k8fPFDeVZl/zQ/gJW4Jrq6XjQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", @@ -22019,10 +22004,16 @@ "color-convert": "^2.0.1" } }, + "balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true + }, "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", "dev": true, "requires": { "ansi-styles": "^4.1.0", @@ -22074,9 +22065,9 @@ "dev": true }, "globby": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", - "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", + "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", "dev": true, "requires": { "array-union": "^2.1.0", @@ -22099,6 +22090,40 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + }, + "dependencies": { + "picomatch": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", + "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==", + "dev": true + } + } + }, "postcss": { "version": "7.0.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz", @@ -22174,14 +22199,12 @@ } }, "postcss-selector-parser": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", - "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.5.tgz", + "integrity": "sha512-aFYPoYmXbZ1V6HZaSvat08M97A8HqO6Pjz+PiNpw/DhuRrC72XWAdp3hL6wusDCN31sSmcZyMGa2hZEuX+Xfhg==", "dev": true, "requires": { "cssesc": "^3.0.0", - "indexes-of": "^1.0.1", - "uniq": "^1.0.1", "util-deprecate": "^1.0.2" } }, @@ -22232,6 +22255,21 @@ "has-flag": "^4.0.0" } }, + "table": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.5.1.tgz", + "integrity": "sha512-xGDXWTBJxahkzPQCsn1S9ESHEenU7TbMD5Iv4FeopXv/XwJyWatFjfbor+6ipI10/MNPXBYUamYukOrbPZ9L/w==", + "dev": true, + "requires": { + "ajv": "^8.0.1", + "lodash.clonedeep": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0" + } + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", @@ -22247,18 +22285,18 @@ "dev": true }, "stylelint-config-recommended": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-4.0.0.tgz", - "integrity": "sha512-sgna89Ng+25Hr9kmmaIxpGWt2LStVm1xf1807PdcWasiPDaOTkOHRL61sINw0twky7QMzafCGToGDnHT/kTHtQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-5.0.0.tgz", + "integrity": "sha512-c8aubuARSu5A3vEHLBeOSJt1udOdS+1iue7BmJDTSXoCBmfEQmmWX+59vYIj3NQdJBY6a/QRv1ozVFpaB9jaqA==", "dev": true }, "stylelint-config-standard": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-21.0.0.tgz", - "integrity": "sha512-Yf6mx5oYEbQQJxWuW7X3t1gcxqbUx52qC9SMS3saC2ruOVYEyqmr5zSW6k3wXflDjjFrPhar3kp68ugRopmlzg==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-22.0.0.tgz", + "integrity": "sha512-uQVNi87SHjqTm8+4NIP5NMAyY/arXrBgimaaT7skvRfE9u3JKXRK9KBkbr4pVmeciuCcs64kAdjlxfq6Rur7Hw==", "dev": true, "requires": { - "stylelint-config-recommended": "^4.0.0" + "stylelint-config-recommended": "^5.0.0" } }, "stylelint-prettier": { @@ -24202,9 +24240,9 @@ "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, "twilio": { - "version": "3.60.0", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.60.0.tgz", - "integrity": "sha512-f8Ts9rfrkycjgnlmu1TzLigoAbrAsIoEnfPoZl+kf94FbBI3QXTUVWhvYj+NrCwLjOewDxrV8qIea1IcyVbG2Q==", + "version": "3.61.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.61.0.tgz", + "integrity": "sha512-hSnPvxogJLC6RrAkE1p2COqO6L0TMElImYDaI4eJJAn6EpJhwpHIwulpNH1R11TsJp0f9lqT7VvwYHhVXSvrvw==", "requires": { "axios": "^0.21.1", "dayjs": "^1.8.29", @@ -24718,9 +24756,9 @@ } }, "validator": { - "version": "13.5.2", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.5.2.tgz", - "integrity": "sha512-mD45p0rvHVBlY2Zuy3F3ESIe1h5X58GPfAtslBjY7EtTqGquZTj+VX/J4RnHWN8FKq0C9WRVt1oWAcytWRuYLQ==" + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", + "integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg==" }, "vary": { "version": "1.1.2", @@ -25884,30 +25922,23 @@ "dev": true }, "xml-crypto": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.1.tgz", - "integrity": "sha512-M+m4+HIJa83lu/CnspQjA7ap8gmanNDxxRjSisU8mPD4bqhxbo5N2bdpvG2WgVYOrPpOIOq55iY8Cz8Ai40IeQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.2.tgz", + "integrity": "sha512-DBhZXtBjENtLwJmeJhLUBwUm9YWNjCRvAx6ESP4VJyM9PDuKqZu2Fp5Y5HKqcdJT7vV7eI25Z4UBMezji6QloQ==", "requires": { - "xmldom": "0.5.0", + "xmldom": "^0.6.0", "xpath": "0.0.32" } }, "xml-encryption": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.3.tgz", - "integrity": "sha512-oVZIicsZM1VobJ5Hxxgh2ovglIY2ZuXFTeZHmJSV7hABvgkD20PSy4G+qwRToQCkagymS1zJU2XV4wjkoCS9mQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-+4aSBIv/lwmv5PntfYsZyelOnCcyDmCt/MNxXUukRGlcWW8DObJ26obbVX3iXYRdqkLqbv3AKk8ntNCGKIq/UQ==", "requires": { "escape-html": "^1.0.3", "node-forge": "^0.10.0", - "xmldom": "~0.5.0", - "xpath": "0.0.27" - }, - "dependencies": { - "xpath": { - "version": "0.0.27", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz", - "integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==" - } + "xmldom": "~0.6.0", + "xpath": "0.0.32" } }, "xml-name-validator": { @@ -25942,9 +25973,9 @@ "dev": true }, "xmldom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz", - "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz", + "integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==" }, "xmlhttprequest-ssl": { "version": "1.5.5", diff --git a/package.json b/package.json index 667e3f3fe5..d67fe65622 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "5.7.1", + "version": "5.8.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " @@ -54,7 +54,7 @@ ] }, "dependencies": { - "@babel/runtime": "^7.13.10", + "@babel/runtime": "^7.13.17", "@joi/date": "^2.1.0", "@opengovsg/angular-daterangepicker-webpack": "^1.1.5", "@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0", @@ -62,9 +62,9 @@ "@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.5", - "@sentry/browser": "^6.2.5", - "@sentry/integrations": "^6.2.5", + "@opengovsg/spcp-auth-client": "^1.4.6", + "@sentry/browser": "^6.3.1", + "@sentry/integrations": "^6.3.1", "@stablelib/base64": "^1.0.0", "JSONStream": "^1.3.5", "abortcontroller-polyfill": "^1.7.1", @@ -83,7 +83,7 @@ "angular-ui-bootstrap": "~2.5.6", "angular-ui-router": "~1.0.29", "aws-info": "^1.2.0", - "aws-sdk": "^2.888.0", + "aws-sdk": "^2.893.0", "axios": "^0.21.1", "bcrypt": "^5.0.1", "bluebird": "^3.5.2", @@ -112,7 +112,7 @@ "file-loader": "^4.3.0", "file-saver": "^2.0.5", "font-awesome": "4.7.0", - "fp-ts": "^2.10.3", + "fp-ts": "^2.10.4", "has-ansi": "^4.0.1", "helmet": "^4.5.0", "http-status-codes": "^2.1.4", @@ -124,7 +124,7 @@ "lodash": "^4.17.21", "moment-timezone": "0.5.33", "mongodb-uri": "^0.9.7", - "mongoose": "^5.12.3", + "mongoose": "^5.12.5", "multiparty": ">=4.2.2", "neverthrow": "^4.2.1", "ng-infinite-scroll": "^1.3.0", @@ -145,21 +145,21 @@ "toastr": "^2.1.4", "triple-beam": "^1.3.0", "tweetnacl": "^1.0.1", - "twilio": "^3.60.0", + "twilio": "^3.61.0", "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "uuid": "^8.3.2", - "validator": "^13.5.2", + "validator": "^13.6.0", "web-streams-polyfill": "^3.0.3", "whatwg-fetch": "^3.6.2", "winston": "^3.3.3", "winston-cloudwatch": "^2.5.2" }, "devDependencies": { - "@babel/core": "^7.13.15", + "@babel/core": "^7.13.16", "@babel/plugin-transform-runtime": "^7.13.15", "@babel/preset-env": "^7.13.15", - "@opengovsg/mockpass": "^2.6.8", + "@opengovsg/mockpass": "^2.6.9", "@types/bcrypt": "^3.0.1", "@types/bluebird": "^3.5.33", "@types/busboy": "^0.2.3", @@ -176,7 +176,7 @@ "@types/has-ansi": "^3.0.0", "@types/helmet": "4.0.0", "@types/ip": "^1.1.0", - "@types/jest": "^26.0.22", + "@types/jest": "^26.0.23", "@types/json-stringify-safe": "^5.0.0", "@types/mongodb": "^3.6.12", "@types/mongodb-uri": "^0.9.0", @@ -198,17 +198,17 @@ "babel-loader": "^8.2.2", "concurrently": "^6.0.2", "copy-webpack-plugin": "^6.0.2", - "core-js": "^3.10.2", + "core-js": "^3.11.0", "coveralls": "^3.1.0", "css-loader": "^2.1.1", "csv-parse": "^4.15.4", "date-fns": "^2.21.1", "env-cmd": "^10.1.0", - "eslint": "^7.24.0", - "eslint-config-prettier": "^8.2.0", + "eslint": "^7.25.0", + "eslint-config-prettier": "^8.3.0", "eslint-plugin-angular": "^4.0.1", "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jest": "^24.3.5", + "eslint-plugin-jest": "^24.3.6", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-typesafe": "^0.5.2", @@ -232,9 +232,9 @@ "proxyquire": "^2.1.3", "regenerator": "^0.14.4", "rimraf": "^3.0.2", - "stylelint": "^13.12.0", + "stylelint": "^13.13.0", "stylelint-config-prettier": "^8.0.2", - "stylelint-config-standard": "^21.0.0", + "stylelint-config-standard": "^22.0.0", "stylelint-prettier": "^1.2.0", "supertest": "^6.1.3", "supertest-session": "^4.1.0", diff --git a/src/app/config/feature-manager/spcp-myinfo.config.ts b/src/app/config/feature-manager/spcp-myinfo.config.ts index b01fa20502..21bfadf481 100644 --- a/src/app/config/feature-manager/spcp-myinfo.config.ts +++ b/src/app/config/feature-manager/spcp-myinfo.config.ts @@ -24,7 +24,7 @@ const spcpMyInfoFeature: RegisterableFeature = { spCookieMaxAge: { doc: 'Max SingPass cookie age with remember me unchecked', format: 'int', - default: 1 * HOUR_IN_MILLIS, + default: 3 * HOUR_IN_MILLIS, env: 'SP_COOKIE_MAX_AGE', }, spCookieMaxAgePreserved: { diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index bd303f59af..cad8922349 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import { ObjectId } from 'bson-ext' -import { merge, omit, orderBy, pick } from 'lodash' -import mongoose from 'mongoose' +import { cloneDeep, merge, omit, orderBy, pick } from 'lodash' +import mongoose, { Types } from 'mongoose' import getFormModel, { FORM_PUBLIC_FIELDS, @@ -8,14 +9,19 @@ import getFormModel, { getEncryptedFormModel, } from 'src/app/models/form.server.model' import { + BasicField, + FormFieldWithId, IEncryptedForm, + IFieldSchema, IFormSchema, + ILogicSchema, IPopulatedUser, Permission, ResponseMode, Status, } from 'src/types' +import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' import dbHandler from 'tests/unit/backend/helpers/jest-db' const Form = getFormModel(mongoose) @@ -379,7 +385,7 @@ describe('Form Model', () => { expect(actualSavedObject).toEqual(expectedObject) // Remove indeterministic id from actual permission list - const actualPermissionList = (saved.toObject() as IEncryptedForm).permissionList?.map( + const actualPermissionList = ((saved.toObject() as unknown) as IEncryptedForm).permissionList?.map( (permission) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -1048,6 +1054,103 @@ describe('Form Model', () => { expect(actual).toEqual(expected) }) }) + + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + it('should return form upon successful delete', async () => { + // arrange + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that form logic has been deleted + expect(modifiedForm?.form_logics).toBeEmpty() + }) + + it('should return form with remaining logic upon successful delete of one logic', async () => { + // arrange + + const logicId2 = new ObjectId().toHexString() + const mockFormLogicMultiple = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + { + _id: logicId2, + id: logicId2, + } as ILogicSchema, + ], + } + + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogicMultiple, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that correct form logic has been deleted + expect(modifiedForm?.form_logics).toBeDefined() + expect(modifiedForm?.form_logics).toHaveLength(1) + const logic = modifiedForm?.form_logics || ['some logic'] + expect((logic[0] as any)['_id'].toString()).toEqual(logicId2) + }) + + it('should return null if formId is invalid', async () => { + // arrange + + const invalidFormId = new ObjectId().toHexString() + + // act + const modifiedForm = await Form.deleteFormLogic(invalidFormId, logicId) + + // assert + // should return null + expect(modifiedForm).toBeNull() + }) + }) }) describe('Methods', () => { @@ -1306,5 +1409,221 @@ describe('Form Model', () => { ) }) }) + + describe('updateFormFieldById', () => { + let form: IFormSchema + + beforeEach(async () => { + form = await Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Checkbox), + generateDefaultField(BasicField.HomeNo, { + title: 'some mock title', + }), + generateDefaultField(BasicField.Email), + ], + }) + }) + + it('should return updated form when successfully updating form field', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField = { + ...originalFormFields[1], + title: 'another mock title', + } + + // Act + const actual = await form.updateFormFieldById(newField._id, newField) + + // Assert + expect(actual).not.toBeNull() + // Current fields should not be touched + expect( + (actual?.form_fields as Types.DocumentArray).toObject(), + ).toEqual([originalFormFields[0], newField, originalFormFields[2]]) + }) + + it('should return null if fieldId does not correspond to any field in the form', async () => { + // Arrange + const invalidFieldId = new ObjectId().toHexString() + const someNewField = { + description: 'this does not matter', + } as FormFieldWithId + + // Act + const actual = await form.updateFormFieldById( + invalidFieldId, + someNewField, + ) + + // Assert + expect(actual).toBeNull() + }) + + it('should return validation error if field type of new field does not match the field to update', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField: FormFieldWithId = { + ...originalFormFields[1], + // Updating field type from HomeNo to Mobile. + fieldType: BasicField.Mobile, + title: 'another mock title', + } + + // Act + const actual = await form + .updateFormFieldById(newField._id, newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + expect(actual.message).toEqual( + expect.stringContaining('Changing form field type is not allowed'), + ) + }) + + it('should return validation error if model validation fails whilst updating field', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField: FormFieldWithId = { + ...originalFormFields[2], + title: 'another mock title', + // Invalid value for email field. + isVerifiable: 'some string, but this should be boolean', + } + + // Act + const actual = await form + .updateFormFieldById(newField._id, newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + }) + }) + + describe('insertFormField', () => { + it('should return updated document with inserted form field', async () => { + // Arrange + const newField = generateDefaultField(BasicField.Checkbox) + expect(validForm.form_fields).toBeEmpty() + + // Act + const actual = await validForm.insertFormField(newField) + + // Assert + const expectedField = { + ...omit(newField, 'getQuestion'), + _id: new ObjectId(newField._id), + } + // @ts-ignore + expect(actual?.form_fields.toObject()).toEqual([expectedField]) + }) + + it('should return validation error if model validation fails whilst creating field', async () => { + // Arrange + const newField = { + ...generateDefaultField(BasicField.Email), + // Invalid value for email field. + isVerifiable: 'some string, but this should be boolean', + } + + // Act + const actual = await validForm + .insertFormField(newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + }) + }) + + describe('reorderFormFieldById', () => { + let form: IFormSchema + const FIELD_ID_TO_REORDER = new ObjectId().toHexString() + + beforeEach(async () => { + form = await Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Checkbox), + generateDefaultField(BasicField.HomeNo, { + title: 'some mock title', + _id: FIELD_ID_TO_REORDER, + }), + generateDefaultField(BasicField.Email), + ], + }) + }) + + it('should return updated form with reordered fields successfully', async () => { + // Act + const originalFields = + cloneDeep( + (form.form_fields as Types.DocumentArray).toObject(), + ) ?? [] + const updatedForm = await form.reorderFormFieldById( + FIELD_ID_TO_REORDER, + 0, + ) + + // Assert + expect(updatedForm).not.toBeNull() + expect( + (updatedForm?.form_fields as Types.DocumentArray).toObject(), + ).toEqual([ + // Should be rearranged to the 0th index position, and the previously + // 0th index field should be pushed to 1st index. + originalFields[1], + originalFields[0], + originalFields[2], + ]) + }) + + it('should return updated form with reordered field at end of fields array when newPosition > form_fields.length', async () => { + // Act + const originalFields = + cloneDeep( + (form.form_fields as Types.DocumentArray).toObject(), + ) ?? [] + const updatedForm = await form.reorderFormFieldById( + FIELD_ID_TO_REORDER, + // new position is vastly over array length. + originalFields.length + 200, + ) + + // Assert + expect(updatedForm).not.toBeNull() + expect( + (updatedForm?.form_fields as Types.DocumentArray).toObject(), + ).toEqual([ + originalFields[0], + originalFields[2], + // Field to reorder (index 1) should now be at the end. + originalFields[1], + ]) + }) + + it('should return null if given fieldId is invalid', async () => { + const updatedForm = await form.reorderFormFieldById( + new ObjectId().toHexString(), + 3, + ) + + // Assert + expect(updatedForm).toBeNull() + }) + }) }) }) diff --git a/src/app/models/__tests__/submission.server.model.spec.ts b/src/app/models/__tests__/submission.server.model.spec.ts index 766a4cb86b..d99438fecf 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -3,6 +3,7 @@ import { times } from 'lodash' import mongoose from 'mongoose' import getSubmissionModel, { + getEmailSubmissionModel, getEncryptSubmissionModel, } from 'src/app/models/submission.server.model' @@ -17,6 +18,7 @@ import { const Submission = getSubmissionModel(mongoose) const EncryptedSubmission = getEncryptSubmissionModel(mongoose) +const EmailSubmission = getEmailSubmissionModel(mongoose) // TODO: Add more tests for the rest of the submission schema. describe('Submission Model', () => { @@ -107,7 +109,7 @@ describe('Submission Model', () => { // Arrange const formId = new ObjectID() - const submission = await Submission.create({ + const submission = await EncryptedSubmission.create({ submissionType: SubmissionType.Encrypt, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, @@ -136,7 +138,7 @@ describe('Submission Model', () => { it('should return null view with non-encryptSubmission type', async () => { // Arrange const formId = new ObjectID() - const submission = await Submission.create({ + const submission = await EmailSubmission.create({ submissionType: SubmissionType.Email, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, @@ -182,7 +184,6 @@ describe('Submission Model', () => { response: { data: '{"result":"test-result"}', status: 200, - statusText: 'success', headers: '{}', }, }) as IWebhookResponse @@ -223,6 +224,11 @@ describe('Submission Model', () => { created: submission.created, signature: 'some signature', webhookUrl: 'https://form.gov.sg/endpoint', + response: { + status: 200, + headers: '', + data: '', + }, } as IWebhookResponse const invalidSubmissionId = new ObjectID().toHexString() diff --git a/src/app/models/field/baseField.ts b/src/app/models/field/baseField.ts index dc394749ee..3bad73ba90 100644 --- a/src/app/models/field/baseField.ts +++ b/src/app/models/field/baseField.ts @@ -2,7 +2,6 @@ import { Schema } from 'mongoose' import UIDGenerator from 'uid-generator' import { - AuthType, BasicField, IFieldSchema, IMyInfoSchema, @@ -20,17 +19,6 @@ export const MyInfoSchema = new Schema( attr: { type: String, enum: Object.values(MyInfoAttribute), - validate: { - validator: function (this: IMyInfoSchema) { - const { authType, responseMode } = this.ownerDocument() - return ( - authType === AuthType.MyInfo && - responseMode !== ResponseMode.Encrypt - ) - }, - message: - 'MyInfo field is invalid. Check that your form has MyInfo enabled, or is not an encrypted mode form.', - }, }, }, { diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 0638eacbc3..d6230a2af4 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -1,12 +1,21 @@ import BSON from 'bson-ext' import { compact, pick, uniq } from 'lodash' -import mongoose, { Mongoose, Query, Schema, SchemaOptions } from 'mongoose' +import mongoose, { + Mongoose, + Query, + Schema, + SchemaOptions, + Types, +} from 'mongoose' import validator from 'validator' +import { reorder } from '../../shared/util/immutable-array-fns' import { AuthType, BasicField, Colors, + FormField, + FormFieldWithId, FormLogoState, FormMetaView, FormOtpData, @@ -15,6 +24,7 @@ import { IEmailFormSchema, IEncryptedFormModel, IEncryptedFormSchema, + IFieldSchema, IFormDocument, IFormModel, IFormSchema, @@ -30,7 +40,7 @@ import { 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 { getFormFieldById, transformEmails } from '../modules/form/form.utils' import { validateWebhookUrl } from '../modules/webhook/webhook.validation' import getAgencyModel from './agency.server.model' @@ -175,7 +185,25 @@ const compileFormModel = (db: Mongoose): IFormModel => { trim: true, }, - form_fields: [BaseFieldSchema], + form_fields: { + type: [BaseFieldSchema], + validate: { + validator: function (this: IFormSchema) { + const myInfoFieldCount = (this.form_fields ?? []).reduce( + (acc, field) => acc + (field.myInfo ? 1 : 0), + 0, + ) + return ( + myInfoFieldCount === 0 || + (this.authType === AuthType.MyInfo && + this.responseMode === ResponseMode.Email && + myInfoFieldCount <= 30) + ) + }, + message: + 'Check that your form is MyInfo-authenticated, is an email mode form and has 30 or fewer MyInfo fields.', + }, + }, form_logics: [LogicSchema], admin: { @@ -342,7 +370,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Add discriminators for the various field types. const FormFieldPath = FormSchema.path( 'form_fields', - ) as Schema.Types.DocumentArrayWithLooseDiscriminator + ) as Schema.Types.DocumentArray const TableFieldSchema = createTableFieldSchema() @@ -376,7 +404,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { FormFieldPath.discriminator(BasicField.Table, TableFieldSchema) const TableColumnPath = TableFieldSchema.path( 'columns', - ) as Schema.Types.DocumentArrayWithLooseDiscriminator + ) as Schema.Types.DocumentArray TableColumnPath.discriminator( BasicField.ShortText, createShortTextFieldSchema(), @@ -389,13 +417,13 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Discriminator defines all possible values of startPage.logo const StartPageLogoPath = FormSchema.path( 'startPage.logo', - ) as Schema.Types.DocumentArrayWithLooseDiscriminator + ) as Schema.Types.DocumentArray StartPageLogoPath.discriminator(FormLogoState.Custom, CustomFormLogoSchema) // Discriminator defines different logic types const FormLogicPath = FormSchema.path( 'form_logics', - ) as Schema.Types.DocumentArrayWithLooseDiscriminator + ) as Schema.Types.DocumentArray FormLogicPath.discriminator(LogicType.ShowFields, ShowFieldsLogicSchema) FormLogicPath.discriminator(LogicType.PreventSubmit, PreventSubmitLogicSchema) @@ -495,6 +523,53 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } + FormDocumentSchema.methods.updateFormFieldById = function ( + this: IFormDocument, + fieldId: string, + newField: FormFieldWithId, + ) { + const fieldToUpdate = getFormFieldById(this.form_fields, fieldId) + if (!fieldToUpdate) return Promise.resolve(null) + + if (fieldToUpdate.fieldType !== newField.fieldType) { + this.invalidate('form_fields', 'Changing form field type is not allowed') + } else { + fieldToUpdate.set(newField) + } + + return this.save() + } + + FormDocumentSchema.methods.insertFormField = function ( + this: IFormDocument, + newField: FormField, + ) { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(this.form_fields as Types.DocumentArray).push(newField) + return this.save() + } + + FormDocumentSchema.methods.reorderFormFieldById = function ( + this: IFormDocument, + fieldId: string, + newPosition: number, + ): Promise { + const existingFieldPosition = this.form_fields.findIndex( + (f) => String(f._id) === fieldId, + ) + + if (existingFieldPosition === -1) return Promise.resolve(null) + + // Exist, reorder form fields and save. + const updatedFormFields = reorder( + this.form_fields, + existingFieldPosition, + newPosition, + ) + this.form_fields = updatedFormFields + return this.save() + } + // Statics // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function ( @@ -525,8 +600,9 @@ const compileFormModel = (db: Mongoose): IFormModel => { FormSchema.statics.getFullFormById = async function ( this: IFormModel, formId: string, + fields?: (keyof IPopulatedForm)[], ): Promise { - return this.findById(formId).populate({ + return this.findById(formId, fields).populate({ path: 'admin', populate: { path: 'agency', @@ -577,6 +653,24 @@ const compileFormModel = (db: Mongoose): IFormModel => { ) } + // Deletes specified form logic. + FormSchema.statics.deleteFormLogic = async function ( + this: IFormModel, + formId: string, + logicId: string, + ): Promise { + return this.findByIdAndUpdate( + mongoose.Types.ObjectId(formId), + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ).exec() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index b8314c5d51..4d0e434d4f 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -126,12 +126,19 @@ EmailSubmissionSchema.methods.getWebhookView = function (): null { const webhookResponseSchema = new Schema( { - webhookUrl: String, - signature: String, - errorMessage: String, + webhookUrl: { + type: String, + required: true, + }, + signature: { + type: String, + required: true, + }, response: { - status: Number, - statusText: String, + status: { + type: Number, + required: true, + }, headers: String, data: String, }, diff --git a/src/app/modules/auth/auth.controller.ts b/src/app/modules/auth/auth.controller.ts index 8eae149063..d649f9a95b 100644 --- a/src/app/modules/auth/auth.controller.ts +++ b/src/app/modules/auth/auth.controller.ts @@ -105,7 +105,7 @@ export const handleLoginSendOtp: RequestHandler< } /** - * Handler for POST /auth/verifyotp endpoint. + * Handler for POST /auth/verifyotp endpoint. * @returns 200 when user has successfully logged in, with session cookie set * @returns 401 when the email domain is invalid * @returns 422 when the OTP is invalid diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index 9bda20eb17..ccd8d1e38b 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -1,5 +1,5 @@ import mongoose from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import { errAsync, okAsync, Result, ResultAsync } from 'neverthrow' import validator from 'validator' import { LINKS } from '../../../shared/constants' @@ -285,18 +285,34 @@ export const getFormAfterPermissionChecks = ({ IPopulatedForm, FormNotFoundError | FormDeletedError | DatabaseError | ForbiddenFormError > => { - // Step 1: Retrieve full form. - return FormService.retrieveFullFormById(formId).andThen((fullForm) => - // Step 2: Check whether form is available to be retrieved. - assertFormAvailable(fullForm).andThen(() => - // Step 3: Check required permission levels. - getAssertPermissionFn(level)(user, fullForm) - // Step 4: If success, return retrieved form. - .map(() => fullForm), - ), - ) + return FormService.retrieveFullFormById(formId) + .map((form) => ({ form, user })) + .andThen(checkFormForPermissions(level)) } +/** + * Ensures that the given user has the required pre-specified permissions + * for the form. + * + * @returns ok(form) if the user has the required permissions + * @returns err(FormNotFoundError) if form does not exist in the database + * @returns err(FormDeleteError) if form is already archived + * @returns err(ForbiddenFormError if user does not have permission + * @returns err(DatabaseError) if any database error occurs + */ +export const checkFormForPermissions = (level: PermissionLevel) => ({ + user, + form, +}: { + user: IUserSchema + form: IPopulatedForm +}): Result => + // Step 1: Check whether form is available to be retrieved. + assertFormAvailable(form) + // Step 2: Check required permission levels. + .andThen(() => getAssertPermissionFn(level)(user, form)) + .map(() => form) + /** * Retrieves the form of given formId provided that the form is public. * diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 20be4ce147..5bcd535d64 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -142,6 +142,98 @@ describe('FormService', () => { }) }) + describe('retrieveFormKeysById', () => { + it('should return form successfully', async () => { + // Arrange + const formId = new ObjectId().toHexString() + const expectedForm = ({ + _id: formId, + title: 'mock title', + admin: { + _id: new ObjectId(), + email: 'mockEmail@example.com', + }, + } as unknown) as IPopulatedForm + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(expectedForm) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedForm) + }) + + it('should return FormNotFoundError if formId is invalid', async () => { + // Arrange + const formId = new ObjectId().toHexString() + // Resolve query to null. + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(null) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(FormNotFoundError) + }) + + it('should still return retrieved form even when it does not contain admin', async () => { + // Arrange + const formId = new ObjectId().toHexString() + const expectedForm = ({ + _id: formId, + title: 'mock title', + // Note no admin key-value. + } as unknown) as IPopulatedForm + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(expectedForm) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedForm) + }) + + it('should return DatabaseError when error occurs whilst querying database', async () => { + // Arrange + const formId = new ObjectId().toHexString() + // Mock rejection. + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockRejectedValueOnce(new Error('Some error')) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) + }) + }) + describe('retrieveFormById', () => { it('should return form successfully', async () => { // Arrange diff --git a/src/app/modules/form/__tests__/form.utils.spec.ts b/src/app/modules/form/__tests__/form.utils.spec.ts index 968f45c678..0f63b8504c 100644 --- a/src/app/modules/form/__tests__/form.utils.spec.ts +++ b/src/app/modules/form/__tests__/form.utils.spec.ts @@ -1,6 +1,11 @@ -import { Permission } from 'src/types' +import { ObjectId } from 'bson-ext' +import { Types } from 'mongoose' -import { getCollabEmailsWithPermission } from '../form.utils' +import { BasicField, IFieldSchema, Permission } from 'src/types' + +import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' + +import { getCollabEmailsWithPermission, getFormFieldById } from '../form.utils' const MOCK_EMAIL_1 = 'a@abc.com' const MOCK_EMAIL_2 = 'b@def.com' @@ -42,4 +47,63 @@ describe('form.utils', () => { expect(result).toEqual([MOCK_EMAIL_2]) }) }) + + describe('getFormFieldById', () => { + it('should return form field with valid id when form fields given is a primitive array', async () => { + // Arrange + const fieldToFind = generateDefaultField(BasicField.HomeNo) + const formFields = [generateDefaultField(BasicField.Date), fieldToFind] + + // Act + const result = getFormFieldById(formFields, fieldToFind._id) + + // Assert + expect(result).toEqual(fieldToFind) + }) + + it('should return form field with valid id when form fields given is a mongoose document array', async () => { + // Arrange + const fieldToFind = generateDefaultField(BasicField.Number) + // Should not turn this unit test into an integration test, so mocking return and leaving responsibility to mongoose. + const mockDocArray = ({ + 0: generateDefaultField(BasicField.LongText), + 1: fieldToFind, + isMongooseDocumentArray: true, + id: jest.fn().mockReturnValue(fieldToFind), + } as unknown) as Types.DocumentArray + + // Act + const result = getFormFieldById(mockDocArray, fieldToFind._id) + + // Assert + expect(result).toEqual(fieldToFind) + expect(mockDocArray.id).toHaveBeenCalledWith(fieldToFind._id) + }) + + it('should return null when given form fields are undefined', async () => { + // Arrange + const someFieldId = new ObjectId() + + // Act + const result = getFormFieldById(undefined, someFieldId) + + // Assert + expect(result).toEqual(null) + }) + + it('should return null when no fields correspond to given field id', async () => { + // Arrange + const invalidFieldId = new ObjectId() + const formFields = [ + generateDefaultField(BasicField.Date), + generateDefaultField(BasicField.Date), + ] + + // Act + const result = getFormFieldById(formFields, invalidFieldId) + + // Assert + expect(result).toEqual(null) + }) + }) }) 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 2a47cba706..1337ffbe2f 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 { err, errAsync, ok, okAsync } from 'neverthrow' +import { assignIn, cloneDeep, merge, pick } from 'lodash' +import { err, errAsync, ok, okAsync, Result } from 'neverthrow' import { PassThrough } from 'stream' import { MockedObject } from 'ts-jest/dist/utils/testing' import { mocked } from 'ts-jest/utils' @@ -46,8 +46,10 @@ import { FormSettings, IEmailSubmissionSchema, IEncryptedSubmissionSchema, + IFieldSchema, IForm, IFormSchema, + ILogicSchema, IPopulatedEmailForm, IPopulatedEncryptedForm, IPopulatedForm, @@ -57,7 +59,11 @@ import { ResponseMode, Status, } from 'src/types' -import { EncryptSubmissionDto } from 'src/types/api' +import { + EncryptSubmissionDto, + FieldCreateDto, + FieldUpdateDto, +} from 'src/types/api' import { generateDefaultField, @@ -71,13 +77,16 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../../form.errors' +import * as FormService from '../../form.service' import * as AdminFormController from '../admin-form.controller' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from '../admin-form.errors' import * as AdminFormService from '../admin-form.service' @@ -105,6 +114,8 @@ jest.mock('src/app/utils/encryption') const MockEncryptionUtils = mocked(EncryptionUtils) jest.mock('../admin-form.service') const MockAdminFormService = mocked(AdminFormService) +jest.mock('../../form.service') +const MockFormService = mocked(FormService) jest.mock('../../../user/user.service') const MockUserService = mocked(UserService) jest.mock('src/app/services/mail/mail.service') @@ -892,7 +903,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleCreatePresignedPostUrlForImages', () => { + describe('createPresignedPostUrlForImages', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -942,7 +953,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -969,7 +980,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -999,7 +1010,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1025,7 +1036,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1054,7 +1065,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1083,7 +1094,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1109,7 +1120,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1127,7 +1138,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleCreatePresignedPostUrlForLogos', () => { + describe('createPresignedPostUrlForLogos', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -1177,7 +1188,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1204,7 +1215,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1234,7 +1245,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1260,7 +1271,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1289,7 +1300,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1318,7 +1329,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1344,7 +1355,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -4157,39 +4168,6 @@ describe('admin-form.controller', () => { expect(editFormFieldSpy).not.toHaveBeenCalled() }) - it('should return 400 when performing invalid update to form fields', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - // Mock services to return expected results. - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - okAsync(MOCK_FORM), - ) - // Return error when editing form fields - const expectedErrorString = 'invalid field update' - editFormFieldSpy.mockReturnValueOnce( - errAsync(new EditFieldError(expectedErrorString)), - ) - - // Act - await AdminFormController.handleUpdateForm( - MOCK_EDIT_FIELD_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(editFormFieldSpy).toHaveBeenCalledTimes(1) - expect(updateFormSpy).not.toHaveBeenCalled() - }) - it('should return 403 when current user does not have permissions to update form', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -4341,6 +4319,39 @@ describe('admin-form.controller', () => { expect(updateFormSpy).not.toHaveBeenCalled() }) + it('should return 422 when performing invalid update to form fields', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Mock services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + // Return error when editing form fields + const expectedErrorString = 'invalid field update' + editFormFieldSpy.mockReturnValueOnce( + errAsync(new EditFieldError(expectedErrorString)), + ) + + // Act + await AdminFormController.handleUpdateForm( + MOCK_EDIT_FIELD_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(editFormFieldSpy).toHaveBeenCalledTimes(1) + expect(updateFormSpy).not.toHaveBeenCalled() + }) + it('should return 422 when an invalid update is attempted on the form', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -4807,7 +4818,256 @@ describe('admin-form.controller', () => { }) }) - describe('handleEmailPreviewSubmission', () => { + describe('handleGetSettings', () => { + const MOCK_FORM_SETTINGS: FormSettings = { + authType: AuthType.NIL, + hasCaptcha: false, + inactiveMessage: 'some inactive message', + status: Status.Private, + submissionLimit: 42069, + title: 'mock title', + webhook: { + url: '', + }, + } + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_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, + getSettings: () => MOCK_FORM_SETTINGS, + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + it('should return 200 with settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + const adminCheck = jest.fn( + ({ + form, + }: { + form: IPopulatedForm + }): Result => + ok(form), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_FORM_SETTINGS) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + }) + + it('should return 403 when current user does not have permissions to view form settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + const expectedErrorString = 'no write permissions' + const adminCheck = jest.fn( + (): Result => + err(new ForbiddenFormError(expectedErrorString)), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + }) + + it('should return 404 when form to view settings for cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'nope' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 409 when version conflict occurs whilst retrieving form settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'some conflict happened' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new DatabaseConflictError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 410 when viewing settings of archived form', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'already deleted' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 500 when generic database error occurs during settings retrieval', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'some database error bam' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + }) + + describe('submitEmailPreview', () => { const MOCK_FIELD_ID = new ObjectId().toHexString() const MOCK_RESPONSES = [ generateUnprocessedSingleAnswerResponse(BasicField.Email, { @@ -4895,11 +5155,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -4970,11 +5226,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5023,11 +5275,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5076,11 +5324,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5133,11 +5377,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5190,11 +5430,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5247,11 +5483,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5304,11 +5536,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5361,11 +5589,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5418,11 +5642,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5475,11 +5695,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5535,11 +5751,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5595,11 +5807,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5655,11 +5863,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5724,11 +5928,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5793,11 +5993,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5852,7 +6048,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleEncryptPreviewSubmission', () => { + describe('submitEncryptPreview', () => { const MOCK_RESPONSES = [ generateUnprocessedSingleAnswerResponse(BasicField.Email), ] @@ -5924,7 +6120,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -5988,7 +6184,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6036,7 +6232,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6084,7 +6280,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6136,7 +6332,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6188,7 +6384,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6240,7 +6436,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6292,7 +6488,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6344,7 +6540,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6396,7 +6592,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6451,7 +6647,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6506,7 +6702,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6544,4 +6740,1082 @@ describe('admin-form.controller', () => { }) }) }) + + describe('_handleUpdateFormField', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FIELD = generateDefaultField(BasicField.Rating) + const MOCK_UPDATED_FIELD = { + ...MOCK_FIELD, + title: 'some new title', + } as FieldUpdateDto + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + fieldId: MOCK_FIELD._id, + }, + body: MOCK_UPDATED_FIELD, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + beforeEach(() => { + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + + MockAdminFormService.updateFormField.mockReturnValue( + okAsync(MOCK_UPDATED_FIELD as IFieldSchema), + ) + }) + it('should return 200 with updated form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_UPDATED_FIELD) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 404 when field cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new FieldNotFoundError()), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Field to modify not found', + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 403 when current user does not have permissions to update form', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedErrorString = 'no write permissions' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when form to update form field for cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'nope' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 410 when form to update form field for is already archived', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'already deleted' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(410) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 413 when updated form is too large to be saved in the database', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'payload too large' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabasePayloadSizeError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(413) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 422 when performing invalid update to form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedErrorString = 'invalid field update' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabaseValidationError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'user not in session??!!' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new MissingUserError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when generic database error occurs during form field update', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'some database error bam' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + }) + + describe('_handleCreateFormField', () => { + const MOCK_FORM_ID = new ObjectId() + const MOCK_USER_ID = new ObjectId() + 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 IPopulatedForm + + const MOCK_RETURNED_FIELD = generateDefaultField(BasicField.Nric) + const MOCK_CREATE_FIELD_BODY = pick(MOCK_RETURNED_FIELD, [ + 'fieldType', + 'title', + ]) as FieldCreateDto + const MOCK_REQ = expressHandler.mockRequest({ + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + params: { + formId: String(MOCK_FORM_ID), + }, + body: MOCK_CREATE_FIELD_BODY, + }) + beforeEach(() => { + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.createFormField.mockReturnValue( + okAsync(MOCK_RETURNED_FIELD), + ) + }) + + it('should return 200 with created form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_RETURNED_FIELD) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 403 when current user does not have permissions to create a form field', async () => { + // Arrange + const expectedErrorString = 'no permissions pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when form cannot be found', async () => { + // Arrange + const expectedErrorString = 'no form pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 410 when attempting to create a form field for an archived form', async () => { + // Arrange + const expectedErrorString = 'form gone pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(410) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 413 when creating a form field causes the form to be too large to be saved in the database', async () => { + // Arrange + const expectedErrorString = 'payload too large' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabasePayloadSizeError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(413) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 422 when DatabaseValidationError occurs', async () => { + // Arrange + const expectedErrorString = 'invalid thing' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabaseValidationError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + const expectedErrorString = 'user gone' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new MissingUserError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving user from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving form from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: String(MOCK_FORM_ID), + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst creating form field', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + }) + + describe('_handleReorderFormField', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FIELDS = [ + generateDefaultField(BasicField.Rating), + generateDefaultField(BasicField.Table), + ] + const MOCK_UPDATED_FIELDS = [MOCK_FIELDS[1], MOCK_FIELDS[0]] + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + form_fields: MOCK_FIELDS, + title: 'mock title', + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + fieldId: MOCK_FIELDS[1]._id, + }, + query: { to: 2 }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + beforeEach(() => { + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + + MockAdminFormService.reorderFormField.mockReturnValue( + okAsync(MOCK_UPDATED_FIELDS), + ) + }) + + it('should return 200 with reordered form fields', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_UPDATED_FIELDS) + expect(MockAdminFormService.reorderFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_REQ.params.fieldId, + MOCK_REQ.query.to, + ) + }) + + it('should return 403 when current user does not have permissions to reorder a form field', async () => { + // Arrange + const expectedErrorString = 'no permissions pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when form cannot be found', async () => { + // Arrange + const expectedErrorString = 'no form pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when field cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + MockAdminFormService.reorderFormField.mockReturnValueOnce( + errAsync(new FieldNotFoundError()), + ) + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Field to modify not found', + }) + expect(MockAdminFormService.reorderFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_REQ.params.fieldId, + MOCK_REQ.query.to, + ) + }) + + it('should return 410 when attempting to reorder a form field for an archived form', async () => { + // Arrange + const expectedErrorString = 'form gone pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(410) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 413 when reordering a form field causes the form to be too large to be saved in the database', async () => { + // Arrange + const expectedErrorString = 'payload too large' + MockAdminFormService.reorderFormField.mockReturnValueOnce( + errAsync(new DatabasePayloadSizeError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(413) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_REQ.params.fieldId, + MOCK_REQ.query.to, + ) + }) + + it('should return 422 when DatabaseValidationError occurs', async () => { + // Arrange + const expectedErrorString = 'invalid thing' + MockAdminFormService.reorderFormField.mockReturnValueOnce( + errAsync(new DatabaseValidationError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_REQ.params.fieldId, + MOCK_REQ.query.to, + ) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + const expectedErrorString = 'user gone' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new MissingUserError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving user from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving form from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: String(MOCK_FORM_ID), + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.reorderFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst reordering form field', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAdminFormService.reorderFormField.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleReorderFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.reorderFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_REQ.params.fieldId, + MOCK_REQ.query.to, + ) + }) + }) + + describe('handleDeleteLogic', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + ...mockFormLogic, + } as IPopulatedForm + + const mockReq = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + logicId, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + const mockRes = expressHandler.mockResponse() + beforeEach(() => { + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.deleteFormLogic.mockReturnValue(okAsync(true)) + }) + + it('should call all services correctly when request is valid', async () => { + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.sendStatus).toHaveBeenCalledWith(200) + }) + + it('should return 403 when user does not have permissions to delete logic', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync( + new ForbiddenFormError('not authorized to perform write operation'), + ), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(403) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'not authorized to perform write operation', + }) + }) + + it('should return 404 when logicId cannot be found', async () => { + MockAdminFormService.deleteFormLogic.mockReturnValue( + errAsync(new LogicNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'logicId does not exist on form', + }) + }) + + it('should return 404 when form cannot be found', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync(new FormNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Form not found', + }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new MissingUserError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(422) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'User not found', + }) + }) + + it('should return 500 when database error occurs', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new DatabaseError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(500) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }) + }) + }) }) 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 a746e0fa30..a690f42933 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 @@ -1524,41 +1524,6 @@ describe('admin-form.routes', () => { 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) @@ -1647,6 +1612,41 @@ describe('admin-form.routes', () => { expect(response.body).toEqual({ message: 'Form has been archived' }) }) + it('should return 422 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(422) + expect(response.body).toEqual({ + message: 'Field to be updated does not exist', + }) + }) + it('should return 422 when user in session cannot be found in the database', async () => { // Arrange const formToArchive = await EmailFormModel.create({ diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index db632f7da3..1425b0bb37 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson-ext' -import { assignIn, cloneDeep, merge, omit } from 'lodash' +import { assignIn, cloneDeep, merge, omit, pick } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import { mocked } from 'ts-jest/utils' @@ -30,6 +31,7 @@ import { IEmailFormSchema, IFormDocument, IFormSchema, + ILogicSchema, IPopulatedForm, IPopulatedUser, IUserSchema, @@ -37,26 +39,35 @@ import { ResponseMode, Status, } from 'src/types' -import { SettingsUpdateDto } from 'src/types/api' +import { + FieldCreateDto, + FieldUpdateDto, + SettingsUpdateDto, +} from 'src/types/api' import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' -import { TransferOwnershipError } from '../../form.errors' +import { LogicNotFoundError, TransferOwnershipError } from '../../form.errors' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from '../admin-form.errors' import { archiveForm, createForm, + createFormField, createPresignedPostUrlForImages, createPresignedPostUrlForLogos, + deleteFormLogic, duplicateForm, editFormFields, getDashboardForms, + reorderFormField, transferFormOwnership, updateForm, + updateFormField, updateFormSettings, } from '../admin-form.service' import { @@ -167,7 +178,7 @@ describe('admin-form.service', () => { // Mock external service success. const s3Spy = jest .spyOn(aws.s3, 'createPresignedPost') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockImplementationOnce((_obj, cb) => { cb(null, expectedPresignedPostUrl) @@ -184,7 +195,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.imageS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -233,7 +244,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.imageS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -257,7 +268,7 @@ describe('admin-form.service', () => { // Mock external service success. const s3Spy = jest .spyOn(aws.s3, 'createPresignedPost') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockImplementationOnce((_obj, cb) => { cb(null, expectedPresignedPostUrl) @@ -274,7 +285,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.logoS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -323,7 +334,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.logoS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -818,7 +829,7 @@ describe('admin-form.service', () => { } const createSpy = jest .spyOn(FormModel, 'create') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockRejectedValueOnce(new mongoose.Error.ValidationError() as never) @@ -841,7 +852,6 @@ describe('admin-form.service', () => { publicKey: 'some key', } const createSpy = jest.spyOn(FormModel, 'create').mockRejectedValueOnce( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new mongoose.Error.VersionError({}, 1, ['none']) as never, ) @@ -980,7 +990,7 @@ describe('admin-form.service', () => { .spyOn(AdminFormUtils, 'getUpdatedFormFields') .mockReturnValueOnce(ok(mockUpdatedFields)) // Mock database save error. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const mockError = new mongoose.Error.VersionError({}, 1, ['none']) MOCK_INTIAL_FORM.save.mockRejectedValueOnce(mockError as never) @@ -1095,14 +1105,14 @@ describe('admin-form.service', () => { const EMAIL_UPDATE_SPY = jest .spyOn(EmailFormModel, 'findByIdAndUpdate') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockReturnValue({ exec: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), }) const ENCRYPT_UPDATE_SPY = jest .spyOn(EncryptFormModel, 'findByIdAndUpdate') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockReturnValue({ exec: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), @@ -1163,11 +1173,9 @@ describe('admin-form.service', () => { title: 'does not matter', } // Mock database error - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ENCRYPT_UPDATE_SPY.mockReturnValueOnce({ exec: jest.fn().mockRejectedValue( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new mongoose.Error.ValidationError({ errors: 'some error' }), ), @@ -1191,4 +1199,348 @@ describe('admin-form.service', () => { expect(MOCK_UPDATED_FORM.getSettings).toHaveBeenCalledTimes(0) }) }) + + describe('updateFormField', () => { + it('should return updated form field', async () => { + // Arrange + const fieldToUpdate = generateDefaultField(BasicField.YesNo, { + title: 'random title', + }) + const mockNewField = { + ...fieldToUpdate, + title: 'new title', + } as FieldUpdateDto + + const mockUpdatedForm = { + title: 'some mock form', + form_fields: [mockNewField], + } + const mockForm = ({ + ...mockUpdatedForm, + form_fields: [fieldToUpdate], + updateFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), + } as unknown) as IPopulatedForm + + // Act + const actual = await updateFormField( + mockForm, + fieldToUpdate._id, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrap()).toEqual(mockNewField) + expect(mockForm.updateFormFieldById).toHaveBeenCalledWith( + fieldToUpdate._id, + mockNewField, + ) + }) + + it('should return FieldNotFoundError when field update returns null', async () => { + // Arrange + const mockForm = ({ + title: 'another mock form', + form_fields: [], + updateFormFieldById: jest.fn().mockResolvedValue(null), + } as unknown) as IPopulatedForm + + const invalidFieldId = new ObjectId().toHexString() + const mockNewField = generateDefaultField( + BasicField.Number, + ) as FieldUpdateDto + + // Act + const actual = await updateFormField( + mockForm, + invalidFieldId, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toEqual(new FieldNotFoundError()) + }) + + it('should return DatabaseValidationError when field model update throws a validation error', async () => { + // Arrange + const mockForm = ({ + title: 'another another mock form', + form_fields: [], + updateFormFieldById: jest.fn().mockRejectedValue( + // @ts-ignore + new mongoose.Error.ValidationError(), + ), + } as unknown) as IPopulatedForm + + const invalidFieldId = new ObjectId().toHexString() + const mockNewField = generateDefaultField( + BasicField.Number, + ) as FieldUpdateDto + + // Act + const actual = await updateFormField( + mockForm, + invalidFieldId, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) + }) + }) + + describe('createFormField', () => { + it('should return created form field', async () => { + // Arrange + const initialFields = [ + generateDefaultField(BasicField.Mobile), + generateDefaultField(BasicField.Image), + ] + const expectedCreatedField = generateDefaultField(BasicField.Nric, { + title: 'some nric title', + }) + const mockUpdatedForm = { + title: 'some mock form', + // Append created field to end of form_fields. + form_fields: [...initialFields, expectedCreatedField], + } + const mockForm = ({ + title: 'some mock form', + form_fields: initialFields, + insertFormField: jest.fn().mockResolvedValue(mockUpdatedForm), + } as unknown) as IPopulatedForm + const formCreateParams = pick(expectedCreatedField, [ + 'title', + 'fieldType', + ]) as FieldCreateDto + + // Act + const actual = await createFormField(mockForm, formCreateParams) + + // Assert + // Should return last element in form_field + expect(actual._unsafeUnwrap()).toEqual(expectedCreatedField) + }) + + it('should return DatabaseValidationError when field model update throws a validation error', async () => { + // Arrange + const initialFields = [ + generateDefaultField(BasicField.Mobile), + generateDefaultField(BasicField.Image), + ] + const mockForm = ({ + title: 'some mock form', + form_fields: initialFields, + insertFormField: jest.fn().mockRejectedValue( + // @ts-ignore + new mongoose.Error.ValidationError({ errors: 'does not matter' }), + ), + } as unknown) as IPopulatedForm + const formCreateParams = { + fieldType: BasicField.ShortText, + title: 'some title', + } as FieldCreateDto + + // Act + const actual = await createFormField(mockForm, formCreateParams) + + // Assert + expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) + }) + }) + + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const DELETE_SPY = jest.spyOn(FormModel, 'deleteFormLogic') + + let mockEmailForm: IPopulatedForm, mockEncryptForm: IPopulatedForm + + beforeEach(() => { + mockEmailForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + } as unknown) as IPopulatedForm + mockEncryptForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Encrypt, + ...mockFormLogic, + } as unknown) as IPopulatedForm + }) + + it('should return ok(form) on successful form logic delete for email mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEmailForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEmailForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEmailForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEmailForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEmailForm._id), + logicId, + ) + }) + + it('should return ok(form) on successful form logic delete for encrypt mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEncryptForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEncryptForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEncryptForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEncryptForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEncryptForm._id), + logicId, + ) + }) + + it('should return LogicNotFoundError if logic does not exist on form', async () => { + // Act + const wrongLogicId = new ObjectId().toHexString() + const actualResult = await deleteFormLogic(mockEmailForm, wrongLogicId) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new LogicNotFoundError()) + expect(DELETE_SPY).not.toHaveBeenCalled() + }) + }) + + describe('reorderFormField', () => { + it('should return reordered fields successfully', async () => { + // Arrange + const mockFormFields = [ + generateDefaultField(BasicField.YesNo), + generateDefaultField(BasicField.Date), + ] + const mockUpdatedForm = { + form_fields: [mockFormFields[1], mockFormFields[0]], + } + const mockForm = ({ + form_fields: mockFormFields, + reorderFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), + } as unknown) as IPopulatedForm + const fieldToReorder = String(mockFormFields[0]._id) + const newPosition = 1 + + // Act + const actual = await reorderFormField( + mockForm, + fieldToReorder, + newPosition, + ) + + // Assert + expect(actual._unsafeUnwrap()).toEqual(mockUpdatedForm.form_fields) + expect(mockForm.reorderFormFieldById).toHaveBeenCalledWith( + fieldToReorder, + newPosition, + ) + }) + + it('should return FieldNotFoundError when null is returned from the model instance method', async () => { + // Arrange + const mockForm = ({ + form_fields: [generateDefaultField(BasicField.YesNo)], + reorderFormFieldById: jest.fn().mockResolvedValue(null), + } as unknown) as IPopulatedForm + const fieldToReorder = new ObjectId().toHexString() + const newPosition = 2 + + // Act + const actual = await reorderFormField( + mockForm, + fieldToReorder, + newPosition, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toEqual(new FieldNotFoundError()) + expect(mockForm.reorderFormFieldById).toHaveBeenCalledWith( + fieldToReorder, + newPosition, + ) + }) + + it('should return database error when error occurs whilst reordering fields', async () => { + // Arrange + const mockForm = ({ + form_fields: [generateDefaultField(BasicField.YesNo)], + // Rejection + reorderFormFieldById: jest + .fn() + .mockRejectedValue(new Error('some error')), + } as unknown) as IPopulatedForm + const fieldToReorder = new ObjectId().toHexString() + const newPosition = 2 + + // Act + const actual = await reorderFormField( + mockForm, + fieldToReorder, + newPosition, + ) + + // Assert + const actualError = actual._unsafeUnwrapErr() + expect(actualError).toBeInstanceOf(DatabaseError) + expect(actualError.message).toEqual(expect.stringContaining('some error')) + expect(mockForm.reorderFormFieldById).toHaveBeenCalledWith( + fieldToReorder, + newPosition, + ) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts index d575a0b5b7..6c8f30068c 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts @@ -4,6 +4,7 @@ import { cloneDeep, omit, tail } from 'lodash' import { EditFieldActions } from 'src/shared/constants' import { BasicField, + IEmailFieldSchema, IFieldSchema, IPopulatedForm, IPopulatedUser, @@ -443,6 +444,79 @@ describe('admin-form.utils', () => { ]) }) + it('should return synced email field when updating with desynced email field', async () => { + // Arrange + const initialField = generateDefaultField(BasicField.Email, { + title: 'some old title', + }) + const desyncedEmailField = ({ + ...initialField, + title: 'new title', + hasAllowedEmailDomains: true, + // true but empty array + allowedEmailDomains: [], + } as unknown) as IEmailFieldSchema + + const updateFieldParams: EditFormFieldParams = { + action: { + name: EditFieldActions.Update, + }, + field: desyncedEmailField, + } + + // Act + const actualResult = getUpdatedFormFields( + [initialField], + updateFieldParams, + ) + + // Assert + // Email field should be updated but synced + expect(actualResult._unsafeUnwrap()).toEqual([ + // hasAllowedEmailDomains should be false since allowedEmailDomains is empty + { + ...desyncedEmailField, + hasAllowedEmailDomains: false, + allowedEmailDomains: [], + }, + ]) + }) + + it('should return synced email field when creating with desynced email field', async () => { + // Arrange + const desyncedEmailField = ({ + ...generateDefaultField(BasicField.Email), + hasAllowedEmailDomains: false, + // False but contains domains. + allowedEmailDomains: ['@example.com'], + } as unknown) as IEmailFieldSchema + + const createFieldParams: EditFormFieldParams = { + action: { + name: EditFieldActions.Create, + }, + field: desyncedEmailField, + } + + // Act + const actualResult = getUpdatedFormFields( + INITIAL_FIELDS, + createFieldParams, + ) + + // Assert + // Email field should be updated but synced + expect(actualResult._unsafeUnwrap()).toEqual([ + ...INITIAL_FIELDS, + // allowedEmailDomains should be empty since hasAllowedEmailDomains is false + { + ...desyncedEmailField, + hasAllowedEmailDomains: false, + allowedEmailDomains: [], + }, + ]) + }) + it('should return EditFieldError when field to be created already exists', async () => { // Arrange const existingField = cloneDeep(INITIAL_FIELDS[0]) 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 8c7838596c..423932d5e0 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -1,13 +1,15 @@ import JoiDate from '@joi/date' import { celebrate, Joi as BaseJoi, Segments } from 'celebrate' import { Request, RequestHandler } from 'express' -import { ParamsDictionary } from 'express-serve-static-core' +import { ParamsDictionary, Query } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' import { ResultAsync } from 'neverthrow' +import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants' import { AuthType, + BasicField, FieldResponse, FormMetaView, FormSettings, @@ -18,6 +20,9 @@ import { import { EncryptSubmissionDto, ErrorDto, + FieldCreateDto, + FieldUpdateDto, + FormFieldDto, SettingsUpdateDto, } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' @@ -36,17 +41,20 @@ import { createCorppassParsedResponses, createSingpassParsedResponses, } from '../../spcp/spcp.util' +import * as EmailSubmissionMiddleware from '../../submission/email-submission/email-submission.middleware' 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 EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware' 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 * as FormService from '../form.service' import { PREVIEW_CORPPASS_UID, @@ -63,7 +71,7 @@ import { import { mapRouteError } from './admin-form.utils' // NOTE: Refer to this for documentation: https://github.com/sideway/joi-date/blob/master/API.md -const Joi = BaseJoi.extend(JoiDate) +const Joi = BaseJoi.extend(JoiDate) as typeof BaseJoi const logger = createLoggerWithLabel(module) @@ -139,6 +147,16 @@ const transferFormOwnershipValidator = celebrate({ }, }) +const fileUploadValidator = celebrate({ + [Segments.BODY]: { + fileId: Joi.string().required(), + fileMd5Hash: Joi.string().base64().required(), + fileType: Joi.string() + .valid(...VALID_UPLOAD_FILE_TYPES) + .required(), + }, +}) + /** * Handler for GET /adminform endpoint. * @security session @@ -276,7 +294,7 @@ export const handlePreviewAdminForm: RequestHandler<{ formId: string }> = ( * @returns 410 when form is archived * @returns 422 when user in session cannot be retrieved from the database */ -export const handleCreatePresignedPostUrlForImages: RequestHandler< +export const createPresignedPostUrlForImages: RequestHandler< { formId: string }, unknown, { @@ -313,7 +331,7 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< logger.error({ message: 'Presigning post data encountered an error', meta: { - action: 'handleCreatePresignedPostUrlForImages', + action: 'createPresignedPostUrlForImages', ...createReqMeta(req), }, error, @@ -325,6 +343,11 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< ) } +export const handleCreatePresignedPostUrlForImages = [ + fileUploadValidator, + createPresignedPostUrlForImages, +] as RequestHandler[] + /** * Handler for POST /:formId([a-fA-F0-9]{24})/adminform/logos. * @security session @@ -336,7 +359,7 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< * @returns 410 when form is archived * @returns 422 when user in session cannot be retrieved from the database */ -export const handleCreatePresignedPostUrlForLogos: RequestHandler< +export const createPresignedPostUrlForLogos: RequestHandler< ParamsDictionary, unknown, { @@ -373,7 +396,7 @@ export const handleCreatePresignedPostUrlForLogos: RequestHandler< logger.error({ message: 'Presigning post data encountered an error', meta: { - action: 'handleCreatePresignedPostUrlForLogos', + action: 'createPresignedPostUrlForLogos', ...createReqMeta(req), }, error, @@ -385,6 +408,11 @@ export const handleCreatePresignedPostUrlForLogos: RequestHandler< ) } +export const handleCreatePresignedPostUrlForLogos = [ + fileUploadValidator, + createPresignedPostUrlForLogos, +] as RequestHandler[] + // Validates that the ending date >= starting date const validateDateRange = celebrate({ [Segments.QUERY]: Joi.object() @@ -1075,7 +1103,7 @@ export const handleUpdateForm: RequestHandler< } /** - * Handler for PATCH /form/:formId/settings. + * Handler for PATCH /forms/:formId/settings. * @security session * * @returns 200 with updated form settings @@ -1129,6 +1157,103 @@ export const handleUpdateSettings: RequestHandler< }) } +/** + * NOTE: Exported for testing. + * Private handler for PUT /forms/:formId/fields/:fieldId + * @precondition Must be preceded by request validation + */ +export const _handleUpdateFormField: RequestHandler< + { + formId: string + fieldId: string + }, + FormFieldDto | ErrorDto, + FieldUpdateDto +> = (req, res) => { + const { formId, fieldId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: User has permissions, update form field of retrieved form. + .andThen((form) => + AdminFormService.updateFormField(form, fieldId, req.body), + ) + .map((updatedFormField) => + res.status(StatusCodes.OK).json(updatedFormField), + ) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when updating form field', + meta: { + action: 'handleUpdateFormField', + ...createReqMeta(req), + userId: sessionUserId, + formId, + fieldId, + updateFieldBody: req.body, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + +/** + * Handler for GET /form/:formId/settings. + * @security session + * + * @returns 200 with latest form settings on successful update + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to obtain form settings + * @returns 404 when form to retrieve settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 500 when database error occurs + */ +export const handleGetSettings: RequestHandler< + { formId: string }, + FormSettings | ErrorDto +> = (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + return UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Retrieve form for settings as well as for permissions checking + FormService.retrieveFullFormById(formId).map((form) => ({ + form, + user, + })), + ) + .andThen(AuthService.checkFormForPermissions(PermissionLevel.Read)) + .map((form) => res.status(StatusCodes.OK).json(form.getSettings())) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when retrieving form settings', + meta: { + action: 'handleGetSettings', + ...createReqMeta(req), + userId: sessionUserId, + formId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) +} + /** * Handler for POST /v2/submissions/encrypt/preview/:formId. * @security session @@ -1141,7 +1266,7 @@ export const handleUpdateSettings: RequestHandler< * @returns 422 when user ID in session is not found in database * @returns 500 when database error occurs */ -export const handleEncryptPreviewSubmission: RequestHandler< +export const submitEncryptPreview: RequestHandler< { formId: string }, { message: string; submissionId: string } | ErrorDto, EncryptSubmissionDto @@ -1151,7 +1276,7 @@ export const handleEncryptPreviewSubmission: RequestHandler< // No need to process attachments as we don't do anything with them const { encryptedContent, responses, version } = req.body const logMeta = { - action: 'handleEncryptPreviewSubmission', + action: 'submitEncryptPreview', formId, } @@ -1217,6 +1342,11 @@ export const handleEncryptPreviewSubmission: RequestHandler< }) } +export const handleEncryptPreviewSubmission = [ + EncryptSubmissionMiddleware.validateEncryptSubmissionParams, + submitEncryptPreview, +] as RequestHandler[] + /** * Handler for POST /v2/submissions/encrypt/preview/:formId. * @security session @@ -1229,7 +1359,7 @@ export const handleEncryptPreviewSubmission: RequestHandler< * @returns 422 when user ID in session is not found in database * @returns 500 when database error occurs */ -export const handleEmailPreviewSubmission: RequestHandler< +export const submitEmailPreview: RequestHandler< { formId: string }, { message: string; submissionId?: string }, { responses: FieldResponse[]; isPreview: boolean }, @@ -1240,7 +1370,7 @@ export const handleEmailPreviewSubmission: RequestHandler< // No need to process attachments as we don't do anything with them const { responses } = req.body const logMeta = { - action: 'handleEmailPreviewSubmission', + action: 'submitEmailPreview', formId, ...createReqMeta(req as Request), } @@ -1354,3 +1484,248 @@ export const handleEmailPreviewSubmission: RequestHandler< submissionId: submission.id, }) } + +export const handleEmailPreviewSubmission = [ + EmailSubmissionMiddleware.receiveEmailSubmission, + EmailSubmissionMiddleware.validateResponseParams, + submitEmailPreview, +] as RequestHandler[] + +/** + * Handler for PUT /forms/:formId/fields/:fieldId + * @security session + * + * @returns 200 with updated form field + * @returns 403 when current user does not have permissions to update form field + * @returns 404 when form cannot be found + * @returns 404 when form field cannot be found + * @returns 410 when updating form field of an archived form + * @returns 413 when updating form field causes form to be too large to be saved in the database + * @returns 422 when an invalid form field update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const handleUpdateFormField = [ + celebrate( + { + [Segments.BODY]: Joi.object({ + // Ensures given field is same as accessed field. + _id: Joi.string().valid(Joi.ref('$params.fieldId')).required(), + fieldType: Joi.string() + .valid(...Object.values(BasicField)) + .required(), + description: Joi.string().allow('').required(), + required: Joi.boolean().required(), + title: Joi.string().required(), + disabled: Joi.boolean().required(), + // Allow other field related key-values to be provided and let the model + // layer handle the validation. + }).unknown(true), + }, + undefined, + // Required so req.body can be validated against values in req.params. + // See https://github.com/arb/celebrate#celebrateschema-joioptions-opts. + { reqContext: true }, + ), + _handleUpdateFormField, +] + +/** + * NOTE: Exported for testing. + * Private handler for POST /forms/:formId/fields + * @precondition Must be preceded by request validation + * @security session + * + * @returns 200 with created form field + * @returns 403 when current user does not have permissions to create a form field + * @returns 404 when form cannot be found + * @returns 410 when creating form field for an archived form + * @returns 413 when creating form field causes form to be too large to be saved in the database + * @returns 422 when an invalid form field creation is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const _handleCreateFormField: RequestHandler< + { formId: string }, + FormFieldDto | ErrorDto, + FieldCreateDto +> = (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: User has permissions, proceed to create form field with provided body. + .andThen((form) => AdminFormService.createFormField(form, req.body)) + .map((createdFormField) => + res.status(StatusCodes.OK).json(createdFormField), + ) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when creating form field', + meta: { + action: '_handleCreateFormField', + ...createReqMeta(req), + userId: sessionUserId, + formId, + createFieldBody: req.body, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + +/** + * Handler for DELETE /forms/:formId/logic/:logicId + * @security session + * + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const handleDeleteLogic: RequestHandler = (req, res) => { + const { formId, logicId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + + // Step 3: Delete form logic + .andThen((retrievedForm) => + AdminFormService.deleteFormLogic(retrievedForm, logicId), + ) + .map(() => res.sendStatus(StatusCodes.OK)) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'handleDeleteLogic', + ...createReqMeta(req), + userId: sessionUserId, + formId, + logicId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + +/** + * Handler for POST /forms/:formId/fields + */ +export const handleCreateFormField = [ + celebrate({ + [Segments.BODY]: Joi.object({ + // Ensures id is not provided. + _id: Joi.any().forbidden(), + globalId: Joi.any().forbidden(), + fieldType: Joi.string() + .valid(...Object.values(BasicField)) + .required(), + title: Joi.string().required(), + description: Joi.string().allow(''), + required: Joi.boolean(), + disabled: Joi.boolean(), + // Allow other field related key-values to be provided and let the model + // layer handle the validation. + }).unknown(true), + }), + _handleCreateFormField, +] + +/** + * NOTE: Exported for testing. + * Private handler for POST /forms/:formId/fields/:fieldId/reorder + * @precondition Must be preceded by request validation + * @security session + * + * @returns 200 with new ordering of form fields + * @returns 403 when current user does not have permissions to create a form field + * @returns 404 when form cannot be found + * @returns 404 when given fieldId cannot be found in form + * @returns 410 when reordering form fields for an archived form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const _handleReorderFormField: RequestHandler< + { formId: string; fieldId: string }, + FormFieldDto[] | ErrorDto, + unknown, + Query & { to: number } +> = (req, res) => { + const { formId, fieldId } = req.params + const { to } = req.query + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: User has permissions, proceed to reorder field + .andThen((form) => AdminFormService.reorderFormField(form, fieldId, to)) + .map((reorderedFormFields) => + res.status(StatusCodes.OK).json(reorderedFormFields), + ) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when reordering form field', + meta: { + action: '_handleReorderFormField', + ...createReqMeta(req), + userId: sessionUserId, + formId, + fieldId, + reqQuery: req.query, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + +/** + * Handler for POST /forms/:formId/fields/:fieldId/reorder + */ +export const handleReorderFormField = [ + celebrate({ + [Segments.QUERY]: { + to: Joi.number().min(0).required(), + }, + }), + _handleReorderFormField, +] as RequestHandler[] diff --git a/src/app/modules/form/admin-form/admin-form.errors.ts b/src/app/modules/form/admin-form/admin-form.errors.ts index effd7a0934..e7c7594eba 100644 --- a/src/app/modules/form/admin-form/admin-form.errors.ts +++ b/src/app/modules/form/admin-form/admin-form.errors.ts @@ -17,3 +17,9 @@ export class EditFieldError extends ApplicationError { super(message) } } + +export class FieldNotFoundError extends ApplicationError { + constructor(message = 'Field to modify not found') { + super(message) + } +} 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 46c712e649..e9594cb1cb 100644 --- a/src/app/modules/form/admin-form/admin-form.routes.ts +++ b/src/app/modules/form/admin-form/admin-form.routes.ts @@ -6,12 +6,9 @@ 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 { 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' import * as AdminFormController from './admin-form.controller' import { DuplicateFormBody } from './admin-form.types' @@ -47,16 +44,6 @@ const duplicateFormValidator = celebrate({ }), }) -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) @@ -337,6 +324,7 @@ AdminFormsRouter.get( * Retrieve metadata of responses for a form with encrypted storage * @route GET /:formId/adminform/submissions/metadata * @security session + * @deprecated in favour of GET /api/v3/admin/forms/:formId/submissions/metadata * * @returns 200 with paginated submission metadata when no submissionId is provided * @returns 200 with single submission metadata of submissionId when provided @@ -350,15 +338,6 @@ AdminFormsRouter.get( 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, ) @@ -400,7 +379,6 @@ AdminFormsRouter.get( AdminFormsRouter.post( '/:formId([a-fA-F0-9]{24})/adminform/images', withUserAuthentication, - fileUploadValidator, AdminFormController.handleCreatePresignedPostUrlForImages, ) @@ -421,7 +399,6 @@ AdminFormsRouter.post( AdminFormsRouter.post( '/:formId([a-fA-F0-9]{24})/adminform/logos', withUserAuthentication, - fileUploadValidator, AdminFormController.handleCreatePresignedPostUrlForLogos, ) @@ -441,7 +418,6 @@ AdminFormsRouter.post( AdminFormsRouter.post( '/v2/submissions/encrypt/preview/:formId([a-fA-F0-9]{24})', withUserAuthentication, - EncryptSubmissionMiddleware.validateEncryptSubmissionParams, AdminFormController.handleEncryptPreviewSubmission, ) @@ -461,7 +437,5 @@ AdminFormsRouter.post( 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 461bc64906..c679bd3e07 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -1,10 +1,11 @@ import { PresignedPost } from 'aws-sdk/clients/s3' -import { assignIn, omit } from 'lodash' +import { assignIn, last, omit } from 'lodash' import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' import { Except, Merge } from 'type-fest' import { + EditFieldActions, MAX_UPLOAD_FILE_SIZE, VALID_UPLOAD_FILE_TYPES, } from '../../../../shared/constants' @@ -19,7 +20,11 @@ import { IPopulatedForm, IUserSchema, } from '../../../../types' -import { SettingsUpdateDto } from '../../../../types/api' +import { + FieldCreateDto, + FieldUpdateDto, + SettingsUpdateDto, +} from '../../../../types/api' import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getFormModel from '../../../models/form.server.model' @@ -33,16 +38,23 @@ import { DatabaseError, DatabasePayloadSizeError, DatabaseValidationError, + PossibleDatabaseError, } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' -import { FormNotFoundError, TransferOwnershipError } from '../form.errors' +import { + FormNotFoundError, + LogicNotFoundError, + TransferOwnershipError, +} from '../form.errors' import { getFormModelByResponseMode } from '../form.service' +import { getFormFieldById } from '../form.utils' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from './admin-form.errors' import { @@ -411,6 +423,123 @@ export const duplicateForm = ( ) } +/** + * Updates the targeted form field with the new field provided + * @param form the form the field to update belongs to + * @param fieldId the id of the field to update + * @param newField the new field to replace with + * @returns ok(updatedField) + * @returns err(FieldNotFoundError) if fieldId does not correspond to any field in the form + * @returns err(PossibleDatabaseError) when database errors arise + */ +export const updateFormField = ( + form: IPopulatedForm, + fieldId: string, + newField: FieldUpdateDto, +): ResultAsync => { + return ResultAsync.fromPromise( + form.updateFormFieldById(fieldId, newField), + (error) => { + logger.error({ + message: 'Error encountered while updating form field', + meta: { + action: 'updateFormField', + formId: form._id, + fieldId, + newField, + }, + error, + }) + + return transformMongoError(error) + }, + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FieldNotFoundError()) + } + const updatedFormField = getFormFieldById(updatedForm.form_fields, fieldId) + return updatedFormField + ? okAsync(updatedFormField) + : errAsync(new FieldNotFoundError()) + }) +} + +/** + * Inserts a new form field into given form's fields with the field provided + * @param form the form to insert the new field into + * @param newField the new field to insert + * @returns ok(created form field) + * @returns err(PossibleDatabaseError) when database errors arise + */ +export const createFormField = ( + form: IPopulatedForm, + newField: FieldCreateDto, +): ResultAsync< + IFieldSchema, + PossibleDatabaseError | FormNotFoundError | FieldNotFoundError +> => { + return ResultAsync.fromPromise(form.insertFormField(newField), (error) => { + logger.error({ + message: 'Error encountered while inserting new form field', + meta: { + action: 'createFormField', + formId: form._id, + newField, + }, + error, + }) + + return transformMongoError(error) + }).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + const updatedField = last(updatedForm.form_fields) + return updatedField + ? okAsync(updatedField) + : errAsync(new FieldNotFoundError()) + }) +} + +/** + * Reorders field with given fieldId to the given newPosition + * @param form the form to reorder the field from + * @param fieldId the id of the field to reorder + * @param newPosition the new position of the field + * @returns ok(reordered field) + * @returns err(FieldNotFoundError) if field id is invalid + * @returns err(PossibleDatabaseError) if any database errors occur + */ +export const reorderFormField = ( + form: IPopulatedForm, + fieldId: string, + newPosition: number, +): ResultAsync => { + return ResultAsync.fromPromise( + form.reorderFormFieldById(fieldId, newPosition), + (error) => { + logger.error({ + message: 'Error encountered while reordering form field', + meta: { + action: 'reorderFormField', + formId: form._id, + fieldId, + newPosition, + }, + error, + }) + + return transformMongoError(error) + }, + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FieldNotFoundError()) + } + + return okAsync(updatedForm.form_fields) + }) +} + /** * Updates form fields of given form depending on the given editFormFieldParams * @param originalForm the original form to update form fields for @@ -427,6 +556,22 @@ export const editFormFields = ( IPopulatedForm, EditFieldError | ReturnType > => { + // TODO(#1210): Remove this function when no longer being called. + if ( + [EditFieldActions.Create, EditFieldActions.Update].includes( + editFormFieldParams.action.name, + ) + ) { + logger.info({ + message: 'deprecated editFormFields functions are still being used', + meta: { + action: 'editFormFields', + fieldAction: editFormFieldParams.action.name, + field: editFormFieldParams.field, + }, + }) + } + // TODO(#815): Split out this function into their own separate service functions depending on the update type. return getUpdatedFormFields( originalForm.form_fields, @@ -538,3 +683,52 @@ export const updateFormSettings = ( return okAsync(updatedForm.getSettings()) }) } + +/** + * Deletes form logic. + * @param form The original form to delete logic in + * @param logicId the logicId to delete + * @returns ok(true) on success + * @returns err(database errors) if db error is thrown during logic delete + * @returns err(LogicNotFoundError) if logicId does not exist on form + */ +export const deleteFormLogic = ( + form: IPopulatedForm, + logicId: string, +): ResultAsync => { + // First check if specified logic exists + if (!form.form_logics.some((logic) => logic.id === logicId)) { + logger.error({ + message: 'Error occurred - logicId to be deleted does not exist', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + }) + return errAsync(new LogicNotFoundError()) + } + + // Remove specified logic and then update form logic + return ResultAsync.fromPromise( + FormModel.deleteFormLogic(String(form._id), logicId), + (error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + error, + }) + return transformMongoError(error) + }, + // On success, return true + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + return okAsync(updatedForm) + }) +} diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 6f6009a3b4..57c6788265 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes' import { err, ok, Result } from 'neverthrow' import { EditFieldActions } from '../../../../shared/constants' +import { reorder, replaceAt } from '../../../../shared/util/immutable-array-fns' import { IFieldSchema, IPopulatedForm, @@ -9,7 +10,7 @@ import { Status, } from '../../../../types' import { createLoggerWithLabel } from '../../../config/logger' -import { reorder, replaceAt } from '../../../utils/immutable-array-fns' +import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' import { ApplicationError, DatabaseConflictError, @@ -24,6 +25,7 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../form.errors' @@ -31,6 +33,7 @@ import { import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from './admin-form.errors' import { @@ -55,18 +58,23 @@ export const mapRouteError = ( coreErrorMessage?: string, ): ErrorResponseData => { switch (error.constructor) { - case EditFieldError: case InvalidFileTypeError: case CreatePresignedUrlError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, } + case FieldNotFoundError: case FormNotFoundError: return { statusCode: StatusCodes.NOT_FOUND, errorMessage: error.message, } + case LogicNotFoundError: + return { + statusCode: StatusCodes.NOT_FOUND, + errorMessage: error.message, + } case FormDeletedError: return { statusCode: StatusCodes.GONE, @@ -78,6 +86,7 @@ export const mapRouteError = ( statusCode: StatusCodes.FORBIDDEN, errorMessage: error.message, } + case EditFieldError: case DatabaseValidationError: case MissingUserError: return { @@ -345,11 +354,25 @@ const reorderField = ( * @returns err(EditFieldError) if any errors occur whilst updating fields */ export const getUpdatedFormFields = ( - currentFormFields: IPopulatedForm['form_fields'], + currentFormFields: IFieldSchema[], editFieldParams: EditFormFieldParams, ): EditFormFieldResult => { const { field: fieldToUpdate, action } = editFieldParams + // TODO(#1210): Remove this function when no longer being called. + // Sync states for backwards compatibility with old clients send inconsistent + // email fields + if (isPossibleEmailFieldSchema(fieldToUpdate)) { + if (fieldToUpdate.hasAllowedEmailDomains === false) { + fieldToUpdate.allowedEmailDomains = [] + } else { + fieldToUpdate.hasAllowedEmailDomains = fieldToUpdate.allowedEmailDomains + ?.length + ? fieldToUpdate.allowedEmailDomains.length > 0 + : false + } + } + switch (action.name) { // Duplicate is just an alias of create for the use case. case EditFieldActions.Create: diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index 65ea70cfeb..38b9382d6e 100644 --- a/src/app/modules/form/form.errors.ts +++ b/src/app/modules/form/form.errors.ts @@ -1,3 +1,5 @@ +import { AuthType } from 'src/types' + import { ApplicationError } from '../core/core.errors' export class FormNotFoundError extends ApplicationError { @@ -51,3 +53,35 @@ export class TransferOwnershipError extends ApplicationError { super(message) } } + +/** + * Error to be returned when form logic cannot be found + */ +export class LogicNotFoundError extends ApplicationError { + constructor(message = 'logicId does not exist on form') { + super(message) + } +} + +/** + * Error to be returned when the form's auth type does not match what is required + */ +export class AuthTypeMismatchError extends ApplicationError { + constructor(attemptedAuthType: AuthType, formAuthType?: AuthType) { + super( + `Attempted authentication type ${attemptedAuthType} did not match form auth type ${formAuthType}`, + ) + } +} + +/** + * Error to be returned when the form has authentication enabled but is lacking an eServiceId + */ + +export class FormAuthNoEsrvcIdError extends ApplicationError { + constructor(formId: string) { + super( + `Attempted to validate form ${formId} whhich did not have an eServiceId`, + ) + } +} diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index dd668230a7..916d7d83ad 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -112,6 +112,39 @@ export const retrieveFullFormById = ( }) } +/** + * Retrieves the specified form fields of the given formId + * @param formId the id of the form + * @param fields an array of field names to retrieve + * @returns ok(form) if form exists + * @returns err(FormNotFoundError) if the form or form admin does not exist + * @returns err(DatabaseError) if error occurs whilst querying the database + */ +export const retrieveFormKeysById = ( + formId: string, + fields: (keyof IPopulatedForm)[], +): ResultAsync => { + if (!mongoose.Types.ObjectId.isValid(formId)) { + return errAsync(new FormNotFoundError()) + } + + return ResultAsync.fromPromise( + FormModel.getFullFormById(formId, fields), + (error) => { + logger.error({ + message: 'Error retrieving form from database', + meta: { + action: 'retrieveFormKeysById', + }, + error, + }) + return new DatabaseError() + }, + ).andThen((result) => + result ? okAsync(result) : errAsync(new FormNotFoundError()), + ) +} + /** * Retrieves (non-populated) form document of the given formId. * @param formId the id of the form to retrieve diff --git a/src/app/modules/form/form.utils.ts b/src/app/modules/form/form.utils.ts index 3e28efb482..f914221f31 100644 --- a/src/app/modules/form/form.utils.ts +++ b/src/app/modules/form/form.utils.ts @@ -1,11 +1,13 @@ import { IEncryptedFormSchema, + IFieldSchema, IFormSchema, IPopulatedEmailForm, IPopulatedForm, Permission, ResponseMode, } from '../../../types' +import { isMongooseDocumentArray } from '../../utils/mongoose' // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] export const transformEmailString = (v: string): string[] => { @@ -76,3 +78,24 @@ export const isEmailModeForm = ( ): form is IPopulatedEmailForm => { return form.responseMode === ResponseMode.Email } + +/** + * Finds and returns form field in given form by its id + * @param formFields the form fields to search from + * @param fieldId the id of the field to retrieve + * @returns the form field if found, `null` otherwise + */ +export const getFormFieldById = ( + formFields: IFormSchema['form_fields'], + fieldId: IFieldSchema['_id'], +): IFieldSchema | null => { + if (!formFields) { + return null + } + + if (isMongooseDocumentArray(formFields)) { + return formFields.id(fieldId) + } + + return formFields.find((f) => fieldId === String(f._id)) ?? null +} 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 2f164208af..f1b4149f60 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 @@ -14,15 +14,17 @@ import { } 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 { + MyInfoCookieState, + MyInfoForm, +} from 'src/app/modules/myinfo/myinfo.types' +import { JwtPayload, SpcpForm } from 'src/app/modules/spcp/spcp.types' import { AuthType, + IFormSchema, IPopulatedForm, IPopulatedUser, MyInfoAttribute, @@ -34,9 +36,14 @@ 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 { + CreateRedirectUrlError, + MissingJwtError, +} from '../../../spcp/spcp.errors' import { SpcpFactory } from '../../../spcp/spcp.factory' import { + AuthTypeMismatchError, + FormAuthNoEsrvcIdError, FormDeletedError, FormNotFoundError, PrivateFormError, @@ -731,7 +738,7 @@ describe('public-form.controller', () => { }) MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( - errAsync(new MyInfoAuthTypeError()), + errAsync(new AuthTypeMismatchError()), ) // Act @@ -758,7 +765,7 @@ describe('public-form.controller', () => { }) MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( - errAsync(new MyInfoNoESrvcIdError()), + errAsync(new FormAuthNoEsrvcIdError()), ) // Act @@ -1138,4 +1145,347 @@ describe('public-form.controller', () => { }) }) }) + + describe('_handleFormAuthRedirect', () => { + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: new ObjectId().toHexString(), + }, + query: { + isPersistentLogin: true, + }, + }) + const MOCK_REDIRECT_URL = 'www.mockata.com' + + it('should return 200 with the redirect url when the request is valid and the form has authType SP', async () => { + // Arrange + const MOCK_FORM = { + authType: AuthType.SP, + esrvcId: '12345', + } as SpcpForm + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockSpcpFactory.createRedirectUrl.mockReturnValueOnce( + ok(MOCK_REDIRECT_URL), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(200) + expect(mockRes.json).toBeCalledWith({ redirectURL: MOCK_REDIRECT_URL }) + }) + + it('should return 200 with the redirect url when the request is valid, form has authType SP and isPersistentLogin is undefined', async () => { + // Arrange + const MOCK_REQ_WITHOUT_PERSISTENT_LOGIN = expressHandler.mockRequest({ + params: { + formId: new ObjectId().toHexString(), + }, + }) + const MOCK_FORM = { + authType: AuthType.SP, + esrvcId: '12345', + } as SpcpForm + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockSpcpFactory.createRedirectUrl.mockReturnValueOnce( + ok(MOCK_REDIRECT_URL), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ_WITHOUT_PERSISTENT_LOGIN, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(200) + expect(mockRes.json).toBeCalledWith({ redirectURL: MOCK_REDIRECT_URL }) + }) + + it('should return 200 with the redirect url when the request is valid, form has authType SP and isPersistentLogin is false', async () => { + // Arrange + const MOCK_REQ_WITH_FALSE_PERSISTENT_LOGIN = expressHandler.mockRequest({ + params: { + formId: new ObjectId().toHexString(), + }, + query: { + isPersistentLogin: false, + }, + }) + const MOCK_FORM = { + authType: AuthType.SP, + esrvcId: '12345', + } as SpcpForm + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockSpcpFactory.createRedirectUrl.mockReturnValueOnce( + ok(MOCK_REDIRECT_URL), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ_WITH_FALSE_PERSISTENT_LOGIN, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(200) + expect(mockRes.json).toBeCalledWith({ redirectURL: MOCK_REDIRECT_URL }) + }) + + it('should return 200 with the redirect url when the request is valid and the form has authType CP', async () => { + // Arrange + const MOCK_FORM = { + authType: AuthType.CP, + esrvcId: '12345', + } as SpcpForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockSpcpFactory.createRedirectUrl.mockReturnValueOnce( + ok(MOCK_REDIRECT_URL), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(200) + expect(mockRes.json).toBeCalledWith({ redirectURL: MOCK_REDIRECT_URL }) + }) + + it('should return 200 with the redirect url when the request is valid and the form has authType MyInfo', async () => { + // Arrange + const MOCK_FORM = ({ + authType: AuthType.MyInfo, + esrvcId: '12345', + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + } as unknown) as MyInfoForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockMyInfoFactory.createRedirectURL.mockReturnValueOnce( + ok(MOCK_REDIRECT_URL), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(200) + expect(mockRes.json).toBeCalledWith({ redirectURL: MOCK_REDIRECT_URL }) + }) + + it('should return 400 when the form has authType NIL', async () => { + // Arrange + const MOCK_FORM = { + authType: AuthType.NIL, + esrvcId: '12345', + } + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(400) + expect(mockRes.json).toBeCalledWith({ + message: + 'Please ensure that the form has authentication enabled. Please refresh and try again.', + }) + }) + + it('should return 400 when the form has authType MyInfo and is missing esrvcId', async () => { + // Arrange + const MOCK_FORM = ({ + authType: AuthType.MyInfo, + } as unknown) as MyInfoForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(400) + expect(mockRes.json).toBeCalledWith({ + message: + 'This form does not have a valid eServiceId. Please refresh and try again.', + }) + }) + + it('should return 400 when the form has authType SP and is missing esrvcId', async () => { + // Arrange + const MOCK_FORM = ({ + authType: AuthType.SP, + } as unknown) as SpcpForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(400) + expect(mockRes.json).toBeCalledWith({ + message: + 'This form does not have a valid eServiceId. Please refresh and try again.', + }) + }) + + it('should return 400 when the form has authType CP and is missing esrvcId', async () => { + // Arrange + const MOCK_FORM = ({ + authType: AuthType.CP, + } as unknown) as SpcpForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(400) + expect(mockRes.json).toBeCalledWith({ + message: + 'This form does not have a valid eServiceId. Please refresh and try again.', + }) + }) + + it('should return 500 when the form could not be retrieved from the database', async () => { + // Arrange + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(500) + expect(mockRes.json).toBeCalledWith({ + message: 'Sorry, something went wrong. Please try again.', + }) + }) + + it('should return 500 when the redirectURL could not be created', async () => { + // Arrange + const MOCK_FORM = ({ + esrvcId: '234', + authType: AuthType.CP, + } as unknown) as SpcpForm + + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockSpcpFactory.createRedirectUrl.mockReturnValueOnce( + err(new CreateRedirectUrlError()), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(500) + expect(mockRes.json).toBeCalledWith({ + message: 'Sorry, something went wrong. Please try again.', + }) + }) + + it('should return 500 when the redirectURL feature is not implemented', async () => { + // Arrange + const MOCK_FORM = ({ + esrvcId: '234', + authType: AuthType.MyInfo, + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + } as unknown) as SpcpForm + const mockRes = expressHandler.mockResponse() + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + MockMyInfoFactory.createRedirectURL.mockReturnValueOnce( + err(new MissingFeatureError('Redirect url')), + ) + + // Act + await PublicFormController._handleFormAuthRedirect( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toBeCalledWith(500) + expect(mockRes.json).toBeCalledWith({ + message: 'Sorry, something went wrong. Please try again.', + }) + }) + }) }) 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 5b3d4b762a..aa1542b1b3 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,11 +1,17 @@ import { celebrate, Joi, Segments } from 'celebrate' import { RequestHandler } from 'express' +import { Query } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' +import { err } from 'neverthrow' import querystring from 'querystring' import { UnreachableCaseError } from 'ts-essentials' import { AuthType } from '../../../../types' -import { ErrorDto, PrivateFormErrorDto } from '../../../../types/api' +import { + ErrorDto, + PrivateFormErrorDto, + PublicFormAuthRedirectDto, +} from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' import { isMongoError } from '../../../utils/handle-mongo-error' import { createReqMeta, getRequestIp } from '../../../utils/request' @@ -19,15 +25,19 @@ import { MyInfoMissingAccessTokenError, } from '../../myinfo/myinfo.errors' import { MyInfoFactory } from '../../myinfo/myinfo.factory' -import { extractAndAssertMyInfoCookieValidity } from '../../myinfo/myinfo.util' +import { + extractAndAssertMyInfoCookieValidity, + validateMyInfoForm, +} from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' -import { PrivateFormError } from '../form.errors' +import { getRedirectTarget, validateSpcpForm } from '../../spcp/spcp.util' +import { AuthTypeMismatchError, PrivateFormError } from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' import { PublicFormViewDto, RedirectParams } from './public-form.types' -import { mapRouteError } from './public-form.utils' +import { mapFormAuthRedirectError, mapRouteError } from './public-form.utils' const logger = createLoggerWithLabel(module) @@ -350,3 +360,94 @@ export const handleGetPublicForm: RequestHandler< return new UnreachableCaseError(authType) } } + +/** + * NOTE: This is exported only for testing + * Generates redirect URL to Official SingPass/CorpPass log in page + * @param isPersistentLogin whether the client wants to have their login information stored + * @returns 200 with the redirect url when the user authenticates successfully + * @returns 400 when there is an error on the authType of the form + * @returns 400 when the eServiceId of the form does not exist + * @returns 404 when form with given ID does not exist + * @returns 500 when database error occurs + * @returns 500 when the redirect url could not be created + * @returns 500 when the redirect feature is not enabled + */ +export const _handleFormAuthRedirect: RequestHandler< + { formId: string }, + PublicFormAuthRedirectDto | ErrorDto, + unknown, + Query & { isPersistentLogin?: boolean } +> = (req, res) => { + const { formId } = req.params + const { isPersistentLogin } = req.query + const logMeta = { + action: 'handleFormAuthRedirect', + ...createReqMeta(req), + formId, + } + // NOTE: Using retrieveFullForm instead of retrieveForm to ensure authType always exists + return FormService.retrieveFullFormById(formId) + .andThen((form) => { + switch (form.authType) { + case AuthType.MyInfo: + return validateMyInfoForm(form).andThen((form) => + MyInfoFactory.createRedirectURL({ + formEsrvcId: form.esrvcId, + formId, + requestedAttributes: form.getUniqueMyInfoAttrs(), + }), + ) + case AuthType.SP: + case AuthType.CP: { + // NOTE: Persistent login is only set (and relevant) when the authType is SP. + // If authType is not SP, assume that it was set erroneously and default it to false + return validateSpcpForm(form).andThen((form) => { + const target = getRedirectTarget( + formId, + form.authType, + isPersistentLogin, + ) + return SpcpFactory.createRedirectUrl( + form.authType, + target, + form.esrvcId, + ) + }) + } + // NOTE: Only MyInfo and SPCP should have redirects as the point of a redirect is + // to provide auth for users from a third party + default: + return err( + new AuthTypeMismatchError(form.authType), + ) + } + }) + .map((redirectURL) => { + return res.status(StatusCodes.OK).json({ redirectURL }) + }) + .mapErr((error) => { + logger.error({ + message: 'Error while creating redirect URL', + meta: logMeta, + error, + }) + const { statusCode, errorMessage } = mapFormAuthRedirectError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) +} + +/** + * Handler for /forms/:formId/auth/redirect + */ +export const handleFormAuthRedirect = [ + celebrate({ + [Segments.PARAMS]: Joi.object({ + formId: Joi.string().pattern(/^[a-fA-F0-9]{24}$/), + }), + [Segments.QUERY]: Joi.object({ + isPersistentLogin: Joi.boolean().optional(), + }), + }), + _handleFormAuthRedirect, +] as RequestHandler[] diff --git a/src/app/modules/form/public-form/public-form.utils.ts b/src/app/modules/form/public-form/public-form.utils.ts index bf7b73f574..e52d8ab9cc 100644 --- a/src/app/modules/form/public-form/public-form.utils.ts +++ b/src/app/modules/form/public-form/public-form.utils.ts @@ -1,8 +1,15 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes' +import { MapRouteError } from 'src/types' + import { createLoggerWithLabel } from '../../../config/logger' -import { ApplicationError, DatabaseError } from '../../core/core.errors' +import { + ApplicationError, + DatabaseError, + MissingFeatureError, +} from '../../core/core.errors' import { ErrorResponseData } from '../../core/core.types' +import { CreateRedirectUrlError } from '../../spcp/spcp.errors' import * as FormErrors from '../form.errors' const logger = createLoggerWithLabel(module) @@ -53,3 +60,53 @@ export const mapRouteError = ( } } } + +/** + * Handler to map ApplicationErrors from redirecting to SPCP login page to HTTP errors + * @param error The error to retrieve the status codes and error messages + * @param coreErrorMessage Any error message to return instead of the default core error message, if any + */ +export const mapFormAuthRedirectError: MapRouteError = ( + error, + coreErrorMessage = 'Sorry, something went wrong. Please try again.', +) => { + switch (error.constructor) { + case FormErrors.FormNotFoundError: + return { + statusCode: StatusCodes.NOT_FOUND, + errorMessage: + 'Could not find the form requested. Please refresh and try again.', + } + case FormErrors.FormAuthNoEsrvcIdError: + return { + statusCode: StatusCodes.BAD_REQUEST, + errorMessage: + 'This form does not have a valid eServiceId. Please refresh and try again.', + } + case FormErrors.AuthTypeMismatchError: + return { + statusCode: StatusCodes.BAD_REQUEST, + errorMessage: + 'Please ensure that the form has authentication enabled. Please refresh and try again.', + } + case DatabaseError: + case CreateRedirectUrlError: + case MissingFeatureError: + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: coreErrorMessage, + } + default: + logger.error({ + message: 'Unknown route error observed', + meta: { + action: 'mapRouteError', + }, + error, + }) + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: 'Sorry, something went wrong. Please try again.', + } + } +} diff --git a/src/app/modules/myinfo/__tests__/myinfo.routes.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.routes.spec.ts index ed607a5f9e..0e1c751506 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.routes.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.routes.spec.ts @@ -1,4 +1,4 @@ -import { MyInfoGovClient } from '@opengovsg/myinfo-gov-client' +import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' import SPCPAuthClient from '@opengovsg/spcp-auth-client' import axios from 'axios' import { ObjectId } from 'bson' @@ -30,7 +30,10 @@ jest.mock('@opengovsg/spcp-auth-client') const MockAuthClient = mocked(SPCPAuthClient, true) jest.mock('@opengovsg/myinfo-gov-client', () => ({ - MyInfoGovClient: jest.fn(), + MyInfoGovClient: jest.fn().mockReturnValue({ + createRedirectURL: jest.fn(), + getAccessToken: jest.fn(), + }), MyInfoMode: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoMode, MyInfoSource: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoSource, MyInfoAddressType: jest.requireActual('@opengovsg/myinfo-gov-client') @@ -38,15 +41,9 @@ jest.mock('@opengovsg/myinfo-gov-client', () => ({ MyInfoAttribute: jest.requireActual('@opengovsg/myinfo-gov-client') .MyInfoAttribute, })) -const MockMyInfoGovClient = mocked(MyInfoGovClient, true) -const mockCreateRedirectURL = jest.fn() -const mockGetAccessToken = jest.fn() -MockMyInfoGovClient.mockImplementation( - () => - (({ - createRedirectURL: mockCreateRedirectURL, - getAccessToken: mockGetAccessToken, - } as unknown) as MyInfoGovClient), +const MockMyInfoGovClient = mocked( + new MyInfoClient.MyInfoGovClient({} as IMyInfoConfig), + true, ) // Import last so that mocks are imported correctly @@ -68,7 +65,7 @@ describe('myinfo.routes', () => { let request: Session beforeAll(() => { - mockCreateRedirectURL.mockReturnValue(MOCK_REDIRECT_URL) + MockMyInfoGovClient.createRedirectURL.mockReturnValue(MOCK_REDIRECT_URL) }) beforeEach(async () => { @@ -178,7 +175,7 @@ describe('myinfo.routes', () => { it('should return 200 with isValid false and errorCode when e-service ID is invalid', async () => { MockAxios.get.mockResolvedValueOnce({ - data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, + data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, }) const response = await request.get(ROUTE).query({ formId: MOCK_FORM_ID }) @@ -218,7 +215,9 @@ describe('myinfo.routes', () => { beforeEach(async () => { request = session(myInfoApp) - mockGetAccessToken.mockResolvedValueOnce(MOCK_ACCESS_TOKEN) + MockMyInfoGovClient.getAccessToken.mockResolvedValueOnce( + MOCK_ACCESS_TOKEN, + ) await dbHandler.insertEmailForm({ formId: new ObjectId(MOCK_FORM_ID), formOptions: { @@ -346,8 +345,8 @@ describe('myinfo.routes', () => { it('should redirect to form with error cookie when access token cannot be retrieved', async () => { // Clear default mock implementation from beforeEach - mockGetAccessToken.mockReset() - mockGetAccessToken.mockRejectedValueOnce('rejected') + MockMyInfoGovClient.getAccessToken.mockReset() + MockMyInfoGovClient.getAccessToken.mockRejectedValueOnce('rejected') const response = await request .get(ROUTE) diff --git a/src/app/modules/myinfo/myinfo.errors.ts b/src/app/modules/myinfo/myinfo.errors.ts index f149653498..1f1cea6054 100644 --- a/src/app/modules/myinfo/myinfo.errors.ts +++ b/src/app/modules/myinfo/myinfo.errors.ts @@ -56,27 +56,6 @@ export class MyInfoParseRelayStateError extends ApplicationError { } } -/** - * Attempt to perform a MyInfo-related operation on a form without MyInfo - * authentication enabled. - */ -export class MyInfoAuthTypeError extends ApplicationError { - constructor( - message = 'MyInfo function called on form without MyInfo authentication type', - ) { - super(message) - } -} - -/** - * MyInfo form missing e-service ID. - */ -export class MyInfoNoESrvcIdError extends ApplicationError { - constructor(message = 'Form does not have e-service ID') { - super(message) - } -} - /** * Submission on MyInfo form missing access token. */ diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 479e4113e9..d4e66be084 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -13,11 +13,14 @@ import FeatureManager, { RegisteredFeature, } from '../../config/feature-manager' import { DatabaseError, MissingFeatureError } from '../core/core.errors' +import { + AuthTypeMismatchError, + FormAuthNoEsrvcIdError, +} from '../form/form.errors' import { ProcessedFieldResponse } from '../submission/submission.types' import { MyInfoData } from './myinfo.adapter' import { - MyInfoAuthTypeError, MyInfoCircuitBreakerError, MyInfoCookieAccessError, MyInfoCookieStateError, @@ -27,7 +30,6 @@ import { MyInfoInvalidAccessTokenError, MyInfoMissingAccessTokenError, MyInfoMissingHashError, - MyInfoNoESrvcIdError, MyInfoParseRelayStateError, } from './myinfo.errors' import { MyInfoService } from './myinfo.service' @@ -91,8 +93,8 @@ interface IMyInfoFactory { MyInfoData, | MyInfoMissingAccessTokenError | MyInfoCookieStateError - | MyInfoNoESrvcIdError - | MyInfoAuthTypeError + | FormAuthNoEsrvcIdError + | AuthTypeMismatchError | MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError diff --git a/src/app/modules/myinfo/myinfo.routes.ts b/src/app/modules/myinfo/myinfo.routes.ts index e147206e70..3420c6b21d 100644 --- a/src/app/modules/myinfo/myinfo.routes.ts +++ b/src/app/modules/myinfo/myinfo.routes.ts @@ -12,6 +12,7 @@ export const MyInfoRouter = Router() /** * Serves requests to supply a redirect URL to log in to * MyInfo. + * @deprecated in favour of GET /api/v3/forms/:formId/auth/redirect */ MyInfoRouter.get('/redirect', handleRedirectURLRequest) diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 020aeb4543..dae865966a 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -20,6 +20,10 @@ import { } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' import { DatabaseError, MissingFeatureError } from '../core/core.errors' +import { + AuthTypeMismatchError, + FormAuthNoEsrvcIdError, +} from '../form/form.errors' import { ProcessedFieldResponse } from '../submission/submission.types' import { internalAttrListToScopes, MyInfoData } from './myinfo.adapter' @@ -29,7 +33,6 @@ import { MYINFO_ROUTER_PREFIX, } from './myinfo.constants' import { - MyInfoAuthTypeError, MyInfoCircuitBreakerError, MyInfoCookieAccessError, MyInfoCookieStateError, @@ -39,7 +42,6 @@ import { MyInfoInvalidAccessTokenError, MyInfoMissingAccessTokenError, MyInfoMissingHashError, - MyInfoNoESrvcIdError, MyInfoParseRelayStateError, } from './myinfo.errors' import { @@ -484,8 +486,8 @@ export class MyInfoService { * @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(FormAuthNoEsrvcIdError) if form has no eserviceId + * @returns err(AuthTypeMismatchError) 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 @@ -497,8 +499,8 @@ export class MyInfoService { MyInfoData, | MyInfoMissingAccessTokenError | MyInfoCookieStateError - | MyInfoNoESrvcIdError - | MyInfoAuthTypeError + | FormAuthNoEsrvcIdError + | AuthTypeMismatchError | MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index d472230e07..d28b697f7c 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -69,7 +69,7 @@ export type MyInfoParsedRelayState = MyInfoRelayState & { cookieDuration: number } -export interface IMyInfoForm extends IFormSchema { +export type MyInfoForm = T & { authType: AuthType.MyInfo esrvcId: string } diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 87629fa864..ecb97642a0 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -12,13 +12,16 @@ import { IFormSchema, IHashes, IMyInfo, - IPopulatedForm, MapRouteError, } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' import { hasProp } from '../../utils/has-prop' import { DatabaseError, MissingFeatureError } from '../core/core.errors' -import { FormNotFoundError } from '../form/form.errors' +import { + AuthTypeMismatchError, + FormAuthNoEsrvcIdError, + FormNotFoundError, +} from '../form/form.errors' import { CreateRedirectUrlError, FetchLoginPageError, @@ -28,21 +31,19 @@ import { ProcessedFieldResponse } from '../submission/submission.types' import { MYINFO_COOKIE_NAME } from './myinfo.constants' import { - MyInfoAuthTypeError, MyInfoCookieAccessError, MyInfoCookieStateError, MyInfoHashDidNotMatchError, MyInfoHashingError, MyInfoMissingAccessTokenError, MyInfoMissingHashError, - MyInfoNoESrvcIdError, } from './myinfo.errors' import { - IMyInfoForm, IPossiblyPrefilledField, MyInfoComparePromises, MyInfoCookiePayload, MyInfoCookieState, + MyInfoForm, MyInfoHashPromises, MyInfoRelayState, MyInfoSuccessfulCookiePayload, @@ -176,7 +177,7 @@ export const mapRedirectURLError: MapRouteError = ( errorMessage: 'Could not find the form requested. Please refresh and try again.', } - case MyInfoAuthTypeError: + case AuthTypeMismatchError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: @@ -219,13 +220,13 @@ export const mapEServiceIdCheckError: MapRouteError = ( errorMessage: 'Could not find the form requested. Please refresh and try again.', } - case MyInfoAuthTypeError: + case AuthTypeMismatchError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: 'This form does not have MyInfo enabled. Please refresh and try again.', } - case MyInfoNoESrvcIdError: + case FormAuthNoEsrvcIdError: return { statusCode: StatusCodes.FORBIDDEN, errorMessage: @@ -285,16 +286,23 @@ export const createRelayState = (formId: string): string => * Validates that a form is a MyInfo form with an e-service ID * @param form Form to validate */ -export const validateMyInfoForm = ( - form: IFormSchema | IPopulatedForm, -): Result => { +export const validateMyInfoForm = ( + form: T, +): Result, FormAuthNoEsrvcIdError | AuthTypeMismatchError> => { if (!form.esrvcId) { - return err(new MyInfoNoESrvcIdError()) + return err(new FormAuthNoEsrvcIdError(form._id)) } - if (form.authType !== AuthType.MyInfo) { - return err(new MyInfoAuthTypeError()) + if (isMyInfoFormWithEsrvcId(form)) { + return ok(form) } - return ok(form as IMyInfoForm) + return err(new AuthTypeMismatchError(AuthType.MyInfo, form.authType)) +} + +// Typeguard to ensure that form has eserviceId and MyInfo authType +const isMyInfoFormWithEsrvcId = ( + form: F, +): form is MyInfoForm => { + return form.authType === AuthType.MyInfo && !!form.esrvcId } /** diff --git a/src/app/modules/spcp/__tests__/spcp.routes.spec.ts b/src/app/modules/spcp/__tests__/spcp.routes.spec.ts index 028ce40b8f..f8c9f441a2 100644 --- a/src/app/modules/spcp/__tests__/spcp.routes.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.routes.spec.ts @@ -236,7 +236,7 @@ describe('spcp.routes', () => { it('should return 200 with isValid false and errorCode when Singpass request is invalid', async () => { MockAxios.get.mockResolvedValueOnce({ - data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, + data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, }) const response = await request .get(ROUTE) @@ -251,7 +251,7 @@ describe('spcp.routes', () => { it('should return 200 with isValid false and errorCode when Corppass request is invalid', async () => { MockAxios.get.mockResolvedValueOnce({ - data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, + data: `ErrorSystem Code: ${MOCK_ERROR_CODE}`, }) const response = await request .get(ROUTE) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 2d72705590..dde6630c65 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -199,7 +199,7 @@ describe('spcp.service', () => { it('should return error code when there is error in title', () => { const spcpService = new SpcpService(MOCK_PARAMS) - const mockHtml = `ErrorSystem Code: ${MOCK_ERROR_CODE}` + const mockHtml = `ErrorSystem Code: ${MOCK_ERROR_CODE}` const result = spcpService.validateLoginPage(mockHtml) expect(result._unsafeUnwrap()).toEqual({ isValid: false, diff --git a/src/app/modules/spcp/__tests__/spcp.test.constants.ts b/src/app/modules/spcp/__tests__/spcp.test.constants.ts index 10dbe981c8..bb9234c51f 100644 --- a/src/app/modules/spcp/__tests__/spcp.test.constants.ts +++ b/src/app/modules/spcp/__tests__/spcp.test.constants.ts @@ -48,7 +48,7 @@ export const MOCK_REDIRECT_URL = 'redirectUrl' export const MOCK_REMEMBER_ME = true export const MOCK_RELAY_STATE = `${MOCK_DESTINATION},${MOCK_REMEMBER_ME}` export const MOCK_LOGIN_HTML = 'html' -export const MOCK_ERROR_CODE = 'errorCode' +export const MOCK_ERROR_CODE = '138' export const MOCK_TITLE = 'title' export const MOCK_JWT = 'jwt' diff --git a/src/app/modules/spcp/spcp.errors.ts b/src/app/modules/spcp/spcp.errors.ts index 7a523de4db..f905e97c57 100644 --- a/src/app/modules/spcp/spcp.errors.ts +++ b/src/app/modules/spcp/spcp.errors.ts @@ -1,4 +1,3 @@ -import { AuthType } from '../../../types' import { ApplicationError } from '../../modules/core/core.errors' /** @@ -55,17 +54,6 @@ export class RetrieveAttributesError extends ApplicationError { } } -/** - * Form auth type did not match attempted auth method. - */ -export class AuthTypeMismatchError extends ApplicationError { - constructor(attemptedAuthType: AuthType, formAuthType?: AuthType) { - super( - `Attempted authentication type ${attemptedAuthType} did not match form auth type ${formAuthType}`, - ) - } -} - /** * Attributes given by SP/CP did not contain NRIC or entity ID/UID. */ diff --git a/src/app/modules/spcp/spcp.routes.ts b/src/app/modules/spcp/spcp.routes.ts index af475cbfbb..2dc929ebc1 100644 --- a/src/app/modules/spcp/spcp.routes.ts +++ b/src/app/modules/spcp/spcp.routes.ts @@ -14,6 +14,7 @@ export const SpcpRouter = Router() * Provide a URL that web agents are obliged to redirect users to. * Due to cross-origin restrictions in place by SingPass/CorpPass, * this endpoint cannot and should not issue a 302 redirect + * @deprecated in favour of GET /api/v3/forms/:formId/auth/redirect * @route GET /spcp/redirect * @group SPCP - SingPass/CorpPass logins for form-fillers * @param target.query.required - the destination URL after login diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index 9c193347c6..68499d61ea 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -178,8 +178,8 @@ export class SpcpService { } else { // The error page should have text like 'System Code: 138' const errorCode = getSubstringBetween( - loginHtml, - 'System Code: ', + loginHtml.toLowerCase(), + 'system code: ', '', ) logger.warn({ diff --git a/src/app/modules/spcp/spcp.types.ts b/src/app/modules/spcp/spcp.types.ts index a13976278a..117676b227 100644 --- a/src/app/modules/spcp/spcp.types.ts +++ b/src/app/modules/spcp/spcp.types.ts @@ -1,3 +1,5 @@ +import { AuthType, IFormSchema } from '../../../types' + export enum JwtName { SP = 'jwtSp', CP = 'jwtCp', @@ -43,3 +45,8 @@ export interface ParsedSpcpParams { rememberMe: boolean cookieDuration: number } + +export type SpcpForm = T & { + authType: AuthType.SP | AuthType.CP + esrvcId: string +} diff --git a/src/app/modules/spcp/spcp.util.ts b/src/app/modules/spcp/spcp.util.ts index c802aded61..fde8713544 100644 --- a/src/app/modules/spcp/spcp.util.ts +++ b/src/app/modules/spcp/spcp.util.ts @@ -1,11 +1,22 @@ import SPCPAuthClient from '@opengovsg/spcp-auth-client' import crypto from 'crypto' import { StatusCodes } from 'http-status-codes' +import { err, ok, Result } from 'neverthrow' -import { BasicField, MapRouteError, SPCPFieldTitle } from '../../../types' +import { + AuthType, + BasicField, + IFormSchema, + MapRouteError, + SPCPFieldTitle, +} from '../../../types' import { createLoggerWithLabel } from '../../config/logger' import { hasProp } from '../../utils/has-prop' import { MissingFeatureError } from '../core/core.errors' +import { + AuthTypeMismatchError, + FormAuthNoEsrvcIdError, +} from '../form/form.errors' import { ProcessedSingleAnswerResponse } from '../submission/submission.types' import { @@ -16,7 +27,7 @@ import { MissingJwtError, VerifyJwtError, } from './spcp.errors' -import { CorppassJwtPayload, SingpassJwtPayload } from './spcp.types' +import { CorppassJwtPayload, SingpassJwtPayload, SpcpForm } from './spcp.types' const logger = createLoggerWithLabel(module) const DESTINATION_REGEX = /^\/([\w]+)\/?/ @@ -210,6 +221,32 @@ export const createCorppassParsedResponses = ( ] } +/** + * Validates that a form is a SPCP form with an e-service ID + * @param form Form to validate + */ +export const validateSpcpForm = ( + form: T, +): Result, FormAuthNoEsrvcIdError | AuthTypeMismatchError> => { + // This is an extra check to return the specific error encountered + if (!form.esrvcId) { + return err(new FormAuthNoEsrvcIdError(form.id)) + } + if (isSpcpForm(form)) { + return ok(form) + } + return err(new AuthTypeMismatchError(AuthType.CP, form.authType)) +} + +// Typeguard to ensure that form has eserviceId and correct authType +const isSpcpForm = (form: F): form is SpcpForm => { + return ( + !!form.authType && + [AuthType.SP, AuthType.CP].includes(form.authType) && + !!form.esrvcId + ) +} + /** * Maps errors to status codes and error messages to return to frontend. * @param error @@ -257,3 +294,16 @@ export const mapRouteError: MapRouteError = ( } } } + +// Generates the target to redirect to for the given form id +export const getRedirectTarget = ( + formId: string, + authType: AuthType.SP | AuthType.CP, + isPersistentLogin?: boolean, +): string => + `/${formId},${ + // Need to cast to boolean because undefined is allowed as a valid value + // We are not following corppass's official spec for + // the target parameter + authType === AuthType.SP ? !!isPersistentLogin : false + }` diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.routes.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.routes.spec.ts index b8ca2d45d9..39a36a3d67 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.routes.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.routes.spec.ts @@ -1,4 +1,4 @@ -import { MyInfoGovClient } from '@opengovsg/myinfo-gov-client' +import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' import SPCPAuthClient from '@opengovsg/spcp-auth-client' import { omit } from 'lodash' import mongoose from 'mongoose' @@ -44,7 +44,9 @@ jest.mock('nodemailer', () => ({ })) jest.mock('@opengovsg/myinfo-gov-client', () => ({ - MyInfoGovClient: jest.fn(), + MyInfoGovClient: jest.fn().mockReturnValue({ + extractUinFin: jest.fn(), + }), MyInfoMode: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoMode, MyInfoSource: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoSource, MyInfoAddressType: jest.requireActual('@opengovsg/myinfo-gov-client') @@ -52,13 +54,10 @@ jest.mock('@opengovsg/myinfo-gov-client', () => ({ MyInfoAttribute: jest.requireActual('@opengovsg/myinfo-gov-client') .MyInfoAttribute, })) -const MockMyInfoGovClient = mocked(MyInfoGovClient, true) -const mockExtractUinFin = jest.fn() -MockMyInfoGovClient.mockImplementation( - () => - (({ - extractUinFin: mockExtractUinFin, - } as unknown) as MyInfoGovClient), + +const MockMyInfoGovClient = mocked( + new MyInfoClient.MyInfoGovClient({} as IMyInfoConfig), + true, ) const SUBMISSIONS_ENDPT_BASE = '/v2/submissions/email' @@ -616,7 +615,7 @@ describe('email-submission.routes', () => { describe('MyInfo', () => { it('should return 200 when submission is valid', async () => { - mockExtractUinFin.mockReturnValueOnce(MOCK_UINFIN) + MockMyInfoGovClient.extractUinFin.mockReturnValueOnce(MOCK_UINFIN) const { form } = await dbHandler.insertEmailForm({ formOptions: { esrvcId: 'mockEsrvcId', @@ -704,7 +703,7 @@ describe('email-submission.routes', () => { it('should return 401 when submission has invalid cookie', async () => { // Mock MyInfoGovClient to return error when decoding JWT - mockExtractUinFin.mockImplementationOnce(() => { + MockMyInfoGovClient.extractUinFin.mockImplementationOnce(() => { throw new Error() }) const { form } = await dbHandler.insertEmailForm({ 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 6f0bf35461..5f3c838500 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -106,11 +106,15 @@ const getMyInfoPrefix = ( /** * Determines the prefix for a question based on whether it was verified * by a user during form submission. + * + * Verified prefixes are not added for optional fields that are left blank. * @param response * @returns the prefix */ const getVerifiedPrefix = (response: ResponseFormattedForEmail): string => { - return response.isUserVerified ? VERIFIED_PREFIX : '' + const { answer, isUserVerified } = response + const isAnswerBlank = answer === '' + return isUserVerified && !isAnswerBlank ? VERIFIED_PREFIX : '' } /** diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts index 8f3aa69f8a..56624036a2 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -30,8 +30,8 @@ import { } from '../../submission.errors' import { getEncryptedResponseUsingQueryParams, + getMetadata, handleGetEncryptedResponse, - handleGetMetadata, streamEncryptedResponses, } from '../encrypt-submission.controller' import * as EncryptSubmissionService from '../encrypt-submission.service' @@ -633,7 +633,7 @@ describe('encrypt-submission.controller', () => { }) }) - describe('handleGetMetadata', () => { + describe('getMetadata', () => { const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER_ID = new ObjectId().toHexString() @@ -684,7 +684,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -721,7 +721,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -767,7 +767,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith(expectedMetadataList) @@ -802,7 +802,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(400) @@ -837,7 +837,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(403) @@ -872,7 +872,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(404) @@ -907,7 +907,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(410) @@ -941,7 +941,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(422) @@ -982,7 +982,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -1019,7 +1019,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ 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 a79ebabbbb..64bf113dc9 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -5,7 +5,7 @@ import { Query } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' import mongoose from 'mongoose' -import { SetOptional } from 'type-fest' +import { RequireAtLeastOne, SetOptional } from 'type-fest' import { AuthType, @@ -682,7 +682,8 @@ export const handleGetEncryptedResponse: RequestHandler< } /** - * Handler for GET /:formId([a-fA-F0-9]{24})/adminform/submissions/metadata + * Handler for GET /:formId/submissions/metadata + * This is exported solely for testing purposes * * @returns 200 with single submission metadata if query.submissionId is provided * @returns 200 with list of submission metadata with total count (and optional offset if query.page is provided) if query.submissionId is not provided @@ -693,11 +694,15 @@ export const handleGetEncryptedResponse: RequestHandler< * @returns 422 when user in session cannot be retrieved from the database * @returns 500 if any errors occurs whilst querying database */ -export const handleGetMetadata: RequestHandler< +export const getMetadata: RequestHandler< { formId: string }, SubmissionMetadataList | ErrorDto, unknown, - Query & { page?: number; submissionId?: string } + Query & + RequireAtLeastOne< + { page?: number; submissionId?: string }, + 'page' | 'submissionId' + > > = async (req, res) => { const sessionUserId = (req.session as Express.AuthedSession).user._id const { formId } = req.params @@ -753,3 +758,19 @@ export const handleGetMetadata: RequestHandler< }) ) } + +// Handler for GET /:formId/submissions/metadata +export const handleGetMetadata = [ + // NOTE: If submissionId is set, then page is optional. + // Otherwise, if there is no submissionId, then page >= 1 + celebrate({ + [Segments.QUERY]: { + submissionId: Joi.string().optional(), + page: Joi.number().min(1).when('submissionId', { + not: Joi.exist(), + then: Joi.required(), + }), + }, + }), + getMetadata, +] as RequestHandler[] diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts index 03be2ea88d..e0e5485dda 100644 --- a/src/app/modules/webhook/__tests__/webhook.service.spec.ts +++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts @@ -9,7 +9,6 @@ import { getEncryptSubmissionModel } from 'src/app/models/submission.server.mode 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 { IEncryptedSubmissionSchema, IWebhookResponse, @@ -59,7 +58,6 @@ const MOCK_WEBHOOK_SUCCESS_RESPONSE: Pick = { response: { data: '{"result":"test-result"}', status: 200, - statusText: 'success', headers: '{}', }, } @@ -67,7 +65,6 @@ const MOCK_WEBHOOK_FAILURE_RESPONSE: Pick = { response: { data: '{"result":"test-result"}', status: 400, - statusText: 'failed', headers: '{}', }, } @@ -78,7 +75,6 @@ const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick< response: { data: '', status: 0, - statusText: '', headers: '', }, } @@ -305,7 +301,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: AXIOS_ERROR_MSG, ...MOCK_WEBHOOK_FAILURE_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -334,7 +329,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: DEFAULT_ERROR_MSG, ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -359,9 +353,6 @@ describe('webhook.service', () => { MockAxios.post.mockRejectedValue(mockOriginalError) MockAxios.isAxiosError.mockReturnValue(false) - const hasPropSpy = jest - .spyOn(HasPropModule, 'hasProp') - .mockReturnValueOnce(false) // Act const actual = await sendWebhook( @@ -371,7 +362,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: '', ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -380,7 +370,6 @@ describe('webhook.service', () => { expect( MockWebhookValidationModule.validateWebhookUrl, ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) - expect(hasPropSpy).toHaveBeenCalledWith(mockOriginalError, 'message') expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, testSubmissionWebhookView, diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts index 5632573728..ce4e6bf55c 100644 --- a/src/app/modules/webhook/webhook.service.ts +++ b/src/app/modules/webhook/webhook.service.ts @@ -12,7 +12,6 @@ import formsgSdk from '../../config/formsg-sdk' import { createLoggerWithLabel } from '../../config/logger' 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' @@ -159,14 +158,9 @@ export const sendWebhook = ( // 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(), @@ -177,7 +171,6 @@ export const sendWebhook = ( return okAsync({ signature, webhookUrl, - errorMessage: axiosError.message, response: formatWebhookResponse(axiosError.response), }) }) diff --git a/src/app/modules/webhook/webhook.utils.ts b/src/app/modules/webhook/webhook.utils.ts index fef9a3a545..e3331465b4 100644 --- a/src/app/modules/webhook/webhook.utils.ts +++ b/src/app/modules/webhook/webhook.utils.ts @@ -11,7 +11,6 @@ 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/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts new file mode 100644 index 0000000000..6c5cdeadaa --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts @@ -0,0 +1,199 @@ +import { ObjectId } from 'bson-ext' +import mongoose from 'mongoose' +import supertest, { Session } from 'supertest-session' + +import getUserModel from 'src/app/models/user.server.model' +import { ILogicSchema } from 'src/types' + +import { createAuthedSession } from 'tests/integration/helpers/express-auth' +import { setupApp } from 'tests/integration/helpers/express-setup' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { AdminFormsRouter } from '../admin-forms.routes' + +const UserModel = getUserModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.logic.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('DELETE /forms/:formId/logic/:logicId', () => { + it('should return 200 on successful form logic delete for email mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 200 on successful form logic delete for encrypt mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 403 when current user does not have permissions to delete form logic', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, agency } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const diffUser = await dbHandler.insertUser({ + mailName: 'newUser', + agencyId: agency._id, + }) + // Log in as different user. + const session = await createAuthedSession(diffUser.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(403) + expect(response.body).toEqual({ + message: expect.stringContaining( + 'not authorized to perform write operation', + ), + }) + }) + + it('should return 404 with error message if logicId does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const wrongLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${wrongLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'logicId does not exist on form', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 404 with error message if form does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const { user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const wrongFormId = new ObjectId() + const response = await session + .delete(`/admin/forms/${wrongFormId}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'Form not found', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 422 when userId cannot be found in the database', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Delete user after login. + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'User not found', + } + expect(response.status).toEqual(422) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts new file mode 100644 index 0000000000..8dbcdc420c --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts @@ -0,0 +1,528 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ObjectId } from 'bson-ext' +import mongoose from 'mongoose' +import SparkMD5 from 'spark-md5' +import supertest, { Session } from 'supertest-session' + +import { aws } from 'src/app/config/config' +import { getEncryptedFormModel } from 'src/app/models/form.server.model' +import getUserModel from 'src/app/models/user.server.model' +import { VALID_UPLOAD_FILE_TYPES } from 'src/shared/constants' +import { IUserSchema, ResponseMode, Status } 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 { AdminFormsRouter } from '../admin-forms.routes' + +// 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 EncryptFormModel = getEncryptedFormModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.presign.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('POST /admin/forms/:formId/images/presign', () => { + 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(`/admin/forms/${form._id}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .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(`/admin/forms/${form._id}/images/presign`) + .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(`/admin/forms/${invalidFormId}/images/presign`) + .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(`/admin/forms/${archivedForm._id}/images/presign`) + .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(`/admin/forms/${new ObjectId()}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ message: 'User not found' }) + }) + }) + + describe('POST /admin/forms/:formId/logos/presign', () => { + 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(`/admin/forms/${form._id}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .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(`/admin/forms/${form._id}/logos/presign`) + .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(`/admin/forms/${invalidFormId}/logos/presign`) + .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(`/admin/forms/${archivedForm._id}/logos/presign`) + .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(`/admin/forms/${new ObjectId()}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ message: 'User not found' }) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts new file mode 100644 index 0000000000..854d8786de --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts @@ -0,0 +1,1029 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ObjectId } from 'bson-ext' +import { omit } from 'lodash' +import mongoose from 'mongoose' +import supertest, { Session } from 'supertest-session' + +import getFormModel, { + getEmailFormModel, + getEncryptedFormModel, +} from 'src/app/models/form.server.model' +import getUserModel from 'src/app/models/user.server.model' +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 { + BasicField, + IFieldSchema, + IFormSchema, + IUserSchema, + ResponseMode, + Status, +} from 'src/types' +import { EncryptSubmissionDto } from 'src/types/api' + +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, + 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' + +import { AdminFormsRouter } from '../admin-forms.routes' + +// 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) +const EmailFormModel = getEmailFormModel(mongoose) +const EncryptFormModel = getEncryptedFormModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.preview.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 /admin/forms/:formId/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( + `/admin/forms/${formToPreview._id}/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( + `/admin/forms/${collabFormToPreview._id}/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( + `/admin/forms/${unauthedForm._id}/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( + `/admin/forms/${new ObjectId()}/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( + `/admin/forms/${archivedForm._id}/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( + `/admin/forms/${formToPreview._id}/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( + `/admin/forms/${formToPreview._id}/preview`, + ) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual({ + message: 'Something went wrong. Please try again.', + }) + }) + }) + + describe('POST /admin/forms/:formId/preview/submissions/email', () => { + const SUBMISSIONS_ENDPT_BASE = '/admin/forms' + 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}/preview/submissions/email`) + // 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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + // 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}/preview/submissions/email`) + // 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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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}/preview/submissions/email`) + .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 /admin/forms/:formId/preview/submissions/encrypt', () => { + 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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .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`, + }, + }), + ) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts index 09a625a080..4b1f5fa894 100644 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts @@ -1192,6 +1192,274 @@ describe('admin-form.submissions.routes', () => { }) }) }) + + describe('GET /:formId/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(`/admin/forms/${defaultForm._id}/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}`, + }), + ), + ) + const createdSubmissionIds = submissions.map((s) => String(s._id)) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + page: 1, + }) + + // Assert + const expected = times(10, (index) => ({ + number: 11 - index, + // 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) + // 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(`/admin/forms/${defaultForm._id}/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(`/admin/forms/${defaultForm._id}/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(`/admin/forms/${defaultForm._id}/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(`/admin/forms/${inaccessibleForm._id}/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(`/admin/forms/${invalidFormId}/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(`/admin/forms/${archivedForm._id}/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(`/admin/forms/${new ObjectId()}/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(`/admin/forms/${defaultForm._id}/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(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + submissionId: new ObjectId().toHexString(), + }) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual({ + message: expect.stringContaining('ohno'), + }) + }) + }) }) // Helper utils diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index 0f3537e76c..ef7cbe287b 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -90,3 +90,34 @@ AdminFormsFormRouter.post( '/:formId([a-fA-F0-9]{24})/collaborators/transfer-owner', AdminFormController.handleTransferFormOwnership, ) + +/** + * Update form field according to given new body. + * @route PUT /admin/forms/:formId/fields/:fieldId + * + * @param body the new field to override current field + * @returns 200 with updated form field + * @returns 400 when given body fails Joi validation + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to update form field + * @returns 404 when form cannot be found + * @returns 404 when field cannot be found + * @returns 410 when updating form field for archived form + * @returns 422 when an invalid form field update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsFormRouter.put( + '/:formId([a-fA-F0-9]{24})/fields/:fieldId([a-fA-F0-9]{24})', + AdminFormController.handleUpdateFormField, +) + +AdminFormsFormRouter.post( + '/:formId([a-fA-F0-9]{24})/fields/:fieldId([a-fA-F0-9]{24})/reorder', + AdminFormController.handleReorderFormField, +) + +AdminFormsFormRouter.post( + '/:formId([a-fA-F0-9]{24})/fields', + AdminFormController.handleCreateFormField, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts new file mode 100644 index 0000000000..595c89671f --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsLogicRouter = Router() + +/** + * Deletes a logic. + * @route DELETE /admin/forms/:formId/logic/:logicId + * @group admin + * @produces application/json + * @consumes application/json + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsLogicRouter.route( + '/:formId([a-fA-F0-9]{24})/logic/:logicId([a-fA-F0-9]{24})', +).delete(AdminFormController.handleDeleteLogic) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts new file mode 100644 index 0000000000..3565d96eb2 --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsPresignRouter = Router() + +// Validators + +/** + * Upload images + * @route POST /api/v3/admin/forms/:formId/images/presign + * @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 + */ +AdminFormsPresignRouter.post( + '/:formId([a-fA-F0-9]{24})/images/presign', + AdminFormController.handleCreatePresignedPostUrlForImages, +) + +/** + * Upload logos + * @route POST /api/v3/admin/forms/:formId/logos/presign + * @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 + */ +AdminFormsPresignRouter.post( + '/:formId([a-fA-F0-9]{24})/logos/presign', + AdminFormController.handleCreatePresignedPostUrlForLogos, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts new file mode 100644 index 0000000000..7d873426dd --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsPreviewRouter = Router() + +/** + * Return the preview form to the user. + * Allows for both public and private forms, only for users with at least read permission. + * @route GET api/v3/admin/forms/:formId/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 + */ +AdminFormsPreviewRouter.get( + '/:formId([a-fA-F0-9]{24})/preview', + AdminFormController.handlePreviewAdminForm, +) + +/** + * Submit an email mode form in preview mode + * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/email + * @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 + */ +AdminFormsPreviewRouter.post( + '/:formId([a-fA-F0-9]{24})/preview/submissions/email', + AdminFormController.handleEmailPreviewSubmission, +) + +/** + * Submit an encrypt mode form in preview mode + * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt + * @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 + */ +AdminFormsPreviewRouter.post( + '/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt', + AdminFormController.handleEncryptPreviewSubmission, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index 8b99b246a5..183f2a8a8f 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -4,6 +4,9 @@ import { withUserAuthentication } from '../../../../../modules/auth/auth.middlew import { AdminFormsFeedbackRouter } from './admin-forms.feedback.routes' import { AdminFormsFormRouter } from './admin-forms.form.routes' +import { AdminFormsLogicRouter } from './admin-forms.logic.routes' +import { AdminFormsPresignRouter } from './admin-forms.presign.routes' +import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' @@ -16,3 +19,6 @@ AdminFormsRouter.use(AdminFormsSettingsRouter) AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) +AdminFormsRouter.use(AdminFormsPreviewRouter) +AdminFormsRouter.use(AdminFormsPresignRouter) +AdminFormsRouter.use(AdminFormsLogicRouter) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts index 54e2677cf9..9880aa6a61 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts @@ -3,7 +3,10 @@ import { Router } from 'express' import { AuthType, Status } from '../../../../../../types' import { SettingsUpdateDto } from '../../../../../../types/api' -import { handleUpdateSettings } from '../../../../../modules/form/admin-form/admin-form.controller' +import { + handleGetSettings, + handleUpdateSettings, +} from '../../../../../modules/form/admin-form/admin-form.controller' export const AdminFormsSettingsRouter = Router() @@ -29,26 +32,37 @@ const updateSettingsValidator = celebrate({ }).min(1), }) -/** - * Update form settings according to given subset of settings. - * @route PATCH /admin/forms/:formId/settings - * @group admin - * @param body the subset of settings to patch - * @produces application/json - * @consumes application/json - * @returns 200 with latest form settings on successful update - * @returns 400 when given body fails Joi validation - * @returns 401 when current user is not logged in - * @returns 403 when current user does not have permissions to update form settings - * @returns 404 when form to update settings for cannot be found - * @returns 409 when saving form settings incurs a conflict in the database - * @returns 410 when updating settings for archived form - * @returns 413 when updating settings causes form to be too large to be saved in the database - * @returns 422 when an invalid settings update is attempted on the form - * @returns 422 when user in session cannot be retrieved from the database - * @returns 500 when database error occurs - */ -AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings').patch( - updateSettingsValidator, - handleUpdateSettings, -) +AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings') + /** + * Update form settings according to given subset of settings. + * @route PATCH /admin/forms/:formId/settings + * @group admin + * @param body the subset of settings to patch + * @produces application/json + * @consumes application/json + * @returns 200 with latest form settings on successful update + * @returns 400 when given body fails Joi validation + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to update form settings + * @returns 404 when form to update settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 410 when updating settings for archived form + * @returns 413 when updating settings causes form to be too large to be saved in the database + * @returns 422 when an invalid settings update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ + .patch(updateSettingsValidator, handleUpdateSettings) + /** + * Retrieve the settings of the specified form + * @route GET /admin/forms/:formId/settings + * @group admin + * @produces application/json + * @returns 200 with latest form settings on successful update + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to obtain form settings + * @returns 404 when form to retrieve settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 500 when database error occurs + */ + .get(handleGetSettings) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts index b51f85f2ac..9252b64a13 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts @@ -61,3 +61,22 @@ AdminFormsSubmissionsRouter.route( AdminFormsSubmissionsRouter.route( '/:formId([a-fA-F0-9]{24})/submissions/:submissionId([a-fA-F0-9]{24})', ).get(EncryptSubmissionController.handleGetEncryptedResponse) + +/** + * Retrieve metadata of responses for a form with encrypted storage + * @route GET /:formId/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 + */ +AdminFormsSubmissionsRouter.get( + '/:formId([a-fA-F0-9]{24})/submissions/metadata', + EncryptSubmissionController.handleGetMetadata, +) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.auth.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.auth.routes.spec.ts new file mode 100644 index 0000000000..98125c129e --- /dev/null +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.auth.routes.spec.ts @@ -0,0 +1,282 @@ +import { ObjectId } from 'bson-ext' +import { StatusCodes } from 'http-status-codes' +import { err, errAsync } from 'neverthrow' +import supertest, { Session } from 'supertest-session' + +import { + DatabaseError, + MissingFeatureError, +} from 'src/app/modules/core/core.errors' +import { getRedirectTarget } from 'src/app/modules/spcp/spcp.util' +import { AuthType, Status } 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 * as FormService from '../../../../../modules/form/form.service' +import { MyInfoFactory } from '../../../../../modules/myinfo/myinfo.factory' +import { CreateRedirectUrlError } from '../../../../../modules/spcp/spcp.errors' +import { SpcpFactory } from '../../../../../modules/spcp/spcp.factory' +import { PublicFormsRouter } from '../public-forms.routes' + +const app = setupApp('/forms', PublicFormsRouter) + +describe('public-form.auth.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + describe('GET /forms/:formId/auth/redirect', () => { + it('should return 200 with the redirect URL when the form is valid and has authType SP', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + authType: AuthType.SP, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.OK) + expect(response.body).toMatchObject({ + redirectURL: expect.toIncludeMultiple([ + encodeURI(getRedirectTarget(form._id, AuthType.SP, false)), + form.esrvcId!, + ]), + }) + }) + + it('should return 200 with the redirect URL when the form is valid and has authType CP', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + authType: AuthType.CP, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.OK) + expect(response.body).toMatchObject({ + redirectURL: expect.toIncludeMultiple([ + encodeURI(getRedirectTarget(form._id, AuthType.CP, false)), + form.esrvcId!, + ]), + }) + }) + + it('should return 200 with the redirect URL when the form is valid and has authType MyInfo', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + authType: AuthType.MyInfo, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.OK) + expect(response.body).toMatchObject({ + redirectURL: expect.toIncludeMultiple([ + String(form._id), + form.esrvcId!, + ]), + }) + }) + + it('should return 400 when the request has an invalid type for isPersistentLogin', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + authType: AuthType.MyInfo, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + const expectedResponse = buildCelebrateError({ + query: { + key: 'isPersistentLogin', + message: '"isPersistentLogin" must be a boolean', + }, + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: '1' }) + + // Assert + expect(response.status).toEqual(StatusCodes.BAD_REQUEST) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 400 when the form has authType NIL', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + const expectedResponse = jsonParseStringify({ + message: + 'Please ensure that the form has authentication enabled. Please refresh and try again.', + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.BAD_REQUEST) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 400 with the redirect URL when the form has no esrvcId', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + authType: AuthType.MyInfo, + status: Status.Public, + }, + }) + const expectedResponse = jsonParseStringify({ + message: + 'This form does not have a valid eServiceId. Please refresh and try again.', + }) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.BAD_REQUEST) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 404 when the form is not in the database', async () => { + // Arrange + const expectedResponse = jsonParseStringify({ + message: + 'Could not find the form requested. Please refresh and try again.', + }) + + // Act + const response = await request + .get(`/forms/${new ObjectId().toHexString()}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.NOT_FOUND) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 500 when database error occurs', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + authType: AuthType.SP, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + const expectedResponse = jsonParseStringify({ + message: 'Sorry, something went wrong. Please try again.', + }) + jest + .spyOn(FormService, 'retrieveFullFormById') + .mockReturnValueOnce(errAsync(new DatabaseError())) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 500 when the redirect url feature is not enabled', async () => { + // Arrange + const MOCK_FEATURE_NAME = 'no direct only direct' + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + authType: AuthType.MyInfo, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + const expectedResponse = jsonParseStringify({ + message: `Sorry, something went wrong. Please try again.`, + }) + jest + .spyOn(MyInfoFactory, 'createRedirectURL') + .mockReturnValueOnce(err(new MissingFeatureError(MOCK_FEATURE_NAME))) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 500 when the redirect url could not be created', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + authType: AuthType.SP, + status: Status.Public, + esrvcId: new ObjectId().toHexString(), + }, + }) + const expectedResponse = jsonParseStringify({ + message: 'Sorry, something went wrong. Please try again.', + }) + jest + .spyOn(SpcpFactory, 'createRedirectUrl') + .mockReturnValueOnce(err(new CreateRedirectUrlError())) + + // Act + const response = await request + .get(`/forms/${form._id}/auth/redirect`) + .query({ isPersistentLogin: false }) + + // Assert + expect(response.status).toEqual(StatusCodes.INTERNAL_SERVER_ERROR) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts new file mode 100644 index 0000000000..8f77df7651 --- /dev/null +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts @@ -0,0 +1,160 @@ +import { getReasonPhrase, StatusCodes } from 'http-status-codes' +import { errAsync } from 'neverthrow' +import supertest, { Session } from 'supertest-session' + +import { DatabaseError } from 'src/app/modules/core/core.errors' +import { Status } 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 FormService from '../../../../../modules/form/form.service' +import { PublicFormsRouter } from '../public-forms.routes' + +const app = setupApp('/forms', PublicFormsRouter) + +describe('public-form.feedback.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + describe('POST /forms/:formId/feedback', () => { + it('should return 200 when feedback was successfully saved', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Public, + }, + }) + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ message: 'Successfully submitted feedback' }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(200) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 400 when form feedback submitted is malformed', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm() + const MOCK_FEEDBACK = { rating: 6 } + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'rating', + message: '"rating" must be less than or equal to 5', + }, + }), + ) + }) + + it('should return 404 when form is private', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm() + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: form.inactiveMessage, + formTitle: form.title, + isPageFound: true, + }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 410 when form is archived', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Archived, + }, + }) + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: getReasonPhrase(StatusCodes.GONE), + }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(410) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 500 when form could not be retrieved due to a database error', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Public, + }, + }) + const MOCK_ERROR_MESSAGE = 'mock me' + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: MOCK_ERROR_MESSAGE, + }), + ) + jest + .spyOn(FormService, 'retrieveFullFormById') + .mockReturnValueOnce(errAsync(new DatabaseError(MOCK_ERROR_MESSAGE))) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts new file mode 100644 index 0000000000..65a0eca891 --- /dev/null +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts @@ -0,0 +1,292 @@ +import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' +import SPCPAuthClient from '@opengovsg/spcp-auth-client' +import { ObjectId } from 'bson-ext' +import { errAsync } from 'neverthrow' +import supertest, { Session } from 'supertest-session' +import { mocked } from 'ts-jest/utils' + +import { DatabaseError } from 'src/app/modules/core/core.errors' +import { MYINFO_COOKIE_NAME } from 'src/app/modules/myinfo/myinfo.constants' +import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.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 * as AuthService from '../../../../../modules/auth/auth.service' +import { PublicFormsRouter } from '../public-forms.routes' + +import { MOCK_UINFIN } from './public-forms.routes.spec.constants' + +jest.mock('@opengovsg/spcp-auth-client') +const MockAuthClient = mocked(SPCPAuthClient, true) + +jest.mock('@opengovsg/myinfo-gov-client', () => { + return { + MyInfoGovClient: jest.fn().mockReturnValue({ + extractUinFin: jest.fn(), + getPerson: jest.fn(), + }), + MyInfoMode: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoMode, + MyInfoSource: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoSource, + MyInfoAddressType: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoAddressType, + MyInfoAttribute: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoAttribute, + } +}) + +const MockMyInfoGovClient = mocked( + new MyInfoClient.MyInfoGovClient({} as IMyInfoConfig), + true, +) + +const app = setupApp('/forms', PublicFormsRouter) + +describe('public-form.form.routes', () => { + let request: Session + + const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) + const mockCpClient = mocked(MockAuthClient.mock.instances[1], true) + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + describe('GET /:formId', () => { + const MOCK_COOKIE_PAYLOAD = { + userName: 'mock', + rememberMe: false, + } + + it('should return 200 with public form when form has AuthType.NIL and valid formId', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Public }, + }) + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(form._id) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm.getPublicView(), + isIntranetUser: false, + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 200 with public form when form has AuthType.SP and valid formId', async () => { + // Arrange + mockSpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => + cb(null, { + userName: MOCK_COOKIE_PAYLOAD.userName, + }), + ) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.SP, + hasCaptcha: false, + status: Status.Public, + }, + }) + const formId = form._id + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(formId) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm?.getPublicView(), + spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, + isIntranetUser: false, + }), + ) + + // Act + // Set cookie on request + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + it('should return 200 with public form when form has AuthType.CP and valid formId', async () => { + // Arrange + mockCpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => + cb(null, { + userName: MOCK_COOKIE_PAYLOAD.userName, + userInfo: 'MyCorpPassUEN', + }), + ) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.CP, + hasCaptcha: false, + status: Status.Public, + }, + }) + const formId = form._id + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(formId) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm?.getPublicView(), + spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, + isIntranetUser: false, + }), + ) + + // Act + // Set cookie on request + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + it('should return 200 with public form when form has AuthType.MyInfo and valid formId', async () => { + // Arrange + MockMyInfoGovClient.getPerson.mockResolvedValueOnce({ + uinFin: MOCK_UINFIN, + }) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.MyInfo, + hasCaptcha: false, + status: Status.Public, + }, + }) + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(form._id) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm.getPublicView(), + spcpSession: { userName: 'S1234567A' }, + isIntranetUser: false, + }), + ) + const cookie = JSON.stringify({ + accessToken: 'mockAccessToken', + usedCount: 0, + state: MyInfoCookieState.Success, + }) + + // Act + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', [ + // The j: indicates that the cookie is in JSON + `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, + ]) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form does not exist', async () => { + // Arrange + const cookie = JSON.stringify({ + accessToken: 'mockAccessToken', + usedCount: 0, + state: MyInfoCookieState.Success, + }) + const MOCK_FORM_ID = new ObjectId().toHexString() + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'Form not found', + }), + ) + + // Act + const actualResponse = await request + .get(`/forms/${MOCK_FORM_ID}`) + .set('Cookie', [ + // The j: indicates that the cookie is in JSON + `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, + ]) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form is private', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Private }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: form.inactiveMessage, + formTitle: form.title, + isPageFound: true, + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 410 if the form has been archived', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Archived }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'Gone', + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(410) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 500 if a database error occurs', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Public }, + }) + const expectedError = new DatabaseError('all your base are belong to us') + const expectedResponseBody = JSON.parse( + JSON.stringify({ message: expectedError.message }), + ) + jest + .spyOn(AuthService, 'getFormIfPublic') + .mockReturnValueOnce(errAsync(expectedError)) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(500) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + }) +}) diff --git a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.constants.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts similarity index 100% rename from src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.constants.ts rename to src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts diff --git a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts similarity index 78% rename from src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts rename to src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts index ff29d1744d..2908806e1e 100644 --- a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts @@ -1,25 +1,18 @@ import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' import SPCPAuthClient from '@opengovsg/spcp-auth-client' -import { ObjectId } from 'bson-ext' -import { getReasonPhrase, StatusCodes } from 'http-status-codes' import { omit } from 'lodash' import mongoose from 'mongoose' -import { errAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' import { mocked } from 'ts-jest/utils' -import { DatabaseError } from 'src/app/modules/core/core.errors' import { MYINFO_COOKIE_NAME } from 'src/app/modules/myinfo/myinfo.constants' import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' import getMyInfoHashModel from 'src/app/modules/myinfo/myinfo_hash.model' import { AuthType, IFieldSchema, Status } 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 * as FormService from '../../../../../modules/form/form.service' import { PublicFormsRouter } from '../public-forms.routes' import { @@ -72,7 +65,7 @@ const MockMyInfoGovClient = mocked( const app = setupApp('/forms', PublicFormsRouter) -describe('public-form.routes', () => { +describe('public-form.submissions.routes', () => { let request: Session const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) @@ -87,367 +80,6 @@ describe('public-form.routes', () => { jest.restoreAllMocks() }) afterAll(async () => await dbHandler.closeDatabase()) - describe('GET /:formId', () => { - const MOCK_COOKIE_PAYLOAD = { - userName: 'mock', - rememberMe: false, - } - - it('should return 200 with public form when form has AuthType.NIL and valid formId', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Public }, - }) - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(form._id) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm.getPublicView(), - isIntranetUser: false, - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 200 with public form when form has AuthType.SP and valid formId', async () => { - // Arrange - mockSpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => - cb(null, { - userName: MOCK_COOKIE_PAYLOAD.userName, - }), - ) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.SP, - hasCaptcha: false, - status: Status.Public, - }, - }) - const formId = form._id - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(formId) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm?.getPublicView(), - spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, - isIntranetUser: false, - }), - ) - - // Act - // Set cookie on request - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', ['jwtSp=mockJwt']) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - it('should return 200 with public form when form has AuthType.CP and valid formId', async () => { - // Arrange - mockCpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => - cb(null, { - userName: MOCK_COOKIE_PAYLOAD.userName, - userInfo: 'MyCorpPassUEN', - }), - ) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.CP, - hasCaptcha: false, - status: Status.Public, - }, - }) - const formId = form._id - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(formId) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm?.getPublicView(), - spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, - isIntranetUser: false, - }), - ) - - // Act - // Set cookie on request - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', ['jwtCp=mockJwt']) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - it('should return 200 with public form when form has AuthType.MyInfo and valid formId', async () => { - // Arrange - MockMyInfoGovClient.getPerson.mockResolvedValueOnce({ - uinFin: MOCK_UINFIN, - }) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.MyInfo, - hasCaptcha: false, - status: Status.Public, - }, - }) - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(form._id) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm.getPublicView(), - spcpSession: { userName: 'S1234567A' }, - isIntranetUser: false, - }), - ) - const cookie = JSON.stringify({ - accessToken: 'mockAccessToken', - usedCount: 0, - state: MyInfoCookieState.Success, - }) - - // Act - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', [ - // The j: indicates that the cookie is in JSON - `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, - ]) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 404 if the form does not exist', async () => { - // Arrange - const cookie = JSON.stringify({ - accessToken: 'mockAccessToken', - usedCount: 0, - state: MyInfoCookieState.Success, - }) - const MOCK_FORM_ID = new ObjectId().toHexString() - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: 'Form not found', - }), - ) - - // Act - const actualResponse = await request - .get(`/forms/${MOCK_FORM_ID}`) - .set('Cookie', [ - // The j: indicates that the cookie is in JSON - `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, - ]) - - // Assert - expect(actualResponse.status).toEqual(404) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 404 if the form is private', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Private }, - }) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: form.inactiveMessage, - formTitle: form.title, - isPageFound: true, - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(404) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 410 if the form has been archived', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Archived }, - }) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: 'Gone', - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(410) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 500 if a database error occurs', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Public }, - }) - const expectedError = new DatabaseError('all your base are belong to us') - const expectedResponseBody = JSON.parse( - JSON.stringify({ message: expectedError.message }), - ) - jest - .spyOn(AuthService, 'getFormIfPublic') - .mockReturnValueOnce(errAsync(expectedError)) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(500) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - }) - describe('POST /forms/:formId/feedback', () => { - it('should return 200 when feedback was successfully saved', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Public, - }, - }) - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ message: 'Successfully submitted feedback' }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(200) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 400 when form feedback submitted is malformed', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm() - const MOCK_FEEDBACK = { rating: 6 } - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'rating', - message: '"rating" must be less than or equal to 5', - }, - }), - ) - }) - - it('should return 404 when form is private', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm() - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: form.inactiveMessage, - formTitle: form.title, - isPageFound: true, - }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(404) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 410 when form is archived', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Archived, - }, - }) - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: getReasonPhrase(StatusCodes.GONE), - }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(410) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 500 when form could not be retrieved due to a database error', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Public, - }, - }) - const MOCK_ERROR_MESSAGE = 'mock me' - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: MOCK_ERROR_MESSAGE, - }), - ) - jest - .spyOn(FormService, 'retrieveFullFormById') - .mockReturnValueOnce(errAsync(new DatabaseError(MOCK_ERROR_MESSAGE))) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(500) - expect(response.body).toEqual(expectedResponse) - }) - }) describe('POST /forms/:formId/submissions/email', () => { const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) diff --git a/src/app/routes/api/v3/public/index.ts b/src/app/routes/api/v3/forms/index.ts similarity index 100% rename from src/app/routes/api/v3/public/index.ts rename to src/app/routes/api/v3/forms/index.ts diff --git a/src/app/routes/api/v3/forms/public-forms.auth.routes.ts b/src/app/routes/api/v3/forms/public-forms.auth.routes.ts new file mode 100644 index 0000000000..03f8bd52f2 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.auth.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express' + +import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' + +export const PublicFormsAuthRouter = Router() + +/** + * Redirects the user to the specified authentication provider. + * After authenticating their identity, the user is redirected back to the form + * @route /:formId/auth/redirect + * @param isPersistentLogin if the user chooses to have their information saved to avoid future logins + * + * @returns 200 with the redirect url when the user authenticates successfully + * @returns 400 when there is an error on the authType of the form + * @returns 400 when the eServiceId of the form does not exist + * @returns 404 when form with given ID does not exist + * @returns 500 when database error occurs + * @returns 500 when the redirect url could not be created + * @returns 500 when the redirect feature is not enabled + */ +PublicFormsAuthRouter.route('/:formId([a-fA-F0-9]{24})/auth/redirect').get( + PublicFormController.handleFormAuthRedirect, +) diff --git a/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts b/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts new file mode 100644 index 0000000000..ee79212066 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express' + +import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' + +export const PublicFormsFeedbackRouter = Router() + +/** + * 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 + */ +PublicFormsFeedbackRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( + PublicFormController.handleSubmitFeedback, +) diff --git a/src/app/routes/api/v3/forms/public-forms.form.routes.ts b/src/app/routes/api/v3/forms/public-forms.form.routes.ts new file mode 100644 index 0000000000..e87cf9e443 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.form.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express' + +import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' + +export const PublicFormsFormRouter = Router() + +/** + * Returns the specified form to the user, along with 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 + * + * @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 + */ +PublicFormsFormRouter.route('/:formId([a-fA-F0-9]{24})').get( + PublicFormController.handleGetPublicForm, +) diff --git a/src/app/routes/api/v3/forms/public-forms.routes.ts b/src/app/routes/api/v3/forms/public-forms.routes.ts new file mode 100644 index 0000000000..73745954e5 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express' + +import { PublicFormsAuthRouter } from './public-forms.auth.routes' +import { PublicFormsFeedbackRouter } from './public-forms.feedback.routes' +import { PublicFormsFormRouter } from './public-forms.form.routes' +import { PublicFormsSubmissionsRouter } from './public-forms.submissions.routes' + +export const PublicFormsRouter = Router() + +PublicFormsRouter.use(PublicFormsSubmissionsRouter) +PublicFormsRouter.use(PublicFormsFeedbackRouter) +PublicFormsRouter.use(PublicFormsFormRouter) +PublicFormsRouter.use(PublicFormsAuthRouter) diff --git a/src/app/routes/api/v3/public/public-forms.routes.ts b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts similarity index 56% rename from src/app/routes/api/v3/public/public-forms.routes.ts rename to src/app/routes/api/v3/forms/public-forms.submissions.routes.ts index c3a842b484..b226ea494a 100644 --- a/src/app/routes/api/v3/public/public-forms.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts @@ -1,31 +1,12 @@ import { Router } from 'express' import { rateLimitConfig } from '../../../../config/config' -import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' import * as EmailSubmissionController from '../../../../modules/submission/email-submission/email-submission.controller' import * as EncryptSubmissionController from '../../../../modules/submission/encrypt-submission/encrypt-submission.controller' import { CaptchaFactory } from '../../../../services/captcha/captcha.factory' import { limitRate } from '../../../../utils/limit-rate' -export const PublicFormsRouter = Router() - -/** - * 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 - */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( - PublicFormController.handleSubmitFeedback, -) +export const PublicFormsSubmissionsRouter = Router() /** * Submit a form response, processing it as an email to be sent to @@ -43,7 +24,9 @@ PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( * @returns 200 - submission made * @returns 400 - submission has bad data and could not be processed */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/email').post( +PublicFormsSubmissionsRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/email', +).post( limitRate({ max: rateLimitConfig.submissions }), CaptchaFactory.validateCaptchaParams, EmailSubmissionController.handleEmailSubmission, @@ -61,27 +44,10 @@ PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/email').post( * @returns 200 - submission made * @returns 400 - submission has bad data and could not be processed */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/encrypt').post( +PublicFormsSubmissionsRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/encrypt', +).post( limitRate({ max: rateLimitConfig.submissions }), CaptchaFactory.validateCaptchaParams, EncryptSubmissionController.handleEncryptedSubmission, ) - -/** - * Returns the specified form to the user, along with 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 - * - * @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 - */ -PublicFormsRouter.get( - '/:formId([a-fA-F0-9]{24})', - PublicFormController.handleGetPublicForm, -) diff --git a/src/app/routes/api/v3/v3.routes.ts b/src/app/routes/api/v3/v3.routes.ts index f2e5c1b054..945c684441 100644 --- a/src/app/routes/api/v3/v3.routes.ts +++ b/src/app/routes/api/v3/v3.routes.ts @@ -5,8 +5,8 @@ import { AnalyticsRouter } from './analytics' import { AuthRouter } from './auth' import { BillingsRouter } from './billings' import { ClientRouter } from './client' +import { PublicFormsRouter } from './forms' import { NotificationsRouter } from './notifications' -import { PublicFormsRouter } from './public' import { UserRouter } from './user' export const V3Router = Router() diff --git a/src/app/utils/field-validation/field-validation.guards.ts b/src/app/utils/field-validation/field-validation.guards.ts index fa3a96163b..221f1bd92c 100644 --- a/src/app/utils/field-validation/field-validation.guards.ts +++ b/src/app/utils/field-validation/field-validation.guards.ts @@ -1,5 +1,7 @@ +import { get } from 'lodash' + import { types as basicTypes } from '../../../shared/resources/basic' -import { BasicField, ITableRow } from '../../../types' +import { BasicField, IEmailFieldSchema, ITableRow } from '../../../types' import { ColumnResponse, ProcessedAttachmentResponse, @@ -74,3 +76,15 @@ export const isProcessedAttachmentResponse = ( // Hence hidden attachment fields - which still return empty response - will not have response.filename property ) } + +/** + * Utility to check if the given field is a possible IEmailFieldSchema object. + * Can be used to assign IEmailFieldSchema variables safely. + * @param field the field to check + * @returns true if given field's fieldType is BasicField.Email. + */ +export const isPossibleEmailFieldSchema = ( + field: unknown, +): field is Partial => { + return get(field, 'fieldType') === BasicField.Email +} diff --git a/src/app/utils/mongoose.ts b/src/app/utils/mongoose.ts new file mode 100644 index 0000000000..875071bac8 --- /dev/null +++ b/src/app/utils/mongoose.ts @@ -0,0 +1,14 @@ +import { Document, Types } from 'mongoose' + +/** + * Type guard for whether given array is a mongoose DocumentArray + * @param array the array to check + */ +export const isMongooseDocumentArray = ( + array: T[] & { isMongooseDocumentArray?: boolean }, +): array is Types.DocumentArray => { + /** + * @see {mongoose.Types.DocumentArray.isMongooseDocumentArray} + */ + return !!array.isMongooseDocumentArray +} diff --git a/src/app/views/templates/bounce-notification-permanent.server.view.html b/src/app/views/templates/bounce-notification-permanent.server.view.html index e7fcb103ba..581bca0f1d 100644 --- a/src/app/views/templates/bounce-notification-permanent.server.view.html +++ b/src/app/views/templates/bounce-notification-permanent.server.view.html @@ -29,8 +29,7 @@
  • Consider switching your form to Storage mode, or - setting up AutoArchiving which automates clearing of mailbox capacity (see @@ -58,15 +57,13 @@
    1. Here is a guide to - switching your form from Email mode to Storage mode.
    2. Be sure - not to lose the secret key, which you will need to access responses. Responses cannot be recovered if this key is lost. @@ -75,14 +72,12 @@

      Other useful links

    diff --git a/src/app/views/templates/bounce-notification-transient.server.view.html b/src/app/views/templates/bounce-notification-transient.server.view.html index 283bdd4cd5..7ce1f4375e 100644 --- a/src/app/views/templates/bounce-notification-transient.server.view.html +++ b/src/app/views/templates/bounce-notification-transient.server.view.html @@ -25,8 +25,7 @@
  • Consider switching your form to Storage mode, or - setting up AutoArchiving which automates clearing of mailbox capacity (see @@ -49,15 +48,13 @@
    1. Here is a guide to - switching your form from Email mode to Storage mode.
    2. Be sure - not to lose the secret key, which you will need to access responses. Responses cannot be recovered if this key is lost. @@ -66,14 +63,12 @@

      Other useful links

    diff --git a/src/public/main.js b/src/public/main.js index 1bdfeacf96..dc69a9f1db 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -262,7 +262,6 @@ require('./modules/forms/services/form-factory.client.service.js') require('./modules/forms/services/form-api.client.factory.js') require('./modules/forms/services/form-error.client.factory.js') require('./modules/forms/services/spcp-session.client.factory.js') -require('./modules/forms/services/spcp-redirect.client.factory.js') require('./modules/forms/services/spcp-validate-esrvcid.client.factory.js') require('./modules/forms/services/submissions.client.factory.js') require('./modules/forms/services/toastr.client.factory.js') diff --git a/src/public/modules/core/css/core.css b/src/public/modules/core/css/core.css index 18340816de..e5503e2ec2 100755 --- a/src/public/modules/core/css/core.css +++ b/src/public/modules/core/css/core.css @@ -259,6 +259,11 @@ textarea.input-custom { color: #23489b; } +.alert-prefill { + background-color: #fef8e3; + color: #4b411f; +} + .alert-success, .alert-success a { background-color: #e5f6f2; diff --git a/src/public/modules/core/services/gtag.client.service.js b/src/public/modules/core/services/gtag.client.service.js index ad7a0cb3a8..632ff5b1bb 100644 --- a/src/public/modules/core/services/gtag.client.service.js +++ b/src/public/modules/core/services/gtag.client.service.js @@ -500,5 +500,12 @@ function GTag(Auth, $rootScope, $window) { }) } + /** + * Logs client form reCAPTCHA onError. + */ + gtagService.reCaptchaOnError = (form) => { + _gtagPublicFormEvents(form, 'reCAPTCHA connection failure') + } + return gtagService } diff --git a/src/public/modules/forms/admin/components/edit-logic.client.component.js b/src/public/modules/forms/admin/components/edit-logic.client.component.js index 79b752fb91..abef915000 100644 --- a/src/public/modules/forms/admin/components/edit-logic.client.component.js +++ b/src/public/modules/forms/admin/components/edit-logic.client.component.js @@ -1,6 +1,7 @@ 'use strict' const { LogicType } = require('../../../../../types') +const AdminFormService = require('../../../../services/AdminFormService') angular.module('forms').component('editLogicComponent', { templateUrl: 'modules/forms/admin/componentViews/edit-logic.client.view.html', @@ -9,11 +10,17 @@ angular.module('forms').component('editLogicComponent', { isLogicError: '<', updateForm: '&', }, - controller: ['$uibModal', 'FormFields', editLogicComponentController], + controller: [ + '$uibModal', + 'FormFields', + 'Toastr', + '$q', + editLogicComponentController, + ], controllerAs: 'vm', }) -function editLogicComponentController($uibModal, FormFields) { +function editLogicComponentController($uibModal, FormFields, Toastr, $q) { const vm = this vm.LogicType = LogicType const getNewCondition = function () { @@ -74,8 +81,15 @@ function editLogicComponentController($uibModal, FormFields) { } vm.deleteLogic = function (logicIndex) { - vm.myform.form_logics.splice(logicIndex, 1) - updateLogic({ form_logics: vm.myform.form_logics }) + const logicIdToDelete = vm.myform.form_logics[logicIndex]._id + $q.when(AdminFormService.deleteFormLogic(vm.myform._id, logicIdToDelete)) + .then(() => { + vm.myform.form_logics.splice(logicIndex, 1) + }) + .catch((logicDeleteError) => { + console.error(logicDeleteError) + Toastr.error('Failed to delete logic, please refresh and try again!') + }) } vm.openEditLogicModal = function (currLogic, isNew, logicIndex = -1) { diff --git a/src/public/modules/forms/admin/constants/update-form-types.ts b/src/public/modules/forms/admin/constants/update-form-types.ts new file mode 100644 index 0000000000..cff8eab77f --- /dev/null +++ b/src/public/modules/forms/admin/constants/update-form-types.ts @@ -0,0 +1,5 @@ +export const UPDATE_FORM_TYPES = { + UpdateField: 'update-field', + CreateField: 'create-field', + ReorderField: 'reorder-field', +} diff --git a/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js b/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js index e478a0df31..8deb6ec6fc 100644 --- a/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/activate-form-modal.client.controller.js @@ -65,7 +65,7 @@ function ActivateFormController( vm.savingStatus = 1 const toastMessage = dedent` Congrats! Your form is now live.
    For high-traffic forms, - + AutoArchive your mailbox to prevent lost responses. ` return updateFormStatusAndSave(toastMessage, { diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index b4700eb019..2df64b9ef7 100644 --- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js +++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js @@ -1,8 +1,11 @@ 'use strict' const { StatusCodes } = require('http-status-codes') +const get = require('lodash/get') const { LogicType } = require('../../../../../types') const AdminFormService = require('../../../../services/AdminFormService') +const FieldFactory = require('../../helpers/field-factory') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') // All viewable tabs. readOnly is true if that tab cannot be used to edit form. const VIEW_TABS = [ @@ -38,6 +41,7 @@ angular '$translate', '$uibModal', 'FormData', + 'FormFields', 'Auth', 'moment', 'Toastr', @@ -53,6 +57,7 @@ function AdminFormController( $translate, $uibModal, FormData, + FormFields, Auth, moment, Toastr, @@ -139,7 +144,8 @@ function AdminFormController( return } let errorMessage - switch (error.status) { + const status = get(error, 'response.status') || get(error, 'status') + switch (status) { case StatusCodes.CONFLICT: case StatusCodes.BAD_REQUEST: errorMessage = @@ -151,9 +157,9 @@ function AdminFormController( break case StatusCodes.UNPROCESSABLE_ENTITY: // Validation can fail for many reasons, so return more specific message - errorMessage = _.get( + errorMessage = get( error, - 'data.message', + 'response.data.message', 'Your changes contain invalid input.', ) break @@ -179,13 +185,88 @@ function AdminFormController( * @returns Promise */ $scope.updateForm = (update) => { - return FormApi.update({ formId: $scope.myform._id }, { form: update }) - .$promise.then((savedForm) => { - // Updating this form updates lastModified - // and also updates myform if a formToUse is passed in - $scope.myform = savedForm - }) - .catch(handleUpdateError) + const updateType = get(update, 'type') + + switch (updateType) { + case UPDATE_FORM_TYPES.CreateField: { + const { body } = update + return $q + .when(AdminFormService.createSingleFormField($scope.myform._id, body)) + .then((updatedFormField) => { + // !!! Convert retrieved form field objects into their class counterparts. + const updatedFieldClass = FieldFactory.createFieldFromData( + updatedFormField, + ) + FormFields.injectMyInfoFieldInfo(updatedFieldClass) + + // insert created field into form + $scope.myform.form_fields = [ + ...$scope.myform.form_fields, + updatedFieldClass, + ] + }) + .catch(handleUpdateError) + } + case UPDATE_FORM_TYPES.UpdateField: { + const { fieldId, body } = update + return $q + .when( + AdminFormService.updateSingleFormField( + $scope.myform._id, + fieldId, + body, + ), + ) + .then((updatedFormField) => { + // !!! Convert retrieved form field objects into their class counterparts. + const updatedFieldClass = FieldFactory.createFieldFromData( + updatedFormField, + ) + FormFields.injectMyInfoFieldInfo(updatedFieldClass) + + // merge back into the form fields + const updateIndex = $scope.myform.form_fields.findIndex( + (f) => f._id === fieldId, + ) + if (updateIndex !== -1) { + $scope.myform.form_fields[updateIndex] = updatedFieldClass + } else { + Toastr.error('An error occurred while saving your changes.') + } + }) + .catch(handleUpdateError) + } + case UPDATE_FORM_TYPES.ReorderField: { + const { fieldId, newPosition } = update + + return $q + .when( + AdminFormService.reorderSingleFormField( + $scope.myform._id, + fieldId, + newPosition, + ), + ) + .then((updatedFields) => { + // !!! Convert retrieved form field objects into their class counterparts. + const updatedFieldClasses = updatedFields.map((f) => { + const fieldClass = FieldFactory.createFieldFromData(f) + FormFields.injectMyInfoFieldInfo(fieldClass) + return fieldClass + }) + $scope.myform.form_fields = updatedFieldClasses + }) + .catch(handleUpdateError) + } + default: + return FormApi.update({ formId: $scope.myform._id }, { form: update }) + .$promise.then((savedForm) => { + // Updating this form updates lastModified + // and also updates myform if a formToUse is passed in + $scope.myform = savedForm + }) + .catch(handleUpdateError) + } } /** diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js index 9ea4350b31..1b6480d756 100644 --- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js @@ -2,22 +2,20 @@ const axios = require('axios').default const values = require('lodash/values') +const cloneDeep = require('lodash/cloneDeep') const { - EditFieldActions, VALID_UPLOAD_FILE_TYPES, MAX_UPLOAD_FILE_SIZE, } = require('shared/constants') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') const { uploadImage } = require('../../../../services/FileHandlerService') +const { + DateSelectedValidation: DateValidationOptions, +} = require('../../../../../shared/constants') const CancelToken = axios.CancelToken -const DATE_VALIDATION_OPTIONS = { - disallowPast: 'Disallow past dates', - disallowFuture: 'Disallow future dates', - custom: 'Custom date range', -} - const EMAIL_MODE_ALLOWED_SIZES = ['1', '2', '3', '7'] angular @@ -34,6 +32,7 @@ angular 'Betas', 'Auth', '$state', + 'Toastr', EditFieldsModalController, ]) @@ -49,6 +48,7 @@ function EditFieldsModalController( Betas, Auth, $state, + Toastr, ) { let source const vm = this @@ -73,13 +73,23 @@ function EditFieldsModalController( vm.user = Auth.getUser() || $state.go('signin') if (vm.field.fieldType === 'email') { const userEmailDomain = '@' + vm.user.email.split('@').pop() + + // Backwards compatibility and inconsistency fix. + // Set allowedEmailDomains array to empty if allow domains toggle is off. + if (vm.field.hasAllowedEmailDomains === false) { + vm.field.allowedEmailDomains = [] + } else { + // hasAllowedEmailDomains is true, set "true" state based on length of allowedEmailDomains. + vm.field.hasAllowedEmailDomains = vm.field.allowedEmailDomains.length > 0 + } + vm.field.allowedEmailDomainsPlaceholder = `${userEmailDomain}\n@agency.gov.sg` - if (vm.field.allowedEmailDomains.length > 0) { + if (vm.field.hasAllowedEmailDomains) { vm.field.allowedEmailDomainsFromText = vm.field.allowedEmailDomains.join( '\n', ) } - $scope.$watch('vm.field.allowedEmailDomainsFromText', (newValue) => { + $scope.$watch('vm.field.isVerifiable', (newValue) => { if (newValue) { vm.tooltipHtml = 'e.g. @mom.gov.sg, @moe.gov.sg' } else { @@ -144,6 +154,14 @@ function EditFieldsModalController( } } + vm.handleRestrictEmailDomainsToggle = function () { + const field = vm.field + if (field.hasAllowedEmailDomains === false) { + // Reset email domains. + field.allowedEmailDomainsFromText = '' + } + } + vm.ratingSteps = Rating.steps vm.ratingShapes = Rating.shapes @@ -188,7 +206,7 @@ function EditFieldsModalController( customMaxDate, } = dateValidation - if (selectedDateValidation !== DATE_VALIDATION_OPTIONS.custom) { + if (selectedDateValidation !== DateValidationOptions.Custom) { return false } return !customMinDate && !customMaxDate @@ -248,10 +266,10 @@ function EditFieldsModalController( // Controls for date validation - vm.dateValidationOptions = values(DATE_VALIDATION_OPTIONS) + vm.dateValidationOptionList = values(DateValidationOptions) - // Make DATE_VALIDATION_OPTIONS accessible to view - vm.DATE_VALIDATION_OPTIONS = DATE_VALIDATION_OPTIONS + // Make date validation option enum accessible to view + vm.DateValidationOptions = DateValidationOptions vm.clearDateValidation = function () { const field = vm.field @@ -456,9 +474,10 @@ function EditFieldsModalController( return } - const field = vm.field + const field = cloneDeep(vm.field) if (field.fieldOptionsFromText) { field.fieldOptions = field.fieldOptionsFromText.split('\n') + delete field.fieldOptionsFromText } else { field.fieldOptions = field.manualOptions } @@ -473,6 +492,9 @@ function EditFieldsModalController( .map((s) => s.trim()) .filter((s) => s) } + field.hasAllowedEmailDomains = field.allowedEmailDomains.length > 0 + delete field.allowedEmailDomainsFromText + delete field.allowedEmailDomainsPlaceholder } // set total attachment size left @@ -483,27 +505,24 @@ function EditFieldsModalController( previousAttachmentSize } - // TODO: Separate code flow for create and update, ideally calling PUT and PATCH endpoints - const editFormField = - vm.field.globalId === undefined - ? // Create a new field - { - action: { - name: EditFieldActions.Create, - }, - field: vm.field, - } - : // Edit existing field - { - action: { - name: EditFieldActions.Update, - }, - field: vm.field, - } - vm.saveInProgress = true - externalScope - .updateField({ editFormField }) + // No id, creation + let updateFieldPromise + if (!field._id) { + updateFieldPromise = externalScope.updateField({ + body: field, + type: UPDATE_FORM_TYPES.CreateField, + }) + } else { + // Update field + updateFieldPromise = externalScope.updateField({ + fieldId: field._id, + body: field, + type: UPDATE_FORM_TYPES.UpdateField, + }) + } + + return updateFieldPromise .then((error) => { if (!error) { $uibModalInstance.close() @@ -614,4 +633,18 @@ function EditFieldsModalController( // On error, we explicitly clear the files stored in the model, as the library does not always automatically do this field.uploadedFile = '' } + + /** + * Inform user that field id has been copied to clipboard + */ + + vm.toastSuccessfulFieldIdCopy = () => { + Toastr.success('Field ID copied to clipboard!') + } + /** + * Inform user that field id was not copied to clipboard + */ + vm.toastFailedFieldIdCopy = () => { + Toastr.error('Failed to copy to clipboard') + } } diff --git a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js index daac8c74ef..d62af7829e 100644 --- a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js @@ -1,11 +1,13 @@ 'use strict' -const { EditFieldActions } = require('shared/constants') +const cloneDeep = require('lodash/cloneDeep') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') angular .module('forms') .controller('EditMyInfoFieldController', [ '$uibModalInstance', + 'FormFields', 'externalScope', 'updateField', EditMyInfoFieldController, @@ -13,6 +15,7 @@ angular function EditMyInfoFieldController( $uibModalInstance, + FormFields, externalScope, updateField, ) { @@ -29,25 +32,29 @@ function EditMyInfoFieldController( vm.verifiedForF = externalScope.currField.myInfo.verified.includes('F') vm.saveMyInfoField = function () { - // TODO: Separate code flow for create and update, ideally calling PUT and PATCH endpoints - const editFormField = - externalScope.currField.globalId === undefined - ? // Create a new field - { - action: { - name: EditFieldActions.Create, - }, - field: externalScope.currField, - } - : // Edit existing field - { - action: { - name: EditFieldActions.Update, - }, - field: externalScope.currField, - } - - updateField({ editFormField }).then((error) => { + // No id, creation + let updateFieldPromise + const field = cloneDeep(externalScope.currField) + + // Mutate and remove MyInfo data + FormFields.removeMyInfoFieldInfo(field) + + // Field creation + if (!field._id) { + updateFieldPromise = updateField({ + body: field, + type: UPDATE_FORM_TYPES.CreateField, + }) + } else { + // Update field + updateFieldPromise = updateField({ + fieldId: field._id, + body: field, + type: UPDATE_FORM_TYPES.UpdateField, + }) + } + + return updateFieldPromise.then((error) => { if (!error) { $uibModalInstance.close() externalScope.closeMobileFields() diff --git a/src/public/modules/forms/admin/css/edit-form.css b/src/public/modules/forms/admin/css/edit-form.css index fe52d24398..f8d910a2c0 100644 --- a/src/public/modules/forms/admin/css/edit-form.css +++ b/src/public/modules/forms/admin/css/edit-form.css @@ -1404,3 +1404,21 @@ a.modal-cancel-btn:hover { height: 2px; margin: 30px 0; } + +.prefill-copy-icon { + position: absolute; + top: 10px; + right: 25px; + cursor: grab; + color: #b8b8b8; +} + +.prefill-input { + background: #f0f0f0; + border: 1px solid #ccc; + color: #979797; +} + +.pull-right { + cursor: pointer; +} diff --git a/src/public/modules/forms/admin/directiveViews/edit-form.client.view.html b/src/public/modules/forms/admin/directiveViews/edit-form.client.view.html index 9be28713db..5f400ca260 100644 --- a/src/public/modules/forms/admin/directiveViews/edit-form.client.view.html +++ b/src/public/modules/forms/admin/directiveViews/edit-form.client.view.html @@ -37,7 +37,9 @@ MyInfo -
    +
    PERSONAL
    + +
    + +
    + You have reached the limit of {{ maxMyInfoFields }} MyInfo fields for + your form. +
    +
    + +
    +
    + + Only {{ maxMyInfoFields }} MyInfo fields are allowed in Email mode. You + currently have {{ numMyInfoFields }} MyInfo field(s). + Learn more +
    +
    diff --git a/src/public/modules/forms/admin/directives/edit-form.client.directive.js b/src/public/modules/forms/admin/directives/edit-form.client.directive.js index 0ba5418ed3..aba9cd16c3 100644 --- a/src/public/modules/forms/admin/directives/edit-form.client.directive.js +++ b/src/public/modules/forms/admin/directives/edit-form.client.directive.js @@ -1,7 +1,9 @@ 'use strict' const { EditFieldActions } = require('shared/constants') const { groupLogicUnitsByField } = require('shared/util/logic') +const { reorder } = require('shared/util/immutable-array-fns') const FieldFactory = require('../../helpers/field-factory') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') const newFields = new Set() // Adding a fieldTypes will add a "new" label. @@ -73,20 +75,22 @@ function editFormController( forceFallback: true, ghostClass: 'field-placeholder', animation: 0, - onEnd({ model, newIndex, oldIndex }) { + onUpdate: function (evt) { + const { model, models, newIndex, oldIndex } = evt // Clear selected after drop $scope.resetFieldMore() - if (newIndex !== oldIndex) { - updateField({ - editFormField: { - action: { - name: EditFieldActions.Reorder, - position: newIndex, - }, - field: model, - }, - }) - } + updateField({ + fieldId: model._id, + newPosition: newIndex, + type: UPDATE_FORM_TYPES.ReorderField, + }).then((error) => { + // Will be undefined if no error occurs. + if (error) { + // Rollback changes, reorder list. + const oldList = reorder(models, newIndex, oldIndex) + $scope.myform.form_fields = oldList + } + }) }, } @@ -134,6 +138,31 @@ function editFormController( return conditionFieldSet.has(field._id) } + /** + * Returns the number of myInfo fields in a form + * @param {Object} A form object + * @returns {Integer} The number of MyInfo fields + */ + $scope.countMyInfoFields = function (form) { + let count = 0 + form.form_fields.forEach(function (field) { + if (field.myInfo !== undefined) { + count++ + } + }) + return count + } + + // Update myInfo counts when the form field changes + $scope.maxMyInfoFields = 30 + $scope.numMyInfoFields = $scope.countMyInfoFields($scope.myform) + $scope.$watch( + (scope) => scope.myform.form_fields, + function (_newVal, _oldVal) { + $scope.numMyInfoFields = $scope.countMyInfoFields($scope.myform) + }, + ) + // Default Attachments Total Size if ($scope.myform.responseMode === responseModeEnum.ENCRYPT) { Attachment.attachmentsTotal = 20 @@ -327,6 +356,7 @@ function editFormController( } $scope.addNewMyInfoField = function (myInfoAttr) { + if ($scope.numMyInfoFields >= $scope.maxMyInfoFields) return let newField = FormFields.createMyInfoField(myInfoAttr) $scope.openMyInfoEditModal(newField) } @@ -442,6 +472,11 @@ function editFormController( } const duplicateField = (fieldToDuplicate) => { + if ( + fieldToDuplicate.myInfo !== undefined && + $scope.numMyInfoFields >= $scope.maxMyInfoFields + ) + return let duplicatedField = _.cloneDeep(fieldToDuplicate) // Remove unique ids before saving delete duplicatedField.globalId diff --git a/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js b/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js index 013c49b77c..79e81adbe6 100644 --- a/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js +++ b/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js @@ -16,8 +16,9 @@ function isVerifiableSaveInterceptor(Toastr) { Toastr.error( 'Turn on OTP verification again if you wish to restrict email domains.', ) + scope.vm.field.hasAllowedEmailDomains = false + scope.vm.field.allowedEmailDomainsFromText = '' } - scope.vm.field.hasAllowedEmailDomains = false return inputValue }) }, diff --git a/src/public/modules/forms/admin/directives/settings-form.client.directive.js b/src/public/modules/forms/admin/directives/settings-form.client.directive.js index b95e2f6944..dfc377036e 100644 --- a/src/public/modules/forms/admin/directives/settings-form.client.directive.js +++ b/src/public/modules/forms/admin/directives/settings-form.client.directive.js @@ -341,7 +341,7 @@ function settingsFormDirective( if ($scope.tempForm.status === 'PRIVATE') { const toastMessage = dedent` Congrats! Your form is now live.
    For high-traffic forms, - + AutoArchive your mailbox to prevent lost responses. ` updateFormStatusAndSave(toastMessage, { diff --git a/src/public/modules/forms/admin/views/edit-fields.client.modal.html b/src/public/modules/forms/admin/views/edit-fields.client.modal.html index 488f66e67e..70fdfa5cff 100644 --- a/src/public/modules/forms/admin/views/edit-fields.client.modal.html +++ b/src/public/modules/forms/admin/views/edit-fields.client.modal.html @@ -294,7 +294,7 @@