From 874b6c25a42f55b95ca9f7017a262cf9bb00d5bc Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Wed, 4 Jan 2023 11:02:51 -0500 Subject: [PATCH] Release v2.0.0 (#1449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Arney Co-authored-by: Paul Craig Co-authored-by: James Eberhardt Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Clément JANIN Co-authored-by: Dave Samojlenko Co-authored-by: sre-read-write[bot] <92993749+sre-read-write[bot]@users.noreply.github.com> Co-authored-by: Tim Arney Co-authored-by: Omar Nasr Co-authored-by: Peter Thiessen Co-authored-by: samsadasivan Co-authored-by: Matt Co-authored-by: Pete <107579368+thiessenp-cds@users.noreply.github.com> Co-authored-by: Pat Heard --- .devcontainer/Dockerfile | 2 +- .devcontainer/docker-compose.yml | 4 +- .env.example | 11 +- .eslintrc.js | 7 +- .../workflows/build-and-deploy-storybooks.yml | 18 +- .github/workflows/codeql-analysis.yml | 17 +- .github/workflows/cypress.yml | 17 +- .github/workflows/eslint.yml | 12 +- .github/workflows/generate-sbom.yml | 4 +- .github/workflows/jest.yml | 2 +- .../workflows/prod-build-push-container.yml | 27 +- .github/workflows/s3-backup.yml | 4 +- .../staging-build-push-container.yml | 33 +- .github/workflows/staging-deploy.yml | 10 +- .../test-container-build-production.yml | 29 + .../test-container-build-staging.yml | 28 + .idea/.name | 1 + .storybook/Layout.js | 4 +- .storybook/preview.js | 2 +- CHANGELOG.md | 96 +- Dockerfile | 30 +- README.md | 36 +- VERSION | 2 +- __fixtures__/accessibilityTestForm.json | 329 + __fixtures__/attestationTestForm.json | 47 + __fixtures__/brokenFormTemplate.json | 646 + .../cdsIntakeTestForm.json | 13 +- __fixtures__/duplicateElementIds.json | 384 + .../dynamicRowsTestForm.json | 13 +- __fixtures__/invalidLayoutIds.json | 644 + __fixtures__/invalidSubElementIds.json | 177 + .../platformIntakeTestForm.json | 13 +- __fixtures__/testData.json | 303 + .../textFieldTestForm.json | 0 .../tsbContactTestForm.json | 13 +- .../tsbDisableFooterGCBranding.json | 10 +- __fixtures__/validFormTemplate.json | 644 + ...validFormTemplateWithHTMLInDynamicRow.json | 644 + __tests__/api/acceptable-use.test.ts | 88 + __tests__/api/account/confirmpassword.test.ts | 166 + __tests__/api/account/forgotpassword.test.ts | 159 + __tests__/api/flags.test.ts | 251 + __tests__/api/id/form/apiUsers.test.ts | 1218 ++ __tests__/api/id/form/bearer.test.ts | 420 + .../api/id/form/retrieval.test.ts | 448 +- {tests => __tests__}/api/log.test.ts | 10 +- .../api/notify-callbacks.test.js | 3 + __tests__/api/request/publish.test.ts | 148 + __tests__/api/request/support.test.ts | 186 + __tests__/api/signup/confirm.test.ts | 161 + __tests__/api/signup/register.test.ts | 188 + .../api/signup/resendconfirmation.test.ts | 157 + __tests__/api/templates.test.ts | 507 + __tests__/api/token/temporary.test.ts | 225 + __tests__/api/users.test.ts | 356 + __tests__/id/[form]/settings.test.js | 106 + __utils__/index.ts | 2 + __utils__/jestShim.ts | 4 + {lib => __utils__}/jestUtils.ts | 0 __utils__/mocks/middleware.ts | 17 + __utils__/permissions.ts | 70 + __utils__/prismaConnector.ts | 32 + {lib/tests => __utils__}/setupTests.ts | 4 +- .../BearerRefresh/BearerRefresh.stories.tsx | 2 +- .../admin/BearerRefresh/BearerRefresh.tsx | 11 +- .../admin/FormAccess/FormAccess.stories.tsx | 2 +- .../admin/FormAccess/FormAccess.test.js | 44 +- components/admin/FormAccess/FormAccess.tsx | 16 +- .../admin/JsonUpload/JsonUpload.stories.tsx | 42 +- .../admin/JsonUpload/JsonUpload.test.js | 52 +- components/admin/JsonUpload/JsonUpload.tsx | 133 +- components/admin/TemplateDelete/Settings.tsx | 96 + components/auth/AcceptableUse.test.js | 44 + components/auth/AcceptableUse.tsx | 79 + .../auth/Confirmation/Confirmation.test.js | 72 + components/auth/Confirmation/Confirmation.tsx | 160 + components/auth/LoginMenu.test.js | 15 + components/auth/LoginMenu.tsx | 43 + .../__tests__/useActivePathname.tsx | 11 + .../__tests__/useAllowPublish.test.tsx | 678 + .../__tests__/useModalStore.test.tsx | 110 + .../__tests__/useTemplateStore.test.tsx | 378 + .../form-builder/__tests__/util.test.js | 108 + components/form-builder/app/Preview.tsx | 168 + components/form-builder/app/Publish.tsx | 149 + components/form-builder/app/PublishNoAuth.tsx | 20 + components/form-builder/app/Published.tsx | 81 + components/form-builder/app/Settings.tsx | 249 + components/form-builder/app/Start.tsx | 136 + components/form-builder/app/Template.tsx | 99 + .../app/edit/ConfirmationDescription.tsx | 17 + components/form-builder/app/edit/Edit.tsx | 142 + .../form-builder/app/edit/ElementDropDown.tsx | 43 + .../form-builder/app/edit/ElementPanel.tsx | 90 + .../form-builder/app/edit/ElementRequired.tsx | 33 + components/form-builder/app/edit/Modal.tsx | 201 + .../form-builder/app/edit/ModalForm.tsx | 141 + .../form-builder/app/edit/PanelActions.tsx | 237 + .../app/edit/PanelActionsLocked.tsx | 37 + .../form-builder/app/edit/PanelBody.tsx | 55 + .../form-builder/app/edit/PanelBodyRoot.tsx | 97 + .../app/edit/PrivacyDescription.tsx | 12 + .../form-builder/app/edit/SelectedElement.tsx | 92 + .../app/edit/elements/BulkAdd.tsx | 54 + .../app/edit/elements/DropDown.tsx | 68 + .../form-builder/app/edit/elements/Option.tsx | 115 + .../app/edit/elements/Options.tsx | 152 + .../app/edit/elements/RichText.tsx | 38 + .../app/edit/elements/RichTextLocked.tsx | 48 + .../app/edit/elements/ShortAnswer.tsx | 9 + .../form-builder/app/edit/elements/index.ts | 9 + .../edit/elements/lexical-editor/Editor.tsx | 108 + .../lexical-editor/RichTextEditor.tsx | 63 + .../edit/elements/lexical-editor/ToolTip.tsx | 23 + .../edit/elements/lexical-editor/Toolbar.tsx | 386 + .../edit/elements/lexical-editor/config.ts | 83 + .../FloatingLinkEditorPlugin/index.tsx | 286 + .../lexical-editor/plugins/FocusEditor.tsx | 16 + .../plugins/ListMaxIndentPlugin.tsx | 81 + .../lexical-editor/useEditorFocus.tsx | 37 + .../lexical-editor/utils/getSelectedNode.ts | 25 + .../utils/setFloatingElemPosition.ts | 46 + .../edit/elements/lexical-editor/utils/url.ts | 32 + .../app/edit/elements/question/Question.tsx | 48 + .../edit/elements/question/QuestionInput.tsx | 74 + .../edit/elements/question/QuestionNumber.tsx | 38 + .../app/edit/elements/tests/Dropdown.test.js | 46 + .../app/edit/elements/tests/Option.test.js | 34 + .../app/edit/elements/tests/Options.test.js | 59 + .../app/edit/elements/tests/RichText.test.js | 46 + .../elements/tests/RichTextLocked.test.js | 16 + .../edit/elements/tests/ShortAnswer.test.js | 17 + components/form-builder/app/edit/index.ts | 13 + .../app/edit/tests/ElementDropDown.test.js | 33 + .../app/edit/tests/ElementPanel.test.js | 120 + .../app/edit/tests/ElementRequired.test.js | 33 + .../form-builder/app/edit/tests/Modal.test.js | 75 + components/form-builder/app/index.ts | 11 + .../app/navigation/EditNavigation.tsx | 24 + .../form-builder/app/navigation/Header.tsx | 57 + .../app/navigation/LeftNavLink.tsx | 30 + .../app/navigation/LeftNavigation.tsx | 54 + .../app/navigation/PreviewNavigation.tsx | 12 + .../app/navigation/SubNavLink.tsx | 31 + components/form-builder/app/shared/Button.tsx | 144 + .../app/shared/ConfirmFormDeleteDialog.tsx | 71 + .../app/shared/CopyToClipboard.tsx | 26 + components/form-builder/app/shared/Dialog.tsx | 77 + .../app/shared/DownloadFileButton.tsx | 135 + components/form-builder/app/shared/Input.tsx | 68 + .../form-builder/app/shared/LangSwitcher.tsx | 70 + .../app/shared/MultipleChoice.tsx | 67 + components/form-builder/app/shared/Output.tsx | 8 + .../app/shared/ResumeEditingForm.tsx | 37 + .../form-builder/app/shared/SaveButton.tsx | 72 + .../form-builder/app/shared/TextArea.tsx | 39 + components/form-builder/app/shared/index.ts | 9 + .../app/translate/Description.tsx | 102 + .../app/translate/DownloadCSV.tsx | 109 + .../app/translate/FieldsetLegend.tsx | 9 + .../app/translate/LanguageLabel.tsx | 25 + .../form-builder/app/translate/Options.tsx | 93 + .../form-builder/app/translate/RichText.tsx | 78 + .../app/translate/SectionTitle.tsx | 10 + .../form-builder/app/translate/Title.tsx | 94 + .../form-builder/app/translate/Translate.tsx | 331 + .../form-builder/app/translate/index.ts | 3 + components/form-builder/example-form.json | 248 + components/form-builder/hooks/index.ts | 7 + .../form-builder/hooks/useActivePathname.tsx | 24 + .../form-builder/hooks/useAllowPublish.tsx | 124 + components/form-builder/hooks/useDelete.tsx | 24 + .../form-builder/hooks/useElementOptions.tsx | 31 + components/form-builder/hooks/usePublish.tsx | 51 + .../form-builder/hooks/useTemplateApi.tsx | 57 + .../form-builder/hooks/useTemplateStatus.tsx | 54 + .../form-builder/icons/BackArrowIcon.tsx | 16 + components/form-builder/icons/BoldIcon.tsx | 16 + .../form-builder/icons/BulletListIcon.tsx | 16 + .../form-builder/icons/BulletedListIcon.tsx | 16 + .../form-builder/icons/CalendarIcon.tsx | 17 + components/form-builder/icons/CancelIcon.tsx | 16 + .../form-builder/icons/CheckBoxEmptyIcon.tsx | 19 + components/form-builder/icons/CheckIcon.tsx | 20 + components/form-builder/icons/ChevronDown.tsx | 16 + components/form-builder/icons/ChevronUp.tsx | 16 + .../form-builder/icons/CircleCheckIcon.tsx | 16 + components/form-builder/icons/Close.tsx | 16 + components/form-builder/icons/DesignIcon.tsx | 16 + components/form-builder/icons/Duplicate.tsx | 16 + components/form-builder/icons/EditIcon.tsx | 16 + components/form-builder/icons/EmailIcon.tsx | 17 + .../form-builder/icons/ExternalLinkIcon.tsx | 16 + components/form-builder/icons/FolderIcon.tsx | 16 + components/form-builder/icons/GearIcon.tsx | 16 + components/form-builder/icons/GlobeIcon.tsx | 16 + components/form-builder/icons/H2Icon.tsx | 16 + components/form-builder/icons/H3Icon.tsx | 16 + components/form-builder/icons/InfoIcon.tsx | 20 + components/form-builder/icons/ItalicIcon.tsx | 16 + components/form-builder/icons/LinkIcon.tsx | 16 + components/form-builder/icons/LockIcon.tsx | 20 + .../form-builder/icons/MenuOpenIcon.tsx | 16 + .../form-builder/icons/NumberedListIcon.tsx | 16 + .../form-builder/icons/NumericFieldIcon.tsx | 17 + components/form-builder/icons/PageIcon.tsx | 16 + .../form-builder/icons/ParagraphIcon.tsx | 17 + components/form-builder/icons/PhoneIcon.tsx | 17 + components/form-builder/icons/PreviewIcon.tsx | 16 + components/form-builder/icons/PublishIcon.tsx | 16 + .../form-builder/icons/RadioEmptyIcon.tsx | 20 + components/form-builder/icons/RadioIcon.tsx | 19 + components/form-builder/icons/RocketIcon.tsx | 26 + components/form-builder/icons/SaveIcon.tsx | 16 + .../form-builder/icons/SelectMenuIcon.tsx | 20 + components/form-builder/icons/ShareIcon.tsx | 16 + .../form-builder/icons/ShortAnswerIcon.tsx | 17 + .../form-builder/icons/ThreeDotsIcon.tsx | 17 + components/form-builder/icons/ToggleLeft.tsx | 16 + components/form-builder/icons/ToggleRight.tsx | 16 + components/form-builder/icons/WarningIcon.tsx | 27 + components/form-builder/icons/index.ts | 44 + components/form-builder/notes.md | 8 + components/form-builder/store/index.ts | 2 + .../form-builder/store/useModalStore.tsx | 51 + .../form-builder/store/useTemplateStore.tsx | 327 + .../form-builder/test-utils/Providers.js | 26 + .../form-builder/test-utils/defaultStore.json | 46 + components/form-builder/test-utils/index.ts | 3 + components/form-builder/types/index.ts | 92 + components/form-builder/util.ts | 161 + components/form-builder/validate.ts | 36 + components/forms/Alert/Alert.stories.js | 61 + components/forms/Alert/Alert.stories.tsx | 96 - components/forms/Alert/Alert.tsx | 27 +- components/forms/Button/Button.stories.tsx | 2 +- components/forms/Button/Button.test.js | 6 +- components/forms/Button/Button.tsx | 2 - components/forms/Button/DeleteButton.tsx | 6 +- components/forms/Checkbox/Checkbox.test.js | 6 +- components/forms/Checkbox/Checkbox.tsx | 4 +- .../forms/Description/Description.test.js | 2 +- components/forms/Description/Description.tsx | 2 - components/forms/Dropdown/Dropdown.test.js | 6 +- components/forms/Dropdown/Dropdown.tsx | 4 +- .../forms/DynamicRow/DynamicRow.test.js | 65 +- components/forms/DynamicRow/DynamicRow.tsx | 5 +- .../ErrorListItem/ErrorListItem.stories.tsx | 2 +- .../ErrorListItem/ErrorListItem.test.tsx | 2 +- .../forms/ErrorListItem/ErrorListItem.tsx | 4 +- .../forms/ErrorMessage/ErrorMessage.test.js | 2 +- .../forms/ErrorMessage/ErrorMessage.tsx | 2 - .../forms/FileInput/FileInput.stories.tsx | 6 +- components/forms/FileInput/FileInput.test.tsx | 2 +- components/forms/FileInput/FileInput.tsx | 4 +- components/forms/Form/Form.test.js | 99 +- components/forms/Form/Form.tsx | 169 +- components/forms/FormGroup/FormGroup.test.js | 2 +- components/forms/FormGroup/FormGroup.tsx | 2 - components/forms/Heading/Heading.stories.tsx | 28 - components/forms/Heading/Heading.test.js | 23 - components/forms/Heading/Heading.tsx | 28 - components/forms/Label/Label.test.js | 2 +- components/forms/Label/Label.tsx | 2 - .../MultipleChoiceGroup.tsx | 6 +- components/forms/Radio/Radio.test.js | 2 +- components/forms/Radio/Radio.tsx | 4 +- components/forms/RichText/RichText.test.js | 18 +- components/forms/RichText/RichText.tsx | 61 +- components/forms/TextArea/TextArea.test.js | 20 +- components/forms/TextArea/TextArea.tsx | 4 +- components/forms/TextInput/TextInput.test.js | 14 +- components/forms/TextInput/TextInput.tsx | 39 +- components/forms/TextPage/TextPage.tsx | 22 +- components/forms/index.ts | 2 +- components/globals/AdminNav.tsx | 57 +- .../globals/Attention/Attention.test.tsx | 30 + components/globals/Attention/Attention.tsx | 38 + components/globals/Base.js | 58 - components/globals/Base.test.js | 55 - components/globals/Fip.js | 50 - components/globals/Fip.tsx | 52 + components/globals/Footer.js | 36 - components/globals/Footer.tsx | 41 + components/globals/LanguageToggle.js | 50 +- components/globals/PhaseBanner.js | 20 - .../globals/StyledLink/StyledLink.test.js | 70 + components/globals/StyledLink/StyledLink.tsx | 69 + components/globals/layouts/AdminNavLayout.tsx | 42 + components/globals/layouts/BaseLayout.test.js | 20 + components/globals/layouts/BaseLayout.tsx | 40 + .../globals/layouts/FormDisplayLayout.tsx | 45 + components/globals/layouts/UserNavLayout.tsx | 55 + components/myforms/Card/Card.test.js | 95 + components/myforms/Card/Card.tsx | 116 + components/myforms/CardGrid/CardGrid.test.js | 43 + components/myforms/CardGrid/CardGrid.tsx | 34 + components/myforms/LeftNav/LeftNavLink.tsx | 27 + .../myforms/LeftNav/LeftNavigation.test.js | 32 + components/myforms/LeftNav/LeftNavigation.tsx | 56 + components/myforms/LeftNav/index.ts | 2 + components/myforms/MenuDropdown/Menu.ts | 200 + .../myforms/MenuDropdown/MenuDropdown.test.js | 77 + .../myforms/MenuDropdown/MenuDropdown.tsx | 129 + components/myforms/Tabs/Tab.tsx | 42 + components/myforms/Tabs/TabPanel.tsx | 23 + components/myforms/Tabs/Tabs.test.js | 86 + components/myforms/Tabs/TabsKeynav.ts | 97 + components/myforms/Tabs/TabsList.tsx | 47 + cypress.config.js | 9 + cypress.json | 1 - cypress/e2e/acceptable_use.cy.js | 62 + cypress/e2e/accessibility.cy.js | 20 + .../attestation.cy.js} | 2 +- .../cds_intake.cy.js} | 2 +- .../character_limit.cy.js} | 4 +- .../dynamic_rows.cy.js} | 2 +- .../forms_functionality.cy.js} | 10 +- cypress/e2e/login_page.cy.js | 94 + cypress/e2e/logout.cy.js | 22 + .../platform_intake.cy.js} | 2 +- cypress/e2e/register_page.cy.js | 144 + .../terms_conditions_page.cy.js} | 2 +- .../tsb_contact.cy.js} | 2 +- .../tsb_contact_remove_footer_gc_word.cy.js} | 2 +- cypress/integration/accessibility.spec.js | 41 - cypress/integration/form-builder.spec.js | 85 + cypress/integration/welcome_page.spec.js | 13 - cypress/support/commands.js | 17 +- cypress/support/{index.js => e2e.js} | 0 cypress/tsconfig.json | 7 - docker-compose.yml | 4 +- documentation/Forms/FormDBSchema.stories.mdx | 1 - documentation/Forms/FormViewer.stories.mdx | 9 +- email.domains.json | 24 - .../default_flag_settings.json | 7 +- flag_initialization/index.js | 61 +- flag_initialization/package.json | 4 +- flag_initialization/yarn.lock | 60 +- jest.config.js | 12 +- lib/acceptableUseCache.ts | 28 + lib/adminLogs.ts | 45 +- lib/auth.ts | 168 +- lib/cache/flags.ts | 71 + lib/{cache.ts => cache/formCache.ts} | 61 +- lib/cache/privilegeCache.ts | 89 + lib/fileAttachments.ts | 41 + lib/flags.ts | 60 - lib/formBuilder.tsx | 18 +- lib/{integration => }/helpers.ts | 38 +- lib/hooks/index.ts | 2 + lib/hooks/useAccessControl.tsx | 38 + lib/hooks/useAuth.ts | 436 + ...xternalScript.tsx => useExternalScript.ts} | 0 lib/hooks/{useFlag.tsx => useFlag.ts} | 0 .../{useFormTimer.tsx => useFormTimer.ts} | 0 lib/hooks/{useRefresh.tsx => useRefresh.ts} | 2 +- lib/integration/crud.ts | 329 - lib/integration/dbConnector.ts | 11 - lib/integration/prismaConnector.ts | 44 + lib/integration/queryManager.ts | 18 - lib/lockout.ts | 90 + lib/logger.ts | 2 +- lib/markdown.ts | 4 +- lib/middleware/csrfProtected.ts | 2 +- lib/middleware/index.ts | 1 - lib/middleware/jsonIDValidator.ts | 140 + lib/middleware/jsonValidator.ts | 41 +- lib/middleware/middleware.ts | 4 +- lib/middleware/schemas/templates.schema.json | 353 +- lib/middleware/sessionExists.ts | 6 +- lib/middleware/validBearerToken.ts | 51 - lib/middleware/validTemporaryToken.ts | 53 +- lib/privileges.ts | 316 + lib/routeUtils.ts | 4 +- lib/templates.ts | 505 + lib/tests/auth.test.ts | 366 + lib/tests/cors.test.js | 6 +- lib/tests/crud.test.js | 130 - lib/tests/csrfProtected.test.js | 23 +- lib/tests/dbconnector.test.js | 39 - lib/tests/fileAttachments.test.ts | 35 + lib/tests/formBuilder.test.js | 5 +- lib/tests/helpers.test.js | 55 +- lib/tests/jsonIDValidator.test.js | 144 + lib/tests/jsonValidator.test.js | 29 +- lib/tests/lockout.test.ts | 154 + lib/tests/markdown.test.js | 32 +- lib/tests/middleware.test.js | 4 + lib/tests/privileges.test.ts | 116 + lib/tests/queryManager.test.js | 41 - lib/tests/sessionExists.test.js | 55 +- lib/tests/templates.test.ts | 577 + lib/tests/testData.js | 312 - lib/tests/useFormTimer.test.js | 20 +- lib/tests/users.test.js | 59 - lib/tests/users.test.ts | 139 + lib/tests/validBearerToken.test.js | 85 - lib/tests/validation.test.js | 216 +- lib/types/constants.ts | 4 + lib/types/form-types.ts | 56 +- lib/types/index.ts | 30 +- lib/types/organization-types.ts | 13 - lib/types/privileges-types.ts | 47 + lib/types/retrieval-types.ts | 1 + lib/types/user-types.ts | 2 +- lib/types/utility-types.ts | 24 +- lib/users.ts | 153 +- lib/validation.tsx | 76 +- migrations/index.js | 26 +- migrations/migrations/011-next-auth-v4.js | 70 + migrations/models/User.js | 24 - migrations/models/index.js | 8 - next-i18next.config.js | 3 +- package.json | 142 +- pages/404.js | 6 +- pages/_app.tsx | 52 +- pages/_error.js | 6 +- pages/admin/flags.tsx | 32 +- pages/admin/index.tsx | 47 +- pages/admin/login.tsx | 30 +- pages/admin/organizations/[id].tsx | 111 - pages/admin/organizations/create.tsx | 96 - pages/admin/organizations/index.tsx | 67 - pages/admin/privileges.tsx | 266 + pages/admin/unauthorized.tsx | 32 +- pages/admin/upload.tsx | 39 +- pages/admin/users.tsx | 278 +- pages/admin/vault.tsx | 305 - pages/admin/view-templates.tsx | 235 +- pages/api/acceptableuse.ts | 23 + pages/api/account/confirmpassword.ts | 48 + pages/api/account/forgotpassword.ts | 45 + pages/api/auth/[...nextauth].js | 43 - pages/api/auth/[...nextauth].ts | 204 + pages/api/flags/[key]/check.tsx | 9 +- pages/api/flags/[key]/disable.tsx | 36 +- pages/api/flags/[key]/enable.tsx | 35 +- pages/api/flags/index.tsx | 26 +- pages/api/id/[form]/apiusers.ts | 248 + pages/api/id/[form]/bearer.ts | 224 +- pages/api/id/[form]/owners.ts | 154 - pages/api/id/[form]/retrieval.ts | 15 +- pages/api/notify-callback.ts | 2 +- pages/api/organizations.tsx | 47 - pages/api/privileges.ts | 110 + pages/api/request/publish.ts | 62 + pages/api/request/support.ts | 90 + pages/api/signup/confirm.ts | 46 + pages/api/signup/register.ts | 63 + pages/api/signup/resendconfirmation.ts | 47 + pages/api/submit.ts | 69 +- pages/api/templates.ts | 180 +- pages/api/token/temporary.ts | 183 +- pages/api/users.ts | 73 +- pages/api/verify.ts | 2 +- pages/auth/login.tsx | 205 + pages/auth/logout.tsx | 51 + pages/auth/policy.tsx | 53 + pages/auth/resetpassword.tsx | 351 + pages/changelog.tsx | 18 +- pages/form-builder/edit/[[...params]].tsx | 49 + pages/form-builder/edit/translate.tsx | 24 + pages/form-builder/index.tsx | 88 + pages/form-builder/preview/[[...params]].tsx | 24 + pages/form-builder/publish.tsx | 55 + pages/form-builder/published.tsx | 31 + pages/form-builder/settings/[[...params]].tsx | 23 + .../support/[[...supportType]].tsx | 388 + pages/id/[form]/[[...step]].tsx | 83 +- pages/id/[form]/retrieval.tsx | 50 + pages/id/[form]/settings.tsx | 94 +- pages/id/[form]/users.tsx | 155 + pages/id/builder-preview.js | 53 - pages/index.js | 34 - pages/index.tsx | 44 + pages/myforms/[[...path]].tsx | 165 + pages/sandbox.tsx | 102 - pages/signup/account-created.tsx | 66 + pages/signup/register.tsx | 246 + pages/sla.tsx | 34 + pages/terms-avis.js | 25 - pages/terms-avis.tsx | 34 + pages/unlock-publishing.tsx | 274 + pages/welcome-bienvenue.js | 53 - .../20220506182127_initial/migration.sql | 140 + .../migration.sql | 178 + .../20220721184312_access_logs/migration.sql | 12 + .../migration.sql | 12 + .../20221006181804_permissions/migration.sql | 95 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 3 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 110 + prisma/seeds/fixtures/privileges.ts | 129 + prisma/seeds/fixtures/templates.ts | 599 + prisma/seeds/seed.ts | 84 + public/img/form-builder-delete-dialog.svg | 7 + public/img/form-builder-download.svg | 36 + public/static/content/en/sla.md | 129 + public/static/content/en/terms-of-use.md | 9 + public/static/content/en/terms.md | 32 +- public/static/content/fr/sla.md | 129 + public/static/content/fr/terms-of-use.md | 14 + public/static/content/fr/terms.md | 37 +- public/static/locales/en/admin-flags.json | 16 +- .../static/locales/en/admin-privileges.json | 7 + public/static/locales/en/admin-templates.json | 11 +- public/static/locales/en/admin-users.json | 10 +- public/static/locales/en/cognito-errors.json | 19 + public/static/locales/en/common.json | 43 +- public/static/locales/en/form-builder.json | 256 + .../locales/en/forms-responses-retrieval.json | 5 + public/static/locales/en/login.json | 20 + public/static/locales/en/logout.json | 6 + public/static/locales/en/my-forms.json | 39 + public/static/locales/en/organizations.json | 9 - public/static/locales/en/policy.json | 3 + public/static/locales/en/reset-password.json | 45 + public/static/locales/en/signup.json | 83 + public/static/locales/en/sla.json | 3 + public/static/locales/en/terms.json | 3 + .../static/locales/en/unlock-publishing.json | 30 + public/static/locales/en/welcome.json | 29 - public/static/locales/fr/admin-flags.json | 16 +- .../static/locales/fr/admin-privileges.json | 7 + public/static/locales/fr/admin-templates.json | 19 +- public/static/locales/fr/admin-users.json | 10 +- public/static/locales/fr/cognito-errors.json | 13 + public/static/locales/fr/common.json | 46 +- public/static/locales/fr/form-builder.json | 256 + .../locales/fr/forms-responses-retrieval.json | 5 + public/static/locales/fr/login.json | 20 + public/static/locales/fr/logout.json | 5 + public/static/locales/fr/my-forms.json | 39 + public/static/locales/fr/organizations.json | 9 - public/static/locales/fr/policy.json | 3 + public/static/locales/fr/reset-password.json | 45 + public/static/locales/fr/signup.json | 80 + public/static/locales/fr/sla.json | 3 + public/static/locales/fr/terms.json | 3 + .../static/locales/fr/unlock-publishing.json | 30 + public/static/locales/fr/welcome.json | 29 - renovate.json | 40 + styles/_base.scss | 116 +- styles/_form-builder.scss | 419 + styles/_forms.scss | 64 +- styles/_header.scss | 29 +- styles/app.scss | 1 + tailwind.config.js | 222 +- tests/Settings.test.js | 65 - tests/api/flags.test.js | 108 - tests/api/id/[form]/bearer.test.js | 378 - tests/api/id/[form]/owners.test.js | 527 - tests/api/templates.test.js | 177 - tests/api/token/temporary.test.js | 143 - tests/api/users.test.js | 273 - tests/data/attestationTestForm.json | 50 - tests/data/brokenFormTemplate.json | 673 - tests/data/validFormTemplate.json | 671 - tsconfig.json | 9 +- types/i18next.d.ts | 7 + types/next_auth/index.d.ts | 32 +- types/react-app-polyfill/index.d.ts | 1 + utils/cognitoBackup/.env_example | 3 + utils/cognitoBackup/README.md | 21 + utils/cognitoBackup/package.json | 22 + utils/cognitoBackup/restoreUsers.ts | 153 + utils/cognitoBackup/retrieveUsers.ts | 105 + utils/cognitoBackup/tsconfig.json | 103 + utils/cognitoBackup/yarn.lock | 1170 ++ utils/retrievalAPI/package.json | 4 +- utils/retrievalAPI/yarn.lock | 16 +- yarn.lock | 10882 +++++++++------- 575 files changed, 42298 insertions(+), 13511 deletions(-) create mode 100644 .github/workflows/test-container-build-production.yml create mode 100644 .github/workflows/test-container-build-staging.yml create mode 100644 .idea/.name create mode 100644 __fixtures__/accessibilityTestForm.json create mode 100644 __fixtures__/attestationTestForm.json create mode 100644 __fixtures__/brokenFormTemplate.json rename {tests/data => __fixtures__}/cdsIntakeTestForm.json (96%) create mode 100644 __fixtures__/duplicateElementIds.json rename {tests/data => __fixtures__}/dynamicRowsTestForm.json (99%) create mode 100644 __fixtures__/invalidLayoutIds.json create mode 100644 __fixtures__/invalidSubElementIds.json rename {tests/data => __fixtures__}/platformIntakeTestForm.json (97%) create mode 100644 __fixtures__/testData.json rename {tests/data => __fixtures__}/textFieldTestForm.json (100%) rename {tests/data => __fixtures__}/tsbContactTestForm.json (98%) rename {tests/data => __fixtures__}/tsbDisableFooterGCBranding.json (95%) create mode 100644 __fixtures__/validFormTemplate.json create mode 100644 __fixtures__/validFormTemplateWithHTMLInDynamicRow.json create mode 100644 __tests__/api/acceptable-use.test.ts create mode 100644 __tests__/api/account/confirmpassword.test.ts create mode 100644 __tests__/api/account/forgotpassword.test.ts create mode 100644 __tests__/api/flags.test.ts create mode 100644 __tests__/api/id/form/apiUsers.test.ts create mode 100644 __tests__/api/id/form/bearer.test.ts rename tests/api/id/[form]/retrieval.test.js => __tests__/api/id/form/retrieval.test.ts (66%) rename {tests => __tests__}/api/log.test.ts (96%) rename {tests => __tests__}/api/notify-callbacks.test.js (99%) create mode 100644 __tests__/api/request/publish.test.ts create mode 100644 __tests__/api/request/support.test.ts create mode 100644 __tests__/api/signup/confirm.test.ts create mode 100644 __tests__/api/signup/register.test.ts create mode 100644 __tests__/api/signup/resendconfirmation.test.ts create mode 100644 __tests__/api/templates.test.ts create mode 100644 __tests__/api/token/temporary.test.ts create mode 100644 __tests__/api/users.test.ts create mode 100644 __tests__/id/[form]/settings.test.js create mode 100644 __utils__/index.ts create mode 100644 __utils__/jestShim.ts rename {lib => __utils__}/jestUtils.ts (100%) create mode 100644 __utils__/mocks/middleware.ts create mode 100644 __utils__/permissions.ts create mode 100644 __utils__/prismaConnector.ts rename {lib/tests => __utils__}/setupTests.ts (81%) create mode 100644 components/admin/TemplateDelete/Settings.tsx create mode 100644 components/auth/AcceptableUse.test.js create mode 100644 components/auth/AcceptableUse.tsx create mode 100644 components/auth/Confirmation/Confirmation.test.js create mode 100644 components/auth/Confirmation/Confirmation.tsx create mode 100644 components/auth/LoginMenu.test.js create mode 100644 components/auth/LoginMenu.tsx create mode 100644 components/form-builder/__tests__/useActivePathname.tsx create mode 100644 components/form-builder/__tests__/useAllowPublish.test.tsx create mode 100644 components/form-builder/__tests__/useModalStore.test.tsx create mode 100644 components/form-builder/__tests__/useTemplateStore.test.tsx create mode 100644 components/form-builder/__tests__/util.test.js create mode 100644 components/form-builder/app/Preview.tsx create mode 100644 components/form-builder/app/Publish.tsx create mode 100644 components/form-builder/app/PublishNoAuth.tsx create mode 100644 components/form-builder/app/Published.tsx create mode 100644 components/form-builder/app/Settings.tsx create mode 100644 components/form-builder/app/Start.tsx create mode 100644 components/form-builder/app/Template.tsx create mode 100644 components/form-builder/app/edit/ConfirmationDescription.tsx create mode 100644 components/form-builder/app/edit/Edit.tsx create mode 100644 components/form-builder/app/edit/ElementDropDown.tsx create mode 100644 components/form-builder/app/edit/ElementPanel.tsx create mode 100644 components/form-builder/app/edit/ElementRequired.tsx create mode 100644 components/form-builder/app/edit/Modal.tsx create mode 100644 components/form-builder/app/edit/ModalForm.tsx create mode 100644 components/form-builder/app/edit/PanelActions.tsx create mode 100644 components/form-builder/app/edit/PanelActionsLocked.tsx create mode 100644 components/form-builder/app/edit/PanelBody.tsx create mode 100644 components/form-builder/app/edit/PanelBodyRoot.tsx create mode 100644 components/form-builder/app/edit/PrivacyDescription.tsx create mode 100644 components/form-builder/app/edit/SelectedElement.tsx create mode 100644 components/form-builder/app/edit/elements/BulkAdd.tsx create mode 100644 components/form-builder/app/edit/elements/DropDown.tsx create mode 100644 components/form-builder/app/edit/elements/Option.tsx create mode 100644 components/form-builder/app/edit/elements/Options.tsx create mode 100644 components/form-builder/app/edit/elements/RichText.tsx create mode 100644 components/form-builder/app/edit/elements/RichTextLocked.tsx create mode 100644 components/form-builder/app/edit/elements/ShortAnswer.tsx create mode 100644 components/form-builder/app/edit/elements/index.ts create mode 100644 components/form-builder/app/edit/elements/lexical-editor/Editor.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/RichTextEditor.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/ToolTip.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/Toolbar.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/config.ts create mode 100644 components/form-builder/app/edit/elements/lexical-editor/plugins/FloatingLinkEditorPlugin/index.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/plugins/FocusEditor.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/plugins/ListMaxIndentPlugin.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/useEditorFocus.tsx create mode 100644 components/form-builder/app/edit/elements/lexical-editor/utils/getSelectedNode.ts create mode 100644 components/form-builder/app/edit/elements/lexical-editor/utils/setFloatingElemPosition.ts create mode 100644 components/form-builder/app/edit/elements/lexical-editor/utils/url.ts create mode 100644 components/form-builder/app/edit/elements/question/Question.tsx create mode 100644 components/form-builder/app/edit/elements/question/QuestionInput.tsx create mode 100644 components/form-builder/app/edit/elements/question/QuestionNumber.tsx create mode 100644 components/form-builder/app/edit/elements/tests/Dropdown.test.js create mode 100644 components/form-builder/app/edit/elements/tests/Option.test.js create mode 100644 components/form-builder/app/edit/elements/tests/Options.test.js create mode 100644 components/form-builder/app/edit/elements/tests/RichText.test.js create mode 100644 components/form-builder/app/edit/elements/tests/RichTextLocked.test.js create mode 100644 components/form-builder/app/edit/elements/tests/ShortAnswer.test.js create mode 100644 components/form-builder/app/edit/index.ts create mode 100644 components/form-builder/app/edit/tests/ElementDropDown.test.js create mode 100644 components/form-builder/app/edit/tests/ElementPanel.test.js create mode 100644 components/form-builder/app/edit/tests/ElementRequired.test.js create mode 100644 components/form-builder/app/edit/tests/Modal.test.js create mode 100644 components/form-builder/app/index.ts create mode 100644 components/form-builder/app/navigation/EditNavigation.tsx create mode 100644 components/form-builder/app/navigation/Header.tsx create mode 100644 components/form-builder/app/navigation/LeftNavLink.tsx create mode 100644 components/form-builder/app/navigation/LeftNavigation.tsx create mode 100644 components/form-builder/app/navigation/PreviewNavigation.tsx create mode 100644 components/form-builder/app/navigation/SubNavLink.tsx create mode 100644 components/form-builder/app/shared/Button.tsx create mode 100644 components/form-builder/app/shared/ConfirmFormDeleteDialog.tsx create mode 100644 components/form-builder/app/shared/CopyToClipboard.tsx create mode 100644 components/form-builder/app/shared/Dialog.tsx create mode 100644 components/form-builder/app/shared/DownloadFileButton.tsx create mode 100644 components/form-builder/app/shared/Input.tsx create mode 100644 components/form-builder/app/shared/LangSwitcher.tsx create mode 100644 components/form-builder/app/shared/MultipleChoice.tsx create mode 100644 components/form-builder/app/shared/Output.tsx create mode 100644 components/form-builder/app/shared/ResumeEditingForm.tsx create mode 100644 components/form-builder/app/shared/SaveButton.tsx create mode 100644 components/form-builder/app/shared/TextArea.tsx create mode 100644 components/form-builder/app/shared/index.ts create mode 100644 components/form-builder/app/translate/Description.tsx create mode 100644 components/form-builder/app/translate/DownloadCSV.tsx create mode 100644 components/form-builder/app/translate/FieldsetLegend.tsx create mode 100644 components/form-builder/app/translate/LanguageLabel.tsx create mode 100644 components/form-builder/app/translate/Options.tsx create mode 100644 components/form-builder/app/translate/RichText.tsx create mode 100644 components/form-builder/app/translate/SectionTitle.tsx create mode 100644 components/form-builder/app/translate/Title.tsx create mode 100644 components/form-builder/app/translate/Translate.tsx create mode 100644 components/form-builder/app/translate/index.ts create mode 100644 components/form-builder/example-form.json create mode 100644 components/form-builder/hooks/index.ts create mode 100644 components/form-builder/hooks/useActivePathname.tsx create mode 100644 components/form-builder/hooks/useAllowPublish.tsx create mode 100644 components/form-builder/hooks/useDelete.tsx create mode 100644 components/form-builder/hooks/useElementOptions.tsx create mode 100644 components/form-builder/hooks/usePublish.tsx create mode 100644 components/form-builder/hooks/useTemplateApi.tsx create mode 100644 components/form-builder/hooks/useTemplateStatus.tsx create mode 100644 components/form-builder/icons/BackArrowIcon.tsx create mode 100644 components/form-builder/icons/BoldIcon.tsx create mode 100644 components/form-builder/icons/BulletListIcon.tsx create mode 100644 components/form-builder/icons/BulletedListIcon.tsx create mode 100644 components/form-builder/icons/CalendarIcon.tsx create mode 100644 components/form-builder/icons/CancelIcon.tsx create mode 100644 components/form-builder/icons/CheckBoxEmptyIcon.tsx create mode 100644 components/form-builder/icons/CheckIcon.tsx create mode 100644 components/form-builder/icons/ChevronDown.tsx create mode 100644 components/form-builder/icons/ChevronUp.tsx create mode 100644 components/form-builder/icons/CircleCheckIcon.tsx create mode 100644 components/form-builder/icons/Close.tsx create mode 100644 components/form-builder/icons/DesignIcon.tsx create mode 100644 components/form-builder/icons/Duplicate.tsx create mode 100644 components/form-builder/icons/EditIcon.tsx create mode 100644 components/form-builder/icons/EmailIcon.tsx create mode 100644 components/form-builder/icons/ExternalLinkIcon.tsx create mode 100644 components/form-builder/icons/FolderIcon.tsx create mode 100644 components/form-builder/icons/GearIcon.tsx create mode 100644 components/form-builder/icons/GlobeIcon.tsx create mode 100644 components/form-builder/icons/H2Icon.tsx create mode 100644 components/form-builder/icons/H3Icon.tsx create mode 100644 components/form-builder/icons/InfoIcon.tsx create mode 100644 components/form-builder/icons/ItalicIcon.tsx create mode 100644 components/form-builder/icons/LinkIcon.tsx create mode 100644 components/form-builder/icons/LockIcon.tsx create mode 100644 components/form-builder/icons/MenuOpenIcon.tsx create mode 100644 components/form-builder/icons/NumberedListIcon.tsx create mode 100644 components/form-builder/icons/NumericFieldIcon.tsx create mode 100644 components/form-builder/icons/PageIcon.tsx create mode 100644 components/form-builder/icons/ParagraphIcon.tsx create mode 100644 components/form-builder/icons/PhoneIcon.tsx create mode 100644 components/form-builder/icons/PreviewIcon.tsx create mode 100644 components/form-builder/icons/PublishIcon.tsx create mode 100644 components/form-builder/icons/RadioEmptyIcon.tsx create mode 100644 components/form-builder/icons/RadioIcon.tsx create mode 100644 components/form-builder/icons/RocketIcon.tsx create mode 100644 components/form-builder/icons/SaveIcon.tsx create mode 100644 components/form-builder/icons/SelectMenuIcon.tsx create mode 100644 components/form-builder/icons/ShareIcon.tsx create mode 100644 components/form-builder/icons/ShortAnswerIcon.tsx create mode 100644 components/form-builder/icons/ThreeDotsIcon.tsx create mode 100644 components/form-builder/icons/ToggleLeft.tsx create mode 100644 components/form-builder/icons/ToggleRight.tsx create mode 100644 components/form-builder/icons/WarningIcon.tsx create mode 100644 components/form-builder/icons/index.ts create mode 100644 components/form-builder/notes.md create mode 100644 components/form-builder/store/index.ts create mode 100644 components/form-builder/store/useModalStore.tsx create mode 100644 components/form-builder/store/useTemplateStore.tsx create mode 100644 components/form-builder/test-utils/Providers.js create mode 100644 components/form-builder/test-utils/defaultStore.json create mode 100644 components/form-builder/test-utils/index.ts create mode 100644 components/form-builder/types/index.ts create mode 100644 components/form-builder/util.ts create mode 100644 components/form-builder/validate.ts create mode 100644 components/forms/Alert/Alert.stories.js delete mode 100644 components/forms/Alert/Alert.stories.tsx delete mode 100644 components/forms/Heading/Heading.stories.tsx delete mode 100644 components/forms/Heading/Heading.test.js delete mode 100644 components/forms/Heading/Heading.tsx create mode 100644 components/globals/Attention/Attention.test.tsx create mode 100644 components/globals/Attention/Attention.tsx delete mode 100644 components/globals/Base.js delete mode 100644 components/globals/Base.test.js delete mode 100644 components/globals/Fip.js create mode 100644 components/globals/Fip.tsx delete mode 100644 components/globals/Footer.js create mode 100644 components/globals/Footer.tsx delete mode 100644 components/globals/PhaseBanner.js create mode 100644 components/globals/StyledLink/StyledLink.test.js create mode 100644 components/globals/StyledLink/StyledLink.tsx create mode 100644 components/globals/layouts/AdminNavLayout.tsx create mode 100644 components/globals/layouts/BaseLayout.test.js create mode 100644 components/globals/layouts/BaseLayout.tsx create mode 100644 components/globals/layouts/FormDisplayLayout.tsx create mode 100644 components/globals/layouts/UserNavLayout.tsx create mode 100644 components/myforms/Card/Card.test.js create mode 100644 components/myforms/Card/Card.tsx create mode 100644 components/myforms/CardGrid/CardGrid.test.js create mode 100644 components/myforms/CardGrid/CardGrid.tsx create mode 100644 components/myforms/LeftNav/LeftNavLink.tsx create mode 100644 components/myforms/LeftNav/LeftNavigation.test.js create mode 100644 components/myforms/LeftNav/LeftNavigation.tsx create mode 100644 components/myforms/LeftNav/index.ts create mode 100644 components/myforms/MenuDropdown/Menu.ts create mode 100644 components/myforms/MenuDropdown/MenuDropdown.test.js create mode 100644 components/myforms/MenuDropdown/MenuDropdown.tsx create mode 100644 components/myforms/Tabs/Tab.tsx create mode 100644 components/myforms/Tabs/TabPanel.tsx create mode 100644 components/myforms/Tabs/Tabs.test.js create mode 100644 components/myforms/Tabs/TabsKeynav.ts create mode 100644 components/myforms/Tabs/TabsList.tsx create mode 100644 cypress.config.js delete mode 100644 cypress.json create mode 100644 cypress/e2e/acceptable_use.cy.js create mode 100644 cypress/e2e/accessibility.cy.js rename cypress/{integration/attestation.spec.js => e2e/attestation.cy.js} (94%) rename cypress/{integration/cds_intake.spec.js => e2e/cds_intake.cy.js} (95%) rename cypress/{integration/character_limit.spec.js => e2e/character_limit.cy.js} (88%) rename cypress/{integration/dynamic_rows.spec.js => e2e/dynamic_rows.cy.js} (98%) rename cypress/{integration/forms_functionality.spec.js => e2e/forms_functionality.cy.js} (86%) create mode 100644 cypress/e2e/login_page.cy.js create mode 100644 cypress/e2e/logout.cy.js rename cypress/{integration/platform_intake.spec.js => e2e/platform_intake.cy.js} (94%) create mode 100644 cypress/e2e/register_page.cy.js rename cypress/{integration/terms_conditions_page.spec.js => e2e/terms_conditions_page.cy.js} (89%) rename cypress/{integration/tsb_contact.spec.js => e2e/tsb_contact.cy.js} (95%) rename cypress/{integration/tsb_contact_remove_footer_gc_word.spec.js => e2e/tsb_contact_remove_footer_gc_word.cy.js} (84%) delete mode 100644 cypress/integration/accessibility.spec.js create mode 100644 cypress/integration/form-builder.spec.js delete mode 100644 cypress/integration/welcome_page.spec.js rename cypress/support/{index.js => e2e.js} (100%) delete mode 100644 cypress/tsconfig.json delete mode 100644 email.domains.json create mode 100644 lib/acceptableUseCache.ts create mode 100644 lib/cache/flags.ts rename lib/{cache.ts => cache/formCache.ts} (56%) create mode 100644 lib/cache/privilegeCache.ts create mode 100644 lib/fileAttachments.ts delete mode 100644 lib/flags.ts rename lib/{integration => }/helpers.ts (92%) create mode 100644 lib/hooks/useAccessControl.tsx create mode 100644 lib/hooks/useAuth.ts rename lib/hooks/{useExternalScript.tsx => useExternalScript.ts} (100%) rename lib/hooks/{useFlag.tsx => useFlag.ts} (100%) rename lib/hooks/{useFormTimer.tsx => useFormTimer.ts} (100%) rename lib/hooks/{useRefresh.tsx => useRefresh.ts} (96%) delete mode 100644 lib/integration/crud.ts delete mode 100644 lib/integration/dbConnector.ts create mode 100644 lib/integration/prismaConnector.ts delete mode 100644 lib/integration/queryManager.ts create mode 100644 lib/lockout.ts create mode 100644 lib/middleware/jsonIDValidator.ts delete mode 100644 lib/middleware/validBearerToken.ts create mode 100644 lib/privileges.ts create mode 100644 lib/templates.ts create mode 100644 lib/tests/auth.test.ts delete mode 100644 lib/tests/crud.test.js delete mode 100644 lib/tests/dbconnector.test.js create mode 100644 lib/tests/fileAttachments.test.ts create mode 100644 lib/tests/jsonIDValidator.test.js create mode 100644 lib/tests/lockout.test.ts create mode 100644 lib/tests/privileges.test.ts delete mode 100644 lib/tests/queryManager.test.js create mode 100644 lib/tests/templates.test.ts delete mode 100644 lib/tests/testData.js delete mode 100644 lib/tests/users.test.js create mode 100644 lib/tests/users.test.ts delete mode 100644 lib/tests/validBearerToken.test.js create mode 100644 lib/types/constants.ts delete mode 100644 lib/types/organization-types.ts create mode 100644 lib/types/privileges-types.ts create mode 100644 migrations/migrations/011-next-auth-v4.js delete mode 100644 migrations/models/User.js delete mode 100644 migrations/models/index.js delete mode 100644 pages/admin/organizations/[id].tsx delete mode 100644 pages/admin/organizations/create.tsx delete mode 100644 pages/admin/organizations/index.tsx create mode 100644 pages/admin/privileges.tsx delete mode 100644 pages/admin/vault.tsx create mode 100644 pages/api/acceptableuse.ts create mode 100644 pages/api/account/confirmpassword.ts create mode 100644 pages/api/account/forgotpassword.ts delete mode 100644 pages/api/auth/[...nextauth].js create mode 100644 pages/api/auth/[...nextauth].ts create mode 100644 pages/api/id/[form]/apiusers.ts delete mode 100644 pages/api/id/[form]/owners.ts delete mode 100644 pages/api/organizations.tsx create mode 100644 pages/api/privileges.ts create mode 100644 pages/api/request/publish.ts create mode 100644 pages/api/request/support.ts create mode 100644 pages/api/signup/confirm.ts create mode 100644 pages/api/signup/register.ts create mode 100644 pages/api/signup/resendconfirmation.ts create mode 100644 pages/auth/login.tsx create mode 100644 pages/auth/logout.tsx create mode 100644 pages/auth/policy.tsx create mode 100644 pages/auth/resetpassword.tsx create mode 100644 pages/form-builder/edit/[[...params]].tsx create mode 100644 pages/form-builder/edit/translate.tsx create mode 100644 pages/form-builder/index.tsx create mode 100644 pages/form-builder/preview/[[...params]].tsx create mode 100644 pages/form-builder/publish.tsx create mode 100644 pages/form-builder/published.tsx create mode 100644 pages/form-builder/settings/[[...params]].tsx create mode 100644 pages/form-builder/support/[[...supportType]].tsx create mode 100644 pages/id/[form]/retrieval.tsx create mode 100644 pages/id/[form]/users.tsx delete mode 100644 pages/id/builder-preview.js delete mode 100644 pages/index.js create mode 100644 pages/index.tsx create mode 100644 pages/myforms/[[...path]].tsx delete mode 100644 pages/sandbox.tsx create mode 100644 pages/signup/account-created.tsx create mode 100644 pages/signup/register.tsx create mode 100644 pages/sla.tsx delete mode 100644 pages/terms-avis.js create mode 100644 pages/terms-avis.tsx create mode 100644 pages/unlock-publishing.tsx delete mode 100644 pages/welcome-bienvenue.js create mode 100644 prisma/migrations/20220506182127_initial/migration.sql create mode 100644 prisma/migrations/20220506200853_prisma_conversion/migration.sql create mode 100644 prisma/migrations/20220721184312_access_logs/migration.sql create mode 100644 prisma/migrations/20220817190507_add_user_roles/migration.sql create mode 100644 prisma/migrations/20221006181804_permissions/migration.sql create mode 100644 prisma/migrations/20221031124445_add_ttl_to_template/migration.sql create mode 100644 prisma/migrations/20221031175512_add_ispublished_to_template/migration.sql create mode 100644 prisma/migrations/20221104174253_template_updated_at/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seeds/fixtures/privileges.ts create mode 100644 prisma/seeds/fixtures/templates.ts create mode 100644 prisma/seeds/seed.ts create mode 100644 public/img/form-builder-delete-dialog.svg create mode 100644 public/img/form-builder-download.svg create mode 100644 public/static/content/en/sla.md create mode 100644 public/static/content/en/terms-of-use.md create mode 100644 public/static/content/fr/sla.md create mode 100644 public/static/content/fr/terms-of-use.md create mode 100644 public/static/locales/en/admin-privileges.json create mode 100644 public/static/locales/en/cognito-errors.json create mode 100644 public/static/locales/en/form-builder.json create mode 100644 public/static/locales/en/forms-responses-retrieval.json create mode 100644 public/static/locales/en/login.json create mode 100644 public/static/locales/en/logout.json create mode 100644 public/static/locales/en/my-forms.json delete mode 100644 public/static/locales/en/organizations.json create mode 100644 public/static/locales/en/policy.json create mode 100644 public/static/locales/en/reset-password.json create mode 100644 public/static/locales/en/signup.json create mode 100644 public/static/locales/en/sla.json create mode 100644 public/static/locales/en/terms.json create mode 100644 public/static/locales/en/unlock-publishing.json delete mode 100644 public/static/locales/en/welcome.json create mode 100644 public/static/locales/fr/admin-privileges.json create mode 100644 public/static/locales/fr/cognito-errors.json create mode 100644 public/static/locales/fr/form-builder.json create mode 100644 public/static/locales/fr/forms-responses-retrieval.json create mode 100644 public/static/locales/fr/login.json create mode 100644 public/static/locales/fr/logout.json create mode 100644 public/static/locales/fr/my-forms.json delete mode 100644 public/static/locales/fr/organizations.json create mode 100644 public/static/locales/fr/policy.json create mode 100644 public/static/locales/fr/reset-password.json create mode 100644 public/static/locales/fr/signup.json create mode 100644 public/static/locales/fr/sla.json create mode 100644 public/static/locales/fr/terms.json create mode 100644 public/static/locales/fr/unlock-publishing.json delete mode 100644 public/static/locales/fr/welcome.json create mode 100644 renovate.json create mode 100644 styles/_form-builder.scss delete mode 100644 tests/Settings.test.js delete mode 100644 tests/api/flags.test.js delete mode 100644 tests/api/id/[form]/bearer.test.js delete mode 100644 tests/api/id/[form]/owners.test.js delete mode 100644 tests/api/templates.test.js delete mode 100644 tests/api/token/temporary.test.js delete mode 100644 tests/api/users.test.js delete mode 100644 tests/data/attestationTestForm.json delete mode 100644 tests/data/brokenFormTemplate.json delete mode 100644 tests/data/validFormTemplate.json create mode 100644 types/i18next.d.ts create mode 100644 types/react-app-polyfill/index.d.ts create mode 100644 utils/cognitoBackup/.env_example create mode 100644 utils/cognitoBackup/README.md create mode 100644 utils/cognitoBackup/package.json create mode 100644 utils/cognitoBackup/restoreUsers.ts create mode 100644 utils/cognitoBackup/retrieveUsers.ts create mode 100644 utils/cognitoBackup/tsconfig.json create mode 100644 utils/cognitoBackup/yarn.lock diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1b72bd20f3..3ccc4c1608 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG VARIANT=16 +ARG VARIANT=16@sha256:1bbd8b82f5a78b6461d3285b62293db99ad60cf4eca35c715636d7143abb057c@sha256:b35e76ba744a975b9a5428b6c3cde1a1cf0be53b246e1e9a4874f87034222b5a@sha256:b35e76ba744a975b9a5428b6c3cde1a1cf0be53b246e1e9a4874f87034222b5a FROM node:${VARIANT} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 3f60372a50..6b7edb2d42 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -14,7 +14,7 @@ services: - 3000 db: - image: postgres:11.2 + image: postgres:11.16@sha256:5d2aa4a7b5f9bdadeddcf87cf7f90a176737a02a30d917de4ab2e6a329bd2d45 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -30,7 +30,7 @@ services: redis: restart: unless-stopped - image: redis:latest + image: redis:latest@sha256:fdaa0102e0c66802845aa5c961cb89a091a188056811802383660cd9e10889da ports: - "6379:6379" expose: diff --git a/.env.example b/.env.example index bd980fff2f..f8a7bbd04c 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ RECAPTCHA_V3_SITE_KEY= # The secret key authorizes communication between your application backend # and the reCAPTCHA server to verify the user's response -RECAPTACHA_V3_SECRET_KEY= +RECAPTCHA_V3_SECRET_KEY= # Used by /api/notify-callback to authorize request GC_NOTIFY_CALLBACK_BEARER_TOKEN= @@ -33,11 +33,10 @@ GOOGLE_CLIENT_ID=[retrieve from lastpass] GOOGLE_CLIENT_SECRET=[retrieve from lastpass] # Local dev -# Temporary token templateID -TEMPORAY_TOKEN_TEMPLATE_ID=b6kdhaud-d10a-422a-973f-05e274d9aa86 - -# An ID of the template to send a form's response -TEMPLATE_ID=8pa6247s75-a1d6-4e3c-8421-042a2b4158b7 +# Temporary token template ID +TEMPORARY_TOKEN_TEMPLATE_ID=something +# Form response template ID +TEMPLATE_ID=something # Used when no Redis instance or Database is available # The application returns all default flags values when this variable is set. diff --git a/.eslintrc.js b/.eslintrc.js index 510e8ff9c1..ce2d5bf9ba 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,9 +11,12 @@ module.exports = { parser: "@typescript-eslint/parser", parserOptions: { - project: "./tsconfig.json", + project: ["./tsconfig.json", "cypress/tsconfig.json"], }, plugins: ["@typescript-eslint", "jsx-a11y", "prettier"], + rules: { + "@typescript-eslint/await-thenable": "error", + }, }, ], env: { @@ -44,9 +47,9 @@ module.exports = { }, }, plugins: ["react", "jsx-a11y", "prettier", "cypress"], - ignorePatterns: ["**/storybook-static/*.*"], rules: { "prettier/prettier": "error", "no-console": "error", + "no-await-in-loop": "error", }, }; diff --git a/.github/workflows/build-and-deploy-storybooks.yml b/.github/workflows/build-and-deploy-storybooks.yml index 0c210061a7..9f8c304330 100644 --- a/.github/workflows/build-and-deploy-storybooks.yml +++ b/.github/workflows/build-and-deploy-storybooks.yml @@ -1,15 +1,25 @@ name: Build and Deploy Storybooks on: pull_request: + paths-ignore: + # Ignore all files and folders that start with '.' + - ".**" + # Ignore all files and folder in fixtures, tests, utils, etc. + - "__*/**" push: branches: [main] + paths-ignore: + # Ignore all files and folders that start with '.' + - ".**" + # Ignore all files and folder in fixtures, tests, utils, etc. + - "__*/**" jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly. + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly. - name: Install 🔧 # This example project is built using yarn and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. run: yarn install @@ -23,7 +33,7 @@ jobs: id: extract_branch - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@3.6.2 + uses: JamesIves/github-pages-deploy-action@e80c869f0057899fc2cd28819b5bbe9de890524a # tag=3.6.2 with: TARGET_FOLDER: ${{ steps.extract_branch.outputs.branch }} BRANCH: gh-pages # The branch the action should deploy to. @@ -32,7 +42,7 @@ jobs: - name: Find Comment if: ${{ github.event_name == 'pull_request' }} - uses: peter-evans/find-comment@v1 + uses: peter-evans/find-comment@d2dae40ed151c634e4189471272b57e76ec19ba8 # v1.3.0 id: fc with: issue-number: ${{ github.event.pull_request.number }} @@ -43,7 +53,7 @@ jobs: - name: Create or update comment # comment in pull request storybooks link if: ${{ github.event_name == 'pull_request' }} - uses: peter-evans/create-or-update-comment@v1 + uses: peter-evans/create-or-update-comment@a35cf36e5301d70b76f316e867e7788a55a31dae # v1.4.5 with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3eb1796b12..1ea69a8b52 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,20 +28,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3.2.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2.1.37 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -52,7 +43,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2.1.37 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -66,4 +57,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2.1.37 diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 7a0a2d69d9..9d293a9535 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -9,23 +9,24 @@ jobs: name: Cypress runs-on: ubuntu-latest env: - AWS_ACCESS_KEY_ID: ${{ secrets.LAMBDA_AWS_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.LAMBDA_AWS_SECRET}} + # Needed for Next Auth to initialize NEXTAUTH_URL: http://localhost:3000 + TOKEN_SECRET: testKey + GOOGLE_CLIENT_ID: testClientID + GOOGLE_CLIENT_SECRET: testClientSecret APP_ENV: test steps: - name: Checkout - uses: actions/checkout@v1 - - uses: cypress-io/github-action@v2 + uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 + - uses: cypress-io/github-action@d79d2d530a66e641eb4a5f227e13bc985c60b964 # v4.2.2 with: + browser: chrome + headed: false build: yarn build start: yarn start:test wait-on: "http://localhost:3000" - command: yarn cypress:headless config: baseUrl=http://localhost:3000 - parallel: false - group: "Form E2E Tests" - - uses: actions/upload-artifact@v1 + - uses: actions/upload-artifact@3446296876d12d4e3a0f3145a3c87e67bf0a16b5 # tag=v1 if: failure() with: name: cypress-screenshots diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 6636830a49..a33d592f56 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 - name: Node.JS 16 - uses: actions/setup-node@v2 + uses: actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561 # v2.5.1 with: node-version: 16 - name: Install Node Dependencies @@ -28,9 +28,9 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 - name: Node.JS 16 - uses: actions/setup-node@v2 + uses: actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561 # v2.5.1 with: node-version: 16 - name: Install Node Dependencies @@ -48,13 +48,13 @@ jobs: # Continue to the next step even if this fails continue-on-error: true - name: Annotate Code Linting Results - uses: ataylorme/eslint-annotate-action@1.1.2 + uses: ataylorme/eslint-annotate-action@47568f60ae08ffa4d3b1bab645e21e9ae8266980 # tag=1.1.2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" report-json: "eslint_report.json" continue-on-error: true - name: Upload ESLint report - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@3446296876d12d4e3a0f3145a3c87e67bf0a16b5 # tag=v1 with: name: eslint_report.json path: eslint_report.json diff --git a/.github/workflows/generate-sbom.yml b/.github/workflows/generate-sbom.yml index d0997d76fb..ad61df5840 100644 --- a/.github/workflows/generate-sbom.yml +++ b/.github/workflows/generate-sbom.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Generate app SBOM - uses: cds-snc/security-tools/.github/actions/generate-sbom@4c6b386722985552f3f008d04279a3f01402cc35 # renovate: tag=v1 + uses: cds-snc/security-tools/.github/actions/generate-sbom@4368a5486da1f0bb698ffe717687612d4231c6cd # v1.1.7 with: dependency_track_api_key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} project_name: forms-client/app diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index 9eb0ddfa62..17b9859665 100644 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -9,7 +9,7 @@ jobs: name: Jest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 - name: "Install dependencies" run: yarn install - name: Jest Tests diff --git a/.github/workflows/prod-build-push-container.yml b/.github/workflows/prod-build-push-container.yml index 9890c1bf7d..dc5c9abdd9 100644 --- a/.github/workflows/prod-build-push-container.yml +++ b/.github/workflows/prod-build-push-container.yml @@ -3,17 +3,26 @@ name: Production — Push container to ECR on: push: branches: [main] + paths-ignore: + # Ignore all files and folders that start with '.' + - ".**" + # Ignore all files and folder in fixtures, tests, utils, etc. + - "__*/**" env: ECR_REPOSITORY: form_viewer_production GITHUB_SHA: ${{ github.sha }} + GOOGLE_CLIENT_SECRET: ${{ secrets.PRODUCTION_GOOGLE_CLIENT_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.PRODUCTION_GOOGLE_CLIENT_ID }} + COGNITO_APP_CLIENT_ID: ${{secrets.PRODUCTION_COGNITO_APP_CLIENT_ID}} + COGNITO_USER_POOL_ID: ${{ secrets.PRODUCTION_COGNITO_USER_POOL_ID}} jobs: push-production: runs-on: ubuntu-latest steps: - name: Wait for Jest tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-jest-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -21,7 +30,7 @@ jobs: ref: ${{ github.sha }} - name: Wait for Cypress tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-cypress-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -29,7 +38,7 @@ jobs: ref: ${{ github.sha }} - name: Wait for ESLint tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-eslint-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -37,17 +46,21 @@ jobs: ref: ${{ github.sha }} - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Build Form Viewer run: | docker build -t base \ --build-arg PRODUCTION_ENV=true \ - --build-arg GITHUB_SHA_ARG=$GITHUB_SHA . + --build-arg GITHUB_SHA_ARG=$GITHUB_SHA \ + --build-arg GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --build-arg GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --build-arg COGNITO_APP_CLIENT_ID=$COGNITO_APP_CLIENT_ID \ + --build-arg COGNITO_USER_POOL_ID=$COGNITO_USER_POOL_ID . - name: Configure Production AWS credentials id: aws-form-viewer - uses: aws-actions/configure-aws-credentials@51e2d042f8c5cf77f151685c9338e989dc0b8fc8 + uses: aws-actions/configure-aws-credentials@3654529dc6db288721684d6c54fefa0c1182728f with: aws-access-key-id: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} @@ -55,7 +68,7 @@ jobs: - name: Login to Production Amazon ECR id: login-ecr-production - uses: aws-actions/amazon-ecr-login@b9c809dc38d74cd0fde3c13cc4fe4ac72ebecdae + uses: aws-actions/amazon-ecr-login@d2897b5335975f749897eb8cb16345b12a17042f - name: Tag Images for Production env: diff --git a/.github/workflows/s3-backup.yml b/.github/workflows/s3-backup.yml index 18ff6cbfd0..d89ee4033c 100644 --- a/.github/workflows/s3-backup.yml +++ b/.github/workflows/s3-backup.yml @@ -10,12 +10,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3.2.0 with: fetch-depth: 0 # retrieve all history - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # tag=v1.7.0 with: aws-access-key-id: ${{ secrets.AWS_S3_BACKUP_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_S3_BACKUP_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/staging-build-push-container.yml b/.github/workflows/staging-build-push-container.yml index 24b19bc387..d6baa01dc3 100644 --- a/.github/workflows/staging-build-push-container.yml +++ b/.github/workflows/staging-build-push-container.yml @@ -3,17 +3,26 @@ name: "Staging — Push container to ECR" on: push: branches: [develop] + paths-ignore: + # Ignore all files and folders that start with '.' + - ".**" + # Ignore all files and folder in fixtures, tests, utils, etc. + - "__*/**" env: ECR_REPOSITORY: form_viewer_staging GITHUB_SHA: ${{ github.sha }} + GOOGLE_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_CLIENT_ID }} + COGNITO_APP_CLIENT_ID: ${{secrets.STAGING_COGNITO_APP_CLIENT_ID}} + COGNITO_USER_POOL_ID: ${{ secrets.STAGING_COGNITO_USER_POOL_ID}} jobs: push-staging: runs-on: ubuntu-latest steps: - name: Wait for Jest tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-jest-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -21,7 +30,7 @@ jobs: ref: ${{ github.sha }} - name: Wait for Cypress tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-cypress-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -29,7 +38,7 @@ jobs: ref: ${{ github.sha }} - name: Wait for ESLint tests to pass - uses: fountainhead/action-wait-for-check@v1.0.0 + uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0 id: wait-for-eslint-tests with: token: ${{ secrets.GITHUB_TOKEN }} @@ -37,16 +46,20 @@ jobs: ref: ${{ github.sha }} - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Build Form Viewer run: | docker build -t base \ - --build-arg GITHUB_SHA_ARG=$GITHUB_SHA . + --build-arg GITHUB_SHA_ARG=$GITHUB_SHA \ + --build-arg GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --build-arg GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --build-arg COGNITO_APP_CLIENT_ID=$COGNITO_APP_CLIENT_ID \ + --build-arg COGNITO_USER_POOL_ID=$COGNITO_USER_POOL_ID . - name: Configure Staging AWS credentials id: aws-form-viewer - uses: aws-actions/configure-aws-credentials@51e2d042f8c5cf77f151685c9338e989dc0b8fc8 + uses: aws-actions/configure-aws-credentials@3654529dc6db288721684d6c54fefa0c1182728f with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -54,7 +67,7 @@ jobs: - name: Login to Staging Amazon ECR id: login-ecr-staging - uses: aws-actions/amazon-ecr-login@b9c809dc38d74cd0fde3c13cc4fe4ac72ebecdae + uses: aws-actions/amazon-ecr-login@d2897b5335975f749897eb8cb16345b12a17042f - name: Tag Images for Staging env: @@ -75,11 +88,11 @@ jobs: run: docker logout ${{ steps.login-ecr-staging.outputs.registry }} - name: Generate forms-client/app/docker SBOM - uses: cds-snc/security-tools/.github/actions/generate-sbom@4c6b386722985552f3f008d04279a3f01402cc35 # renovate: tag=v1 + uses: cds-snc/security-tools/.github/actions/generate-sbom@4368a5486da1f0bb698ffe717687612d4231c6cd # v1.1.7 env: - ECR_REGISTRY: ${{ steps.login-ecr-staging.outputs.registry }} + ECR_REGISTRY: ${{ steps.login-ecr-staging.outputs.registry }} with: dependency_track_api_key: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} docker_image: $ECR_REGISTRY/$ECR_REPOSITORY:$GITHUB_SHA project_name: forms-client/app/docker - project_type: docker + project_type: docker diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 376bb5ed9e..c3b8e010f8 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -22,11 +22,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Configure AWS credentials # v1 as of Jan 28 2021 - uses: aws-actions/configure-aws-credentials@51e2d042f8c5cf77f151685c9338e989dc0b8fc8 + uses: aws-actions/configure-aws-credentials@3654529dc6db288721684d6c54fefa0c1182728f with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -35,7 +35,7 @@ jobs: - name: Login to Amazon ECR id: login-ecr # v1 as of Jan 28 2021 - uses: aws-actions/amazon-ecr-login@b9c809dc38d74cd0fde3c13cc4fe4ac72ebecdae + uses: aws-actions/amazon-ecr-login@d2897b5335975f749897eb8cb16345b12a17042f - name: Get Cluster Name id: cluster @@ -51,7 +51,7 @@ jobs: - name: Render image for form viewer service id: taskdef-form-viewer # v1.0.10 - uses: aws-actions/amazon-ecs-render-task-definition@9666dc9d3bf790a3a6a49737b240f17fa599a5f2 + uses: aws-actions/amazon-ecs-render-task-definition@aeae8fb93c2ca0168fe4fc6e8d35607b1ddd8876 with: task-definition: form_viewer.json container-name: ${{ steps.download-taskdef-form-viewer.outputs.container_name }} @@ -67,7 +67,7 @@ jobs: - name: Deploy image for Form Viewer timeout-minutes: 10 # v1.4.2 - uses: aws-actions/amazon-ecs-deploy-task-definition@c74a8ca2cd0dd04d25f469715e23cb6c2fe0f01a + uses: aws-actions/amazon-ecs-deploy-task-definition@67f6f62a733c5de6ba4ba69fc2a2bb8802945a7f with: task-definition: ${{ steps.taskdef-form-viewer.outputs.task-definition }} service: form-viewer diff --git a/.github/workflows/test-container-build-production.yml b/.github/workflows/test-container-build-production.yml new file mode 100644 index 0000000000..b20180b454 --- /dev/null +++ b/.github/workflows/test-container-build-production.yml @@ -0,0 +1,29 @@ +name: "Test Production Container Build" + +on: + pull_request: + branches: [main] + +env: + GITHUB_SHA: ${{ github.sha }} + GOOGLE_CLIENT_SECRET: ${{ secrets.PRODUCTION_GOOGLE_CLIENT_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.PRODUCTION_GOOGLE_CLIENT_ID }} + COGNITO_APP_CLIENT_ID: ${{secrets.PRODUCTION_COGNITO_APP_CLIENT_ID}} + COGNITO_USER_POOL_ID: ${{ secrets.PRODUCTION_COGNITO_USER_POOL_ID}} + +jobs: + test-container: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + + - name: Build Container + run: | + docker build -t base \ + --build-arg PRODUCTION_ENV=true \ + --build-arg GITHUB_SHA_ARG=$GITHUB_SHA \ + --build-arg GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --build-arg GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --build-arg COGNITO_APP_CLIENT_ID=$COGNITO_APP_CLIENT_ID \ + --build-arg COGNITO_USER_POOL_ID=$COGNITO_USER_POOL_ID . \ No newline at end of file diff --git a/.github/workflows/test-container-build-staging.yml b/.github/workflows/test-container-build-staging.yml new file mode 100644 index 0000000000..a0395542dd --- /dev/null +++ b/.github/workflows/test-container-build-staging.yml @@ -0,0 +1,28 @@ +name: "Test Staging Container Build" + +on: + pull_request: + branches: [develop] + +env: + GITHUB_SHA: ${{ github.sha }} + GOOGLE_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_CLIENT_ID }} + COGNITO_APP_CLIENT_ID: ${{secrets.STAGING_COGNITO_APP_CLIENT_ID}} + COGNITO_USER_POOL_ID: ${{ secrets.STAGING_COGNITO_USER_POOL_ID}} + +jobs: + test-container: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + + - name: Build Container + run: | + docker build -t base \ + --build-arg GITHUB_SHA_ARG=$GITHUB_SHA \ + --build-arg GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --build-arg GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --build-arg COGNITO_APP_CLIENT_ID=$COGNITO_APP_CLIENT_ID \ + --build-arg COGNITO_USER_POOL_ID=$COGNITO_USER_POOL_ID . \ No newline at end of file diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000000..23626337e1 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +ElementPanel.test.js \ No newline at end of file diff --git a/.storybook/Layout.js b/.storybook/Layout.js index 5154b5800d..a4d8adf448 100644 --- a/.storybook/Layout.js +++ b/.storybook/Layout.js @@ -1,8 +1,6 @@ import React from "react"; import "../styles/app.scss"; -const Layout = ({ children }) => { +export const Layout = ({ children }) => { return {children}; }; - -export default Layout; diff --git a/.storybook/preview.js b/.storybook/preview.js index 0d163fbcd3..168de9173b 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,6 @@ import React from "react"; import { addDecorator } from "@storybook/react"; -import Layout from "./Layout"; +import { Layout } from "./Layout"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index e939efd089..ebc6064d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [2.0.0] 2022-12-28 + +### Added + +- Form Builder form creation interface +- User self registration +- Validation of a JSON Config to check the IDs of elements [#892](https://github.com/cds-snc/platform-forms-client/pull/892) +- Added login page [#867](https://github.com/cds-snc/platform-forms-client/issues/867) +- Added login page for temporary token [#900](https://github.com/cds-snc/platform-forms-client/pull/900) +- [BREAKING]: Modified the Prisma schema for the "User" table; removing the `admin` column, and adding the `role` column. After migrating, at least one user role will need to manually be set to `administrator` in order to login the Admin portion of the site. [#906](https://github.com/cds-snc/platform-forms-client/pull/906) +- Added file attachments to retrieval API [#909](https://github.com/cds-snc/platform-forms-client/pull/909) +- New login lockout mechanism plugged on existing temporary token API [#872](https://github.com/cds-snc/platform-forms-client/issues/872) +- Logout Page [#847] (https://github.com/cds-snc/platform-forms-client/issues/870) +- Admin feature to assign users to template [#1203](https://github.com/cds-snc/platform-forms-client/issues/1203) +- New API path to request publishing permission [#1226](https://github.com/cds-snc/platform-forms-client/issues/1226) +- Dynamic footer with SLA and Support links on admin and form builder related pages [#1080](https://github.com/cds-snc/platform-forms-client/issues/1080) + +### Changed + +- Updated Terms and conditions page + text link in the footer [#863](https://github.com/cds-snc/platform-forms-client/issues/863) +- Modified Role Based to Asset Based Access Control [#1176](https://github.com/cds-snc/platform-forms-client/pull/1176) +- Form templates are now marked as archived and will stay in the database for 30 more days before being deleted by a Lambda function. [#1166](https://github.com/cds-snc/platform-forms-client/issues/1166) +- The existing `publishingStatus` field from the form JSON configuration has been replaced by a `isPublished` data field in the database. It can be switch to `true` or `false` using the Template API. A migration process will automatically happen through the Prisma seeding process. [#1181](https://github.com/cds-snc/platform-forms-client/issues/1181) +- Form builder can only load form if the user has the permission to access it [#1228](https://github.com/cds-snc/platform-forms-client/issues/1228) + +### Fixed + +- Fix stuck "Loading..." animation after uploading a new JSON config. [#898](https://github.com/cds-snc/platform-forms-client/pull/898) +- Fix ReCaptcha feature being broken because of missing API Key. +- Last login time on acceptable use page was not formatted properly. [#949](https://github.com/cds-snc/platform-forms-client/issues/949) +- Fix logout session end date [#945](https://github.com/cds-snc/platform-forms-client/issues/945) +- Fix last login date format [#950](https://github.com/cds-snc/platform-forms-client/pull/950) +- Cleared email input field after successfully adding an email to Form Access [#954](https://github.com/cds-snc/platform-forms-client/pull/954) +- Returned only public properties for forms [#1038](https://github.com/cds-snc/platform-forms-client/pull/1038) +- Can't enable/disable user permissions in admin panel + +### Removed + +- Option to preview form submission email to through Notify [#1021](https://github.com/cds-snc/platform-forms-client/pull/1021) + +## [1.3.0] 2022-07-15 + +### Added + +- Make GC Branding in Footer configurable [#847](https://github.com/cds-snc/platform-forms-client/pull/847) + +### Fixed + +- Added CSRF token requirement to `api/log` endpoint [#835](https://github.com/cds-snc/platform-forms-client/pull/835) +- Welcome page link to design system (storybook) [#844](https://github.com/cds-snc/platform-forms-client/pull/844) +- Fix retrieval API [#845](https://github.com/cds-snc/platform-forms-client/pull/845) +- Fix loading of csp scripts to happen after Dom is loaded [#848](https://github.com/cds-snc/platform-forms-client/pull/848) +- Fix remaining characters display issue + +## [No Release Version] 2022-06-14 + +### Added + +- Validation of a JSON Config to check the IDs of elements [#892](https://github.com/cds-snc/platform-forms-client/pull/892) +- Added login page [#867](https://github.com/cds-snc/platform-forms-client/issues/867) +- Added login page for temporary token [#900](https://github.com/cds-snc/platform-forms-client/pull/900) +- [BREAKING]: Modified the Prisma schema for the "User" table; removing the `admin` column, and adding the `role` column. After migrating, at least one user role will need to manually be set to `administrator` in order to login the Admin portion of the site. [#906](https://github.com/cds-snc/platform-forms-client/pull/906) +- Added file attachments to retrieval API [#909](https://github.com/cds-snc/platform-forms-client/pull/909) +- New login lockout mechanism plugged on existing temporary token API [#872](https://github.com/cds-snc/platform-forms-client/issues/872) +- Logout Page [#847] (https://github.com/cds-snc/platform-forms-client/issues/870) +- Admin feature to assign users to template [#1203](https://github.com/cds-snc/platform-forms-client/issues/1203) +- New API path to request publishing permission [#1226](https://github.com/cds-snc/platform-forms-client/issues/1226) +- Dynamic footer with SLA and Support links on admin and form builder related pages [#1080](https://github.com/cds-snc/platform-forms-client/issues/1080) + +### Changed + +- Updated Terms and conditions page + text link in the footer [#863](https://github.com/cds-snc/platform-forms-client/issues/863) +- Modified Role Based to Asset Based Access Control [#1176](https://github.com/cds-snc/platform-forms-client/pull/1176) +- Form templates are now marked as archived and will stay in the database for 30 more days before being deleted by a Lambda function. [#1166](https://github.com/cds-snc/platform-forms-client/issues/1166) +- The existing `publishingStatus` field from the form JSON configuration has been replaced by a `isPublished` data field in the database. It can be switch to `true` or `false` using the Template API. A migration process will automatically happen through the Prisma seeding process. [#1181](https://github.com/cds-snc/platform-forms-client/issues/1181) +- Form builder can only load form if the user has the permission to access it [#1228](https://github.com/cds-snc/platform-forms-client/issues/1228) + +### Fixed + +- Fix stuck "Loading..." animation after uploading a new JSON config. [#898](https://github.com/cds-snc/platform-forms-client/pull/898) +- Fix ReCaptcha feature being broken because of missing API Key. +- Last login time on acceptable use page was not formatted properly. [#949](https://github.com/cds-snc/platform-forms-client/issues/949) +- Fix logout session end date [#945](https://github.com/cds-snc/platform-forms-client/issues/945) +- Fix last login date format [#950](https://github.com/cds-snc/platform-forms-client/pull/950) +- Cleared email input field after successfully adding an email to Form Access [#954](https://github.com/cds-snc/platform-forms-client/pull/954) +- Returned only public properties for forms [#1038](https://github.com/cds-snc/platform-forms-client/pull/1038) +- Can't enable/disable user permissions in admin panel + +### Removed + +- Option to preview form submission email to through Notify [#1021](https://github.com/cds-snc/platform-forms-client/pull/1021) +- `displayAlphaBanner` property in JSON form template is not supported anymore. [#772](https://github.com/cds-snc/platform-forms-client/issues/772) + ## [1.3.0] 2022-07-15 ### Added @@ -40,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded NextJS and other associated GCForms dependencies to next major version. [#725](https://github.com/cds-snc/platform-forms-client/pull/725) - Redesigned file input button [#713](https://github.com/cds-snc/platform-forms-client/issues/713) - Removed list of published forms from welcome page. [#712](https://github.com/cds-snc/platform-forms-client/issues/712) +- Upgraded Next-Auth to version 4 & modified backed to use Prisma [#739](https://github.com/cds-snc/platform-forms-client/pull/739) - Changed ISOLATED_INSTANCE for APP_ENV [#825](https://github.com/cds-snc/platform-forms-client/pull/825) ## [1.2.0] 2022-04-19 @@ -99,7 +193,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Renamed `organisation` to `organization` which has an impact on the API access path - Modified the middleware functionality and separation of scopes between middlewares - A user now needs to have an enabled admin flag (user table) to access the Admin Pages -- An admin user can now add and remove administrative priveleges from other users. +- An admin user can now add and remove administrative privileges from other users. ### Fixed diff --git a/Dockerfile b/Dockerfile index e70bd353d8..a799ad071c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,53 @@ -FROM node:16 +FROM node:16@sha256:1bbd8b82f5a78b6461d3285b62293db99ad60cf4eca35c715636d7143abb057c ENV NODE_ENV=production COPY . /src WORKDIR /src +ARG GOOGLE_CLIENT_ID +ARG GOOGLE_CLIENT_SECRET +ARG COGNITO_REGION="ca-central-1" +ARG COGNITO_APP_CLIENT_ID +ARG COGNITO_USER_POOL_ID + RUN yarn install --silent --production=false RUN yarn build RUN yarn install --production -FROM node:16 +FROM node:16@sha256:1bbd8b82f5a78b6461d3285b62293db99ad60cf4eca35c715636d7143abb057c COPY migrations /src WORKDIR /src RUN yarn install --silent -FROM node:16 +FROM node:16@sha256:1bbd8b82f5a78b6461d3285b62293db99ad60cf4eca35c715636d7143abb057c COPY flag_initialization /src WORKDIR /src RUN yarn install --silent -FROM node:16 +FROM node:16@sha256:1bbd8b82f5a78b6461d3285b62293db99ad60cf4eca35c715636d7143abb057c LABEL maintainer="-" +ARG GOOGLE_CLIENT_ID +ENV GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID + +ARG GOOGLE_CLIENT_SECRET +ENV GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET + ARG GITHUB_SHA_ARG ENV GITHUB_SHA=$GITHUB_SHA_ARG +ARG COGNITO_REGION="ca-central-1" +ENV COGNITO_REGION=$COGNITO_REGION + +ARG COGNITO_APP_CLIENT_ID +ENV COGNITO_APP_CLIENT_ID=$COGNITO_APP_CLIENT_ID + +ARG COGNITO_USER_POOL_ID +ENV COGNITO_USER_POOL_ID=$COGNITO_USER_POOL_ID + ARG TAG_VERSION ENV TAG_VERSION=$TAG_VERSION @@ -40,6 +61,7 @@ COPY public ./public COPY next.config.js . COPY next-i18next.config.js . COPY migrations ./migrations +COPY prisma ./prisma COPY flag_initialization ./flag_initialization COPY --from=1 /src/node_modules ./migrations/node_modules COPY --from=2 /src/node_modules ./flag_initialization/node_modules diff --git a/README.md b/README.md index a8b3affab3..359a687c26 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This is a [Next.js](https://nextjs.org/) and is built with: - Sass (Syntactically Awesome Style Sheets) for reusable styles - [Tailwindcss](https://tailwindcss.com/) a utility-first css framework for rapidly building custom designs - [PostCSS](https://postcss.org/) +- [Prisma](https://www.prisma.io/) ## Running locally @@ -39,7 +40,6 @@ For local development of the NextJS application but leveraging the AWS backend ( NOTIFY_API_KEY= // Can be found in LastPass SUBMISSION_API=Submission TEMPLATES_API=Templates -ORGANIZATIONS_API=Organizations AWS_ACCESS_KEY_ID= // Can be found in LastPass AWS_SECRET_ACCESS_KEY= // Can be found in LastPass GOOGLE_CLIENT_ID= // Can be found in LastPass @@ -69,20 +69,9 @@ Run postgres by using the following command docker-compose up -d db ``` +A GUI manager is installed with prisma and can be launched with `yarn prisma:studio` You can optionally install a gui manager like PgAdmin if you would like. - -in `/migrations`, fill in the separate .env file. - -``` -DB_NAME=formsDB -DB_USERNAME=postgres -DB_PASSWORD=chummy -DB_HOST=localhost -``` - -Note if running in devcontainers on vscode the DB_HOST should be of value `db` - -inside the `/migrations` folder, run `node index.js` to run migrations against the local database. +For more information about developing with prisma migrate please visit: https://www.prisma.io/docs/guides/database/developing-with-prisma-migrate In your main forms .env file, DATABASE_URL can be filled in as followed (replace values in {} with the values you used in your migrations env file) `DATABASE_URL=postgres://{DB_USERNAME}:{DB_PASSWORD}@DB_HOST:5432/{DB_NAME}` @@ -103,9 +92,23 @@ Browse application on `http://localhost:3000` There are some environment variables that can optionally be configured. You can see a list in `.env.example`. ### Grant yourself admin access locally + +There are 2 ways to connect to the database. Either directly using PGAdmin or a PSQL cli tool or through Prisma Studio. Once the change is made you will need to "Log Out" using the + +## Connect to DataBase Directly + +- Login using your email via Google SSO +- Connect to the local database `psql -h db -U postgres -d formsDB` +- Retrieve your users id from the User table in the formsDB `SELECT * FROM "public"."User" WHERE email='$YOUR_EMAIL';` +- Update the record to elevate yourself as an admin `UPDATE "public"."User" SET role='ADMINISTRATOR' WHERE id='$YOUR_ID';` + +## Prisma Studio + - Login using your email via Google SSO -- Retrieve your users id from the users table in the formsDB `select * from users where email='YOUR_EMAIL'` -- Update the record to elevate yourself as an admin `UPDATE users SET admin=true WHERE id=YOUR_ID` +- Launch prisma studio with `yarn prisma:studio` or if you have prisma installed globally `prisma studio` +- A browser window will open at `localhost:5555`. Open the model `User` +- A table will appear. Find your username in the list and double-click on the value under the `role` column to modify to "ADMINISTRATOR". +- Click on "Save Change" button in the top menu bar once completed. ### Notify integration @@ -171,7 +174,6 @@ Pour le développement local de l'application NextJS mais en s'appuyant sur le b NOTIFY_API_KEY= // Can be found in LastPass SUBMISSION_API=Submission TEMPLATES_API=Templates -ORGANIZATIONS_API=Organizations AWS_ACCESS_KEY_ID= // Can be found in LastPass AWS_SECRET_ACCESS_KEY= // Can be found in LastPass GOOGLE_CLIENT_ID= // Can be found in LastPass diff --git a/VERSION b/VERSION index 1e75430133..6e1f4db18c 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -1.3.0 +2.0.0 diff --git a/__fixtures__/accessibilityTestForm.json b/__fixtures__/accessibilityTestForm.json new file mode 100644 index 0000000000..f4edb81108 --- /dev/null +++ b/__fixtures__/accessibilityTestForm.json @@ -0,0 +1,329 @@ +{ + "form": { + "layout": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "endPage": { + "descriptionEn": "#Your submission has been received \n\r Thank you for your interest. We will contact you within 3-5 business days. \n\r Go back", + "descriptionFr": "#[fr] Your submission has been received. \n\r Thank you for your interest. We will contact you within 3-5 business days. \n\r Go back", + "referrerUrlEn": "", + "referrerUrlFr": "" + }, + "titleEn": "Lemonade Stand Funding", + "titleFr": "[fr] Lemonade Stand Funding", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "charLimit": 5000, + "validation": { + "required": false + }, + "descriptionEn": "##Contact Information", + "descriptionFr": "##[fr] Contact Information" + } + }, + { + "id": 2, + "type": "textField", + "properties": { + "titleEn": "1. What is your name?", + "titleFr": "[fr]1. What is your name?]", + "validation": { + "type": "text", + "required": true, + "maxLength": 150 + }, + "autoComplete": "name", + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "English", + "fr": "Anglais" + }, + { + "en": "French", + "fr": "Français" + } + ], + "titleEn": "2. What is your preferred language for communications?", + "titleFr": "[fr] 2. What is your preferred language for communications?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "charLimit": 5000, + "validation": { + "required": false + }, + "descriptionEn": "**Note:** We are not collecting email addresses or phone numbers through this test form to preserve your privacy \n\r ##Poject Information", + "descriptionFr": "[fr] **Note:** We are not collecting email addresses or phone numbers through this test form to preserve your privacy \n\r ##Project Information" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "3. What is the name of your lemonade stand?", + "titleFr": "[fr]3. What is the name of your lemonade stand?", + "validation": { + "type": "text", + "required": true, + "maxLength": 300 + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "fileInput", + "properties": { + "titleEn": "4. Please upload the logo for your lemonade stand.", + "titleFr": "[fr]4. Please upload the logo for your lemonade stand.", + "fileType": ".png,", + "validation": { + "required": false + }, + "descriptionEn": "Please add the .png document provided to you", + "descriptionFr": "[fr]Please add the .png document provided to you" + } + }, + { + "id": 7, + "type": "textArea", + "properties": { + "titleEn": "5. Please describe the taste of your lemonade recipe. ", + "titleFr": "[fr] 5. Please describe the taste of your lemonade recipe. ", + "validation": { + "type": "text", + "required": true, + "maxLength": 1000 + }, + "descriptionEn": "Consider including details about the sweetness or sourness of your lemonade recipe. Maximum characters: 1000", + "descriptionFr": "[fr]Consider including details about the sweetness or sourness of your lemonade recipe. Maximum characters: 1000", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "Park", + "fr": "[fr] Park" + }, + { + "en": "Driveway", + "fr": "[fr]Driveway" + }, + { + "en": "Building lobby", + "fr": "[fr]Building lobby" + }, + { + "en": "Parking lot", + "fr": "[fr]Parking lot" + }, + { + "en": "Other", + "fr": "[fr]Other" + } + ], + "titleEn": "6. Where is your lemonade stand going to be located?", + "titleFr": "[fr]6. Where is your lemonade stand going to be located?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "7. If you checked Other, please specify", + "titleFr": "7. Pour d'autre information veuillez spécifier", + "validation": { + "type": "text", + "required": false, + "maxLength": 100 + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "richText", + "properties": { + "charLimit": 5000, + "validation": { + "required": false + }, + "descriptionEn": "###Materials and Ingredients", + "descriptionFr": "###[fr] Materials and Ingredients" + } + }, + { + "id": 11, + "type": "dynamicRow", + "properties": { + "titleEn": "8. Which ingredients will you purchase with this funding?", + "titleFr": "[fr]8. Which ingredients will you purchase with this funding?", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 1101, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "Sugar", + "fr": "[fr] Sugar" + }, + { + "en": "Lemons", + "fr": "[fr]Lemons" + } + ], + "titleEn": "8a. What is the type of ingredient?", + "titleFr": "[fr] 8a. What is the type of ingredient?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 1102, + "type": "textField", + "properties": { + "titleEn": "8b. How much of it do you need?", + "titleFr": "[fr]8b. How much of it do you need?", + "validation": { + "type": "text", + "required": true, + "maxLength": 10 + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 1103, + "type": "richText", + "properties": { + "charLimit": 5000, + "validation": { + "required": false + }, + "descriptionEn": "To add another ingredient, select 'Add ingredient' below", + "descriptionFr": "[fr] To add another ingredient, select 'Add ingredient' below" + } + } + ], + "placeholderEn": "ingredient", + "placeholderFr": "[fr]ingredient" + } + }, + { + "id": 12, + "type": "checkbox", + "properties": { + "choices": [ + { + "en": "Cups", + "fr": "[fr] Cups" + }, + { + "en": "Napkins", + "fr": "[fr]Napkins" + }, + { + "en": "Straws", + "fr": "[fr]Straws" + } + ], + "titleEn": "9. Which materials will you purchase with this funding? ", + "titleFr": "[fr] 9. Which materials will you purchase with this funding? ", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 13, + "type": "richText", + "properties": { + "charLimit": 5000, + "validation": { + "required": false + }, + "descriptionEn": "##Submission", + "descriptionFr": "##[fr] Submission" + } + }, + { + "id": 14, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Yes", + "fr": "Oui" + }, + { + "en": "No", + "fr": "Non" + } + ], + "titleEn": "10. Do you attest that the information you are providing is true and correct to your knowledge?", + "titleFr": "[fr] 10. Do you attest that the information you are providing is true and correct to your knowledge?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ], + "emailSubjectEn": "Lemonade Stand Funding Testing Response", + "emailSubjectFr": "" + }, + "submission": { + "email": "forms-formulaires@cds-snc.ca" + }, + "internalTitleEn": "CDS - Lemonade Stand Funding", + "internalTitleFr": "CDS - Lemonade Stand Funding" +} diff --git a/__fixtures__/attestationTestForm.json b/__fixtures__/attestationTestForm.json new file mode 100644 index 0000000000..3e29b9b4e4 --- /dev/null +++ b/__fixtures__/attestationTestForm.json @@ -0,0 +1,47 @@ +{ + "form": { + "layout": [1], + "titleEn": "Lemonade Stand Funding", + "titleFr": "Financement des stands de limonade", + "version": 1, + "elements": [ + { + "id": 1, + "type": "checkbox", + "properties": { + "titleEn": "Do you attest that the information you are providing is true and correct to your knowledge?", + "titleFr": "Attestez-vous que les informations que vous fournissez sont, à votre connaissance, vraies et correctes ?", + "choices": [ + { + "en": "I prefer lemons to oranges.", + "fr": "Je préfère les citrons aux oranges." + }, + { + "en": "I agree to keep my lemonade stand a minimum of 1 metre from the sidewalk.", + "fr": "Je m'engage à maintenir mon stand de limonade à un minimum d'un mètre du trottoir." + } + ], + "validation": { + "all": true, + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ], + "emailSubjectEn": "Lemonade Stand Funding Testing Response", + "emailSubjectFr": "" + }, + "submission": { + "vault": true + }, + "endPage": { + "descriptionEn": "#Your submission has been received \n\r Thank you for your interest. We will contact you within 3-5 business days. \n\r Go back", + "descriptionFr": "#[fr] Your submission has been received. \n\r Thank you for your interest. We will contact you within 3-5 business days. \n\r Go back", + "referrerUrlEn": "", + "referrerUrlFr": "" + }, + "internalTitleEn": "CDS - Lemonade Stand Funding", + "internalTitleFr": "CDS - Lemonade Stand Funding" +} diff --git a/__fixtures__/brokenFormTemplate.json b/__fixtures__/brokenFormTemplate.json new file mode 100644 index 0000000000..5578536e09 --- /dev/null +++ b/__fixtures__/brokenFormTemplate.json @@ -0,0 +1,646 @@ +{ + "forms": { + "layout": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27 + ], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 1: Nomination Category", + "descriptionFr": "#### Section 1: Catégorie de la nomination" + } + }, + { + "id": 2, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Team nomination", + "fr": "Mise en candidature d’une équipe" + }, + { + "en": "Individual Nomination", + "fr": "Mise en candidature d’une personne" + } + ], + "titleEn": "Is this a Team or Individual Nomination?", + "titleFr": "Is this a Team or Individual Nomination?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Excellence in Profession", + "fr": "Excellence en profession" + }, + { + "en": "Exceptional Young Public Servant", + "fr": "Jeunes fonctionnaires exceptionnels" + }, + { + "en": "Exemplary Contribution Under Extraordinary Circumstances (Team only)", + "fr": "Contribution exemplaire dans des circonstances extraordinaires (Équipe seulement)" + }, + { + "en": "Joan Atkinson Award for Public Sector Balues in the Workplace", + "fr": "Prix Joan Atkinson pour les Valeurs du secteur public en milieu de travail" + }, + { + "en": "Outstanding Career (Individual only)", + "fr": "Carrière exceptionnelle (Personne seulement)" + }, + { + "en": "60 Years of Service Special Award (Individual only)", + "fr": "Prix spécial récompensant 60 années de service (Personne seulement)" + } + ], + "titleEn": "Category", + "titleFr": "Catégorie", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 2: Nominator Information", + "descriptionFr": "#### Section 2: Information sur le proposant" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 7, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "Title", + "titleFr": "Titre", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "Phone number", + "titleFr": "Numéro de téléphone", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "textField", + "properties": { + "titleEn": "Email address", + "titleFr": "Adresse de courriel", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 11, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 3: Individual Information (For an Individual Nomination)", + "descriptionFr": "#### Section 3: Mise en candidature d'une personne (For an Individual Nomination)" + } + }, + { + "id": 12, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 13, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 14, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 15, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 16, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 17, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 18, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 4: Team Nomination (for a Team Nomination)", + "descriptionFr": "#### Section 4: Mise en candidature d’une équipe (for a Team Nomination)" + } + }, + { + "id": 19, + "type": "textField", + "properties": { + "titleEn": "Team name in english", + "titleFr": "Nom de l’équipe en anglais", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 20, + "type": "textField", + "properties": { + "titleEn": "Team name in french", + "titleFr": "Nom de l’équipe en français", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 21, + "type": "richText", + "properties": { + "titleEn": "Team Members", + "titleFr": "Membres de l’équipe", + "validation": { + "required": false, + "maxLength": 5000 + }, + "descriptionEn": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "descriptionFr": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "maxLength": 100, + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + }, + { + "id": 23, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Annex A: Achievement Summary", + "descriptionFr": "#### Annex A: Résumé des réalisations" + } + }, + { + "id": 24, + "type": "textArea", + "properties": { + "titleEn": "Briefly describe the achievement for which the nomination is being submitted.", + "titleFr": "Décrire brièvement la réalisation pour laquelle la candidature est présentée.", + "validation": { + "required": false + }, + "descriptionEn": "Do not include headings or other formatting. Use a maximum of 200 words", + "descriptionFr": "Do not include headings or other formatting. Use a maximum of 200 words", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 25, + "type": "textField", + "properties": { + "titleEn": "Name of Nominator", + "titleFr": "Nom du proposant", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 26, + "type": "textField", + "properties": { + "titleEn": "Date", + "titleFr": "Date", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 27, + "type": "checkbox", + "properties": { + "choices": [ + { + "en": "I hereby certify that, to the best of my knowledge, the information in this form is true and correct.", + "fr": "J’atteste par la présente qu’à ma connaissance, les renseignements fournis dans ce formulaire sont véridiques et corrects." + } + ], + "titleEn": "Consent of the Nominator", + "titleFr": "Consent of the Nominator", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/tests/data/cdsIntakeTestForm.json b/__fixtures__/cdsIntakeTestForm.json similarity index 96% rename from tests/data/cdsIntakeTestForm.json rename to __fixtures__/cdsIntakeTestForm.json index b7b407966f..04d3192c42 100644 --- a/tests/data/cdsIntakeTestForm.json +++ b/__fixtures__/cdsIntakeTestForm.json @@ -9,15 +9,7 @@ "logoTitleEn": "Canadian Digital Service", "logoTitleFr": "Service numérique canadien" }, - "layout": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7 - ], + "layout": [1, 2, 3, 4, 5, 6, 7], "endPage": { "descriptionEn": "", "descriptionFr": "", @@ -134,6 +126,5 @@ }, "internalTitleEn": "CDS Intake Form", "internalTitleFr": "SNC Formulaire d'admission", - "publishingStatus": false, "securityAttribute": "Unclassified" -} \ No newline at end of file +} diff --git a/__fixtures__/duplicateElementIds.json b/__fixtures__/duplicateElementIds.json new file mode 100644 index 0000000000..9caee41f11 --- /dev/null +++ b/__fixtures__/duplicateElementIds.json @@ -0,0 +1,384 @@ +{ + "form": { + "layout": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 1: Nomination Category", + "descriptionFr": "#### Section 1: Catégorie de la nomination" + } + }, + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "Purposely a duplicate ID", + "descriptionFr": "Purposely a duplicate ID" + } + }, + { + "id": 2, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Team nomination", + "fr": "Mise en candidature d’une équipe" + }, + { + "en": "Individual Nomination", + "fr": "Mise en candidature d’une personne" + } + ], + "titleEn": "Is this a Team or Individual Nomination?", + "titleFr": "Is this a Team or Individual Nomination?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 3, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "Purposely a duplicate ID", + "descriptionFr": "Purposely a duplicate ID" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Excellence in Profession", + "fr": "Excellence en profession" + }, + { + "en": "Exceptional Young Public Servant", + "fr": "Jeunes fonctionnaires exceptionnels" + }, + { + "en": "Exemplary Contribution Under Extraordinary Circumstances (Team only)", + "fr": "Contribution exemplaire dans des circonstances extraordinaires (Équipe seulement)" + }, + { + "en": "Joan Atkinson Award for Public Sector Balues in the Workplace", + "fr": "Prix Joan Atkinson pour les Valeurs du secteur public en milieu de travail" + }, + { + "en": "Outstanding Career (Individual only)", + "fr": "Carrière exceptionnelle (Personne seulement)" + }, + { + "en": "60 Years of Service Special Award (Individual only)", + "fr": "Prix spécial récompensant 60 années de service (Personne seulement)" + } + ], + "titleEn": "Category", + "titleFr": "Catégorie", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 2: Nominator Information", + "descriptionFr": "#### Section 2: Information sur le proposant" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 7, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "Title", + "titleFr": "Titre", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "Phone number", + "titleFr": "Numéro de téléphone", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "textField", + "properties": { + "titleEn": "Email address", + "titleFr": "Adresse de courriel", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "Purposely a duplicate ID", + "descriptionFr": "Purposely a duplicate ID" + } + }, + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 2201, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2202, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2203, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2206, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2206, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2206, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/tests/data/dynamicRowsTestForm.json b/__fixtures__/dynamicRowsTestForm.json similarity index 99% rename from tests/data/dynamicRowsTestForm.json rename to __fixtures__/dynamicRowsTestForm.json index 5c556df83d..2914256897 100644 --- a/tests/data/dynamicRowsTestForm.json +++ b/__fixtures__/dynamicRowsTestForm.json @@ -8,15 +8,7 @@ "logoTitleEn": "Copyright Board of Canada", "logoTitleFr": "Commission du droit d'auteur du Canada" }, - "layout": [ - 1, - 2, - 3, - 6, - 7, - 11, - 12 - ], + "layout": [1, 2, 3, 6, 7, 11, 12], "endPage": { "descriptionEn": "# Thank you for your tariff. \n\r The Proposed Tariff(s) has/have been successfully filed. The Board will inform you when this proposed tariff has/have been published on the Board’s website or if we require additional information. \n\r Go back", "descriptionFr": "# Merci pour votre entrée. \n\r The Proposed Tariff(s) has/have been successfully filed. The Board will inform you when this proposed tariff has/have been published on the Board’s website or if we require additional information. \n\r Retour", @@ -412,6 +404,5 @@ }, "internalTitleEn": "Copyright Board of Canada - Proposed Tariff Filing Form", "internalTitleFr": "FR - Copyright Board of Canada - Proposed Tariff Filing Form", - "publishingStatus": false, "securityAttribute": "Unclassified" -} \ No newline at end of file +} diff --git a/__fixtures__/invalidLayoutIds.json b/__fixtures__/invalidLayoutIds.json new file mode 100644 index 0000000000..4ee5b388da --- /dev/null +++ b/__fixtures__/invalidLayoutIds.json @@ -0,0 +1,644 @@ +{ + "form": { + "layout": [ + 100, 200, 300, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27 + ], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 1: Nomination Category", + "descriptionFr": "#### Section 1: Catégorie de la nomination" + } + }, + { + "id": 2, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Team nomination", + "fr": "Mise en candidature d’une équipe" + }, + { + "en": "Individual Nomination", + "fr": "Mise en candidature d’une personne" + } + ], + "titleEn": "Is this a Team or Individual Nomination?", + "titleFr": "Is this a Team or Individual Nomination?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Excellence in Profession", + "fr": "Excellence en profession" + }, + { + "en": "Exceptional Young Public Servant", + "fr": "Jeunes fonctionnaires exceptionnels" + }, + { + "en": "Exemplary Contribution Under Extraordinary Circumstances (Team only)", + "fr": "Contribution exemplaire dans des circonstances extraordinaires (Équipe seulement)" + }, + { + "en": "Joan Atkinson Award for Public Sector Balues in the Workplace", + "fr": "Prix Joan Atkinson pour les Valeurs du secteur public en milieu de travail" + }, + { + "en": "Outstanding Career (Individual only)", + "fr": "Carrière exceptionnelle (Personne seulement)" + }, + { + "en": "60 Years of Service Special Award (Individual only)", + "fr": "Prix spécial récompensant 60 années de service (Personne seulement)" + } + ], + "titleEn": "Category", + "titleFr": "Catégorie", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 2: Nominator Information", + "descriptionFr": "#### Section 2: Information sur le proposant" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 7, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "Title", + "titleFr": "Titre", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "Phone number", + "titleFr": "Numéro de téléphone", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "textField", + "properties": { + "titleEn": "Email address", + "titleFr": "Adresse de courriel", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 11, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 3: Individual Information (For an Individual Nomination)", + "descriptionFr": "#### Section 3: Mise en candidature d'une personne (For an Individual Nomination)" + } + }, + { + "id": 12, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 13, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 14, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 15, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 16, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 17, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 18, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 4: Team Nomination (for a Team Nomination)", + "descriptionFr": "#### Section 4: Mise en candidature d’une équipe (for a Team Nomination)" + } + }, + { + "id": 19, + "type": "textField", + "properties": { + "titleEn": "Team name in english", + "titleFr": "Nom de l’équipe en anglais", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 20, + "type": "textField", + "properties": { + "titleEn": "Team name in french", + "titleFr": "Nom de l’équipe en français", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 21, + "type": "richText", + "properties": { + "titleEn": "Team Members", + "titleFr": "Membres de l’équipe", + "validation": { + "required": false + }, + "descriptionEn": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "descriptionFr": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + }, + { + "id": 23, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Annex A: Achievement Summary", + "descriptionFr": "#### Annex A: Résumé des réalisations" + } + }, + { + "id": 24, + "type": "textArea", + "properties": { + "titleEn": "Briefly describe the achievement for which the nomination is being submitted.", + "titleFr": "Décrire brièvement la réalisation pour laquelle la candidature est présentée.", + "validation": { + "required": false + }, + "descriptionEn": "Do not include headings or other formatting. Use a maximum of 200 words", + "descriptionFr": "Do not include headings or other formatting. Use a maximum of 200 words", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 25, + "type": "textField", + "properties": { + "titleEn": "Name of Nominator", + "titleFr": "Nom du proposant", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 26, + "type": "textField", + "properties": { + "titleEn": "Date", + "titleFr": "Date", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 27, + "type": "checkbox", + "properties": { + "choices": [ + { + "en": "I hereby certify that, to the best of my knowledge, the information in this form is true and correct.", + "fr": "J’atteste par la présente qu’à ma connaissance, les renseignements fournis dans ce formulaire sont véridiques et corrects." + } + ], + "titleEn": "Consent of the Nominator", + "titleFr": "Consent of the Nominator", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/__fixtures__/invalidSubElementIds.json b/__fixtures__/invalidSubElementIds.json new file mode 100644 index 0000000000..4878852767 --- /dev/null +++ b/__fixtures__/invalidSubElementIds.json @@ -0,0 +1,177 @@ +{ + "form": { + "layout": [22], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 2201, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2301, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2202, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2204, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2207, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2208, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/tests/data/platformIntakeTestForm.json b/__fixtures__/platformIntakeTestForm.json similarity index 97% rename from tests/data/platformIntakeTestForm.json rename to __fixtures__/platformIntakeTestForm.json index eb32564fa5..c224b4adf3 100644 --- a/tests/data/platformIntakeTestForm.json +++ b/__fixtures__/platformIntakeTestForm.json @@ -1,14 +1,6 @@ { "form": { - "layout": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7 - ], + "layout": [1, 2, 3, 4, 5, 6, 7], "endPage": { "descriptionEn": "", "descriptionFr": "", @@ -138,6 +130,5 @@ }, "internalTitleEn": "Work with CDS on a Digital Form", "internalTitleFr": "Travailler avec le SNC sur un formulaire numérique", - "publishingStatus": true, "securityAttribute": "Unclassified" -} \ No newline at end of file +} diff --git a/__fixtures__/testData.json b/__fixtures__/testData.json new file mode 100644 index 0000000000..4525ce4fc2 --- /dev/null +++ b/__fixtures__/testData.json @@ -0,0 +1,303 @@ +{ + "id": "test0form00000id000asdf11", + "form": { + "version": 1, + "titleEn": "Test Form", + "titleFr": "Formulaire de test", + "layout": [1, 2, 3, 4, 5, 6, 7, 8], + "brand": { + "name": "cds-snc", + "logoEn": "https://digital.canada.ca/img/cds/cds-lockup-ko-en.svg", + "logoFr": "https://numerique.canada.ca/img/cds/cds-lockup-ko-fr.svg", + "logoTitleEn": "Canadian Digital Service", + "logoTitleFr": "Service numérique canadien", + "urlEn": "https://digital.canada.ca/", + "urlFr": "https://numerique.canada.ca/" + }, + "endPage": { + "descriptionEn": "", + "descriptionFr": "", + "referrerUrlEn": "https://digital.canada.ca/", + "referrerUrlFr": "https://numerique.canada.ca/" + }, + "elements": [ + { + "id": 1, + "type": "textField", + "properties": { + "titleEn": "What is your full name?", + "titleFr": "Quel est votre nom complet?", + "placeholderEn": "I wish I knew", + "placeholderFr": "Je ne sais pas", + "descriptionEn": "This is a description", + "descriptionFr": "Voice une description", + "validation": { + "required": true + } + } + }, + { + "id": 2, + "type": "textArea", + "properties": { + "titleEn": "What is the problem you are facing", + "titleFr": "Quel est le problème auquel vous êtes confronté?", + "placeholderEn": "Something difficult", + "placeholderFr": "Quelque chose difficile", + "descriptionEn": "Here be a description", + "descriptionFr": "Pour décrire, ou pas décire..", + "validation": { + "required": true + } + } + }, + { + "id": 3, + "type": "richText", + "properties": { + "titleEn": "", + "titleFr": "", + "descriptionEn": "Thank you so much for your interest in the Canadian Digital Service’s Forms product.

Please provide your information below so CDS can contact you about improving, updating, or digitizing a form.", + "descriptionFr": "Merci beaucoup de l’intérêt que vous portez au produit de Formulaire du Service Numérique Canadien.

Veuillez fournir vos renseignements ci-dessous afin que le SNC puisse vous contacter pour discuter davantage l'amélioration, la mise à jour ou la numérisation d'un formulaire.", + "validation": { + "required": false + } + } + }, + { + "id": 4, + "type": "dropdown", + "properties": { + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "placeholderEn": "", + "placeholderFr": "", + "descriptionEn": "", + "descriptionFr": "", + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "validation": { + "required": false + } + } + }, + { + "id": 5, + "type": "radio", + "properties": { + "titleEn": "Status", + "titleFr": "Statut", + "description": "", + "validation": { + "required": false + }, + "choices": [ + { + "en": "Citizen", + "fr": "Cityoen" + }, + { + "en": "Permanent Resident", + "fr": "Permanent Resident" + }, + { + "en": "Student", + "fr": "Student" + }, + { + "en": "Visitor", + "fr": "Visitor" + }, + { + "en": "Other", + "fr": "Autre" + } + ] + } + }, + { + "id": 6, + "type": "checkbox", + "properties": { + "titleEn": "Will the project or any of its activities involve or benefit people in English or French linguistic minority communities in Canada, in some way?", + "titleFr": " Le projet ou les activités connexes impliquent-ils ou s’adressent-ils d’une façon ou d’une autre aux minorités francophones et anglophones du Canada?", + "validation": { + "required": false + }, + "choices": [ + { + "en": "Yes", + "fr": "Oui" + }, + { + "en": "No", + "fr": "Non" + }, + { + "en": "Not Applicable", + "fr": "Non applicable" + } + ] + } + }, + { + "id": 7, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "placeholderEn": "", + "placeholderFr": "", + "descriptionEn": "", + "descriptionFr": "", + + "validation": { + "required": false + } + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "laceholderEn": "", + "placeholderFr": "", + "descriptionEn": "", + "descriptionFr": "", + "validation": { + "required": false + } + } + }, + { + "id": 22, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "placeholderEn": "", + "placeholderFr": "", + "descriptionEn": "", + "descriptionFr": "", + "validation": { + "required": false + } + } + }, + { + "id": 22, + "type": "checkbox", + "properties": { + "titleEn": "Will the project or any of its activities involve or benefit people in English or French linguistic minority communities in Canada, in some way?", + "titleFr": " Le projet ou les activités connexes impliquent-ils ou s’adressent-ils d’une façon ou d’une autre aux minorités francophones et anglophones du Canada?", + "validation": { + "required": false + }, + "choices": [ + { + "en": "Yes", + "fr": "Oui" + }, + { + "en": "No", + "fr": "Non" + }, + { + "en": "Not Applicable", + "fr": "Non applicable" + } + ] + } + } + ] + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "This Answer is empty?", + "titleFr": "Ce reponse est vide?", + "placeholderEn": "yuppers", + "placeholderFr": "oui", + "descriptionEn": "This is a description", + "descriptionFr": "Voice une description", + "validation": { + "required": false + } + } + } + ] + }, + "submission": { + "email": "no-reply@cds-snc.ca" + } +} diff --git a/tests/data/textFieldTestForm.json b/__fixtures__/textFieldTestForm.json similarity index 100% rename from tests/data/textFieldTestForm.json rename to __fixtures__/textFieldTestForm.json diff --git a/tests/data/tsbContactTestForm.json b/__fixtures__/tsbContactTestForm.json similarity index 98% rename from tests/data/tsbContactTestForm.json rename to __fixtures__/tsbContactTestForm.json index 69c4209a5d..a4207e5f7e 100644 --- a/tests/data/tsbContactTestForm.json +++ b/__fixtures__/tsbContactTestForm.json @@ -9,15 +9,7 @@ "logoTitleEn": "Transportation Safety Board of Canada", "logoTitleFr": "Bureau de la sécurité des transports du Canada" }, - "layout": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7 - ], + "layout": [1, 2, 3, 4, 5, 6, 7], "endPage": { "descriptionEn": "#Thank you for your message \n\r The Transportation Safety Board of Canada will respond to you within a week. \n\r Go back.", "descriptionFr": "#Merci pour votre message \n\r Le Bureau de la sécurité des transports du Canada vous répondra d’ici une semaine. \n\r Retour." @@ -184,6 +176,5 @@ }, "internalTitleEn": "Contact Us - TSB", "internalTitleFr": "Nous joindre - BST", - "publishingStatus": true, "securityAttribute": "Unclassified" -} \ No newline at end of file +} diff --git a/tests/data/tsbDisableFooterGCBranding.json b/__fixtures__/tsbDisableFooterGCBranding.json similarity index 95% rename from tests/data/tsbDisableFooterGCBranding.json rename to __fixtures__/tsbDisableFooterGCBranding.json index 46ded6bf4c..4b750f0f4a 100644 --- a/tests/data/tsbDisableFooterGCBranding.json +++ b/__fixtures__/tsbDisableFooterGCBranding.json @@ -10,10 +10,7 @@ "logoTitleFr": "Bureau de la sécurité des transports du Canada", "disableGcBranding": true }, - "layout": [ - 1 - - ], + "layout": [1], "endPage": { "descriptionEn": "#Thank you for your message \n\r The Transportation Safety Board of Canada will respond to you within a week. \n\r Go back.", "descriptionFr": "#Merci pour votre message \n\r Le Bureau de la sécurité des transports du Canada vous répondra d’ici une semaine. \n\r Retour." @@ -44,6 +41,5 @@ "email": "forms-formulaires@cds-snc.ca" }, "internalTitleEn": "Contact Us - TSB", - "internalTitleFr": "Nous joindre - BST", - "publishingStatus": true -} \ No newline at end of file + "internalTitleFr": "Nous joindre - BST" +} diff --git a/__fixtures__/validFormTemplate.json b/__fixtures__/validFormTemplate.json new file mode 100644 index 0000000000..45f37e4d0e --- /dev/null +++ b/__fixtures__/validFormTemplate.json @@ -0,0 +1,644 @@ +{ + "form": { + "layout": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27 + ], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 1: Nomination Category", + "descriptionFr": "#### Section 1: Catégorie de la nomination" + } + }, + { + "id": 2, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Team nomination", + "fr": "Mise en candidature d’une équipe" + }, + { + "en": "Individual Nomination", + "fr": "Mise en candidature d’une personne" + } + ], + "titleEn": "Is this a Team or Individual Nomination?", + "titleFr": "Is this a Team or Individual Nomination?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Excellence in Profession", + "fr": "Excellence en profession" + }, + { + "en": "Exceptional Young Public Servant", + "fr": "Jeunes fonctionnaires exceptionnels" + }, + { + "en": "Exemplary Contribution Under Extraordinary Circumstances (Team only)", + "fr": "Contribution exemplaire dans des circonstances extraordinaires (Équipe seulement)" + }, + { + "en": "Joan Atkinson Award for Public Sector Balues in the Workplace", + "fr": "Prix Joan Atkinson pour les Valeurs du secteur public en milieu de travail" + }, + { + "en": "Outstanding Career (Individual only)", + "fr": "Carrière exceptionnelle (Personne seulement)" + }, + { + "en": "60 Years of Service Special Award (Individual only)", + "fr": "Prix spécial récompensant 60 années de service (Personne seulement)" + } + ], + "titleEn": "Category", + "titleFr": "Catégorie", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 2: Nominator Information", + "descriptionFr": "#### Section 2: Information sur le proposant" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 7, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "Title", + "titleFr": "Titre", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "Phone number", + "titleFr": "Numéro de téléphone", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "textField", + "properties": { + "titleEn": "Email address", + "titleFr": "Adresse de courriel", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 11, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 3: Individual Information (For an Individual Nomination)", + "descriptionFr": "#### Section 3: Mise en candidature d'une personne (For an Individual Nomination)" + } + }, + { + "id": 12, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 13, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 14, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 15, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 16, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 17, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 18, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 4: Team Nomination (for a Team Nomination)", + "descriptionFr": "#### Section 4: Mise en candidature d’une équipe (for a Team Nomination)" + } + }, + { + "id": 19, + "type": "textField", + "properties": { + "titleEn": "Team name in english", + "titleFr": "Nom de l’équipe en anglais", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 20, + "type": "textField", + "properties": { + "titleEn": "Team name in french", + "titleFr": "Nom de l’équipe en français", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 21, + "type": "richText", + "properties": { + "titleEn": "Team Members", + "titleFr": "Membres de l’équipe", + "validation": { + "required": false + }, + "descriptionEn": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "descriptionFr": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 2201, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2202, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2203, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2204, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2205, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2206, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + }, + { + "id": 23, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Annex A: Achievement Summary", + "descriptionFr": "#### Annex A: Résumé des réalisations" + } + }, + { + "id": 24, + "type": "textArea", + "properties": { + "titleEn": "Briefly describe the achievement for which the nomination is being submitted.", + "titleFr": "Décrire brièvement la réalisation pour laquelle la candidature est présentée.", + "validation": { + "required": false + }, + "descriptionEn": "Do not include headings or other formatting. Use a maximum of 200 words", + "descriptionFr": "Do not include headings or other formatting. Use a maximum of 200 words", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 25, + "type": "textField", + "properties": { + "titleEn": "Name of Nominator", + "titleFr": "Nom du proposant", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 26, + "type": "textField", + "properties": { + "titleEn": "Date", + "titleFr": "Date", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 27, + "type": "checkbox", + "properties": { + "choices": [ + { + "en": "I hereby certify that, to the best of my knowledge, the information in this form is true and correct.", + "fr": "J’atteste par la présente qu’à ma connaissance, les renseignements fournis dans ce formulaire sont véridiques et corrects." + } + ], + "titleEn": "Consent of the Nominator", + "titleFr": "Consent of the Nominator", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/__fixtures__/validFormTemplateWithHTMLInDynamicRow.json b/__fixtures__/validFormTemplateWithHTMLInDynamicRow.json new file mode 100644 index 0000000000..deea864427 --- /dev/null +++ b/__fixtures__/validFormTemplateWithHTMLInDynamicRow.json @@ -0,0 +1,644 @@ +{ + "form": { + "layout": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27 + ], + "titleEn": "Public Service Award of Excellence 2020 - Nomination form", + "titleFr": "Prix d’excellence de la fonction publique 2020 - Formulaire de mise en candidature", + "version": 1, + "elements": [ + { + "id": 1, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 1: Nomination Category", + "descriptionFr": "#### Section 1: Catégorie de la nomination" + } + }, + { + "id": 2, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Team nomination", + "fr": "Mise en candidature d’une équipe" + }, + { + "en": "Individual Nomination", + "fr": "Mise en candidature d’une personne" + } + ], + "titleEn": "Is this a Team or Individual Nomination?", + "titleFr": "Is this a Team or Individual Nomination?", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 3, + "type": "radio", + "properties": { + "choices": [ + { + "en": "Excellence in Profession", + "fr": "Excellence en profession" + }, + { + "en": "Exceptional Young Public Servant", + "fr": "Jeunes fonctionnaires exceptionnels" + }, + { + "en": "Exemplary Contribution Under Extraordinary Circumstances (Team only)", + "fr": "Contribution exemplaire dans des circonstances extraordinaires (Équipe seulement)" + }, + { + "en": "Joan Atkinson Award for Public Sector Balues in the Workplace", + "fr": "Prix Joan Atkinson pour les Valeurs du secteur public en milieu de travail" + }, + { + "en": "Outstanding Career (Individual only)", + "fr": "Carrière exceptionnelle (Personne seulement)" + }, + { + "en": "60 Years of Service Special Award (Individual only)", + "fr": "Prix spécial récompensant 60 années de service (Personne seulement)" + } + ], + "titleEn": "Category", + "titleFr": "Catégorie", + "validation": { + "required": true + }, + "descriptionEn": "", + "descriptionFr": "" + } + }, + { + "id": 4, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 2: Nominator Information", + "descriptionFr": "#### Section 2: Information sur le proposant" + } + }, + { + "id": 5, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 6, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 7, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 8, + "type": "textField", + "properties": { + "titleEn": "Title", + "titleFr": "Titre", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 9, + "type": "textField", + "properties": { + "titleEn": "Phone number", + "titleFr": "Numéro de téléphone", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 10, + "type": "textField", + "properties": { + "titleEn": "Email address", + "titleFr": "Adresse de courriel", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 11, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 3: Individual Information (For an Individual Nomination)", + "descriptionFr": "#### Section 3: Mise en candidature d'une personne (For an Individual Nomination)" + } + }, + { + "id": 12, + "type": "textField", + "properties": { + "titleEn": "Family name", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 13, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 14, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 15, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 16, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 17, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 18, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Section 4: Team Nomination (for a Team Nomination)", + "descriptionFr": "#### Section 4: Mise en candidature d’une équipe (for a Team Nomination)" + } + }, + { + "id": 19, + "type": "textField", + "properties": { + "titleEn": "Team name in english", + "titleFr": "Nom de l’équipe en anglais", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 20, + "type": "textField", + "properties": { + "titleEn": "Team name in french", + "titleFr": "Nom de l’équipe en français", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 21, + "type": "richText", + "properties": { + "titleEn": "Team Members", + "titleFr": "Membres de l’équipe", + "validation": { + "required": false + }, + "descriptionEn": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "descriptionFr": "You can click Add a row to add more than one team member to your application. Please note that you can only list a maximum of 15 team members.", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 22, + "type": "dynamicRow", + "properties": { + "titleEn": "", + "titleFr": "", + "validation": { + "required": false + }, + "subElements": [ + { + "id": 2201, + "type": "textField", + "properties": { + "titleEn": "

fsdfdsfdsfdssdfsdfs

", + "titleFr": "Nom", + "validation": { + "required": false + }, + "descriptionEn": "

fsdfsfsdfsdfdsf

", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2202, + "type": "textField", + "properties": { + "titleEn": "Given name", + "titleFr": "Prénom", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2203, + "type": "textField", + "properties": { + "titleEn": "Department or organization", + "titleFr": "Ministère ou organisme", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2204, + "type": "dropdown", + "properties": { + "choices": [ + { + "en": "", + "fr": "" + }, + { + "en": "Alberta", + "fr": "Alberta" + }, + { + "en": "British Columbia", + "fr": "Colombie-Britannique" + }, + { + "en": "Manitoba", + "fr": "Manitoba" + }, + { + "en": "New Brunswick", + "fr": "Nouveau-Brunswick" + }, + { + "en": "Newfoundland and Labrador", + "fr": "Terre-Neuve-et-Labrador" + }, + { + "en": "Northwest Territories", + "fr": "Territoires du Nord-Ouest" + }, + { + "en": "Nova Scotia", + "fr": "Nouvelle-Écosse" + }, + { + "en": "Nunavut", + "fr": "Nunavut" + }, + { + "en": "Ontario", + "fr": "Ontario" + }, + { + "en": "Prince Edward Island", + "fr": "Île-du-Prince-Édouard" + }, + { + "en": "Quebec", + "fr": "Québec" + }, + { + "en": "Saskatchewan", + "fr": "Saskatchewan" + }, + { + "en": "Yukon", + "fr": "Yukon" + } + ], + "titleEn": "Province or territory", + "titleFr": "Province ou territoire", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2205, + "type": "textField", + "properties": { + "titleEn": "Preferred language", + "titleFr": "Langue préférée", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 2206, + "type": "textField", + "properties": { + "titleEn": "Group and level", + "titleFr": "Groupe et niveau", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + } + ] + } + }, + { + "id": 23, + "type": "richText", + "properties": { + "validation": { + "required": false + }, + "descriptionEn": "#### Annex A: Achievement Summary", + "descriptionFr": "#### Annex A: Résumé des réalisations" + } + }, + { + "id": 24, + "type": "textArea", + "properties": { + "titleEn": "Briefly describe the achievement for which the nomination is being submitted.", + "titleFr": "Décrire brièvement la réalisation pour laquelle la candidature est présentée.", + "validation": { + "required": false + }, + "descriptionEn": "Do not include headings or other formatting. Use a maximum of 200 words", + "descriptionFr": "Do not include headings or other formatting. Use a maximum of 200 words", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 25, + "type": "textField", + "properties": { + "titleEn": "Name of Nominator", + "titleFr": "Nom du proposant", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 26, + "type": "textField", + "properties": { + "titleEn": "Date", + "titleFr": "Date", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "", + "placeholderEn": "", + "placeholderFr": "" + } + }, + { + "id": 27, + "type": "checkbox", + "properties": { + "choices": [ + { + "en": "I hereby certify that, to the best of my knowledge, the information in this form is true and correct.", + "fr": "J’atteste par la présente qu’à ma connaissance, les renseignements fournis dans ce formulaire sont véridiques et corrects." + } + ], + "titleEn": "Consent of the Nominator", + "titleFr": "Consent of the Nominator", + "validation": { + "required": false + }, + "descriptionEn": "", + "descriptionFr": "" + } + } + ] + }, + "submission": { + "email": "steven.talbot@cds-snc.ca" + }, + "internalTitleEn": "Public Service Award of Excellence 2020", + "internalTitleFr": "Prix d’excellence de la fonction publique 2020", + "securityAttribute": "Unclassified" +} diff --git a/__tests__/api/acceptable-use.test.ts b/__tests__/api/acceptable-use.test.ts new file mode 100644 index 0000000000..e4b600a049 --- /dev/null +++ b/__tests__/api/acceptable-use.test.ts @@ -0,0 +1,88 @@ +/** + * @jest-environment node + */ + +import { createMocks } from "node-mocks-http"; +import acceptableUse from "@pages/api/acceptableuse"; +import { setAcceptableUse } from "@lib/acceptableUseCache"; +import { getCsrfToken } from "next-auth/react"; +import { unstable_getServerSession } from "next-auth/next"; +import { Session } from "next-auth"; + +jest.mock("next-auth/next"); +jest.mock("next-auth/react"); +jest.mock("@lib/acceptableUseCache"); +const mockedSetAcceptableUse = jest.mocked(setAcceptableUse, { shallow: true }); +const mockedGetCsrfToken = jest.mocked(getCsrfToken, { shallow: true }); +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +describe("Test acceptable use endpoint", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: [], + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + mockedGetCsrfToken.mockResolvedValue("CsrfToken"); + it("Should set acceptableuse value to true for userID 1 and return 200", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "x-csrf-token": "CsrfToken", + }, + body: { + userID: 1, + }, + }); + await acceptableUse(req, res); + expect(res.statusCode).toBe(200); + }); + + it("Should return 401 for unauthenticated user", async () => { + mockGetSession.mockReset(); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "x-csrf-token": "CsrfToken", + }, + body: { + userID: undefined, + }, + }); + await acceptableUse(req, res); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Unauthorized" })); + }); + + it("Should throw an error and return 500 status code", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "x-csrf-token": "CsrfToken", + }, + body: { + userID: 2, + }, + }); + + mockedSetAcceptableUse.mockRejectedValue(Error("Could not connect to cache")); + + await acceptableUse(req, res); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/__tests__/api/account/confirmpassword.test.ts b/__tests__/api/account/confirmpassword.test.ts new file mode 100644 index 0000000000..5f7b49adf2 --- /dev/null +++ b/__tests__/api/account/confirmpassword.test.ts @@ -0,0 +1,166 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { getCsrfToken } from "next-auth/react"; +import { mocked } from "jest-mock"; +import { + CognitoIdentityProviderClient, + ConfirmForgotPasswordCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import confirmpassword from "@pages/api/account/confirmpassword"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); +jest.mock("@aws-sdk/client-cognito-identity-provider", () => ({ + CognitoIdentityProviderClient: jest.fn(), + ConfirmForgotPasswordCommand: jest.fn(), +})); + +describe("/account/confirmpassword", () => { + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + beforeAll(() => { + process.env.COGNITO_REGION = "ca-central-1"; + process.env.COGNITO_APP_CLIENT_ID = "somemockvalue"; + }); + afterAll(() => { + process.env.COGNITO_REGION = undefined; + process.env.COGNITO_APP_CLIENT_ID = undefined; + }); + + describe("Access Control", () => { + test.each(["GET", "PUT", "DELETE"])( + "Should not allow an unaccepted method", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await confirmpassword(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + } + ); + + it("does not allow a non valid CSRF token", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "invalid_csrf", + }, + }); + + await confirmpassword(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ + error: "Access Denied", + }); + }); + }); + describe("Forgot Password Confirmation", () => { + const mockedCognitoIdentityProviderClient: any = mocked(CognitoIdentityProviderClient, true); + const mockedConfirmForgotPasswordCommand: any = mocked(ConfirmForgotPasswordCommand, true); + const sendFunctionMock = jest.fn(); + afterEach(() => { + mockedCognitoIdentityProviderClient.mockReset(); + mockedConfirmForgotPasswordCommand.mockReset(); + sendFunctionMock.mockReset(); + }); + it("handler returns 400 status code if username or confirmation code or password not provided", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + }); + + await confirmpassword(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: + "username, password and confirmation code needs to be provided in the body of the request", + }); + }); + it("handler returns empty body and cognito status code when command succeeds", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockImplementation(async () => { + return { + $metadata: { + httpStatusCode: 200, + }, + }; + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + password: "pass", + confirmationCode: "1921231", + }, + }); + + await confirmpassword(req, res); + expect(res.statusCode).toBe(200); + expect(mockedCognitoIdentityProviderClient).toBeCalledTimes(1); + expect(mockedConfirmForgotPasswordCommand.mock.calls[0][0]).toEqual({ + ClientId: "somemockvalue", + Username: "test", + Password: "pass", + ConfirmationCode: "1921231", + }); + expect(res._getData()).toEqual(""); + }); + it("handles error when the cognito send function fails", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockRejectedValue({ + toString: () => "There is an error", + $metadata: { + httpStatusCode: 400, + }, + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + password: "test", + confirmationCode: "1921231", + }, + }); + + await confirmpassword(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "There is an error", + }); + }); + }); +}); diff --git a/__tests__/api/account/forgotpassword.test.ts b/__tests__/api/account/forgotpassword.test.ts new file mode 100644 index 0000000000..fdc184e0f0 --- /dev/null +++ b/__tests__/api/account/forgotpassword.test.ts @@ -0,0 +1,159 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { getCsrfToken } from "next-auth/react"; +import { mocked } from "jest-mock"; +import { + CognitoIdentityProviderClient, + ForgotPasswordCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import forgotpassword from "@pages/api/account/forgotpassword"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); +jest.mock("@aws-sdk/client-cognito-identity-provider", () => ({ + CognitoIdentityProviderClient: jest.fn(), + ForgotPasswordCommand: jest.fn(), +})); + +describe("/account/confirmpassword", () => { + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + beforeAll(() => { + process.env.COGNITO_REGION = "ca-central-1"; + process.env.COGNITO_APP_CLIENT_ID = "somemockvalue"; + }); + afterAll(() => { + process.env.COGNITO_REGION = undefined; + process.env.COGNITO_APP_CLIENT_ID = undefined; + }); + + describe("Access Control", () => { + test.each(["GET", "PUT", "DELETE"])( + "Should not allow an unaccepted method", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await forgotpassword(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + } + ); + + it("does not allow a non valid CSRF token", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "invalid_csrf", + }, + }); + + await forgotpassword(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ + error: "Access Denied", + }); + }); + }); + describe("Forgot Password", () => { + const mockedCognitoIdentityProviderClient: any = mocked(CognitoIdentityProviderClient, true); + const mockedConfirmForgotPasswordCommand: any = mocked(ForgotPasswordCommand, true); + const sendFunctionMock = jest.fn(); + afterEach(() => { + mockedCognitoIdentityProviderClient.mockReset(); + mockedConfirmForgotPasswordCommand.mockReset(); + sendFunctionMock.mockReset(); + }); + it("handler returns 400 status code if username not provided", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + }); + + await forgotpassword(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "username needs to be provided in the body of the request", + }); + }); + it("handler returns empty body and cognito status code when command succeeds", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockImplementation(async () => { + return { + $metadata: { + httpStatusCode: 200, + }, + }; + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + }, + }); + + await forgotpassword(req, res); + expect(res.statusCode).toBe(200); + expect(mockedCognitoIdentityProviderClient).toBeCalledTimes(1); + expect(mockedConfirmForgotPasswordCommand.mock.calls[0][0]).toEqual({ + ClientId: "somemockvalue", + Username: "test", + }); + expect(res._getData()).toEqual(""); + }); + it("handles error when the cognito send function fails", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockRejectedValue({ + toString: () => "There is an error", + $metadata: { + httpStatusCode: 400, + }, + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + }, + }); + + await forgotpassword(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "There is an error", + }); + }); + }); +}); diff --git a/__tests__/api/flags.test.ts b/__tests__/api/flags.test.ts new file mode 100644 index 0000000000..78b8390d8f --- /dev/null +++ b/__tests__/api/flags.test.ts @@ -0,0 +1,251 @@ +/** + * @jest-environment node + */ +import { createMocks } from "node-mocks-http"; +import { unstable_getServerSession } from "next-auth/next"; +import enable from "@pages/api/flags/[key]/enable"; +import disable from "@pages/api/flags/[key]/disable"; +import check from "@pages/api/flags/[key]/check"; +import checkAllFlags from "@pages/api/flags"; +import { ViewApplicationSettings, ManageApplicationSettings } from "__utils__/permissions"; +import Redis from "ioredis-mock"; +const redis = new Redis(); + +jest.mock("@lib/integration/redisConnector", () => ({ + getRedisInstance: jest.fn(() => redis), +})); + +jest.mock("next-auth/next"); + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +describe("Flags API endpoint", () => { + beforeAll(() => { + // Adding URL to process.env because we are mocking out Redis for these tests + process.env.REDIS_URL = "test_url"; + }); + afterAll(() => { + delete process.env.REDIS_URL; + }); + describe("Authenticated", () => { + describe("ViewApplicationSettings", () => { + beforeEach(async () => { + const mockSession = { + expires: "1", + user: { + email: "forms@cds.ca", + name: "forms user", + id: "1", + privileges: ViewApplicationSettings, + }, + }; + mockGetSession.mockResolvedValue(mockSession); + // Intialize mockRedis with default flags + const testFlags = { + flag1: true, + flag2: false, + }; + const promises = []; + for (const [key, value] of Object.entries(testFlags)) { + promises.push( + redis + .multi() + .sadd("flags", key) + .set(`flag:${key}`, value ? "1" : "0") + .exec() + ); + } + await Promise.all(promises); + }); + afterEach(() => { + redis.flushall(); + mockGetSession.mockReset(); + }); + + it("Enable a feature flag", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/featureFlag/enable", + query: { + key: "featureFlag", + }, + }); + + await enable(req, res); + + expect(res.statusCode).toBe(403); + expect(res._getJSONData()).toMatchObject({ error: "Forbidden" }); + }); + + it("Disable a feature flag", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/featureFlag/disable", + query: { + key: "featureFlag", + }, + }); + + await disable(req, res); + + expect(res.statusCode).toBe(403); + expect(res._getJSONData()).toMatchObject({ error: "Forbidden" }); + }); + it("Gets a list of all feature flags", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/", + }); + + await checkAllFlags(req, res); + + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toMatchObject({ flag1: true, flag2: false }); + }); + }); + describe("ManageApplicationSettings", () => { + beforeEach(async () => { + const mockSession = { + expires: "1", + user: { + email: "forms@cds.ca", + name: "forms user", + id: "1", + privileges: ManageApplicationSettings, + }, + }; + mockGetSession.mockResolvedValue(mockSession); + + // Intialize mockRedis with default flags + const testFlags = { + flag1: true, + flag2: false, + }; + const promises = []; + for (const [key, value] of Object.entries(testFlags)) { + promises.push( + redis + .multi() + .sadd("flags", key) + .set(`flag:${key}`, value ? "1" : "0") + .exec() + ); + } + await Promise.all(promises); + }); + afterEach(() => { + redis.flushall(); + mockGetSession.mockReset(); + }); + + it("Enable a feature flag", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/flag2/enable", + query: { + key: "flag2", + }, + }); + + await enable(req, res); + + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toMatchObject({ flag2: true }); + }); + + it("Disable a feature flag", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/flag1/disable", + query: { + key: "flag1", + }, + }); + + await disable(req, res); + + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toMatchObject({ flag1: false }); + }); + it("Gets a list of all feature flags", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/", + }); + + await checkAllFlags(req, res); + + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toMatchObject({ flag1: true, flag2: false }); + }); + }); + }); + + describe("Unauthenticated", () => { + beforeAll(async () => { + // Intialize mockRedis with default flags + const testFlags = { + flag1: true, + flag2: false, + }; + const promises = []; + for (const [key, value] of Object.entries(testFlags)) { + promises.push( + redis + .multi() + .sadd("flags", key) + .set(`flag:${key}`, value ? "1" : "0") + .exec() + ); + } + await Promise.all(promises); + }); + afterAll(() => { + redis.flushall(); + }); + + it("Check a feature flag", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + url: "/api/flags/flag1/check", + query: { + key: "flag1", + }, + }); + + await check(req, res); + + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toMatchObject({ status: true }); + }); + }); +}); diff --git a/__tests__/api/id/form/apiUsers.test.ts b/__tests__/api/id/form/apiUsers.test.ts new file mode 100644 index 0000000000..bcb1c160ea --- /dev/null +++ b/__tests__/api/id/form/apiUsers.test.ts @@ -0,0 +1,1218 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createMocks, RequestMethod } from "node-mocks-http"; +import { unstable_getServerSession } from "next-auth/next"; +import apiUsers from "@pages/api/id/[form]/apiusers"; +import { logAdminActivity } from "@lib/adminLogs"; +import { prismaMock } from "@jestUtils"; +import { Prisma } from "@prisma/client"; +import { Session } from "next-auth"; +import { Base, ManageForms, getUserPrivileges } from "__utils__/permissions"; + +jest.mock("next-auth/next"); +jest.mock("@lib/adminLogs"); + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +describe("/id/[forms]/owners", () => { + describe("Requires a valid session to access API", () => { + test.each(["GET", "POST", "PUT"])( + "Shouldn't allow a request without a valid session", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + query: { + form: "1", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Unauthorized" }); + } + ); + test.each(["DELETE", "PATCH"])("Shouldn't allow an unaccepted method", async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + query: { + form: "1", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + }); + }); + describe("GET: Retrieve list of emails API endpoint", () => { + describe("Base Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + it("Should return an error 'Malformed API Request'", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + query: { + form: "", + }, + }); + + await apiUsers(req, res); + expect(JSON.parse(res._getData()).error).toEqual("Malformed API Request FormID not define"); + expect(res.statusCode).toBe(400); + }); + + it("Should return all the emails associated with the form ID.", async () => { + // Mocking query to return a list of emails + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ], + + users: [ + { + id: "1", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/89/owners", + }, + query: { + form: "89", + }, + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toEqual([ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ]); + + expect(res.statusCode).toBe(200); + }); + it("Should not allow a user to view a non-owned form", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ], + + users: [ + { + id: "2", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/89/owners", + }, + query: { + form: "89", + }, + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Forbidden" }); + + expect(res.statusCode).toBe(403); + }); + + it("Should return a list that contains only one email", async () => { + // Mocking executeQuery to return a list with only an email + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/1/owners", + }, + query: { + form: "1", + }, + }); + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [{ id: 1, email: "oneEmail@cds.ca", active: true }], + + users: [ + { + id: "1", + }, + ], + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toMatchObject([ + { id: 1, email: "oneEmail@cds.ca", active: true }, + ]); + expect(res.statusCode).toBe(200); + }); + + it("Should return an empty array if form has no emails associated", async () => { + // Mocking executeQuery to return an empty list + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [], + + users: [ + { + id: "1", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "99", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual([]); + }); + + it("Should return 404 as statusCode if there's db's error", async () => { + // Mocking prisma to throw an error + prismaMock.template.findUnique.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Can't reach database server", "P1001", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/33/owners", + }, + query: { + form: "33", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData()).error).toEqual("Form Not Found"); + }); + }); + describe("Manage All Forms Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: getUserPrivileges(ManageForms, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + it("Should return an error 'Malformed API Request'", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + query: { + form: "", + }, + }); + + await apiUsers(req, res); + expect(JSON.parse(res._getData()).error).toEqual("Malformed API Request FormID not define"); + expect(res.statusCode).toBe(400); + }); + + it("Should return all the emails associated with the form ID.", async () => { + // Mocking query to return a list of emails + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ], + + users: [ + { + id: "1", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/89/owners", + }, + query: { + form: "89", + }, + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toEqual([ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ]); + + expect(res.statusCode).toBe(200); + }); + it("Should allow a user to view a non-owned form", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ], + + users: [ + { + id: "2", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/89/owners", + }, + query: { + form: "89", + }, + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toEqual([ + { id: 1, email: "test@cds.ca", active: true }, + { id: 2, email: "forms@cds.ca", active: false }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("Should return a list that contains only one email", async () => { + // Mocking executeQuery to return a list with only an email + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/1/owners", + }, + query: { + form: "1", + }, + }); + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [{ id: 1, email: "oneEmail@cds.ca", active: true }], + + users: [ + { + id: "1", + }, + ], + }); + await apiUsers(req, res); + expect(JSON.parse(res._getData())).toMatchObject([ + { id: 1, email: "oneEmail@cds.ca", active: true }, + ]); + expect(res.statusCode).toBe(200); + }); + + it("Should return an empty array if form has no emails associated", async () => { + // Mocking executeQuery to return an empty list + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + apiUsers: [], + + users: [ + { + id: "1", + }, + ], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "99", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual([]); + }); + + it("Should return 404 as statusCode if there's db's error", async () => { + // Mocking prisma to throw an error + prismaMock.template.findUnique.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Can't reach database server", "P1001", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/33/owners", + }, + query: { + form: "33", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData()).error).toEqual("Form Not Found"); + }); + }); + }); + describe("PUT: Activate and deactivate a form's owners API endpoint", () => { + describe("Base Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + + it("Should return 400 invalid payload error(active) field/value is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/11/owners", + }, + body: { + email: "forms@cds.ca", + active: "", + }, + query: { + form: "11", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Invalid payload fields are not define" }) + ); + }); + + it("Should return 400 invalid payload error(email) field/value is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "", + active: true, + }, + query: { + form: "20", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Invalid payload fields are not define" }) + ); + }); + + it("Should return 404 Email Not Found", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + // Mocking prisma to throw an error + prismaMock.apiUser.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unknown User", "P2025", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "10", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Email Not Found" }) + ); + }); + + it("Should return 404 Form Not Found", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue(null); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "10", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Form Not Found" }) + ); + }); + test.each([0, 1])( + "Should return 200 status code: owners are deactivated/activated for owned forms", + async (elem) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + //Mocking prisma + (prismaMock.apiUser.update as jest.MockedFunction).mockResolvedValue({ + id: elem, + active: true, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "12", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toMatchObject({ id: elem, active: true }); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Update", + "GrantFormAccess", + "Access to form id: 12 has been granted for email: forms@cds.ca" + ); + } + ); + test.each([0, 1])("Should return 403 status code as user does not own form", async (elem) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "2", + }, + ], + }); + //Mocking prisma + (prismaMock.apiUser.update as jest.MockedFunction).mockResolvedValue({ + id: elem, + active: true, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "12", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Forbidden" }); + }); + }); + describe("ManageForms Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: getUserPrivileges(ManageForms, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + + it("Should return 400 invalid payload error(active) field/value is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/11/owners", + }, + body: { + email: "forms@cds.ca", + active: "", + }, + query: { + form: "11", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Invalid payload fields are not define" }) + ); + }); + + it("Should return 400 invalid payload error(email) field/value is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "", + active: true, + }, + query: { + form: "20", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Invalid payload fields are not define" }) + ); + }); + + it("Should return 404 Email Not Found", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + // Mocking prisma to throw an error + prismaMock.apiUser.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unknown User", "P2025", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "10", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Email Not Found" }) + ); + }); + + it("Should return 404 Form Not Found", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue(null); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "10", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Form Not Found" }) + ); + }); + test.each([0, 1])( + "Should return 200 status code: owners are deactivated/activated for forms owned", + async (elem) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + //Mocking prisma + (prismaMock.apiUser.update as jest.MockedFunction).mockResolvedValue({ + id: elem, + active: true, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "12", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toMatchObject({ id: elem, active: true }); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Update", + "GrantFormAccess", + "Access to form id: 12 has been granted for email: forms@cds.ca" + ); + } + ); + test.each([0, 1])( + "Should return 200 status code: owners are deactivated/activated for forms not owned", + async (elem) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "2", + }, + ], + }); + //Mocking prisma + (prismaMock.apiUser.update as jest.MockedFunction).mockResolvedValue({ + id: elem, + active: true, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds.ca", + active: true, + }, + query: { + form: "12", + }, + }); + + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toMatchObject({ id: elem, active: true }); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Update", + "GrantFormAccess", + "Access to form id: 12 has been granted for email: forms@cds.ca" + ); + } + ); + }); + }); + + describe("POST: Associate an email to a template data API endpoint", () => { + describe("Base Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds-snc.ca", + name: "forms user", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + it("Should return 400 FormID doesn't exist or User already assigned in db", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + //Mocking db result by throwing constraint violation error. + prismaMock.apiUser.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unknown User", "P2003", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "888", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + error: "The formID does not exist or User is already assigned", + }) + ); + }); + + it("Should create a new record and return 200 code along with the id", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + // return the id of the newly created record. + + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + success: { + id: 1, + }, + }) + ); + }); + it("Should not allow a new record if the user does not own the form", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "2", + }, + ], + }); + + // return the id of the newly created record. + + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + error: "Forbidden", + }) + ); + }); + + it("Should return 400 undefined formID was supplied", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: undefined, + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Malformed API Request FormID not define" }) + ); + }); + + test.each([ + "", + "wrongEmail.gc.ca", + undefined, + "testNotValidGovDomainName@google.com", + "@gc.ca", + ])( + "Should return 400 status code wiht invalid email in payload for all those cases", + async (elem) => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: elem, + }, + query: { + form: "23", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "The email is not a valid GC email" }) + ); + } + ); + + it("Should log admin activity if POST API call completed successfully", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + // return the id of the newly created record. + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + + await apiUsers(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Create", + "GrantInitialFormAccess", + "Email: forms@cds-snc.ca has been given access to form id: 9" + ); + }); + }); + + describe("ManageForm Permissions", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms user", + privileges: getUserPrivileges(ManageForms, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + afterEach(() => mockGetSession.mockReset()); + it("Should return 400 FormID doesn't exist or User already assigned in db", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + //Mocking db result by throwing constraint violation error. + prismaMock.apiUser.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unknown User", "P2003", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "888", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + error: "The formID does not exist or User is already assigned", + }) + ); + }); + + it("Should create a new record and return 200 code along with the id", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + // return the id of the newly created record. + + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + success: { + id: 1, + }, + }) + ); + }); + it("Should allow a new record if the user does not own the form", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "2", + }, + ], + }); + + // return the id of the newly created record. + + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + success: { + id: 1, + }, + }) + ); + }); + + it("Should return 400 undefined formID was supplied", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: undefined, + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Malformed API Request FormID not define" }) + ); + }); + + test.each([ + "", + "wrongEmail.gc.ca", + undefined, + "testNotValidGovDomainName@google.com", + "@gc.ca", + ])( + "Should return 400 status code wiht invalid email in payload for all those cases", + async (elem) => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: elem, + }, + query: { + form: "23", + }, + }); + await apiUsers(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "The email is not a valid GC email" }) + ); + } + ); + + it("Should log admin activity if POST API call completed successfully", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [ + { + id: "1", + }, + ], + }); + + // return the id of the newly created record. + (prismaMock.apiUser.create as jest.MockedFunction).mockResolvedValue({ + id: 1, + }); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + email: "forms@cds-snc.ca", + }, + query: { + form: "9", + }, + }); + + await apiUsers(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Create", + "GrantInitialFormAccess", + "Email: forms@cds-snc.ca has been given access to form id: 9" + ); + }); + }); + }); +}); diff --git a/__tests__/api/id/form/bearer.test.ts b/__tests__/api/id/form/bearer.test.ts new file mode 100644 index 0000000000..68bca32de1 --- /dev/null +++ b/__tests__/api/id/form/bearer.test.ts @@ -0,0 +1,420 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { unstable_getServerSession } from "next-auth/next"; +import retrieve from "@pages/api/id/[form]/bearer"; +import { Base, ManageForms, getUserPrivileges } from "__utils__/permissions"; +import jwt from "jsonwebtoken"; +import { logAdminActivity } from "@lib/adminLogs"; +import { prismaMock } from "@jestUtils"; +import { Prisma } from "@prisma/client"; +import { Session } from "next-auth"; + +jest.mock("next-auth/next"); +jest.mock("@lib/adminLogs"); + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +jest.mock("@lib/logger"); + +describe("/id/[form]/bearer", () => { + describe("Access Controls", () => { + test.each(["GET", "POST"])("Should deny without a session", async (verb) => { + const { req, res } = createMocks({ + method: verb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await retrieve(req, res); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Unauthorized" }) + ); + }); + }); + + describe.each([ + [Base, "1", "1"], + [ManageForms, "1", "2"], + ])("GET", (privileges, privilegedUserId, mockedUserId) => { + beforeEach(() => { + const mockSession = { + expires: "1", + user: { + id: "1", + email: "admin@cds.ca", + name: "Admin user", + image: "null", + privileges: getUserPrivileges(privileges, { user: { id: privilegedUserId } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => mockGetSession.mockReset()); + + it("Should return 400 if form ID was not supplied in the path", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/8/bearer", + }, + query: { + form: "", //An empty form ID + }, + }); + + await retrieve(req, res); + + expect(JSON.parse(res._getData()).error).toEqual( + "form ID parameter was not provided in the resource path" + ); + expect(res.statusCode).toBe(400); + }); + + it("Should return a 200 status code and Null as token's value should the form exist but no token is present", async () => { + // Mocking executeQuery to return null as bearer token value + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: null, + users: [{ id: mockedUserId }], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "11", + }, + }); + + await retrieve(req, res); + + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ bearerToken: null })); + expect(res.statusCode).toBe(200); + }); + + it("Should return a 200 status code and a valid token if it exists for a form.", async () => { + // Mocking executeQuery to return a valid bearer token + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: "testBearerToken", + users: [{ id: mockedUserId }], + }); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "12", + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ bearerToken: "testBearerToken" }) + ); + }); + + it("Should return 404 status code if no form was found", async () => { + // Mocking executeQuery to return an empty array + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue(null); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "23", + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData()).error).toEqual("Not Found"); + }); + + it("Should return a 404 status code if there's an unexpected Prisma error", async () => { + // Mocking executeQuery to throw an error + prismaMock.template.findUnique.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Timed out", "P2024", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + query: { + form: "101", + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData()).error).toEqual("Not Found"); + }); + }); + + describe.each([ + [Base, "1", "1"], + [ManageForms, "1", "2"], + ])("POST", (privileges, privilegedUserId, mockedUserId) => { + beforeAll(() => { + process.env.TOKEN_SECRET = "some_secret"; + }); + + beforeEach(() => { + const mockSession = { + expires: "1", + user: { + email: "admin@cds.ca", + name: "Admin user", + image: "null", + privileges: getUserPrivileges(privileges, { user: { id: privilegedUserId } }), + id: "1", + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => mockGetSession.mockReset()); + + afterAll(() => { + delete process.env.TOKEN_SECRET; + }); + + it("Should return a 200 status code, the refreshed token, and the id of the form", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: "testBearerToken", + users: [{ id: mockedUserId }], + }); + + (prismaMock.template.update as jest.MockedFunction).mockImplementationOnce( + (args: Record) => + Promise.resolve({ + id: 1, + bearerToken: args.data.bearerToken, + }) + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Length": "0", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(200); + const { id, bearerToken } = JSON.parse(res._getData()); + expect(id).toEqual(1); + const decodedToken = jwt.verify(bearerToken, "some_secret"); + + expect((decodedToken as { formID: string }).formID).toBe(1); + }); + it("Should return a 404 status code if the form is not found", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue(null); + + prismaMock.template.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Form does not exist", "P2025", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Length": "0", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData()).error).toBe("Form Not Found"); + }); + it("Should return a 400 status code if the form parameter is not provided", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Length": "0", + }, + query: { + form: undefined, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData()).error).toBe( + "form ID parameter was not provided in the resource path" + ); + }); + + it("Should return a 500 status code if any unexpected error occurs", async () => { + prismaMock.template.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Timed out", "P2024", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Length": "0", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(500); + expect(JSON.parse(res._getData()).error).toBe("Internal Service Error"); + }); + + it("Should log admin activity if API call completed successfully", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [{ id: mockedUserId }], + }); + + (prismaMock.template.update as jest.MockedFunction).mockImplementationOnce( + (args: Record) => + Promise.resolve({ + id: 1, + bearerToken: args.data.bearerToken, + }) + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Length": "0", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).lastCalledWith( + "1", + "Update", + "RefreshBearerToken", + "Bearer token for form id: 1 has been refreshed" + ); + }); + }); +}); + +describe("Bearer API functions should throw an error if user does not have permissions", () => { + describe("Bearer API functions should throw an error if user does not have any permissions", () => { + beforeAll(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: [], + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterAll(() => { + mockGetSession.mockReset(); + }); + + it.each(["GET", "POST"])( + "User with no permission should not be able to use %s API functions", + async (httpMethod) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [{ id: "1" }], + }); + + const { req, res } = createMocks({ + method: httpMethod as RequestMethod, + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + } + ); + }); + + describe("Bearer API functions should throw an error if user does not have sufficient permissions", () => { + afterAll(() => { + mockGetSession.mockReset(); + }); + + it.each(["GET", "POST"])( + "User with no relation to the template being interacted with should not be able to use the %s API function", + async (httpMethod) => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + users: [{ id: "2" }], + }); + + const { req, res } = createMocks({ + method: httpMethod as RequestMethod, + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + query: { + form: 1, + }, + }); + + await retrieve(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + } + ); + }); +}); diff --git a/tests/api/id/[form]/retrieval.test.js b/__tests__/api/id/form/retrieval.test.ts similarity index 66% rename from tests/api/id/[form]/retrieval.test.js rename to __tests__/api/id/form/retrieval.test.ts index 8d594c67ea..1d0c212d67 100644 --- a/tests/api/id/[form]/retrieval.test.js +++ b/__tests__/api/id/form/retrieval.test.ts @@ -1,29 +1,24 @@ +/** + * @jest-environment node + */ + import { createMocks } from "node-mocks-http"; import retrieval from "@pages/api/id/[form]/retrieval"; -import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; -import executeQuery from "@lib/integration/queryManager"; +import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { prismaMock } from "@jestUtils"; import { logMessage } from "@lib/logger"; -import jwt from "jsonwebtoken"; +import jwt, { Secret } from "jsonwebtoken"; -jest.mock("next-auth/client"); -jest.mock("@lib/integration/queryManager"); +jest.mock("next-auth/next"); jest.mock("@lib/logger"); - -jest.mock("@lib/integration/dbConnector", () => { - const client = { - connect: jest.fn(), - query: jest.fn(), - end: jest.fn(), - }; - return jest.fn(() => client); -}); +const mockLogMessage = jest.mocked(logMessage, { shallow: true }); const ddbMock = mockClient(DynamoDBDocumentClient); -describe("/api/retrieval", () => { - let dateNowSpy; - +// Skipping until API is reactivated. Currently all methods are blocked from accessing. +describe.skip("/api/retrieval", () => { + let dateNowSpy: jest.SpyInstance; beforeEach(() => { dateNowSpy = jest.spyOn(Date, "now"); ddbMock.reset(); @@ -46,9 +41,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -73,9 +68,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -93,19 +88,8 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [], - rowCount: 1, - }; - } - }); - + //Mock bearer token not being found + prismaMock.template.findUnique.mockResolvedValue(null); await retrieval(req, res); expect(res.statusCode).toBe(403); }); @@ -118,7 +102,7 @@ describe("/api/retrieval", () => { email: "test@cds-snc.ca", form: 1, }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -135,21 +119,18 @@ describe("/api/retrieval", () => { form: "22", }, }); - - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); - ddbMock.on(QueryCommand).resolves({ + const dynamodbExpectedReponses = { Items: [ { FormID: "1", @@ -164,8 +145,8 @@ describe("/api/retrieval", () => { SecurityAttribute: "Protected B", }, ], - }); - + }; + ddbMock.on(QueryCommand).resolves(dynamodbExpectedReponses); await retrieval(req, res); expect(res.statusCode).toBe(200); @@ -173,12 +154,14 @@ describe("/api/retrieval", () => { expect.objectContaining({ responses: [ { + fileAttachments: [], formID: "1", submissionID: "12", formSubmission: "true", securityAttribute: "Protected B", }, { + fileAttachments: [], formID: "1", submissionID: "21", formSubmission: "true", @@ -193,9 +176,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - form: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -213,17 +196,15 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); ddbMock @@ -233,33 +214,33 @@ describe("/api/retrieval", () => { .resolves({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "1", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "2", FormSubmission: "true", SecurityAttribute: "Protected B", }, ], - LastEvaluatedKey: 1, + LastEvaluatedKey: { submissionId: 1 }, }) .on(QueryCommand, { - ExclusiveStartKey: 1, + ExclusiveStartKey: { submissionId: 1 }, }) .resolves({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "3", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "4", FormSubmission: "true", SecurityAttribute: "Protected B", @@ -275,25 +256,29 @@ describe("/api/retrieval", () => { expect.objectContaining({ responses: [ { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "1", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "2", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "3", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "4", formSubmission: "true", securityAttribute: "Protected B", @@ -307,9 +292,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - form: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -327,17 +312,15 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); ddbMock @@ -345,99 +328,99 @@ describe("/api/retrieval", () => { .resolvesOnce({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "1", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "2", FormSubmission: "true", SecurityAttribute: "Protected B", }, ], - LastEvaluatedKey: 1, + LastEvaluatedKey: { submissionId: 1 }, }) .resolvesOnce({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "3", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "4", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "5", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "6", FormSubmission: "true", SecurityAttribute: "Protected B", }, ], - LastEvaluatedKey: 2, + LastEvaluatedKey: { submissionId: 2 }, }) .resolvesOnce({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "7", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "8", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "9", FormSubmission: "true", SecurityAttribute: "Protected B", }, ], - LastEvaluatedKey: 3, + LastEvaluatedKey: { submissionId: 3 }, }) .resolvesOnce({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "10", FormSubmission: "true", SecurityAttribute: "Protected B", }, ], - LastEvaluatedKey: 4, + LastEvaluatedKey: { submissionId: 4 }, }) .resolves({ Items: [ { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "11", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "12", FormSubmission: "true", SecurityAttribute: "Protected B", }, { - FormID: "1", + FormID: "test0form00000id000asdf11", SubmissionID: "13", FormSubmission: "true", SecurityAttribute: "Protected B", @@ -453,61 +436,71 @@ describe("/api/retrieval", () => { expect.objectContaining({ responses: [ { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "1", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "2", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "3", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "4", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "5", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "6", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "7", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "8", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "9", formSubmission: "true", securityAttribute: "Protected B", }, { - formID: "1", + fileAttachments: [], + formID: "test0form00000id000asdf11", submissionID: "10", formSubmission: "true", securityAttribute: "Protected B", @@ -520,10 +513,10 @@ describe("/api/retrieval", () => { it("Should return 500 status code if it fails to fetch/send command to dynamoDb", async () => { const token = jwt.sign( { - formID: 2, + formID: "test0form00000id000asdf12", email: "test@cds-snc.ca", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -537,24 +530,20 @@ describe("/api/retrieval", () => { authorization: `Bearer ${token}`, }, query: { - form: "23", + form: "test0form00000id000asdf13", }, }); - - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "test0form00000id000asdf12", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); - - ddbMock.on(QueryCommand).rejects(); + ddbMock.on(QueryCommand).rejects("I'm an Error"); await retrieval(req, res); @@ -564,9 +553,9 @@ describe("/api/retrieval", () => { it("Should return 200 status code and an empty list of form's responses", async () => { const token = jwt.sign( { - formID: 3, + formID: "test0form00000id000asdf13", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -580,27 +569,23 @@ describe("/api/retrieval", () => { authorization: `Bearer ${token}`, }, query: { - form: "12", + form: "test0form00000id000asdf12", }, }); - - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "test0form00000id000asdf11", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); - - ddbMock.on(QueryCommand).resolves({ + const dynamodbExpectedReponses = { Items: [], - }); - + }; + ddbMock.on(QueryCommand).resolves(dynamodbExpectedReponses); await retrieval(req, res); expect(res.statusCode).toBe(200); @@ -614,9 +599,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -634,31 +619,25 @@ describe("/api/retrieval", () => { form: "22", }, }); - - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "test@cds-snc.ca", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); - - ddbMock.on(UpdateCommand).resolves(); - - logMessage.warn.mockImplementation(() => {}); - + ddbMock.on(UpdateCommand).resolves; + mockLogMessage.warn.mockImplementation(jest.fn()); await retrieval(req, res); expect(res.statusCode).toBe(200); expect(JSON.parse(res._getData())).toEqual(["dfhkwehfewhf", "fewfewfewfew"]); expect(ddbMock.commandCalls(UpdateCommand).length).toBe(2); - expect(logMessage.info.mock.calls.length).toBe(1); - expect(logMessage.info.mock.calls[0][0]).toContain( + expect(mockLogMessage.info.mock.calls.length).toBe(1); + expect(mockLogMessage.info.mock.calls[0][0]).toContain( `user:test@cds-snc.ca marked form responses [dfhkwehfewhf,fewfewfewfew] from form ID:22 as retrieved at:1 using token:${token}` ); }); @@ -667,9 +646,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -687,18 +666,15 @@ describe("/api/retrieval", () => { form: "22", }, }); - - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); await retrieval(req, res); @@ -713,9 +689,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -734,19 +710,16 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); - await retrieval(req, res); expect(res.statusCode).toBe(400); @@ -760,9 +733,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -781,17 +754,15 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); await retrieval(req, res); @@ -806,9 +777,9 @@ describe("/api/retrieval", () => { const token = jwt.sign( { email: "test@cds-snc.ca", - formID: 1, + formID: "test0form00000id000asdf11", }, - process.env.TOKEN_SECRET, + process.env.TOKEN_SECRET as Secret, { expiresIn: "1y", } @@ -827,23 +798,34 @@ describe("/api/retrieval", () => { }, }); - executeQuery.mockImplementation((client, sql) => { - if ( - sql.includes( - "SELECT 1 FROM form_users WHERE template_id = ($1) and email = ($2) and temporary_token = ($3) and active = true" - ) - ) { - return { - rows: [{ column: 1 }], - rowCount: 1, - }; - } + // Mock good temporary token + prismaMock.apiUser.findUnique.mockResolvedValue({ + id: "asdf", + templateId: "22", + email: "b@d.a", + active: true, + temporaryToken: token, + created_at: new Date(), + updated_at: new Date(), }); + ddbMock + .on(UpdateCommand, { + Key: { + SubmissionID: "fsdfdsfsdf", + FormID: "22", + }, + }) + .rejects("This is an Error") + .on(UpdateCommand, { + TableName: "Vault", + Key: { + SubmissionID: "dfdsfdsfds", + FormID: "22", + }, + }).resolves; - ddbMock.on(UpdateCommand).resolvesOnce().rejects("some error"); - - logMessage.warn.mockImplementation(() => {}); - logMessage.error.mockImplementation(() => {}); + mockLogMessage.warn.mockImplementation(jest.fn()); + mockLogMessage.error.mockImplementation(jest.fn()); await retrieval(req, res); @@ -851,15 +833,15 @@ describe("/api/retrieval", () => { expect(JSON.parse(res._getData())).toEqual({ error: "Error on Server Side", }); - expect(logMessage.warn.mock.calls.length).toBe(2); - expect(logMessage.warn.mock.calls[0][0]).toBe( + expect(mockLogMessage.warn.mock.calls.length).toBe(2); + expect(mockLogMessage.warn.mock.calls[0][0]).toBe( "Some submissions were potentially not marked as retrieved" ); - expect(logMessage.warn.mock.calls[1][0]).toBe( + expect(mockLogMessage.warn.mock.calls[1][0]).toBe( "The following submissions were not marked as retrieved: [fsdfdsfsdf]" ); - expect(logMessage.error.mock.calls.length).toBe(1); - expect(logMessage.error.mock.calls[0][0]).toEqual(new Error("some error")); + expect(mockLogMessage.error.mock.calls.length).toBe(1); + expect(mockLogMessage.error.mock.calls[0][0]).toEqual(new Error("This is an Error")); }); }); }); diff --git a/tests/api/log.test.ts b/__tests__/api/log.test.ts similarity index 96% rename from tests/api/log.test.ts rename to __tests__/api/log.test.ts index eb13700a59..ae0f7c7d13 100644 --- a/tests/api/log.test.ts +++ b/__tests__/api/log.test.ts @@ -1,11 +1,15 @@ -import { getCsrfToken } from "next-auth/client"; +/** + * @jest-environment node + */ + +import { getCsrfToken } from "next-auth/react"; import logApi from "@pages/api/log"; import { createMocks } from "node-mocks-http"; import { logMessage } from "@lib/logger"; import { Level } from "pino"; -jest.mock("next-auth/client"); -const mockedGetCsrfToken = jest.mocked(getCsrfToken, true); +jest.mock("next-auth/react"); +const mockedGetCsrfToken = jest.mocked(getCsrfToken, { shallow: true }); describe("Log API Endpoint", () => { it.each(["info", "warn", "error"])("Receives and Saves a log for %s level", async (level) => { diff --git a/tests/api/notify-callbacks.test.js b/__tests__/api/notify-callbacks.test.js similarity index 99% rename from tests/api/notify-callbacks.test.js rename to __tests__/api/notify-callbacks.test.js index a862a32247..383623243d 100644 --- a/tests/api/notify-callbacks.test.js +++ b/__tests__/api/notify-callbacks.test.js @@ -1,3 +1,6 @@ +/** + * @jest-environment node + */ import { createMocks } from "node-mocks-http"; import notifyCallback from "../../pages/api/notify-callback"; import { SQSClient } from "@aws-sdk/client-sqs"; diff --git a/__tests__/api/request/publish.test.ts b/__tests__/api/request/publish.test.ts new file mode 100644 index 0000000000..363d087a50 --- /dev/null +++ b/__tests__/api/request/publish.test.ts @@ -0,0 +1,148 @@ +/** + * @jest-environment node + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import publish from "@pages/api/request/publish"; +import { unstable_getServerSession } from "next-auth/next"; +import { Session } from "next-auth"; + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); +jest.mock("next-auth/next"); + +let IsGCNotifyServiceAvailable = true; + +const mockSendEmail = { + sendEmail: jest.fn(() => { + if (IsGCNotifyServiceAvailable) { + return Promise.resolve(); + } else { + return Promise.reject(new Error("something went wrong")); + } + }), +}; + +jest.mock("notifications-node-client", () => ({ + NotifyClient: jest.fn(() => mockSendEmail), +})); + +describe("Request publishing permission API tests (without active session)", () => { + it("Should not be able to use the API without an active session", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + managerEmail: "manager@cds-snc.ca", + department: "department", + }, + }); + + await publish(req, res); + + expect(res.statusCode).toEqual(401); + }); +}); + +describe("Request publishing permission API tests (with active session)", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: [], + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it.each(["GET", "PUT", "DELETE"])( + "Should not be able to use the API with unsupported HTTP methods", + async (httpMethod) => { + const { req, res } = createMocks({ + method: httpMethod as RequestMethod, + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + managerEmail: "manager@cds-snc.ca", + department: "department", + }, + }); + + await publish(req, res); + + expect(res.statusCode).toEqual(403); + } + ); + + it("Should not be able to use the API if payload is invalid", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + managerEmail: "manager@cds-snc.ca", + }, + }); + + await publish(req, res); + + expect(res.statusCode).toEqual(404); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Malformed request" }); + }); + + it("Should succeed if payload is valid", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + managerEmail: "manager@cds-snc.ca", + department: "department", + goals: "do something", + }, + }); + + await publish(req, res); + + expect(res.statusCode).toEqual(200); + }); + + it("Should fail if GC Notify service is unavailable", async () => { + IsGCNotifyServiceAvailable = false; + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + managerEmail: "manager@cds-snc.ca", + department: "department", + goals: "do something", + }, + }); + + await publish(req, res); + + expect(res.statusCode).toEqual(500); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Failed to send request" }); + }); +}); diff --git a/__tests__/api/request/support.test.ts b/__tests__/api/request/support.test.ts new file mode 100644 index 0000000000..8a693d9550 --- /dev/null +++ b/__tests__/api/request/support.test.ts @@ -0,0 +1,186 @@ +/** + * @jest-environment node + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import support from "@pages/api/request/support"; +import { getCsrfToken } from "next-auth/react"; +import { unstable_getServerSession } from "next-auth/next"; +import { mocked } from "jest-mock"; +import { Session } from "next-auth"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); +jest.mock("next-auth/next"); + +let IsGCNotifyServiceAvailable = true; + +const mockSendEmail = { + sendEmail: jest.fn(() => { + if (IsGCNotifyServiceAvailable) { + return Promise.resolve(); + } else { + return Promise.reject(new Error("something went wrong")); + } + }), +}; + +jest.mock("notifications-node-client", () => ({ + NotifyClient: jest.fn(() => mockSendEmail), +})); + +describe("Support email API tests - WITHOUT an active session", () => { + beforeEach(() => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + IsGCNotifyServiceAvailable = true; + }); + + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + + runEmailAPITests(); +}); + +describe("Support email API tests - WITH an active session", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: [], + }, + }; + IsGCNotifyServiceAvailable = true; + mockGetSession.mockResolvedValue(mockSession); + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + runEmailAPITests(); +}); + +function runEmailAPITests() { + it("Should fail if CSRF token is not valid", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "X-CSRF-Token": "invalid_csrf", + }, + body: { + supportType: "support", + name: "name", + email: "email@email.com", + request: "request", + description: "description", + }, + }); + + await support(req, res); + + expect(res.statusCode).toEqual(403); + }); + + it("Should succeed if CSRF token is valid", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": "valid_csrf", + Origin: "http://localhost:3000", + }, + body: { + supportType: "support", + name: "name", + email: "email@email.com", + request: "request", + description: "description", + }, + }); + + await support(req, res); + expect(res.statusCode).toEqual(200); + }); + + it.each(["GET", "PUT", "DELETE"])( + "Should not be able to use the API with unsupported HTTP methods", + async (httpMethod) => { + const { req, res } = createMocks({ + method: httpMethod as RequestMethod, + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "X-CSRF-Token": "valid_csrf", + }, + body: { + supportType: "support", + name: "name", + email: "email@email.com", + request: "request", + description: "description", + }, + }); + + await support(req, res); + + expect(res.statusCode).toEqual(403); + } + ); + + it("Should not be able to use the API if payload is invalid", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "X-CSRF-Token": "valid_csrf", + }, + body: { + email: "email@email.com", + }, + }); + + await support(req, res); + + expect(res.statusCode).toEqual(404); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Malformed request" }); + }); + + it("Should fail if GC Notify service is unavailable", async () => { + IsGCNotifyServiceAvailable = false; + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + "X-CSRF-Token": "valid_csrf", + }, + body: { + supportType: "support", + name: "name", + email: "email@email.com", + request: "request", + description: "description", + }, + }); + + await support(req, res); + + expect(res.statusCode).toEqual(500); + expect(JSON.parse(res._getData())).toMatchObject({ + error: "Internal Service Error: Failed to send request", + }); + }); +} diff --git a/__tests__/api/signup/confirm.test.ts b/__tests__/api/signup/confirm.test.ts new file mode 100644 index 0000000000..fb425a5c37 --- /dev/null +++ b/__tests__/api/signup/confirm.test.ts @@ -0,0 +1,161 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { getCsrfToken } from "next-auth/react"; +import { mocked } from "jest-mock"; +import { + CognitoIdentityProviderClient, + ConfirmSignUpCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import confirm from "@pages/api/signup/confirm"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); +jest.mock("@aws-sdk/client-cognito-identity-provider", () => ({ + CognitoIdentityProviderClient: jest.fn(), + ConfirmSignUpCommand: jest.fn(), +})); + +describe("/signup/confirm", () => { + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + beforeAll(() => { + process.env.COGNITO_REGION = "ca-central-1"; + process.env.COGNITO_APP_CLIENT_ID = "somemockvalue"; + }); + afterAll(() => { + process.env.COGNITO_REGION = undefined; + process.env.COGNITO_APP_CLIENT_ID = undefined; + }); + describe("Access Control", () => { + test.each(["GET", "PUT", "DELETE"])( + "Should not allow an unaccepted method", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await confirm(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + } + ); + + it("does not allow a non valid CSRF token", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "invalid_csrf", + }, + }); + + await confirm(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ + error: "Access Denied", + }); + }); + }); + describe("Sign Up Confirmation", () => { + const mockedCognitoIdentityProviderClient: any = mocked(CognitoIdentityProviderClient, true); + const mockedConfirmSignUpCommand: any = mocked(ConfirmSignUpCommand, true); + const sendFunctionMock = jest.fn(); + afterEach(() => { + mockedCognitoIdentityProviderClient.mockReset(); + mockedConfirmSignUpCommand.mockReset(); + sendFunctionMock.mockReset(); + }); + it("handler returns 400 status code if username or confirmation code not provided", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + }); + + await confirm(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "username and confirmation code needs to be provided in the body of the request", + }); + }); + it("handler returns empty body and cognito status code when command succeeds", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockImplementation(async () => { + return { + $metadata: { + httpStatusCode: 200, + }, + }; + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + confirmationCode: "1921231", + }, + }); + + await confirm(req, res); + expect(res.statusCode).toBe(200); + expect(mockedCognitoIdentityProviderClient).toBeCalledTimes(1); + expect(mockedConfirmSignUpCommand.mock.calls[0][0]).toEqual({ + ClientId: "somemockvalue", + Username: "test", + ConfirmationCode: "1921231", + }); + expect(res._getData()).toEqual(""); + }); + it("handles error when the cognito send function fails", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockRejectedValue({ + toString: () => "There is an error", + $metadata: { + httpStatusCode: 400, + }, + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + confirmationCode: "1921231", + }, + }); + + await confirm(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "There is an error", + }); + }); + }); +}); diff --git a/__tests__/api/signup/register.test.ts b/__tests__/api/signup/register.test.ts new file mode 100644 index 0000000000..2a9727c346 --- /dev/null +++ b/__tests__/api/signup/register.test.ts @@ -0,0 +1,188 @@ +/** + * @jest-environment node + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { getCsrfToken } from "next-auth/react"; +import { mocked } from "jest-mock"; +import { + CognitoIdentityProviderClient, + SignUpCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import register from "@pages/api/signup/register"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); +jest.mock("@aws-sdk/client-cognito-identity-provider", () => ({ + CognitoIdentityProviderClient: jest.fn(), + SignUpCommand: jest.fn(), +})); + +describe("/signup/register", () => { + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + beforeAll(() => { + process.env.COGNITO_REGION = "ca-central-1"; + process.env.COGNITO_APP_CLIENT_ID = "somemockvalue"; + }); + afterAll(() => { + process.env.COGNITO_REGION = undefined; + process.env.COGNITO_APP_CLIENT_ID = undefined; + }); + describe("Access Control", () => { + test.each(["GET", "PUT", "DELETE"])( + "Should not allow an unaccepted method", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await register(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + } + ); + + it("does not allow a non valid CSRF token", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "invalid_csrf", + }, + }); + + await register(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ + error: "Access Denied", + }); + }); + }); + describe("Sign Up Registration", () => { + const mockedCognitoIdentityProviderClient: any = mocked(CognitoIdentityProviderClient, true); + const mockedSignUpCommand: any = mocked(SignUpCommand, true); + const sendFunctionMock = jest.fn(); + afterEach(() => { + mockedCognitoIdentityProviderClient.mockReset(); + mockedSignUpCommand.mockReset(); + sendFunctionMock.mockReset(); + }); + it("handler returns 400 status code when username, password or name is not included", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + }); + await register(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "username and password need to be provided in the body of the request", + }); + }); + it("handler returns 400 status code when username is not part of the acceptable domain", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test@uknown_domain.com", + password: "test", + name: "test", + }, + }); + await register(req, res); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "username does not meet requirements", + }); + }); + it("handler returns empty body and cognito status code when command succeeds", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockImplementation(async () => { + return { + $metadata: { + httpStatusCode: 200, + }, + }; + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test@domain.gc.ca", + password: "test", + name: "test", + }, + }); + + await register(req, res); + expect(res.statusCode).toBe(200); + expect(mockedCognitoIdentityProviderClient).toBeCalledTimes(1); + expect(mockedSignUpCommand.mock.calls[0][0]).toEqual({ + ClientId: "somemockvalue", + Username: "test@domain.gc.ca", + Password: "test", + UserAttributes: [ + { + Name: "name", + Value: "test", + }, + ], + }); + expect(res._getData()).toEqual(""); + }); + it("handles error when the cognito send function fails", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockRejectedValue({ + toString: () => "There is an error", + $metadata: { + httpStatusCode: 400, + }, + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test@domain.gc.ca", + password: "test", + name: "test", + }, + }); + + await register(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "There is an error", + }); + }); + }); +}); diff --git a/__tests__/api/signup/resendconfirmation.test.ts b/__tests__/api/signup/resendconfirmation.test.ts new file mode 100644 index 0000000000..2204a73691 --- /dev/null +++ b/__tests__/api/signup/resendconfirmation.test.ts @@ -0,0 +1,157 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import { getCsrfToken } from "next-auth/react"; +import { mocked } from "jest-mock"; +import { + CognitoIdentityProviderClient, + ResendConfirmationCodeCommand, +} from "@aws-sdk/client-cognito-identity-provider"; +import resendConfirmation from "@pages/api/signup/resendconfirmation"; + +const mockGetCSRFToken = mocked(getCsrfToken, true); + +jest.mock("next-auth/react"); +jest.mock("@aws-sdk/client-cognito-identity-provider", () => ({ + CognitoIdentityProviderClient: jest.fn(), + ResendConfirmationCodeCommand: jest.fn(), +})); + +describe("/signup/resendconfirmation", () => { + afterEach(() => { + mockGetCSRFToken.mockReset(); + }); + beforeAll(() => { + process.env.COGNITO_REGION = "ca-central-1"; + process.env.COGNITO_APP_CLIENT_ID = "somemockvalue"; + }); + afterAll(() => { + process.env.COGNITO_REGION = undefined; + process.env.COGNITO_APP_CLIENT_ID = undefined; + }); + describe("Access Control", () => { + test.each(["GET", "PUT", "DELETE"])( + "Should not allow an unaccepted method", + async (httpVerb) => { + const { req, res } = createMocks({ + method: httpVerb as RequestMethod, + headers: { + "Content-Type": "application/json", + }, + }); + + await resendConfirmation(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toMatchObject({ error: "HTTP Method Forbidden" }); + } + ); + + it("does not allow a non valid CSRF token", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "invalid_csrf", + }, + }); + + await resendConfirmation(req, res); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ + error: "Access Denied", + }); + }); + }); + describe("Resend Confirmation Code", () => { + const mockedCognitoIdentityProviderClient: any = mocked(CognitoIdentityProviderClient, true); + const mockedResendConfirmationCodeCommand: any = mocked(ResendConfirmationCodeCommand, true); + const sendFunctionMock = jest.fn(); + afterEach(() => { + mockedCognitoIdentityProviderClient.mockReset(); + mockedResendConfirmationCodeCommand.mockReset(); + sendFunctionMock.mockReset(); + }); + it("handler returns 400 status code if username not provided", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + }); + + await resendConfirmation(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "username needs to be provided in the body of the request", + }); + }); + it("handler returns empty body and cognito status code when command succeeds", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockImplementationOnce(async () => ({ + $metadata: { + httpStatusCode: 200, + }, + })); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + }, + }); + + await resendConfirmation(req, res); + expect(res.statusCode).toBe(200); + expect(res._getData()).toBe(""); + expect(mockedResendConfirmationCodeCommand.mock.calls[0][0]).toEqual({ + ClientId: "somemockvalue", + Username: "test", + }); + expect(mockedCognitoIdentityProviderClient).toBeCalledTimes(1); + }); + it("handles error when the cognito send function fails", async () => { + mockGetCSRFToken.mockResolvedValueOnce("valid_csrf"); + sendFunctionMock.mockRejectedValue({ + toString: () => "There is an error", + $metadata: { + httpStatusCode: 400, + }, + }); + mockedCognitoIdentityProviderClient.mockImplementationOnce(() => ({ + send: sendFunctionMock, + })); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-csrf-token": "valid_csrf", + }, + body: { + username: "test", + }, + }); + + await resendConfirmation(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual({ + message: "There is an error", + }); + }); + }); +}); diff --git a/__tests__/api/templates.test.ts b/__tests__/api/templates.test.ts new file mode 100644 index 0000000000..e0d2c0f9be --- /dev/null +++ b/__tests__/api/templates.test.ts @@ -0,0 +1,507 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks, RequestMethod } from "node-mocks-http"; +import Redis from "ioredis-mock"; +import templates from "@pages/api/templates"; +import { unstable_getServerSession } from "next-auth/next"; +import validFormTemplate from "../../__fixtures__/validFormTemplate.json"; +import validFormTemplateWithHTMLInDynamicRow from "../../__fixtures__/validFormTemplateWithHTMLInDynamicRow.json"; +import brokenFormTemplate from "../../__fixtures__/brokenFormTemplate.json"; +import { logAdminActivity } from "@lib/adminLogs"; +import { prismaMock } from "@jestUtils"; +import { Session } from "next-auth"; +import { Base, getUserPrivileges, ManageForms, PublishForms } from "__utils__/permissions"; + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +jest.mock("next-auth/next"); +jest.mock("@lib/adminLogs"); + +const redis = new Redis(); + +jest.mock("@lib/integration/redisConnector", () => ({ + getRedisInstance: jest.fn(() => redis), +})); + +describe("Requires a valid session to access API", () => { + it("Should successfully handle a POST request to create a template", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formConfig: brokenFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toMatchObject({ error: "Unauthorized" }); + }); +}); + +describe("Test templates API functions", () => { + beforeAll(() => { + process.env.TOKEN_SECRET = "testsecret"; + }); + + afterAll(() => { + delete process.env.TOKEN_SECRET; + }); + + describe.each([[Base], [ManageForms]])("POST", (privileges) => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: privileges, + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it("Should successfully handle a POST request to create a template", async () => { + (prismaMock.template.create as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + }); + + (prismaMock.template.update as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + }); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formConfig: validFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Create", + "UploadForm", + "Form id: test0form00000id000asdf11 has been uploaded" + ); + }); + + it("Should fail with invalid JSON", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formConfig: brokenFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData()).error).toContain('instance requires property "form"'); + }); + + it("Should reject JSON with html", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formConfig: validFormTemplateWithHTMLInDynamicRow, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData()).error).toContain("HTML detected in JSON"); + }); + }); + + describe.each([[Base], [ManageForms]])("PUT", (privileges) => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: getUserPrivileges(privileges, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it("Should successfully handle PUT request", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "1" }], + }); + + (prismaMock.template.update as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + formConfig: validFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Update", + "UpdateForm", + "Form id: test0form00000id000asdf11 has been updated" + ); + }); + + it("Should failed when trying to update published template", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "1" }], + isPublished: true, + }); + + (prismaMock.template.update as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + formConfig: validFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(500); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Can't update published form" }) + ); + }); + }); + + describe("PUT API that modifies `isPublished`", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: getUserPrivileges(PublishForms, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it("Should successfully handle PUT request", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "1" }], + }); + + (prismaMock.template.update as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + isPublished: true, + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + isPublished: true, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Update", + "UpdateForm", + "Form id: test0form00000id000asdf11 'isPublished' value has been updated" + ); + }); + }); + + describe.each([[Base], [ManageForms]])("DELETE", (privileges) => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: getUserPrivileges(privileges, { user: { id: "1" } }), + }, + }; + + mockGetSession.mockResolvedValue(mockSession); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it("Should successfully handle DELETE request", async () => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "1" }], + }); + + (prismaMock.template.update as jest.MockedFunction).mockResolvedValue({ + id: "test0form00000id000asdf11", + jsonConfig: validFormTemplate, + }); + + const { req, res } = createMocks({ + method: "DELETE", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(200); + expect(logAdminActivity).toHaveBeenCalledWith( + "1", + "Delete", + "DeleteForm", + "Form id: test0form00000id000asdf11 has been deleted" + ); + }); + }); +}); + +describe("Templates API functions should throw an error if user does not have permissions", () => { + describe("Templates API functions should throw an error if user does not have any permissions", () => { + beforeAll(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "a@b.com", + name: "Testing Forms", + privileges: [], + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterAll(() => { + mockGetSession.mockReset(); + }); + + it.each(["GET", "POST", "PUT", "DELETE"])( + "User with no permission should not be able to use %s API functions", + async (httpMethod) => { + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "1" }], + }); + + const { req, res } = createMocks({ + method: httpMethod as RequestMethod, + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + ...(httpMethod !== "GET" && { formID: "test0form00000id000asdf11" }), // To target the getAllTemplates API when testing GET request + formConfig: validFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + } + ); + }); + + describe("Templates API functions should throw an error if user does not have sufficient permissions", () => { + afterAll(() => { + mockGetSession.mockReset(); + }); + + it("User with no relation to the template being interacted with should not be able to use the PUT API function", async () => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "2" }], + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + formConfig: validFormTemplate, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + + it("User with no relation to the template being interacted with should not be able to use the PUT API function that modifies `isPublished`", async () => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: getUserPrivileges(PublishForms, { user: { id: "1" } }), + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "2" }], + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + isPublished: true, + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + + it("User with no relation to the template being interacted with should not be able to use the DELETE API function", async () => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: getUserPrivileges(Base, { user: { id: "1" } }), + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + id: "formtestID", + jsonConfig: validFormTemplate, + users: [{ id: "2" }], + }); + + const { req, res } = createMocks({ + method: "DELETE", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + formID: "test0form00000id000asdf11", + }, + }); + + await templates(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + }); +}); diff --git a/__tests__/api/token/temporary.test.ts b/__tests__/api/token/temporary.test.ts new file mode 100644 index 0000000000..59d32787cc --- /dev/null +++ b/__tests__/api/token/temporary.test.ts @@ -0,0 +1,225 @@ +/** + * @jest-environment node + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Redis from "ioredis-mock"; +import { createMocks } from "node-mocks-http"; +import temporary from "@pages/api/token/temporary"; +import jwt, { Secret } from "jsonwebtoken"; +import { prismaMock } from "@jestUtils"; + +const redis = new Redis(); + +jest.mock("@lib/integration/redisConnector", () => ({ + getRedisInstance: jest.fn(() => redis), +})); + +jest.mock("next-auth/react"); +jest.mock("@pages/api/id/[form]/bearer"); + +let IsGCNotifyServiceAvailable = true; + +const mockSendEmail = { + sendEmail: jest.fn(() => { + if (IsGCNotifyServiceAvailable) { + return Promise.resolve(); + } else { + return Promise.reject(new Error("something went wrong")); + } + }), +}; + +jest.mock("notifications-node-client", () => ({ + NotifyClient: jest.fn(() => mockSendEmail), +})); + +describe("TemporaryBearerToken tests", () => { + beforeAll(() => { + process.env.TOKEN_SECRET = "some_secret_some_secret_some_secret_some_secret"; + process.env.TOKEN_SECRET_WRONG = "wrong_secret_wrong_secret_wrong_secret_wrong_secret"; + }); + + afterAll(() => { + delete process.env.TOKEN_SECRET; + delete process.env.TOKEN_SECRET_WRONG; + }); + + it("creates a temporary token and updates the database", async () => { + const token = jwt.sign( + { formID: "1test0form00000id000asdf11" }, + process.env.TOKEN_SECRET as Secret, + { + expiresIn: "1y", + } + ); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + authorization: `Bearer ${token}`, + }, + body: { + email: "test@cds-snc.ca", + }, + }); + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: token, + }); + + (prismaMock.apiUser.findUnique as jest.MockedFunction).mockResolvedValue({ + templateId: 1, + email: "test@cds-snc.ca", + active: true, + }); + prismaMock.apiUser.update.mockResolvedValue({ + id: "3", + templateId: "1", + email: "test@cds-snc.ca", + temporaryToken: token, + active: true, + created_at: new Date(), + updated_at: new Date(), + }); + + await temporary(req, res); + expect(res.statusCode).toEqual(200); + }); + + it("throws error with invalid payload", async () => { + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + body: { + method: null, + }, + }); + + await temporary(req, res); + expect(res.statusCode).toEqual(400); + }); + + it("throws error with invalid form access token", async () => { + const token = jwt.sign( + { formID: "test0form00000id000asdf11" }, + process.env.TOKEN_SECRET_WRONG as Secret, + { + expiresIn: "1y", + } + ); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + authorization: `Bearer ${token}`, + }, + body: { + email: "test@cds-snc.ca", + }, + }); + + // Mock bearer token not being found + prismaMock.template.findUnique.mockResolvedValue(null); + + await temporary(req, res); + expect(res.statusCode).toEqual(401); + }); + + it("throws error when GC Notify service is unavailable", async () => { + IsGCNotifyServiceAvailable = false; + + const token = jwt.sign( + { formID: "test0form00000id000asdf11" }, + process.env.TOKEN_SECRET as Secret, + { + expiresIn: "1y", + } + ); + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + authorization: `Bearer ${token}`, + }, + body: { + email: "test@cds-snc.ca", + }, + }); + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: token, + }); + (prismaMock.apiUser.findUnique as jest.MockedFunction).mockResolvedValue({ + templateId: 1, + email: "test@cds-snc.ca", + active: true, + }); + prismaMock.apiUser.update.mockResolvedValue({ + id: "3", + templateId: "1", + email: "test@cds-snc.ca", + temporaryToken: token, + active: true, + created_at: new Date(), + updated_at: new Date(), + }); + + await temporary(req, res); + expect(res.statusCode).toEqual(500); + }); + + it("throws error when the authorization header does not contains a valid form access token", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + email: "test@cds-snc.ca", + }, + }); + + await temporary(req, res); + expect(res.statusCode).toEqual(401); + }); + + it("throws error when using expired form access token", async () => { + const token = jwt.sign( + { formID: "test0form00000id000asdf11", exp: 1636501665 }, + process.env.TOKEN_SECRET as Secret + ); + + const { req, res } = createMocks({ + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + authorization: `Bearer ${token}`, + }, + body: { + email: "test@cds-snc.ca", + }, + }); + (prismaMock.template.findUnique as jest.MockedFunction).mockResolvedValue({ + bearerToken: token, + }); + (prismaMock.apiUser.findUnique as jest.MockedFunction).mockResolvedValue({ + templateId: 1, + email: "test@cds-snc.ca", + active: true, + }); + prismaMock.apiUser.update.mockResolvedValue({ + id: "3", + templateId: "1", + email: "test@cds-snc.ca", + temporaryToken: token, + active: true, + created_at: new Date(), + updated_at: new Date(), + }); + + await temporary(req, res); + expect(res.statusCode).toEqual(401); + }); +}); diff --git a/__tests__/api/users.test.ts b/__tests__/api/users.test.ts new file mode 100644 index 0000000000..a698b0b243 --- /dev/null +++ b/__tests__/api/users.test.ts @@ -0,0 +1,356 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createMocks } from "node-mocks-http"; +import Redis from "ioredis-mock"; +import { unstable_getServerSession } from "next-auth/next"; +import users from "@pages/api/users"; +import { prismaMock } from "@jestUtils"; +import { Prisma } from "@prisma/client"; +import { Session } from "next-auth"; +import { getUserPrivileges, ManageUsers, ViewUserPrivileges } from "__utils__/permissions"; + +jest.mock("next-auth/next"); +jest.mock("@lib/adminLogs"); + +//Needed in the typescript version of the test so types are inferred correclty +const mockGetSession = jest.mocked(unstable_getServerSession, { shallow: true }); + +const redis = new Redis(); + +jest.mock("@lib/integration/redisConnector", () => ({ + getRedisInstance: jest.fn(() => redis), +})); + +describe("Users API endpoint", () => { + describe("Access Control", () => { + it("Shouldn't allow a request without a session", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Unauthorized" }) + ); + expect(prismaMock.user.findMany).toBeCalledTimes(0); + }); + + it("Shouldn't allow a request without a session", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + userId: "1", + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Unauthorized" }) + ); + expect(prismaMock.user.update).toBeCalledTimes(0); + }); + }); + + describe.each([[ViewUserPrivileges], [ManageUsers]])("GET", (privileges) => { + beforeAll(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: privileges, + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterAll(() => { + mockGetSession.mockReset(); + }); + + it("Should return all users", async () => { + // Mocking executeQuery to return a list of emails + (prismaMock.user.findMany as jest.MockedFunction).mockResolvedValue([ + { + id: "1", + email: "test@cds.ca", + name: "Zoe", + }, + { + id: "2", + email: "forms@cds.ca", + name: "Joe", + }, + { + id: "3", + email: "forms_2@cds.ca", + name: "Boe", + }, + ]); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + }); + + await users(req, res); + + expect(JSON.parse(res._getData())).toEqual([ + { + id: "1", + email: "test@cds.ca", + name: "Zoe", + }, + { + id: "2", + email: "forms@cds.ca", + name: "Joe", + }, + { + id: "3", + email: "forms_2@cds.ca", + name: "Boe", + }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("Should return empty array if there's db's error", async () => { + prismaMock.user.findMany.mockRejectedValue(new Error("Error Thown")); + + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(500); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Could not process request" }) + ); + }); + }); + + describe("PUT", () => { + beforeEach(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: getUserPrivileges(ManageUsers, {}), + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterEach(() => { + mockGetSession.mockReset(); + }); + + it("Should return 400 invalid payload error when privileges is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/11/owners", + }, + body: { + userId: "forms@cds.ca", + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Malformed Request" }) + ); + }); + + it("Should return 400 invalid payload error when userId is missing", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000/api/id/11/owners", + }, + body: { + privileges: [{ id: "2", action: "add" }], + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "Malformed Request" }) + ); + }); + + it("Should return 404 if userId is not found", async () => { + // Mocking executeQuery it returns 0 updated rows + prismaMock.user.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unknown Entry", "P2025", "4.3.2") + ); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + userID: "2", + privileges: [], + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ error: "User not found" }) + ); + }); + + it("Should successfully handle PUT request", async () => { + (prismaMock.user.update as jest.MockedFunction).mockResolvedValue({ + id: "2", + email: "forms@cds.ca", + emailVerified: null, + image: null, + name: "Joe", + privileges: [], + }); + + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + userID: "2", + privileges: [], + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(200); + }); + }); + + describe("Users API functions should throw an error if user does not have permissions", () => { + describe("Users API functions should throw an error if user does not have any permissions", () => { + beforeAll(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: [], + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterAll(() => { + mockGetSession.mockReset(); + }); + + it("User with no permission should not be able to use GET API functions", async () => { + const { req, res } = createMocks({ + method: "GET", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3000", + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + + it("User with no permission should not be able to use PUT API functions", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + userID: "2", + privileges: [], + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + }); + + describe("Users API functions should throw an error if user does not have sufficient permissions", () => { + beforeAll(() => { + const mockSession: Session = { + expires: "1", + user: { + id: "1", + email: "forms@cds.ca", + name: "forms", + privileges: ViewUserPrivileges, + }, + }; + mockGetSession.mockReturnValue(Promise.resolve(mockSession)); + }); + + afterAll(() => { + mockGetSession.mockReset(); + }); + + it("User with no permission should not be able to use PUT API functions", async () => { + const { req, res } = createMocks({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: { + userID: "2", + privileges: [], + }, + }); + + await users(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual(expect.objectContaining({ error: "Forbidden" })); + }); + }); + }); +}); diff --git a/__tests__/id/[form]/settings.test.js b/__tests__/id/[form]/settings.test.js new file mode 100644 index 0000000000..0e62317132 --- /dev/null +++ b/__tests__/id/[form]/settings.test.js @@ -0,0 +1,106 @@ +/* +Refactored out into Page Component vs Server Component + +https://github.com/nextauthjs/next-auth/issues/4866 +unstable_getServerSession breaks Jest tests due to "node_modules/jose/" dependency + +Thile file tests a NextJS Page which requires both serverside and client side dependencies. +The 'jose' lib in next-auth loads the browser version because the test environment is set to 'jsdom'. +However the 'requireAuthentication' lib which is referenced as an import in the 'settings.tsx' file requires the mode version. + +*********************************************************** +*/ + +import React from "react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Settings from "@components/admin/TemplateDelete/Settings"; +import mockedAxios from "axios"; +import { useRouter } from "next/router"; +import validFormTemplate from "../../../__fixtures__/validFormTemplate.json"; + +jest.mock("axios"); +jest.mock("next/router", () => ({ + useRouter: jest.fn(), +})); + +// Mock your i18n +jest.mock("next-i18next", () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + language: "en", + changeLanguage: () => Promise.resolve(), + }, + }; + }, +})); + +describe("Form Settings Page", () => { + afterEach(cleanup); + const form = { + id: "test0form00000id000asdf11", + isPublished: true, + ...validFormTemplate, + }; + test("renders without errors", () => { + useRouter.mockImplementation(() => ({ + query: {}, + })); + render(); + expect(screen.queryByText("Form Title:")).toBeInTheDocument(); + expect(screen.getByTestId("formID")).toHaveTextContent( + "Public Service Award of Excellence 2020 - Nomination form" + ); + expect(screen.queryByText("Form ID:")).toBeInTheDocument(); + expect(screen.getByTestId("formID")).toHaveTextContent("test0form00000id000asdf11"); + }); + + test("Delete button redirects on success", async () => { + const user = userEvent.setup(); + mockedAxios.mockResolvedValue({ + status: 200, + }); + const push = jest.fn(); + useRouter.mockImplementation(() => ({ + asPath: "/", + push: push, + })); + render(); + + await user.click(screen.queryByTestId("delete")); + expect(screen.queryByTestId("confirmDelete")).toBeInTheDocument(); + + await user.click(screen.queryByTestId("confirmDelete")); + expect(mockedAxios.mock.calls.length).toBe(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ url: "/api/templates", method: "DELETE" }) + ); + await waitFor(() => { + expect(push).toHaveBeenCalled(); + }); + }); + test("Logs errors on failure", async () => { + const user = userEvent.setup(); + mockedAxios.mockRejectedValue({ + status: 400, + }); + // I wanted to spy console.error but it didn't want to work + // for now, the handler here calls JSON.stringify so we can spy that + const spy = jest.spyOn(JSON, "stringify"); + + render(); + + await user.click(screen.queryByTestId("delete")); + expect(screen.queryByTestId("confirmDelete")).toBeInTheDocument(); + + await user.click(screen.queryByTestId("confirmDelete")); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + expect(mockedAxios.mock.calls.length).toBe(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ url: "/api/templates", method: "DELETE" }) + ); + }); +}); diff --git a/__utils__/index.ts b/__utils__/index.ts new file mode 100644 index 0000000000..61bbdd122c --- /dev/null +++ b/__utils__/index.ts @@ -0,0 +1,2 @@ +export { checkLogs } from "./jestUtils"; +export { prismaMock } from "./prismaConnector"; diff --git a/__utils__/jestShim.ts b/__utils__/jestShim.ts new file mode 100644 index 0000000000..cca87b3c6e --- /dev/null +++ b/__utils__/jestShim.ts @@ -0,0 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TextEncoder, TextDecoder } from "util"; +(global as any).TextEncoder = TextEncoder; +(global as any).TextDecoder = TextDecoder; diff --git a/lib/jestUtils.ts b/__utils__/jestUtils.ts similarity index 100% rename from lib/jestUtils.ts rename to __utils__/jestUtils.ts diff --git a/__utils__/mocks/middleware.ts b/__utils__/mocks/middleware.ts new file mode 100644 index 0000000000..32309d6eb2 --- /dev/null +++ b/__utils__/mocks/middleware.ts @@ -0,0 +1,17 @@ +import { MiddlewareProps, MiddlewareRequest } from "@lib/types"; +import { NextApiRequest, NextApiResponse } from "next"; + +/** + * Middleware function that iterates through middleware resolvers + * @param middlewareArray Array of middleware resolvers + * @param handler Api handler function + * @returns + */ +export const mockMiddleware = ( + middlewareArray: Array, + handler: (req: NextApiRequest, res: NextApiResponse, props: MiddlewareProps) => Promise +) => { + return async (req: NextApiRequest, res: NextApiResponse): Promise => { + return handler(req, res, {}); + }; +}; diff --git a/__utils__/permissions.ts b/__utils__/permissions.ts new file mode 100644 index 0000000000..dac730c58c --- /dev/null +++ b/__utils__/permissions.ts @@ -0,0 +1,70 @@ +import { interpolatePermissionCondition } from "@lib/privileges"; +import { Abilities } from "@lib/types/privileges-types"; +import { RawRuleOf, MongoAbility } from "@casl/ability"; + +type AnyObject = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +export const Base: RawRuleOf>[] = [ + { action: "create", subject: "FormRecord" }, + { + action: ["view", "update", "delete"], + subject: "FormRecord", + conditions: { users: { $elemMatch: { id: "${user.id}" } } }, + }, + { action: "update", subject: "FormRecord", fields: ["isPublished"], inverted: true }, +]; + +export const PublishForms: RawRuleOf>[] = [ + { + action: ["update"], + subject: "FormRecord", + fields: ["isPublished"], + conditions: { users: { $elemMatch: { id: "${user.id}" } } }, + }, +]; + +export const ManageForms: RawRuleOf>[] = [ + { action: ["create", "view", "update", "delete"], subject: "FormRecord" }, +]; + +export const ViewUserPrivileges: RawRuleOf>[] = [ + { + action: "view", + subject: ["User", "Privilege"], + }, +]; + +export const ManageUsers: RawRuleOf>[] = [ + { action: "view", subject: ["User", "Privilege"] }, + { action: "update", subject: "User" }, +]; + +export const ManagePrivileges: RawRuleOf>[] = [ + { action: ["create", "view", "update", "delete"], subject: "Privilege" }, +]; + +export const ViewApplicationSettings: RawRuleOf>[] = [ + { action: "view", subject: "Flag" }, +]; + +export const ManageApplicationSettings: RawRuleOf>[] = [ + { action: "view", subject: "Flag" }, + { action: "update", subject: "Flag" }, +]; + +export const getUserPrivileges = ( + permissionSet: RawRuleOf>[], + values: AnyObject +) => { + return permissionSet.map((p) => { + return p.conditions + ? { + ...p, + conditions: interpolatePermissionCondition(p.conditions, values), + } + : p; + }); +}; diff --git a/__utils__/prismaConnector.ts b/__utils__/prismaConnector.ts new file mode 100644 index 0000000000..34c60ff985 --- /dev/null +++ b/__utils__/prismaConnector.ts @@ -0,0 +1,32 @@ +import { PrismaClient } from "@prisma/client"; +import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended"; +import { prisma } from "@lib/integration/prismaConnector"; +import { + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientUnknownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime"; + +jest.mock("@lib/integration/prismaConnector", () => { + const originalModule = jest.requireActual("@lib/integration/prismaConnector"); + return { + __esModule: true, + ...originalModule, + default: jest.fn(), + prisma: mockDeep(), + }; +}); + +beforeEach(() => { + mockReset(prismaMock); +}); + +export type { + PrismaClientKnownRequestError, + PrismaClientInitializationError, + PrismaClientUnknownRequestError, + PrismaClientValidationError, +}; + +export const prismaMock = prisma as unknown as DeepMockProxy; diff --git a/lib/tests/setupTests.ts b/__utils__/setupTests.ts similarity index 81% rename from lib/tests/setupTests.ts rename to __utils__/setupTests.ts index 33912102f7..9f94c84c96 100644 --- a/lib/tests/setupTests.ts +++ b/__utils__/setupTests.ts @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import initialSettings from "../../flag_initialization/default_flag_settings.json"; +import initialSettings from "../flag_initialization/default_flag_settings.json"; jest.mock("next/config", () => () => ({ publicRuntimeConfig: { @@ -7,7 +7,7 @@ jest.mock("next/config", () => () => ({ }, })); -jest.mock("react-i18next", () => ({ +jest.mock("next-i18next", () => ({ useTranslation: () => { return { t: (str: string) => str, diff --git a/components/admin/BearerRefresh/BearerRefresh.stories.tsx b/components/admin/BearerRefresh/BearerRefresh.stories.tsx index 7fba0949c4..69ddada595 100644 --- a/components/admin/BearerRefresh/BearerRefresh.stories.tsx +++ b/components/admin/BearerRefresh/BearerRefresh.stories.tsx @@ -18,5 +18,5 @@ const Template: Story = (args: BearerRefreshProps) => ( export const defaultBearerRefresh = Template.bind({}); defaultBearerRefresh.args = { - formID: 1, + formID: "test0form00000id000asdf11", }; diff --git a/components/admin/BearerRefresh/BearerRefresh.tsx b/components/admin/BearerRefresh/BearerRefresh.tsx index 311b13db8d..29fad58924 100644 --- a/components/admin/BearerRefresh/BearerRefresh.tsx +++ b/components/admin/BearerRefresh/BearerRefresh.tsx @@ -1,4 +1,4 @@ -import { Button } from "@components/forms"; +import { Button, Label } from "@components/forms"; import Loader from "@components/globals/Loader"; import { logMessage } from "@lib/logger"; import axios from "axios"; @@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react"; import { BearerResponse } from "@lib/types"; export interface BearerRefreshProps { - formID: number; + formID: string; } const BearerRefresh = (props: BearerRefreshProps): React.ReactElement => { @@ -21,7 +21,7 @@ const BearerRefresh = (props: BearerRefreshProps): React.ReactElement => { getBearerToken(formID); }, []); - const getBearerToken = async (formID: number) => { + const getBearerToken = async (formID: string) => { try { setSubmitting(true); setErrorState({ message: "" }); @@ -45,7 +45,7 @@ const BearerRefresh = (props: BearerRefreshProps): React.ReactElement => { * * @param formID */ - const handleRefreshBearerToken = async (formID: number) => { + const handleRefreshBearerToken = async (formID: string) => { try { setSubmitting(true); setErrorState({ message: "" }); @@ -75,7 +75,7 @@ const BearerRefresh = (props: BearerRefreshProps): React.ReactElement => { {errorState.message}

)} -

{t("settings.bearerToken.current")}

+ + + +
+ + +
+ + ); +}; diff --git a/components/form-builder/app/edit/elements/DropDown.tsx b/components/form-builder/app/edit/elements/DropDown.tsx new file mode 100644 index 0000000000..d753ac3fd2 --- /dev/null +++ b/components/form-builder/app/edit/elements/DropDown.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { useSelect, UseSelectStateChange } from "downshift"; +import { ElementOption } from "../../../types"; +import { ChevronDown } from "../../../icons"; + +export const DropDown = ({ + items, + selectedItem, + onChange, + ariaLabel, +}: { + items: ElementOption[]; + selectedItem: ElementOption; + onChange: (changes: UseSelectStateChange) => void; + ariaLabel: string; +}) => { + const { isOpen, getToggleButtonProps, getMenuProps, highlightedIndex, getItemProps } = useSelect({ + items, + selectedItem, + onSelectedItemChange: onChange, + }); + + const headerProps = { + ...getToggleButtonProps(), + "aria-label": ariaLabel, + "aria-labelledby": null, + }; + + return ( +
+ +
    + {isOpen && + items.map((item, index) => ( +
  • + {item.icon && ( +
    + {item.icon} +
    + )} +
    + {item.value} +
    +
  • + ))} +
+
+ ); +}; diff --git a/components/form-builder/app/edit/elements/Option.tsx b/components/form-builder/app/edit/elements/Option.tsx new file mode 100644 index 0000000000..fb0d87054e --- /dev/null +++ b/components/form-builder/app/edit/elements/Option.tsx @@ -0,0 +1,115 @@ +import React, { useRef, useEffect, ReactElement, useCallback, useState } from "react"; +import PropTypes from "prop-types"; +import { Close } from "../../../icons"; +import { Button } from "../../shared/Button"; +import { Input } from "../../shared/Input"; +import { useTemplateStore } from "../../../store/useTemplateStore"; +import { useTranslation } from "next-i18next"; +import debounce from "lodash.debounce"; + +type RenderIcon = (index: number) => ReactElement | string | undefined; + +export const Option = ({ + parentIndex, + index, + renderIcon, + initialValue, +}: { + parentIndex: number; + index: number; + renderIcon?: RenderIcon; + initialValue: string; +}) => { + const input = useRef(null); + + const { + addChoice, + removeChoice, + updateField, + getFocusInput, + setFocusInput, + translationLanguagePriority, + getLocalizationAttribute, + } = useTemplateStore((s) => ({ + addChoice: s.addChoice, + removeChoice: s.removeChoice, + updateField: s.updateField, + setFocusInput: s.setFocusInput, + getFocusInput: s.getFocusInput, + translationLanguagePriority: s.translationLanguagePriority, + getLocalizationAttribute: s.getLocalizationAttribute, + })); + + const icon = renderIcon && renderIcon(index); + const { t } = useTranslation("form-builder"); + const [value, setValue] = useState(initialValue); + + useEffect(() => { + // see: https://github.com/cds-snc/platform-forms-client/pull/1194/commits/cf2d08676cb9dfa7bb500f713cc16cdf653c3e93 + if (input.current && getFocusInput()) { + input.current.focus(); + setFocusInput(false); + } + }, [getFocusInput]); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + setFocusInput(true); + addChoice(parentIndex); + } + }; + + const _debounced = useCallback( + debounce((parentIndex, val, lang) => { + updateField(`form.elements[${parentIndex}].properties.choices[${index}].${lang}`, val); + }, 100), + [translationLanguagePriority] + ); + + const updateValue = useCallback( + (parentIndex: number, value: string) => { + setValue(value); + _debounced(parentIndex, value, translationLanguagePriority); + }, + [setValue, translationLanguagePriority] + ); + + return ( +
+
{icon}
+ ) => + updateValue(parentIndex, e.target.value) + } + onKeyDown={handleKeyDown} + className="ml-5 w-80 max-h-9 !my-0" + {...getLocalizationAttribute()} + /> + +
+ ); +}; + +Option.propTypes = { + parentIndex: PropTypes.number, + index: PropTypes.number, + renderIcon: PropTypes.func, +}; diff --git a/components/form-builder/app/edit/elements/Options.tsx b/components/form-builder/app/edit/elements/Options.tsx new file mode 100644 index 0000000000..dab125093d --- /dev/null +++ b/components/form-builder/app/edit/elements/Options.tsx @@ -0,0 +1,152 @@ +import React, { useState, ReactElement, useCallback } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "next-i18next"; + +import { useTemplateStore } from "../../../store/useTemplateStore"; +import { Option } from "./Option"; +import { BulkAdd } from "./BulkAdd"; +import { Button } from "../../shared/Button"; +import { FormElementWithIndex } from "../../../types"; + +const AddButton = ({ index, onClick }: { index: number; onClick: (index: number) => void }) => { + const { t } = useTranslation("form-builder"); + return ( + + ); +}; + +AddButton.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, +}; + +const BulkAddButton = ({ onClick }: { onClick: (onoff: boolean) => void }) => { + const { t } = useTranslation("form-builder"); + return ( + + ); +}; + +BulkAddButton.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, +}; + +const AddOptions = ({ + index, +}: // toggleBulkAdd, +{ + index: number; + // toggleBulkAdd: (onoff: boolean) => void; +}) => { + const { addChoice, setFocusInput } = useTemplateStore((s) => ({ + addChoice: s.addChoice, + setFocusInput: s.setFocusInput, + })); + + return ( + <> + { + setFocusInput(true); + addChoice(index); + }} + /> + + {/* + // feature removed for now + + */} + + ); +}; + +AddOptions.propTypes = { + index: PropTypes.number, +}; + +type RenderIcon = (index: number) => ReactElement | string | undefined; + +export const Options = ({ + item, + renderIcon, +}: { + item: FormElementWithIndex; + renderIcon?: RenderIcon; +}) => { + const { elements, translationLanguagePriority } = useTemplateStore((s) => ({ + elements: s.form.elements, + translationLanguagePriority: s.translationLanguagePriority, + })); + + const [bulkAddAction, setBulkAddAction] = useState(false); + + const toggleBulkAdd = useCallback( + (toggle: boolean) => { + setBulkAddAction(toggle); + }, + [bulkAddAction] + ); + + const index = item.index; + + if (!elements[index]?.properties) { + return null; + } + const { choices } = elements[index].properties; + + if (!choices) { + return ; + } + + if (bulkAddAction) { + return ; + } + + const options = choices.map((child, index) => { + if (!child || !item) return null; + + const initialValue = + elements[item.index].properties.choices?.[index][translationLanguagePriority] ?? ""; + + return ( +