From a0f1a15fe20ca1e6f3e353ed8a5d9907ac198764 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Fri, 29 Nov 2024 23:46:26 +0800 Subject: [PATCH] feat(twilio): remove twilio be (#7870) * feat: remove twilio from fe * chore: restore shared files as they are used by BE * feat: remove twilio from be pass 1 * test: verification.service.spec.ts * test: admin-form.service.spec.ts * fix: remove deleted files * fix: lint issues * fix: verification routes * chore: fix lint errors * Revert "chore: restore shared files as they are used by BE" This reverts commit bc8ee76536692b03e04a4eac001fc577d8235e20. * feat: remove twilio verifications * chore: fix lint issues * chore: remove retrievePublicFormsWithSmsVerification func * chore: fix lint issues * feat: remove get/set for msgSrvcName, remove aws ssm * chore: remove stoplight/prism * feat: remove twilioCredentials from i18n * feat: remove msgSrvcName * chore: document msgSrvcName in code * fix: remove unused import * feat: update docs to mark msgSrvcName as legacy --------- Co-authored-by: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> --- .template-env | 7 - __tests__/integration/helpers/twilio.ts | 20 - __tests__/unit/backend/helpers/jest-db.ts | 45 -- docker-compose.yml | 12 +- frontend/src/constants/links.ts | 1 - .../admin-form/common/AdminViewFormService.ts | 1 - .../features/whats-new/FeatureUpdateList.ts | 9 - .../admin-form/sidebar/fields/en-sg.ts | 6 - .../admin-form/sidebar/fields/index.ts | 6 - package-lock.json | 489 +------------ package.json | 2 - shared/types/form/form.ts | 10 +- shared/types/twilio.ts | 6 - shared/utils/verification.ts | 30 - src/app/config/config.ts | 1 - src/app/config/features/sms.config.ts | 47 -- src/app/config/schema.ts | 7 - .../__tests__/form.server.model.spec.ts | 99 +-- src/app/models/form.server.model.ts | 45 +- src/app/modules/core/core.errors.ts | 6 - .../form/__tests__/form.service.spec.ts | 36 - .../__tests__/admin-form.controller.spec.ts | 656 +----------------- .../__tests__/admin-form.service.spec.ts | 166 +---- .../form/admin-form/admin-form.controller.ts | 181 ----- .../form/admin-form/admin-form.service.ts | 335 +-------- .../form/admin-form/admin-form.utils.ts | 48 -- src/app/modules/form/form.service.ts | 36 - src/app/modules/form/form.utils.ts | 29 - .../__tests__/twilio.controller.spec.ts | 98 --- src/app/modules/twilio/twilio.controller.ts | 110 --- .../modules/twilio/twilio.statsd-client.ts | 5 - .../__tests__/verification.service.spec.ts | 119 +--- .../verification/verification.service.ts | 29 +- .../modules/verification/verification.util.ts | 5 - .../admin-forms.twilio.routes.spec.ts | 412 ----------- .../v3/admin/forms/admin-forms.form.routes.ts | 14 - .../api/v3/admin/forms/admin-forms.routes.ts | 2 - .../admin/forms/admin-forms.twilio.routes.ts | 30 - .../public-forms.verification.routes.spec.ts | 68 +- .../__tests__/notifications.routes.spec.ts | 82 --- .../v3/notifications/notifications.routes.ts | 14 - .../mail/__tests__/mail.service.spec.ts | 315 +-------- src/app/services/mail/mail.service.ts | 305 +------- src/app/services/mail/mail.types.ts | 28 - src/app/services/mail/mail.utils.ts | 68 -- .../sms/__tests__/sms.factory.spec.ts | 64 -- .../sms/__tests__/sms.service.spec.ts | 200 ------ .../__tests__/sms_count.server.model.spec.ts | 649 ----------------- src/app/services/sms/sms.dev.prismclient.ts | 30 - src/app/services/sms/sms.factory.ts | 49 -- src/app/services/sms/sms.service.ts | 385 ---------- src/app/services/sms/sms.types.ts | 160 ----- .../services/sms/sms_count.server.model.ts | 186 ----- src/app/utils/formatters.ts | 5 - ...rification-disabled-admin.server.view.html | 38 - ...ification-disabled-collab.server.view.html | 30 - .../sms-verification-warning-admin.view.html | 31 - .../sms-verification-warning-collab.view.html | 28 - .../sms-verification-warning.view.html | 31 - src/types/config.ts | 2 - src/types/form.ts | 36 +- src/types/index.ts | 1 - src/types/twilio.ts | 19 - 63 files changed, 79 insertions(+), 5905 deletions(-) delete mode 100644 __tests__/integration/helpers/twilio.ts delete mode 100644 shared/types/twilio.ts delete mode 100644 src/app/config/features/sms.config.ts delete mode 100644 src/app/modules/twilio/__tests__/twilio.controller.spec.ts delete mode 100644 src/app/modules/twilio/twilio.controller.ts delete mode 100644 src/app/modules/twilio/twilio.statsd-client.ts delete mode 100644 src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts delete mode 100644 src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts delete mode 100644 src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms.factory.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms.service.spec.ts delete mode 100644 src/app/services/sms/__tests__/sms_count.server.model.spec.ts delete mode 100644 src/app/services/sms/sms.dev.prismclient.ts delete mode 100644 src/app/services/sms/sms.factory.ts delete mode 100644 src/app/services/sms/sms.service.ts delete mode 100644 src/app/services/sms/sms.types.ts delete mode 100644 src/app/services/sms/sms_count.server.model.ts delete mode 100644 src/app/utils/formatters.ts delete mode 100644 src/app/views/templates/sms-verification-disabled-admin.server.view.html delete mode 100644 src/app/views/templates/sms-verification-disabled-collab.server.view.html delete mode 100644 src/app/views/templates/sms-verification-warning-admin.view.html delete mode 100644 src/app/views/templates/sms-verification-warning-collab.view.html delete mode 100644 src/app/views/templates/sms-verification-warning.view.html delete mode 100644 src/types/twilio.ts diff --git a/.template-env b/.template-env index 44d9bf1cf1..e3d4b5dd20 100644 --- a/.template-env +++ b/.template-env @@ -68,12 +68,6 @@ FORMSG_SDK_MODE= ## If the variable exists, the [verified] feature will be enabled. # VERIFICATION_SECRET_KEY= -## Twilio -## If the below variables exists, the [sms] feature will be enabled. -# TWILIO_ACCOUNT_SID= -# TWILIO_API_KEY= -# TWILIO_API_SECRET= -# TWILIO_MESSAGING_SERVICE_SID= ## SingPass, CorpPass related environment variables # If you are not a member of the Singapore Government, you can safely exclude @@ -113,7 +107,6 @@ FORMSG_SDK_MODE= # Used to check if BE Server is currently running on local development environment # One of boolean: "true" | "false" -# USE_MOCK_TWILIO= # USE_MOCK_POSTMAN_SMS= # POSTMAN_MOP_CAMPAIGN_ID= diff --git a/__tests__/integration/helpers/twilio.ts b/__tests__/integration/helpers/twilio.ts deleted file mode 100644 index 1517f22a8e..0000000000 --- a/__tests__/integration/helpers/twilio.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { jest } from '@jest/globals' -import { Twilio } from 'twilio' - -// Mocks the underlying twilio import -// This allows us to inject values into twilio dynamically without having to be verbose -// This is casted for type annotations -const twilio = { - messages: { - create: jest.fn(), - }, -} as unknown as Twilio - -// Mocks the default twilio argument -jest.mock('twilio', () => () => twilio) - -const MockTwilio = jest.mocked(twilio) - -jest.mock('src/app/services/sms/sms.dev.prismclient', () => () => ({})) - -export default MockTwilio diff --git a/__tests__/unit/backend/helpers/jest-db.ts b/__tests__/unit/backend/helpers/jest-db.ts index 373aec4adf..4851d64a31 100644 --- a/__tests__/unit/backend/helpers/jest-db.ts +++ b/__tests__/unit/backend/helpers/jest-db.ts @@ -233,50 +233,6 @@ const insertEncryptForm = async ({ } } -const insertFormWithMsgSrvcName = async ({ - formId, - userId, - mailDomain = 'test.gov.sg', - mailName = 'test', - shortName = 'govtest', - formOptions = {}, - msgSrvcName = 'mockMsgSrvcname', -}: { - formId?: Schema.Types.ObjectId - userId?: Schema.Types.ObjectId - mailName?: string - mailDomain?: string - shortName?: string - formOptions?: Partial - msgSrvcName?: string -} = {}): Promise<{ - form: IFormSchema - user: IUserSchema - agency: IAgencySchema -}> => { - const { user, agency } = await insertFormCollectionReqs({ - userId, - mailDomain, - mailName, - shortName, - }) - - const FormModel = getFormModel(mongoose) - const form = await FormModel.create({ - title: 'example form title', - admin: user._id, - _id: formId, - ...formOptions, - msgSrvcName: msgSrvcName, - }) - - return { - form: form as IFormSchema, - user, - agency, - } -} - const getFullFormById = async ( formId: string, ): Promise => @@ -344,7 +300,6 @@ const dbHandler = { clearCollection, insertEmailForm, insertEncryptForm, - insertFormWithMsgSrvcName, getFullFormById, insertFormSubmission, insertFormFeedback, diff --git a/docker-compose.yml b/docker-compose.yml index 0a91a3a175..ee7b758a11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,11 +53,6 @@ services: # Keep in sync with the development key in # https://github.com/opengovsg/formsg-javascript-sdk/blob/develop/src/resource/signing-keys.ts - SIGNING_SECRET_KEY=HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ== - # Mock Twilio credentials. SMSes do not work in dev environment. - - TWILIO_ACCOUNT_SID=AC00000000000000000000000000000000 - - TWILIO_API_KEY=mockTwilioApiKey - - TWILIO_API_SECRET=mockTwilioApiSecret - - TWILIO_MESSAGING_SERVICE_SID=MG00000000000000000000000000000000 - SP_OIDC_NDI_DISCOVERY_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/openid-configuration - SP_OIDC_NDI_JWKS_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/keys - SP_OIDC_RP_CLIENT_ID=rpClientId @@ -167,7 +162,7 @@ services: depends_on: - backend environment: - - SERVICES=s3,sqs,secretsmanager + - SERVICES=s3,sqs - DNS_ADDRESS=0 - EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:5173 volumes: @@ -189,11 +184,6 @@ services: - STRIPE_API_KEY - STRIPE_DEVICE_NAME=StripeWebhookListener - mocktwilio: - image: stoplight/prism:4 - network_mode: 'service:backend' # reuse backend service's network stack so that it can resolve localhost:4010 to prismtwilio:4010 - command: mock https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json - volumes: mongodb_data: driver: local diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index d2e52c3e13..f6accc4c8e 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -22,7 +22,6 @@ export const GUIDE_SPCP_ESRVCID = 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_ENABLE_SPCP = 'https://go.gov.sg/formsg-guide-singpass-myinfo-enable' -export const GUIDE_TWILIO = 'https://go.gov.sg/formsg-guide-verified-smses' export const GUIDE_ATTACHMENT_SIZE_LIMIT = 'https://go.gov.sg/formsg-guide-attachments' export const GUIDE_E2EE = 'https://go.gov.sg/formsg-guide-e2e' diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index 423cd24bd5..2e6c40d5c9 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -6,7 +6,6 @@ import { FormPermissionsDto, PermissionsUpdateDto, PreviewFormViewDto, - SmsCountsDto, } from '~shared/types/form/form' import { ADMINFORM_USETEMPLATE_ROUTE } from '~constants/routes' diff --git a/frontend/src/features/whats-new/FeatureUpdateList.ts b/frontend/src/features/whats-new/FeatureUpdateList.ts index 9dd4f8b42c..05dd33d1c0 100644 --- a/frontend/src/features/whats-new/FeatureUpdateList.ts +++ b/frontend/src/features/whats-new/FeatureUpdateList.ts @@ -150,14 +150,5 @@ export const FEATURE_UPDATE_LIST: FeatureUpdateList = { alt: 'The new FormSG experience', }, }, - { - title: 'Big little improvements', - date: new Date('12 October 2022 GMT+8'), - description: dedent` - * Easily paste options into Radio fields - * Add your Twilio credentials so form-fillers can verify their mobile number - * Enhanced security to prevent malicious inputs in form responses, [read more about it here](https://formsg.gitbook.io/form-user-guide/faq/faq/storage-mode#why-do-i-have-an-additional-quote-in-some-of-my-responses) - `, - }, ], } diff --git a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts index 99bc3c1770..73b8f7eb67 100644 --- a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts +++ b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/en-sg.ts @@ -105,12 +105,6 @@ export const enSG: Fields = { }, allowInternationalNumber: 'Allow international numbers', smsCounts: 'SMSes used', - twilioCredentials: { - success: 'Twilio credentials added', - exceedQuota: 'You have reached the free tier limit for SMS verification.', - noCredentials: 'Twilio credentials not added.', - cta: 'Add credentials now', - }, }, date: { dateValidation: { diff --git a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts index c15b57a73b..b369be54e0 100644 --- a/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts +++ b/frontend/src/i18n/locales/features/admin-form/sidebar/fields/index.ts @@ -103,12 +103,6 @@ export interface Fields { } allowInternationalNumber: string smsCounts: string - twilioCredentials: { - success: string - exceedQuota: string - noCredentials: string - cta: string - } } date: { dateValidation: { diff --git a/package-lock.json b/package-lock.json index f627f8630a..a299e4380e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,6 @@ "ts-essentials": "^10.0.1", "tweetnacl": "^1.0.1", "tweetnacl-util": "^0.15.1", - "twilio": "~4.19.3", "uid-generator": "^2.0.0", "ulid": "^2.3.0", "uuid": "^10.0.0", @@ -114,7 +113,6 @@ "@babel/preset-env": "^7.25.4", "@opengovsg/mockpass": "^4.3.4", "@playwright/test": "^1.49.0", - "@stoplight/prism-cli": "^5.10.0", "@types/bcrypt": "^5.0.0", "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.4", @@ -5272,66 +5270,6 @@ "version": "2.1.0", "license": "Apache-2.0" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", @@ -5347,276 +5285,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -6737,126 +6405,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.10.tgz", - "integrity": "sha512-Y0TC+FXbFUQ2MQgimJ/7Ina2mXIKhE7F+GUe1SgnzRmwFY3hX2z8nyVCxE82I2RicspdkZnSWMn4oTjIKz4uzA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.10.tgz", - "integrity": "sha512-ZfQ7yOy5zyskSj9rFpa0Yd7gkrBnJTkYVSya95hX3zeBG9E55Z6OTNPn1j2BTFWvOVVj65C3T+qsjOyVI9DQpA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.10.tgz", - "integrity": "sha512-n2i5o3y2jpBfXFRxDREr342BGIQCJbdAUi/K4q6Env3aSx8erM9VuKXHw5KNROK9ejFSPf0LhoSkU/ZiNdacpQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz", - "integrity": "sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz", - "integrity": "sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.10.tgz", - "integrity": "sha512-9NUzZuR8WiXTvv+EiU/MXdcQ1XUvFixbLIMNQiVHuzs7ZIFrJDLJDaOF1KaqttoTujpcxljM/RNAOmw1GhPPQQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.10.tgz", - "integrity": "sha512-fr3aEbSd1GeW3YUMBkWAu4hcdjZ6g4NBl1uku4gAn661tcxd1bHs1THWYzdsbTRLcCKLjrDZlNp6j2HTfrw+Bg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.10.tgz", - "integrity": "sha512-UjeVoRGKNL2zfbcQ6fscmgjBAS/inHBh63mjIlfPg/NG8Yn2ztqylXt5qilYb6hoHIwaU2ogHknHWWmahJjgZQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -12379,11 +11927,6 @@ "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" - }, "node_modules/dc-polyfill": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.6.tgz", @@ -22405,6 +21948,7 @@ }, "node_modules/querystringify": { "version": "2.2.0", + "dev": true, "license": "MIT" }, "node_modules/queue-tick": { @@ -22879,6 +22423,7 @@ }, "node_modules/requires-port": { "version": "1.0.0", + "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -23143,10 +22688,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/scmp": { - "version": "2.1.0", - "license": "BSD-3-Clause" - }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -24630,31 +24171,6 @@ "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, - "node_modules/twilio": { - "version": "4.19.3", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.19.3.tgz", - "integrity": "sha512-3X5Czl9Vg4QFl+2pnfMQ+H8YfEDQ4WeuAmqjUpbK65x0DfmxTCHuPEFWUKVZCJZew6iltJB/1whhVvIKETe54A==", - "dependencies": { - "axios": "^1.6.0", - "dayjs": "^1.11.9", - "https-proxy-agent": "^5.0.0", - "jsonwebtoken": "^9.0.0", - "qs": "^6.9.4", - "scmp": "^2.1.0", - "url-parse": "^1.5.9", - "xmlbuilder": "^13.0.2" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/twilio/node_modules/xmlbuilder": { - "version": "13.0.2", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -25007,6 +24523,7 @@ }, "node_modules/url-parse": { "version": "1.5.10", + "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", diff --git a/package.json b/package.json index a6d151a9f6..2341cee8d9 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "ts-essentials": "^10.0.1", "tweetnacl": "^1.0.1", "tweetnacl-util": "^0.15.1", - "twilio": "~4.19.3", "uid-generator": "^2.0.0", "ulid": "^2.3.0", "uuid": "^10.0.0", @@ -160,7 +159,6 @@ "@babel/preset-env": "^7.25.4", "@opengovsg/mockpass": "^4.3.4", "@playwright/test": "^1.49.0", - "@stoplight/prism-cli": "^5.10.0", "@types/bcrypt": "^5.0.0", "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.4", diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index a704814a81..d0421f8258 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -161,6 +161,11 @@ export interface FormBase { esrvcId?: string + /** + * LEGACY: Was previously used for sending with the correct Twilio. + * @deprecated Twilio support is removed and replaced with postman-sms. + * This is retained since DB records may still contain this field for backward compatibility. + */ msgSrvcName?: string webhook: FormWebhook @@ -328,11 +333,6 @@ export type PublicFormViewDto = { export type PreviewFormViewDto = Pick -export type SmsCountsDto = { - quota: number - freeSmsCounts: number -} - export type AdminFormViewDto = { form: AdminFormDto } diff --git a/shared/types/twilio.ts b/shared/types/twilio.ts deleted file mode 100644 index 9c084aa39d..0000000000 --- a/shared/types/twilio.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface TwilioCredentials { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string -} diff --git a/shared/utils/verification.ts b/shared/utils/verification.ts index 110cf3ac5f..0b2114481b 100644 --- a/shared/utils/verification.ts +++ b/shared/utils/verification.ts @@ -13,33 +13,3 @@ export const MAX_OTP_REQUESTS = 10 */ export const WAIT_FOR_OTP_TOLERANCE_SECONDS = 2 export const NUM_OTP_RETRIES = 4 - -export enum VfnErrors { - ResendOtp = 'RESEND_OTP', - SendOtpFailed = 'SEND_OTP_FAILED', - WaitForOtp = 'WAIT_FOR_OTP', - InvalidOtp = 'INVALID_OTP', - FieldNotFound = 'FIELD_NOT_FOUND', - TransactionNotFound = 'TRANSACTION_NOT_FOUND', - InvalidMobileNumber = 'INVALID_MOBILE_NUMBER', -} - -export enum ADMIN_VERIFIED_SMS_STATES { - limitExceeded = 'LIMIT_EXCEEDED', - belowLimit = 'BELOW_LIMIT', - hasMessageServiceId = 'MESSAGE_SERVICE_ID_OBTAINED', -} - -export enum SMS_WARNING_TIERS { - LOW = 2500, - MED = 5000, - HIGH = 7500, -} - -export const stringifiedSmsWarningTiers: { - [K in keyof typeof SMS_WARNING_TIERS]: string -} = { - LOW: '2.5K', - MED: '5K', - HIGH: '7.5K', -} diff --git a/src/app/config/config.ts b/src/app/config/config.ts index e6f09c13bf..00dfd15985 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -236,7 +236,6 @@ const config: Config = { isDev, isTest, isDevOrTest, - useMockTwilio: basicVars.core.useMockTwilio, useMockPostmanSms: basicVars.core.useMockPostmanSms, nodeEnv, formsgSdkMode: basicVars.formsgSdkMode, diff --git a/src/app/config/features/sms.config.ts b/src/app/config/features/sms.config.ts deleted file mode 100644 index f0649b58ee..0000000000 --- a/src/app/config/features/sms.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import convict, { Schema } from 'convict' - -export interface ISms { - twilioAccountSid: string - twilioApiKey: string - twilioApiSecret: string - twilioMsgSrvcSid: string - smsVerificationLimit: number -} - -const smsSchema: Schema = { - twilioAccountSid: { - doc: 'Twilio messaging ID', - format: String, - default: null, - env: 'TWILIO_ACCOUNT_SID', - }, - twilioApiKey: { - doc: 'Twilio standard API Key', - format: String, - default: null, - env: 'TWILIO_API_KEY', - }, - twilioApiSecret: { - doc: 'Twilio API Secret', - format: String, - default: null, - env: 'TWILIO_API_SECRET', - }, - twilioMsgSrvcSid: { - doc: 'Messaging service ID', - format: String, - default: null, - env: 'TWILIO_MESSAGING_SERVICE_SID', - }, - smsVerificationLimit: { - doc: 'Sms verification limit for an admin', - // Positive int - format: 'nat', - default: 10000, - env: 'SMS_VERIFICATION_LIMIT', - }, -} - -export const smsConfig = convict(smsSchema) - .validate({ allowed: 'strict' }) - .getProperties() diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index 6c4d252606..7870dc5b48 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -329,13 +329,6 @@ export const optionalVarsSchema: Schema = { default: Environment.Prod, env: 'NODE_ENV', }, - // TODO(ken): to remove after twilio is no longer used - useMockTwilio: { - doc: 'Enables twilio API mocking and directs SMS body over to maildev', - format: 'Boolean', - default: false, - env: 'USE_MOCK_TWILIO', - }, useMockPostmanSms: { doc: 'Enables Postman SMS API mocking and directs SMS body over to maildev', format: 'Boolean', diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index d731381418..58eef8b335 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -2,7 +2,7 @@ import { generateDefaultField } from '__tests__/unit/backend/helpers/generate-form-data' import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' -import { cloneDeep, map, merge, omit, orderBy, pick, range } from 'lodash' +import { cloneDeep, map, merge, omit, orderBy, pick } from 'lodash' import mongoose, { Types } from 'mongoose' import { EMAIL_PUBLIC_FORM_FIELDS, @@ -1409,11 +1409,7 @@ describe('Form Model', () => { it('should return otpData of an email form when formId is valid', async () => { // Arrange - const emailFormParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { - msgSrvcName: 'mockSrvcName', - }) - // Create a form with msgSrvcName - const form = await Form.create(emailFormParams) + const form = await Form.create(MOCK_EMAIL_FORM_PARAMS) // Act const actualOtpData = await Form.getOtpData(form._id) @@ -1428,18 +1424,13 @@ describe('Form Model', () => { email: populatedAdmin.email, userId: populatedAdmin._id, }, - msgSrvcName: emailFormParams.msgSrvcName, } expect(actualOtpData).toEqual(expectedOtpData) }) it('should return otpData of an encrypt form when formId is valid', async () => { // Arrange - const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { - msgSrvcName: 'mockSrvcName', - }) - // Create a form with msgSrvcName - const form = await Form.create(encryptFormParams) + const form = await Form.create(MOCK_ENCRYPTED_FORM_PARAMS) // Act const actualOtpData = await Form.getOtpData(form._id) @@ -1454,7 +1445,6 @@ describe('Form Model', () => { email: populatedAdmin.email, userId: populatedAdmin._id, }, - msgSrvcName: encryptFormParams.msgSrvcName, } expect(actualOtpData).toEqual(expectedOtpData) }) @@ -2198,55 +2188,6 @@ describe('Form Model', () => { await expect(Form.countDocuments()).resolves.toEqual(0) }) }) - - describe('retrievePublicFormsWithSmsVerification', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should retrieve only public forms with verifiable mobile fields that are not onboarded', async () => { - // Arrange - const mockFormPromises = range(8).map((_, idx) => { - // Extract bits and use them to represent state - const isPublic = !!(idx % 2) - const isVerifiable = !!((idx >> 1) % 2) - const isOnboarded = !!((idx >> 2) % 2) - return Form.create({ - admin: populatedAdmin._id, - responseMode: FormResponseMode.Email, - title: 'mock mobile form', - emails: [populatedAdmin.email], - status: isPublic ? FormStatus.Public : FormStatus.Private, - ...(isOnboarded && { msgSrvcName: MOCK_MSG_SRVC_NAME }), - form_fields: [ - generateDefaultField(BasicField.Mobile, { isVerifiable }), - ], - }) - }) - await Promise.all(mockFormPromises) - - // Act - const forms = await Form.retrievePublicFormsWithSmsVerification( - populatedAdmin._id, - ) - - // Assert - expect(forms.length).toBe(1) - expect(forms[0].form_fields[0].isVerifiable).toBe(true) - expect(forms[0].status).toBe(FormStatus.Public) - expect(forms[0].msgSrvcName).toBeUndefined() - }) - - it('should return an empty array when there are no forms', async () => { - // NOTE: This is an edge case and should never happen in prod as this method is called when - // a public form has a certain amount of verifications - - // Act - const forms = await Form.retrievePublicFormsWithSmsVerification( - populatedAdmin._id, - ) - - // Assert - expect(forms.length).toBe(0) - }) - }) }) describe('Methods', () => { @@ -3020,39 +2961,5 @@ describe('Form Model', () => { ) }) }) - - describe('updateMsgSrvcName', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should update msgSrvcName of form to new msgSrvcName', async () => { - // Arrange - const form = await Form.create({ - admin: populatedAdmin._id, - title: 'mock mobile form', - }) - - // Act - const updatedForm = await form.updateMsgSrvcName(MOCK_MSG_SRVC_NAME) - // Assert - expect(updatedForm?.msgSrvcName).toBe(MOCK_MSG_SRVC_NAME) - }) - }) - - describe('deleteMsgSrvcName', () => { - const MOCK_MSG_SRVC_NAME = 'mockTwilioName' - it('should delete msgSrvcName of form', async () => { - // Arrange - const form = await Form.create({ - admin: populatedAdmin._id, - title: 'mock mobile form', - msgSrvcName: MOCK_MSG_SRVC_NAME, - }) - - // Act - const updatedForm = await form.deleteMsgSrvcName() - - // Assert - expect(updatedForm?.msgSrvcName).toBeUndefined() - }) - }) }) }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index af03450619..aceaa61567 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -657,6 +657,11 @@ const compileFormModel = (db: Mongoose): IFormModel => { }, }, + /** + * LEGACY: Was previously used for sending with the correct Twilio. + * @deprecated Twilio support is removed and replaced with postman-sms. + * This is retained since DB records may still contain this field for backward compatibility. + */ msgSrvcName: { // Name of credentials for messaging service, stored in secrets manager type: String, @@ -808,22 +813,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } - FormSchema.methods.updateMsgSrvcName = async function ( - msgSrvcName: string, - session?: ClientSession, - ) { - this.msgSrvcName = msgSrvcName - - return this.save({ session }) - } - - FormSchema.methods.deleteMsgSrvcName = async function ( - session?: ClientSession, - ) { - this.msgSrvcName = undefined - return this.save({ session }) - } - const FormDocumentSchema = FormSchema as unknown as Schema FormDocumentSchema.methods.getDashboardView = function ( @@ -1049,7 +1038,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function (formId: string) { try { - const data = await this.findById(formId, 'msgSrvcName admin').populate({ + const data = await this.findById(formId, 'admin').populate({ path: 'admin', select: 'email', }) @@ -1060,7 +1049,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { email: data.admin.email, userId: data.admin._id, }, - msgSrvcName: data.msgSrvcName, } as FormOtpData) : null } catch { @@ -1256,27 +1244,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { ).exec() } - /** - * Retrieves all the public forms for a user which has sms verifications enabled - * This only retrieves forms that are using FormSG credentials - * @param userId The userId to retrieve the forms for - * @returns All public forms that have sms verifications enabled - */ - FormSchema.statics.retrievePublicFormsWithSmsVerification = async function ( - userId: IUserSchema['_id'], - ) { - return this.find({ - admin: userId, - 'form_fields.fieldType': BasicField.Mobile, - 'form_fields.isVerifiable': true, - status: FormStatus.Public, - msgSrvcName: { - $exists: false, - }, - }) - .read('secondary') - .exec() - } FormSchema.statics.getGoLinkSuffix = async function (formId: string) { return this.findById(formId, 'goLinkSuffix').exec() } diff --git a/src/app/modules/core/core.errors.ts b/src/app/modules/core/core.errors.ts index f412756124..f07b94711d 100644 --- a/src/app/modules/core/core.errors.ts +++ b/src/app/modules/core/core.errors.ts @@ -325,12 +325,6 @@ export class SecretsManagerConflictError extends ApplicationError { } } -export class TwilioCacheError extends ApplicationError { - constructor(message?: string) { - super(message, undefined, ErrorCodes.TWILIO_CACHE) - } -} - /** * Union of all possible database errors */ diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 2af82d0098..aca33e49f1 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -456,40 +456,4 @@ describe('FormService', () => { expect(actual._unsafeUnwrapErr()).toEqual(new ApplicationError()) }) }) - - describe('retrievePublicFormsWithSmsVerification', () => { - it('should call the db method successfully', async () => { - // Arrange - const retrieveFormSpy = jest - .spyOn(Form, 'retrievePublicFormsWithSmsVerification') - .mockResolvedValueOnce([]) - const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() - const expected: IFormSchema[] = [] - - // Act - const actual = - await FormService.retrievePublicFormsWithSmsVerification(MOCK_ADMIN_ID) - - // Assert - expect(actual._unsafeUnwrap()).toEqual(expected) - expect(retrieveFormSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) - }) - - it('should propagate the error received when error occurs while querying', async () => { - // Arrange - const expected = new DatabaseError('whoops') - const retrieveFormSpy = jest - .spyOn(Form, 'retrievePublicFormsWithSmsVerification') - .mockRejectedValueOnce(expected) - const MOCK_ADMIN_ID = MOCK_ADMIN_OBJ_ID.toString() - - // Act - const actual = - await FormService.retrievePublicFormsWithSmsVerification(MOCK_ADMIN_ID) - - // Assert - expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) - expect(retrieveFormSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) - }) - }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 74c50f2e1e..eb07770029 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -42,7 +42,6 @@ import { MailSendError, } from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' -import { TwilioCredentials } from 'src/app/services/sms/sms.types' import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { EditFieldActions } from 'src/shared/constants' import { @@ -77,8 +76,6 @@ import { LogicDto, } from '../../../../../../shared/types' import * as CryptoUtil from '../../../../../../shared/utils/crypto' -import { smsConfig } from '../../../../config/features/sms.config' -import * as SmsService from '../../../../services/sms/sms.service' import ParsedResponsesObject from '../../../submission/ParsedResponsesObject.class' import * as UserService from '../../../user/user.service' import { @@ -135,8 +132,6 @@ jest.mock('../../../user/user.service') const MockUserService = jest.mocked(UserService) jest.mock('src/app/services/mail/mail.service') const MockMailService = jest.mocked(MailService) -jest.mock('../../../../services/sms/sms.service') -const MockSmsService = jest.mocked(SmsService) jest.mock('src/app/modules/workspace/workspace.service.ts') const MockWorkspaceService = jest.mocked(WorkspaceService) @@ -436,6 +431,7 @@ describe('admin-form.controller', () => { responseMode: FormResponseMode.Encrypt, publicKey: 'some public key', title: 'some form title', + emails: [], } const MOCK_REQ = expressHandler.mockRequest({ session: { @@ -3007,7 +3003,9 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( okAsync(MOCK_FORM as IPopulatedForm), ) - MockAdminFormService.archiveForm.mockReturnValueOnce(okAsync(true)) + MockAdminFormService.archiveForm.mockReturnValueOnce( + okAsync(true as unknown as IFormSchema), + ) MockWorkspaceService.removeFormsFromAllWorkspaces.mockReturnValueOnce( okAsync(true), ) @@ -10671,650 +10669,4 @@ describe('admin-form.controller', () => { expect(MockAdminFormService.getFormField).not.toHaveBeenCalled() }) }) - - describe('handleGetFreeSmsCountForFormAdmin', () => { - const mockForm = { - admin: new ObjectId().toHexString(), - } as unknown as IFormSchema - const VERIFICATION_SMS_COUNT = 3 - - beforeAll(() => { - MockFormService.retrieveFormById.mockReturnValue(okAsync(mockForm)) - MockSmsService.retrieveFreeSmsCounts.mockReturnValue( - okAsync(VERIFICATION_SMS_COUNT), - ) - }) - - it('should retrieve sms counts and quota when the user and the form exist', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const expected = { - freeSmsCounts: VERIFICATION_SMS_COUNT, - quota: smsConfig.smsVerificationLimit, - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 404 when the form is not found in the database', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: new ObjectId().toHexString(), - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - MockFormService.retrieveFormById.mockReturnValueOnce( - errAsync(new FormNotFoundError()), - ) - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Form not found', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 500 when a database error occurs during form retrieval', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const retrieveSpy = jest.spyOn(FormService, 'retrieveFormById') - retrieveSpy.mockReturnValueOnce(errAsync(new DatabaseError())) - const expected = { - message: 'Something went wrong. Please try again.', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - - it('should return 500 when a database error occurs during count retrieval', async () => { - // Arrange - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: mockForm._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - const mockRes = expressHandler.mockResponse() - const retrieveSpy = jest.spyOn(SmsService, 'retrieveFreeSmsCounts') - retrieveSpy.mockReturnValueOnce(errAsync(new DatabaseError())) - const expected = { - message: 'Something went wrong. Please try again.', - } - - // Act - await AdminFormController.handleGetFreeSmsCountForFormAdmin( - MOCK_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith(expected) - }) - }) - - describe('updateTwilioCredentials', () => { - const MOCK_USER_ID = new ObjectId().toHexString() - const MOCK_FORM_ID = new ObjectId().toHexString() - const MOCK_USER = { - _id: MOCK_USER_ID, - email: 'somerandom@example.com', - } as IPopulatedUser - const MOCK_FORM = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - title: 'mock title', - } as IPopulatedForm - - const MOCK_FORM_WITH_MSG_SRVC_NAME = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - msgSrvcName: '123', - title: 'mock title', - } as IPopulatedForm - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const MOCK_TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const createTwilioSpy = jest.spyOn( - MockAdminFormService, - 'createTwilioCredentials', - ) - const updateTwilioSpy = jest.spyOn( - MockAdminFormService, - 'updateTwilioCredentials', - ) - - it('should return 200 after the twilio credentials are successfully created', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Returns empty response because mongo transaction returns Promise - createTwilioSpy.mockReturnValueOnce(okAsync(null)) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - message: 'Successfully updated Twilio credentials', - }) - expect(createTwilioSpy).toHaveBeenCalledTimes(1) - expect(updateTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 200 after the twilio credentials are successfully updated', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM_WITH_MSG_SRVC_NAME), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - updateTwilioSpy.mockReturnValueOnce(okAsync(1)) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - message: 'Successfully updated Twilio credentials', - }) - expect(updateTwilioSpy).toHaveBeenCalledTimes(1) - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 403 when current user does not have permissions to update form', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'no write permissions' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new ForbiddenFormError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(403) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 404 when form to update cannot be found', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'Form not found' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new FormNotFoundError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 422 on MissingUserError', async () => { - // Arrange - const errorMessage = 'User not found' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new MissingUserError(errorMessage)), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - const expectedResponse = { message: errorMessage } - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(422) - expect(mockRes.json).toHaveBeenCalledWith(expectedResponse) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 500 when generic database error occurs during form field retrieval', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - const expectedErrorString = 'A Database error occured!' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new DatabaseError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - body: MOCK_TWILIO_CREDENTIALS, - }) - - // Act - await AdminFormController.updateTwilioCredentials( - mockReq, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(updateTwilioSpy).not.toHaveBeenCalled() - expect(createTwilioSpy).not.toHaveBeenCalled() - }) - }) - - describe('handleDeleteTwilio', () => { - const MOCK_USER_ID = new ObjectId().toHexString() - const MOCK_FORM_ID = new ObjectId().toHexString() - const MOCK_USER = { - _id: MOCK_USER_ID, - email: 'somerandom@example.com', - } as IPopulatedUser - const MOCK_FORM = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - title: 'mock title', - } as IPopulatedForm - - const MOCK_FORM_WITH_CREDENTIALS = { - admin: MOCK_USER, - _id: MOCK_FORM_ID, - msgSrvcName: '123', - title: 'mock title', - } as IPopulatedForm - - const deleteTwilioSpy = jest.spyOn( - MockAdminFormService, - 'deleteTwilioCredentials', - ) - - it('should return 200 if twilio credentials are successfully deleted', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM_WITH_CREDENTIALS), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Returns empty response because mongo transaction returns Promise - deleteTwilioSpy.mockReturnValueOnce(okAsync(1)) - - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Successfully deleted Twilio credentials', - } - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - expect(deleteTwilioSpy).toHaveBeenCalledTimes(1) - }) - - it('should return 200 if no twilio credentials need to be deleted', async () => { - // Arrange - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValue( - okAsync(MOCK_FORM), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Returns empty response because mongo transaction returns Promise - deleteTwilioSpy.mockReturnValueOnce(okAsync(null)) - - const mockRes = expressHandler.mockResponse() - const expected = { - message: 'Successfully deleted Twilio credentials', - } - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith(expected) - expect(deleteTwilioSpy).toHaveBeenCalled() - }) - - it('should return 403 when current user does not have permissions to update form', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'no write permissions' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new ForbiddenFormError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(403) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 404 when form to update cannot be found', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - - const expectedErrorString = 'Form not found' - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - errAsync(new FormNotFoundError(expectedErrorString)), - ) - - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(404) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 422 on MissingUserError', async () => { - // Arrange - const errorMessage = 'User not found' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new MissingUserError(errorMessage)), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - const mockRes = expressHandler.mockResponse() - - // Act - await AdminFormController.handleDeleteTwilio(mockReq, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(422) - expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - - it('should return 500 when generic database error occurs during form field retrieval', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - const expectedErrorString = 'A Database error occured!' - MockUserService.getPopulatedUserById.mockReturnValueOnce( - errAsync(new DatabaseError(expectedErrorString)), - ) - - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_WITH_CREDENTIALS._id, - }, - session: { - user: { - _id: 'exists', - }, - }, - }) - - // Act - await AdminFormController.handleDeleteTwilio(MOCK_REQ, mockRes, jest.fn()) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(500) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(deleteTwilioSpy).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 77a3c609b8..22c7f37093 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -11,7 +11,7 @@ import { EncryptedStringsMessageContentWithMyPrivateKey, } from 'shared/utils/crypto' -import config, { aws } from 'src/app/config/config' +import { aws } from 'src/app/config/config' import getAgencyModel from 'src/app/models/agency.server.model' import getFormModel, { getEmailFormModel, @@ -29,7 +29,6 @@ import { } from 'src/app/modules/core/core.errors' import { MissingUserError } from 'src/app/modules/user/user.errors' import * as UserService from 'src/app/modules/user/user.service' -import { TwilioCredentials } from 'src/app/services/sms/sms.types' import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error' import { EditFieldActions } from 'src/shared/constants' @@ -75,7 +74,6 @@ import { PaymentType, SettingsUpdateDto, } from '../../../../../../shared/types' -import * as SmsService from '../../../../services/sms/sms.service' import { FormNotFoundError, LogicNotFoundError, @@ -91,8 +89,6 @@ import * as AdminFormService from '../admin-form.service' import { OverrideProps } from '../admin-form.types' import * as AdminFormUtils from '../admin-form.utils' -import { secretsManager } from './../admin-form.service' - const FormModel = getFormModel(mongoose) const EmailFormModel = getEmailFormModel(mongoose) const EncryptFormModel = getEncryptedFormModel(mongoose) @@ -104,9 +100,6 @@ const FormWhitelistedSubmitterIdsModel = jest.mock('src/app/modules/user/user.service') const MockUserService = jest.mocked(UserService) -jest.mock('../../../../services/sms/sms.service') -const MockSmsService = jest.mocked(SmsService) - describe('admin-form.service', () => { beforeEach(async () => { jest.clearAllMocks() @@ -2737,163 +2730,6 @@ describe('admin-form.service', () => { }) }) - describe('createTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - const MOCK_ADMIN_ID = new mongoose.Types.ObjectId() - - const MOCK_FORM = { - _id: MOCK_FORM_ID, - admin: { - _id: MOCK_ADMIN_ID, - }, - } as unknown as IPopulatedForm - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const sessionSpy = jest.spyOn(FormModel, 'startSession') - - it('should return undefined when Twilio credentials was created successfully', async () => { - // Arrange - sessionSpy.mockResolvedValueOnce({ - withTransaction: () => { - return { - then: () => undefined, - } - }, - } as any) - - // Act - const actualResult = await AdminFormService.createTwilioCredentials( - TWILIO_CREDENTIALS, - MOCK_FORM, - ) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(undefined) - - expect(sessionSpy).toHaveBeenCalled() - }) - }) - - describe('updateTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - it('should return the response of performing PutSecretValue operation on the SecretsManager', async () => { - // Arrange - const msgSrvcName = `formsg/${config.secretEnv}/form/${MOCK_FORM_ID}/twilio` - - const getSecretsSpy = jest - .spyOn(secretsManager, 'getSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const twilioCacheSpy = jest - .spyOn(MockSmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const putSecretsSpy = jest - .spyOn(secretsManager, 'putSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - // Act - - const actualResult = await AdminFormService.updateTwilioCredentials( - msgSrvcName, - TWILIO_CREDENTIALS, - ) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(1) - - expect(getSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(putSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - SecretString: JSON.stringify(TWILIO_CREDENTIALS), - }) - }) - }) - - describe('deleteTwilioCredentials', () => { - const MOCK_FORM_ID = new mongoose.Types.ObjectId() - const sessionSpy = jest.spyOn(FormModel, 'startSession') - const MSG_SRVC_NAME = `formsg/${config.secretEnv}/form/${MOCK_FORM_ID}/twilio` - const MOCK_FORM = { - _id: MOCK_FORM_ID, - save: () => MOCK_FORM, - msgSrvcName: MSG_SRVC_NAME, - } as unknown as IPopulatedForm - - it('should return result of clearing TwilioCache entry when Twilio credentials was successfully deleted', async () => { - // Arrange - sessionSpy.mockResolvedValueOnce({ - withTransaction: () => { - return { - then: () => undefined, - } - }, - } as any) - - // formSpy.mockResolvedValueOnce(MOCK_FORM) - - const twilioCacheSpy = jest - .spyOn(MockSmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - // Act - - const actualResult = - await AdminFormService.deleteTwilioCredentials(MOCK_FORM) - - // Assert - expect(actualResult.isOk()).toEqual(true) - expect(actualResult._unsafeUnwrap()).toEqual(1) - - expect(twilioCacheSpy).toHaveBeenCalledWith(MSG_SRVC_NAME) - }) - }) - describe('checkIsWhitelistSettingValid', () => { const MOCK_VALID_UEN = '53244311W' const MOCK_VALID_FIN = 'F1612366T' diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 53268049e3..7f5e87e2ac 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -43,7 +43,6 @@ import { PrivateFormErrorDto, PublicFormDto, SettingsUpdateDto, - SmsCountsDto, StartPageUpdateDto, SubmissionCountQueryDto, WebhookSettingsUpdateDto, @@ -59,10 +58,8 @@ import { ParsedEmailModeSubmissionBody, } from '../../../../types/api' import { goGovConfig } from '../../../config/features/gogov.config' -import { smsConfig } from '../../../config/features/sms.config' import { createLoggerWithLabel } from '../../../config/logger' import MailService from '../../../services/mail/mail.service' -import * as SmsService from '../../../services/sms/sms.service' import { createReqMeta } from '../../../utils/request' import * as AuthService from '../../auth/auth.service' import { @@ -93,7 +90,6 @@ import { removeFormsFromAllWorkspaces } from '../../workspace/workspace.service' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' -import { TwilioCredentials } from './../../../services/sms/sms.types' import { PREVIEW_CORPPASS_UID, PREVIEW_CORPPASS_UINFIN, @@ -3148,183 +3144,6 @@ export const handleUpdateStartPage = [ _handleUpdateStartPage, ] as ControllerHandler[] -/** - * Handler to retrieve the free sms counts used by a form's administrator and the sms verifications quota - * This is the controller for GET /admin/forms/:formId/verified-sms/count/free - * @param formId The id of the form to retrieve the free sms counts for - * @returns 200 with free sms counts and quota when successful - * @returns 404 when the formId is not found in the database - * @returns 500 when a database error occurs during retrieval - */ -export const handleGetFreeSmsCountForFormAdmin: ControllerHandler< - { - formId: string - }, - ErrorDto | SmsCountsDto -> = (req, res) => { - const { formId } = req.params - const logMeta = { - action: 'handleGetFreeSmsCountForFormAdmin', - ...createReqMeta(req), - formId, - } - - // Step 1: Check that the form exists - return ( - FormService.retrieveFormById(formId) - // Step 2: Retrieve the free sms count - .andThen(({ admin }) => { - return SmsService.retrieveFreeSmsCounts(String(admin)) - }) - // Step 3: Map/MapErr accordingly - .map((freeSmsCountForAdmin) => - res.status(StatusCodes.OK).json({ - freeSmsCounts: freeSmsCountForAdmin, - quota: smsConfig.smsVerificationLimit, - }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error while retrieving sms counts for user', - meta: logMeta, - error, - }) - const { statusCode, errorMessage } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) - ) -} - -// Validates Twilio Credentials -const validateTwilioCredentials = celebrate({ - [Segments.BODY]: Joi.object().keys({ - accountSid: Joi.string().required().pattern(new RegExp('^AC')), - apiKey: Joi.string().required().pattern(new RegExp('^SK')), - apiSecret: Joi.string().required(), - messagingServiceSid: Joi.string().required().pattern(new RegExp('^MG')), - }), -}) -/** - * Handler for PUT /:formId/twilio. - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 400 with twilio credentials are invalid - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to update cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ -export const updateTwilioCredentials: ControllerHandler< - { formId: string }, - unknown, - TwilioCredentials -> = (req, res) => { - const { formId } = req.params - const twilioCredentials = req.body - - const sessionUserId = (req.session as AuthedSessionData).user._id - - return UserService.getPopulatedUserById(sessionUserId) - .andThen((user) => - AuthService.getFormAfterPermissionChecks({ - user, - formId, - level: PermissionLevel.Write, - }), - ) - .andThen((retrievedForm) => { - const { msgSrvcName } = retrievedForm - - return msgSrvcName - ? AdminFormService.updateTwilioCredentials( - msgSrvcName, - twilioCredentials, - ) - : AdminFormService.createTwilioCredentials( - twilioCredentials, - retrievedForm, - ) - }) - .map(() => - res - .status(StatusCodes.OK) - .json({ message: 'Successfully updated Twilio credentials' }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error occurred when updating twilio credentials', - meta: { - action: 'handleUpdateTwilio', - ...createReqMeta(req), - userId: sessionUserId, - formId, - twilioCredentials, - }, - error, - }) - const { errorMessage, statusCode } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) -} - -/** - * Handler for DELETE /:formId/twilio. - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to delete credentials cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ -export const handleDeleteTwilio: ControllerHandler<{ formId: string }> = ( - req, - res, -) => { - const { formId } = req.params - const sessionUserId = (req.session as AuthedSessionData).user._id - - return UserService.getPopulatedUserById(sessionUserId) - .andThen((user) => - AuthService.getFormAfterPermissionChecks({ - user, - formId, - level: PermissionLevel.Delete, - }), - ) - .andThen((retrievedForm) => { - return AdminFormService.deleteTwilioCredentials(retrievedForm) - }) - .map(() => - res - .status(StatusCodes.OK) - .json({ message: 'Successfully deleted Twilio credentials' }), - ) - .mapErr((error) => { - logger.error({ - message: 'Error occurred when deleting twilio credentials', - meta: { - action: 'handleDeleteTwilio', - ...createReqMeta(req), - userId: sessionUserId, - formId, - }, - error, - }) - const { errorMessage, statusCode } = mapRouteError(error) - return res.status(statusCode).json({ message: errorMessage }) - }) -} - -// Handler for PUT /admin/forms/:formId/twilio -export const handleUpdateTwilio = [ - validateTwilioCredentials, - updateTwilioCredentials, -] as ControllerHandler[] - export const handleGetGoLinkSuffix: ControllerHandler<{ formId: string }> = ( req, res, diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 49cb88e44e..74545217c0 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -1,10 +1,4 @@ -import { AWSError, SecretsManager } from 'aws-sdk' import { PresignedPost } from 'aws-sdk/clients/s3' -import { - CreateSecretRequest, - DeleteSecretRequest, - PutSecretValueRequest, -} from 'aws-sdk/clients/secretsmanager' import { assignIn, last, omit, pick } from 'lodash' import mongoose, { ClientSession } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' @@ -73,13 +67,12 @@ import { IPopulatedUser, } from '../../../../types' import { EditFormFieldParams, FormUpdateParams } from '../../../../types/api' -import config, { aws as AwsConfig } from '../../../config/config' +import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getAgencyModel from '../../../models/agency.server.model' import getFormModel from '../../../models/form.server.model' import getFormWhitelistSubmitterIdsModel from '../../../models/form_whitelist.server.model' import { getWorkspaceModel } from '../../../models/workspace.server.model' -import { twilioClientCache } from '../../../services/sms/sms.service' import { createPresignedPostDataPromise, CreatePresignedPostError, @@ -96,9 +89,6 @@ import { DatabaseValidationError, MalformedParametersError, PossibleDatabaseError, - SecretsManagerError, - SecretsManagerNotFoundError, - TwilioCacheError, } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' @@ -118,10 +108,6 @@ import { isFormEncryptMode, } from '../form.utils' -import { - TwilioCredentials, - TwilioCredentialsData, -} from './../../../services/sms/sms.types' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { EditFieldError, @@ -130,8 +116,6 @@ import { InvalidFileTypeError, } from './admin-form.errors' import { - checkIsApiSecretKeyName, - generateTwilioCredSecretKeyName, getUpdatedFormFields, insertTableShortTextColumnDefaultValidationOptions, processDuplicateOverrideProps, @@ -144,11 +128,6 @@ const WorkspaceModel = getWorkspaceModel(mongoose) const FormWhitelistedSubmitterIdsModel = getFormWhitelistSubmitterIdsModel(mongoose) -export const secretsManager = new SecretsManager({ - region: config.aws.region, - endpoint: process.env.AWS_ENDPOINT, -}) - type PresignedPostUrlParams = { fileId: string fileMd5Hash: string @@ -2273,318 +2252,6 @@ export const updateStartPage = ( }) } -/** - * Creates msgSrvcName and updates the form in MongoDB as part of a transaction, uses the created - * msgSrvcName as the key to store the Twilio Credentials in AWS Secrets Manager - * @param twilioCredentials The twilio credentials to add - * @param form The form to add Twilio Credentials - * @returns ok(undefined) if the creation is successful - * @returns err(SecretsManagerError) if an error occurs while creating credentials in secrets manager - */ -export const createTwilioCredentials = ( - twilioCredentials: TwilioCredentials, - form: IPopulatedForm, -): ResultAsync< - unknown, - ReturnType | SecretsManagerError -> => { - const twilioCredentialsData: TwilioCredentialsData = - new TwilioCredentialsData(twilioCredentials) - const formId = form._id - - const msgSrvcName = generateTwilioCredSecretKeyName(formId) - - const body: CreateSecretRequest = { - Name: msgSrvcName, - SecretString: twilioCredentialsData.toString(), - Description: `autogenerated via API on ${new Date().toISOString()} by ${ - form.admin._id - }`, - } - - const logMeta = { - action: 'createTwilioCredentials', - formId: formId, - msgSrvcName, - body, - } - - logger.info({ - message: `No msgSrvcName, creating Twilio credentials for form ${formId}`, - meta: logMeta, - }) - - return ResultAsync.fromPromise( - FormModel.startSession().then((session: ClientSession) => - session - .withTransaction(() => - createTwilioTransaction(form, msgSrvcName, body, session), - ) - .then(() => session.endSession()), - ), - (error) => { - logger.error({ - message: 'Error encountered when creating Twilio Secret', - meta: logMeta, - error, - }) - - return error as - | ReturnType - | SecretsManagerError - }, - ) -} - -/** - * Updates msgSrvcName of the form in the database, uses the msgSrvcName as the - * key to store the Twilio Credentials in AWS Secrets Manager - * @param form The form to add Twilio Credentials - * @param msgSrvcName The key under which the credentials is stored in AWS Secrets Manager - * @param body the request body used to create the secret in secrets manager - * @param session session of the transaction - * @returns Promise.ok(void) if the creation is successful - */ -// Exported to use in tests -export const createTwilioTransaction = async ( - form: IPopulatedForm, - msgSrvcName: string, - body: CreateSecretRequest, - session: ClientSession, -): Promise => { - const meta = { - action: 'createTwilioTransaction', - formId: form._id, - msgSrvcName, - body, - } - - try { - await form.updateMsgSrvcName(msgSrvcName, session) - } catch (err) { - logger.error({ - message: - 'Error occured when updating msgSrvcName, rolling back transaction!', - meta, - error: err, - }) - throw transformMongoError(err) - } - - try { - await secretsManager.createSecret(body).promise() - } catch (err) { - const awsError = err as AWSError - - logger.error({ - message: - 'Error occured when creating secret AWS Secrets Manager, rolling back transaction!', - meta, - error: awsError, - }) - throw new SecretsManagerError(awsError.message) - } -} - -/** - * Uses the msgSrvcName to update the Twilio Credentials in AWS Secrets Manager - * Clears the cache entry in which the Twilio Credentials are stored under - * @param twilioCredentials The twilio credentials to add - * @param msgSrvcName The key under which the credentials are stored in Secrets Manager - * @returns ok(number) if the update is successful - * @returns err(SecretsManagerNotFoundError) if there is no secret stored under msgSrvcName in secrets manager - * @returns err(SecretsManagerError) if an error occurs while updating credentials in secrets manager - */ -export const updateTwilioCredentials = ( - msgSrvcName: string, - twilioCredentials: TwilioCredentials, -): ResultAsync< - number, - SecretsManagerError | SecretsManagerNotFoundError | TwilioCacheError -> => { - const twilioCredentialsData: TwilioCredentialsData = - new TwilioCredentialsData(twilioCredentials) - - const body: PutSecretValueRequest = { - SecretId: msgSrvcName, - SecretString: twilioCredentialsData.toString(), - } - - const logMeta = { - action: 'updateTwilioCredentials', - msgSrvcName, - body, - } - - return ( - ResultAsync.fromPromise( - secretsManager.getSecretValue({ SecretId: msgSrvcName }).promise(), - (error) => { - const awsError = error as AWSError - - if (awsError.code === 'ResourceNotFoundException') { - logger.error({ - message: 'Twilio Credentials do not exist in Secrets Manager', - meta: logMeta, - error, - }) - - return new SecretsManagerNotFoundError(awsError.message) - } - - logger.error({ - message: 'Error occurred when retrieving Twilio in Secret Manager!', - meta: { - ...logMeta, - body, - }, - error, - }) - - return new SecretsManagerError(awsError.message) - }, - ) - .andThen(() => { - logger.info({ - message: 'Twilio Credentials has been found in Secrets Manager', - meta: logMeta, - }) - - return ResultAsync.fromPromise( - secretsManager.putSecretValue(body).promise(), - (error) => { - logger.error({ - message: 'Error occurred when updating Twilio in Secret Manager!', - meta: { - ...logMeta, - body, - }, - error, - }) - - return new SecretsManagerError( - 'Error occurred when updating Twilio in Secret Manager!', - ) - }, - ) - }) - // Currently, a call to get twilio credentials will cache the credentials in the twilioCache for ~10s - // If a call to retrieve twilio credentials occurs before 10s passes, it will be a cache hit, retrieving - // the wrong credentials. Hence we need to clear the cache entry - .map(() => twilioClientCache.del(msgSrvcName)) - ) -} - -/** - * Uses the msgSrvcName to schedule the Twilio Credentials for deletion in AWS Secrets Manager and removes - * msgSrvcName from the form in MongoDB as part of a transaction - * - * Clears the cache entry in which the Twilio Credentials are stored under - * @param form The form to delete Twilio Credentials - * @param msgSrvcName The key under which the credentials are stored in Secrets Manager - * @returns ok(number) if the deletion is successful - * @returns err(SecretsManagerNotFoundError) if there is no secret stored under msgSrvcName in secrets manager - * @returns err(SecretsManagerError) if an error occurs while deleting credentials in secrets manager - */ -export const deleteTwilioCredentials = ( - form: IPopulatedForm, -): ResultAsync< - unknown, - | ReturnType - | SecretsManagerError - | TwilioCacheError -> => { - if (!form.msgSrvcName) return okAsync(null) - - const msgSrvcName = form.msgSrvcName - const body: DeleteSecretRequest = { - SecretId: msgSrvcName, - } - /** - * - * The key-value pair will remain in SecretsManager for another 30 days before - * being deleted: https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_delete-secret.html - * - */ - - const formId = form._id - - const logMeta = { - action: 'deleteTwilioCredentials', - formId, - msgSrvcName, - body, - } - - return ResultAsync.fromPromise( - FormModel.startSession().then((session: ClientSession) => - session - .withTransaction(() => deleteTwilioTransaction(form, body, session)) - .then(() => session.endSession()), - ), - (error) => { - logger.error({ - message: 'Error occurred when deleting Twilio in Secret Manager!', - meta: logMeta, - error, - }) - - return error as - | ReturnType - | SecretsManagerError - }, - ).map(() => twilioClientCache.del(msgSrvcName)) -} - -/** - * Deletes the msgSrvcName of the specified form in the database and uses the msgSrvcName as the - * key to delete the Twilio Credentials in AWS Secrets Manager - * @param form The form to delete Twilio Credentials - * @param msgSrvcName The key under which the credentials is stored in AWS Secrets Manager - * @param body the request body used to delete the secret in secrets manager - * @param session session of the transaction - * @returns Promise.ok(void) if the creation is successful - */ -const deleteTwilioTransaction = async ( - form: IPopulatedForm, - body: DeleteSecretRequest, - session: ClientSession, -): Promise => { - const msgSrvcName = body.SecretId - const meta = { - action: 'deleteTwilioTransaction', - formId: form._id, - msgSrvcName, - body, - } - - try { - await form.deleteMsgSrvcName(session) - } catch (err) { - logger.error({ - message: - 'Error occured when deleting msgSrvcName in MongoDB, rolling back transaction!', - meta, - error: err, - }) - throw transformMongoError(err) - } - - try { - if (checkIsApiSecretKeyName(msgSrvcName)) - await secretsManager.deleteSecret(body).promise() - } catch (err) { - const awsError = err as AWSError - logger.error({ - message: - 'Error occured when deleting secret key in AWS Secrets Manager, rolling back transaction!', - meta, - error: awsError, - }) - throw new SecretsManagerError(awsError.message) - } -} - export const archiveForms = async ({ formIds, session, diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 3e0850bd3d..bced735815 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -2,7 +2,6 @@ import { AxiosError } from 'axios' import { type Joi } from 'celebrate' import { StatusCodes } from 'http-status-codes' import { err, ok, Result } from 'neverthrow' -import { v4 as uuidv4 } from 'uuid' import { BasicField, @@ -19,7 +18,6 @@ import { import { EditFieldActions } from '../../../../shared/constants' import { FormFieldSchema, IPopulatedForm, IUserSchema } from '../../../../types' import { EditFormFieldParams } from '../../../../types/api' -import config from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { CreatePresignedPostError } from '../../../utils/aws-s3' import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' @@ -30,10 +28,6 @@ import { DatabasePayloadSizeError, DatabaseValidationError, MalformedParametersError, - SecretsManagerConflictError, - SecretsManagerError, - SecretsManagerNotFoundError, - TwilioCacheError, } from '../../core/core.errors' import { ErrorResponseData } from '../../core/core.types' import { InvalidPaymentAmountError } from '../../payments/payments.errors' @@ -157,26 +151,6 @@ export const mapRouteError = ( statusCode: StatusCodes.INTERNAL_SERVER_ERROR, errorMessage: coreErrorMessage ?? error.message, } - case SecretsManagerNotFoundError: - return { - statusCode: StatusCodes.NOT_FOUND, - errorMessage: coreErrorMessage ?? error.message, - } - case SecretsManagerConflictError: - return { - statusCode: StatusCodes.CONFLICT, - errorMessage: coreErrorMessage ?? error.message, - } - case SecretsManagerError: - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorMessage: coreErrorMessage ?? error.message, - } - case TwilioCacheError: - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorMessage: coreErrorMessage ?? error.message, - } case StripeAccountError: return { statusCode: StatusCodes.BAD_GATEWAY, @@ -516,28 +490,6 @@ export const getUpdatedFormFields = ( } } -/** - * Returns a msgSrvcName that will be used as the key to store secrets - * under AWS Secrets Manager - * - * @param formId in which the secrets belong to - * @returns string representing the msgSrvcName - */ -export const generateTwilioCredSecretKeyName = (formId: string): string => - `formsg/${config.secretEnv}/api/form/${formId}/twilio/${uuidv4()}` - -/** - * Returns boolean indicating if the key to store the secret in AWS Secrets Manager - * was generated by the API - * - * @param msgSrvcName to check - * @returns boolean indicating whether it was generated by the API - */ -export const checkIsApiSecretKeyName = (msgSrvcName: string): boolean => { - const prefix = `formsg/${config.secretEnv}/api` - return msgSrvcName.startsWith(prefix) -} - /** * Validation check for invalid utf-8 encoded unicode-escaped characteres * @param value diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index d0968525e6..67461ca653 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -16,7 +16,6 @@ import { encryptString } from '../../../../shared/utils/crypto' import { IEmailFormModel, IEncryptedFormModel, - IFormDocument, IFormSchema, IMultirespondentFormModel, IPopulatedForm, @@ -449,41 +448,6 @@ export const checkIsIntranetFormAccess = ( return isIntranetUser } -export const retrievePublicFormsWithSmsVerification = ( - userId: string, -): ResultAsync => { - return ResultAsync.fromPromise( - FormModel.retrievePublicFormsWithSmsVerification(userId), - (error) => { - logger.error({ - message: 'Error retrieving public forms with sms verifications', - meta: { - action: 'retrievePublicFormsWithSmsVerification', - userId: userId, - }, - error, - }) - - return transformMongoError(error) - }, - ).andThen((forms) => { - if (!forms.length) { - // NOTE: Warn here because this is supposed to be called to generate a list of form titles - // When the admin has used up their sms verification limit. - // It is not an error because there are potential cases where the admins privatize their form after. - logger.warn({ - message: - 'Attempted to retrieve public forms with sms verifications but none was found', - meta: { - action: 'retrievePublicFormsWithSmsVerification', - userId: userId, - }, - }) - } - return okAsync(forms) - }) -} - export const createSingleSampleSubmissionAnswer = (field: FormFieldDto) => { // Prefill dropdown MyInfo field options for faking const { fieldType } = field diff --git a/src/app/modules/form/form.utils.ts b/src/app/modules/form/form.utils.ts index 1d0a2954e4..0db65dfb0d 100644 --- a/src/app/modules/form/form.utils.ts +++ b/src/app/modules/form/form.utils.ts @@ -1,19 +1,14 @@ import { FormPermission, FormResponseMode } from '../../../../shared/types' import { FormFieldSchema, - FormLinkView, FormLogicSchema, IEncryptedFormSchema, - IForm, - IFormDocument, IFormHasEmailSchema, IFormSchema, IMultirespondentFormSchema, - IOnboardedForm, IPopulatedEmailForm, IPopulatedForm, } from '../../../types' -import { smsConfig } from '../../config/features/sms.config' import { isMongooseDocumentArray } from '../../utils/mongoose' // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] @@ -180,30 +175,6 @@ export const getLogicById = ( return form_logics.find((logic) => logicId === String(logic._id)) ?? null } -/** - * Checks if a given form is onboarded (the form's message service name is defined and different from the default) - * @param form The form to check - * @returns boolean indicating if the form is/is not onboarded - */ -export const isFormOnboarded = ( - form: Pick, -): form is IOnboardedForm => { - return form.msgSrvcName - ? !(form.msgSrvcName === smsConfig.twilioMsgSrvcSid) - : false -} - -export const extractFormLinkView = ( - form: Pick, - appUrl: string, -): FormLinkView => { - const { title, _id } = form - return { - title, - link: `${appUrl}/${_id}`, - } -} - /** * Regex to to detect invalid-encoded utf-8 characters in stringified form field input * Matches any sequence which starts with a non-backslash, an odd number of backslashes, followed by unicode escape sequence diff --git a/src/app/modules/twilio/__tests__/twilio.controller.spec.ts b/src/app/modules/twilio/__tests__/twilio.controller.spec.ts deleted file mode 100644 index dd97ba058c..0000000000 --- a/src/app/modules/twilio/__tests__/twilio.controller.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable import/first */ -import expressHandler from '__tests__/unit/backend/helpers/jest-express' -import getMockLogger from '__tests__/unit/backend/helpers/jest-logger' - -import * as LoggerModule from 'src/app/config/logger' - -const MockLoggerModule = jest.mocked(LoggerModule) -const mockLogger = getMockLogger() - -jest.mock('src/app/config/logger') -MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger) - -import { twilioSmsUpdates } from 'src/app/modules/twilio/twilio.controller' -import { ITwilioSmsWebhookBody } from 'src/types' - -describe('twilio.controller', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - - const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'delivered', - MessageStatus: 'delivered', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - From: '+12345678', - ApiVersion: '2011-11-01', - } - - const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'failed', - MessageStatus: 'failed', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - From: '+12345678', - ApiVersion: '2011-11-01', - ErrorCode: 30001, - ErrorMessage: 'Twilio is down!', - } - - describe('twilioSmsUpdates', () => { - it('should return 200 when successfully delivered message is sent', async () => { - const mockReq = expressHandler.mockRequest({ - body: MOCK_SUCCESSFUL_MESSAGE, - others: { - protocol: 'https', - host: 'webhook-endpoint.gov.sg', - url: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - originalUrl: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - get: () => 'webhook-endpoint.gov.sg', - }, - }) - const mockRes = expressHandler.mockResponse() - await twilioSmsUpdates(mockReq, mockRes, jest.fn()) - - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Sms Delivery update', - meta: { - action: 'twilioSmsUpdates', - body: MOCK_SUCCESSFUL_MESSAGE, - senderIp: '200.0.0.0', - }, - }) - expect(mockLogger.error).not.toHaveBeenCalled() - expect(mockRes.sendStatus).toHaveBeenCalledWith(200) - }) - - it('should return 200 when failed delivered message is sent', async () => { - const mockReq = expressHandler.mockRequest({ - body: MOCK_FAILED_MESSAGE, - others: { - protocol: 'https', - host: 'webhook-endpoint.gov.sg', - url: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - originalUrl: `/endpoint?${encodeURI('senderIp=200.0.0.0')}`, - get: () => 'webhook-endpoint.gov.sg', - }, - }) - const mockRes = expressHandler.mockResponse() - await twilioSmsUpdates(mockReq, mockRes, jest.fn()) - - expect(mockLogger.info).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Error occurred when attempting to send SMS on twillio', - meta: { - action: 'twilioSmsUpdates', - body: MOCK_FAILED_MESSAGE, - senderIp: '200.0.0.0', - }, - }) - expect(mockRes.sendStatus).toHaveBeenCalledWith(200) - }) - }) -}) diff --git a/src/app/modules/twilio/twilio.controller.ts b/src/app/modules/twilio/twilio.controller.ts deleted file mode 100644 index de5bd23340..0000000000 --- a/src/app/modules/twilio/twilio.controller.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { celebrate, Joi, Segments } from 'celebrate' -import { StatusCodes } from 'http-status-codes' - -import { ITwilioSmsWebhookBody, TwilioSmsStatsdTags } from 'src/types/twilio' - -import { createLoggerWithLabel } from '../../config/logger' -import { ControllerHandler } from '../core/core.types' - -import { twilioStatsdClient } from './twilio.statsd-client' - -const logger = createLoggerWithLabel(module) - -/** - * Middleware which validates that a request came from Twilio Webhook - * by checking the presence of X-Twilio-Sgnature in request header and - * sms delivery status request body parameters - */ -const validateTwilioWebhook = celebrate({ - [Segments.HEADERS]: Joi.object({ - 'x-twilio-signature': Joi.string().required(), - }).unknown(), - [Segments.BODY]: Joi.object() - .keys({ - SmsSid: Joi.string().required(), - SmsStatus: Joi.string().required(), - MessageStatus: Joi.string().required(), - To: Joi.string().required(), - MessageSid: Joi.string().required(), - MessagingServiceSid: Joi.string().required(), - AccountSid: Joi.string().required(), - From: Joi.string().required(), - ApiVersion: Joi.string().required(), - ErrorCode: Joi.number(), //Unable to find any official documentation stating the ErrorCode type but should be a number - ErrorMessage: Joi.string(), - }) - .unknown(), -}) - -/** - * Logs all incoming Webhook requests from Twilio in AWS - * - * @param req Express request object - * @param res - Express response object - */ -export const twilioSmsUpdates: ControllerHandler< - unknown, - never, - ITwilioSmsWebhookBody -> = async (req, res) => { - /** - * Currently, it seems like the status are provided as string values, theres - * no other documentation stating the properties and values in the Node SDK - * - * Example: https://www.twilio.com/docs/usage/webhooks/sms-webhooks. - */ - - // Extract public sender's ip address which was passed to twilio as a query param in the status callback - let senderIp = null - try { - const url = new URL( - req.protocol + '://' + req.get('host') + req.originalUrl, - ) - senderIp = url.searchParams.get('senderIp') - } catch { - logger.error({ - message: 'Error occurred when extracting senderIp', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - originalUrl: req.originalUrl, - }, - }) - } - - const ddTags: TwilioSmsStatsdTags = { - // msgSrvcSid not included to limit tag cardinality (for now?) - smsstatus: req.body.SmsStatus, - errorcode: '0', - } - - if (req.body.ErrorCode || req.body.ErrorMessage) { - if (req.body.ErrorCode) { - ddTags.errorcode = `${req.body.ErrorCode}` - } - - logger.error({ - message: 'Error occurred when attempting to send SMS on twillio', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - senderIp, - }, - }) - } else { - logger.info({ - message: 'Sms Delivery update', - meta: { - action: 'twilioSmsUpdates', - body: req.body, - senderIp, - }, - }) - } - - twilioStatsdClient.increment('sms.update', 1, 1, ddTags) - - return res.sendStatus(StatusCodes.OK) -} - -export const handleTwilioSmsUpdates = [validateTwilioWebhook, twilioSmsUpdates] diff --git a/src/app/modules/twilio/twilio.statsd-client.ts b/src/app/modules/twilio/twilio.statsd-client.ts deleted file mode 100644 index 011112927f..0000000000 --- a/src/app/modules/twilio/twilio.statsd-client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { statsdClient } from '../../config/datadog-statsd-client' - -export const twilioStatsdClient = statsdClient.childClient({ - prefix: 'vendor.twilio.', -}) diff --git a/src/app/modules/verification/__tests__/verification.service.spec.ts b/src/app/modules/verification/__tests__/verification.service.spec.ts index 8a612e1a58..949fa86c67 100644 --- a/src/app/modules/verification/__tests__/verification.service.spec.ts +++ b/src/app/modules/verification/__tests__/verification.service.spec.ts @@ -32,13 +32,11 @@ import { MailSendError } from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' import { SmsSendError } from 'src/app/services/postman-sms/postman-sms.errors' import PostmanSmsService from 'src/app/services/postman-sms/postman-sms.service' -import { SmsFactory } from 'src/app/services/sms/sms.factory' import * as HashUtils from 'src/app/utils/hash' import { IFormSchema, IVerificationSchema, UpdateFieldData } from 'src/types' import { BasicField } from '../../../../../shared/types' import { DatabaseError } from '../../core/core.errors' -import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { FormNotFoundError } from '../../form/form.errors' import { FieldNotFoundInTransactionError, @@ -72,10 +70,10 @@ const VerificationModel = getVerificationModel(mongoose) // Set up mocks jest.mock('src/app/config/formsg-sdk') const MockFormsgSdk = jest.mocked(formsgSdk) -jest.mock('src/app/services/sms/sms.factory') -const MockSmsFactory = jest.mocked(SmsFactory) jest.mock('src/app/services/mail/mail.service') const MockMailService = jest.mocked(MailService) +jest.mock('src/app/services/postman-sms/postman-sms.service') +const MockPostmanSmsService = jest.mocked(PostmanSmsService) jest.mock('src/app/modules/form/form.service') const MockFormService = jest.mocked(FormService) jest.mock('src/app/utils/hash') @@ -276,7 +274,6 @@ describe('Verification service', () => { _id: mockFieldIdObj as unknown as string, }), ], - msgSrvcName: 'abc', } as unknown as IFormSchema let updateHashSpy: jest.SpyInstance< @@ -298,7 +295,7 @@ describe('Verification service', () => { } beforeEach(async () => { - MockSmsFactory.sendVerificationOtp.mockReturnValue(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockMailService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockFormsgSdk.verification.generateSignature.mockReturnValue( MOCK_SIGNED_DATA, @@ -321,13 +318,13 @@ describe('Verification service', () => { ) // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - mockTransaction.formId, - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: mockTransaction.formId, + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).toHaveBeenCalledWith({ @@ -339,48 +336,6 @@ describe('Verification service', () => { expect(result._unsafeUnwrap()).toEqual(mockTransactionSuccessful) }) - it('should send OTP with postman if platform has feature flag on', async () => { - jest - .spyOn(FeatureFlagService, 'getFeatureFlag') - .mockReturnValue(okAsync(true)) - - const postmanSpy = jest - .spyOn(PostmanSmsService, 'sendVerificationOtp') - .mockResolvedValueOnce(okAsync(true)) - - await VerificationService.sendNewOtp(mockSendNewFormOtpValidInput) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() - - expect(postmanSpy).toHaveBeenCalledOnce() - }) - - it('should send OTP with twilio if platform has feature flag off', async () => { - jest - .spyOn(FeatureFlagService, 'getFeatureFlag') - .mockReturnValue(okAsync(false)) - const postmanSpy = jest - .spyOn(PostmanSmsService, 'sendVerificationOtp') - .mockResolvedValueOnce(okAsync(true)) - - await VerificationService.sendNewOtp(mockSendNewFormOtpValidInput) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - mockTransaction.formId, - MOCK_SENDER_IP, - ) - - // Default mock params has fieldType: 'mobile' - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalled() - - expect(postmanSpy).not.toHaveBeenCalled() - }) - it('should return TransactionNotFoundError when transaction ID does not exist', async () => { const result = await VerificationService.sendNewOtp({ ...mockSendNewFormOtpValidInput, @@ -389,7 +344,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -416,7 +371,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -436,7 +391,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -465,7 +420,7 @@ describe('Verification service', () => { const result = await VerificationService.sendNewOtp(expiredOtpInput) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -491,7 +446,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -535,7 +490,9 @@ describe('Verification service', () => { const error = new SmsSendError() - MockSmsFactory.sendVerificationOtp.mockReturnValueOnce(errAsync(error)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValueOnce( + errAsync(error), + ) const field = generateFieldParams({ fieldType: BasicField.Mobile, _id: mockFieldIdObj as unknown as string, @@ -550,13 +507,13 @@ describe('Verification service', () => { transactionId: transaction._id, }) - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - new ObjectId(mockFormId), - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: new ObjectId(mockFormId), + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -572,13 +529,13 @@ describe('Verification service', () => { ) // Mock params default to mobile - expect(MockSmsFactory.sendVerificationOtp).toHaveBeenCalledWith( - MOCK_LOCAL_RECIPIENT, - MOCK_OTP, - MOCK_OTP_PREFIX, - new ObjectId(mockFormId), - MOCK_SENDER_IP, - ) + expect(MockPostmanSmsService.sendVerificationOtp).toHaveBeenCalledWith({ + formId: new ObjectId(mockFormId), + otp: MOCK_OTP, + recipientPhoneNumber: MOCK_LOCAL_RECIPIENT, + otpPrefix: MOCK_OTP_PREFIX, + senderIp: MOCK_SENDER_IP, + }) expect( MockFormsgSdk.verification.generateSignature, ).toHaveBeenCalledWith({ @@ -612,7 +569,7 @@ describe('Verification service', () => { } beforeEach(async () => { - MockSmsFactory.sendVerificationOtp.mockReturnValue(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockMailService.sendVerificationOtp.mockReturnValue(okAsync(true)) MockFormsgSdk.verification.generateSignature.mockReturnValue( MOCK_SIGNED_DATA, @@ -660,7 +617,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -687,7 +644,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -707,7 +664,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -736,7 +693,7 @@ describe('Verification service', () => { const result = await VerificationService.sendNewOtp(expiredOtpInput) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() @@ -762,7 +719,7 @@ describe('Verification service', () => { }) expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled() - expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled() + expect(MockPostmanSmsService.sendVerificationOtp).not.toHaveBeenCalled() expect( MockFormsgSdk.verification.generateSignature, ).not.toHaveBeenCalled() diff --git a/src/app/modules/verification/verification.service.ts b/src/app/modules/verification/verification.service.ts index e0cc038fbf..078deebfda 100644 --- a/src/app/modules/verification/verification.service.ts +++ b/src/app/modules/verification/verification.service.ts @@ -1,10 +1,7 @@ import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' -import { - featureFlags, - PAYMENT_CONTACT_FIELD_ID, -} from '../../../../shared/constants' +import { PAYMENT_CONTACT_FIELD_ID } from '../../../../shared/constants' import { BasicField } from '../../../../shared/types' import { startsWithSgPrefix } from '../../../../shared/utils/phone-num-validation' import { NUM_OTP_RETRIES } from '../../../../shared/utils/verification' @@ -22,7 +19,6 @@ import { SmsSendError, } from '../../services/postman-sms/postman-sms.errors' import PostmanSmsService from '../../services/postman-sms/postman-sms.service' -import { SmsFactory } from '../../services/sms/sms.factory' import { transformMongoError } from '../../utils/handle-mongo-error' import { compareHash, HashingError } from '../../utils/hash' import { @@ -30,7 +26,6 @@ import { MalformedParametersError, PossibleDatabaseError, } from '../core/core.errors' -import * as FeatureFlagService from '../feature-flags/feature-flags.service' import { FormNotFoundError } from '../form/form.errors' import * as FormService from '../form/form.service' @@ -455,10 +450,6 @@ const sendOtpForField = ( | OtpRequestError > => { const { fieldType, _id: fieldId } = field - const logMeta = { - action: 'sendOtpForField', - formId, - } switch (fieldType) { case BasicField.Mobile: return fieldId @@ -468,24 +459,6 @@ const sendOtpForField = ( shouldGenerateMobileOtp(form, fieldId, recipient), ) .andThen(() => { - return FeatureFlagService.getFeatureFlag( - featureFlags.postmanSms, - { - fallbackValue: false, - logMeta, - }, - ) - }) - .andThen((shouldUsePostmanSms) => { - if (!shouldUsePostmanSms) { - return SmsFactory.sendVerificationOtp( - recipient, - otp, - otpPrefix, - formId, - senderIp, - ) - } return PostmanSmsService.sendVerificationOtp({ recipientPhoneNumber: recipient, otp, diff --git a/src/app/modules/verification/verification.util.ts b/src/app/modules/verification/verification.util.ts index 7b1958ea7f..f13b4311a5 100644 --- a/src/app/modules/verification/verification.util.ts +++ b/src/app/modules/verification/verification.util.ts @@ -14,7 +14,6 @@ import { IVerificationSchema, MapRouteError, } from '../../../types' -import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' import { OtpRequestCountExceededError, @@ -273,10 +272,6 @@ export const mapRouteError: MapRouteError = ( } } -export const hasAdminExceededFreeSmsLimit = (smsCount: number): boolean => { - return smsCount > smsConfig.smsVerificationLimit -} - /** * Extracts an individual field's data from a transaction document. * @param transaction Transaction document diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts deleted file mode 100644 index 28ad725564..0000000000 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.twilio.routes.spec.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { - createAuthedSession, - logoutSession, -} from '__tests__/integration/helpers/express-auth' -import { setupApp } from '__tests__/integration/helpers/express-setup' -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import { ObjectId } from 'bson' -import mongoose from 'mongoose' -import { errAsync } from 'neverthrow' -import supertest, { Session } from 'supertest-session' - -import config from 'src/app/config/config' -import getFormModel from 'src/app/models/form.server.model' -import getUserModel from 'src/app/models/user.server.model' -import { SecretsManagerError } from 'src/app/modules/core/core.errors' -import { IPopulatedForm } from 'src/types' - -import * as AdminFormService from '../../../../../../../app/modules/form/admin-form/admin-form.service' -import { secretsManager } from '../../../../../../../app/modules/form/admin-form/admin-form.service' -import * as SmsService from '../../../../../../services/sms/sms.service' -import { AdminFormsRouter } from '../admin-forms.routes' - -import { generateTwilioCredSecretKeyName } from './../../../../../../modules/form/admin-form/admin-form.utils' -import { TwilioCredentials } from './../../../../../../services/sms/sms.types' - -// Prevent rate limiting. -jest.mock('src/app/utils/limit-rate') - -// Avoid async refresh calls -jest.mock('src/app/modules/spcp/spcp.oidc.client.ts') - -const MockAdminFormService = jest.mocked(AdminFormService) - -const app = setupApp('/admin/forms', AdminFormsRouter, { - setupWithAuth: true, -}) - -const UserModel = getUserModel(mongoose) -const FormModel = getFormModel(mongoose) - -describe('admin-form.twilio.routes', () => { - let request: Session - - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => { - request = supertest(app) - }) - afterEach(async () => { - await dbHandler.clearDatabase() - jest.clearAllMocks() - }) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('PUT /admin/forms/:formId/twilio', () => { - const MOCK_FORM_ID = new ObjectId().toHexString() - - const MOCK_ACCOUNT_SID = 'AC12345678' - const MOCK_API_KEY_SID = 'SK12345678' - const MOCK_API_KEY_SECRET = 'AZ12345678' - const MOCK_MESSAGING_SERVICE_SID = 'MG12345678' - - const TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const MOCK_INVALID_ACCOUNT_SID = 'ZZ12345678' // Invalid AC prefix - - const INVALID_TWILIO_CREDENTIALS: TwilioCredentials = { - accountSid: MOCK_INVALID_ACCOUNT_SID, - apiKey: MOCK_API_KEY_SID, - apiSecret: MOCK_API_KEY_SECRET, - messagingServiceSid: MOCK_MESSAGING_SERVICE_SID, - } - - const MOCK_SUCCESSFUL_UPDATE = { - message: 'Successfully updated Twilio credentials', - } - - const MOCK_FORM = { - _id: MOCK_FORM_ID, - save: () => MOCK_FORM, - } as unknown as IPopulatedForm - - it('should return 200 on successful twilio credentials addition', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - const msgSrvcName = `formsg/${config.secretEnv}/form/${formToUpdate._id}/twilio` - - const createSecretsSpy = jest - .spyOn(secretsManager, 'createSecret') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const formSpy = jest - .spyOn(FormModel.prototype, 'save') - .mockImplementationOnce(() => null) - - // Actual - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - // Assert - expect(createSecretsSpy).toHaveBeenCalled() - expect(formSpy).toHaveBeenCalled() - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_UPDATE) - }) - - it('should return 200 on successful twilio credentials update', async () => { - const { form: formToUpdate, user } = - await dbHandler.insertFormWithMsgSrvcName() - const session = await createAuthedSession(user.email, request) - const msgSrvcName = formToUpdate.msgSrvcName - - const getSecretsSpy = jest - .spyOn(secretsManager, 'getSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const twilioCacheSpy = jest - .spyOn(SmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const putSecretsSpy = jest - .spyOn(secretsManager, 'putSecretValue') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_UPDATE) - - expect(getSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(putSecretsSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - SecretString: JSON.stringify(TWILIO_CREDENTIALS), - }) - }) - - it('should return 400 when twilio credentials are invalid', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Actual - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(INVALID_TWILIO_CREDENTIALS) - - // Assert - expect(response.status).toEqual(400) - }) - - it('should return 401 when user is not logged in', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - await logoutSession(request) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(401) - expect(response.body).toEqual({ message: 'User is unauthorized.' }) - }) - - it('should return 403 when user does not have permissions to update form', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - - // Create separate user - const collabUser = ( - await dbHandler.insertFormCollectionReqs({ - userId: new ObjectId(), - mailName: 'collab-user', - shortName: 'collabUser', - }) - ).user - - const randomForm = await FormModel.create({ - title: 'form that user has no write access to', - admin: collabUser._id, - publicKey: 'some random key', - // Current user only has read access. - permissionList: [{ email: user.email }], - _id: MOCK_FORM_ID, - }) - - const response = await session - .put(`/admin/forms/${randomForm._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(403) - expect(response.body).toEqual({ - message: `User ${user.email} not authorized to perform write operation on Form ${randomForm._id} with title: ${randomForm.title}.`, - }) - }) - - it('should return 404 when form to update cannot be found', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - const invalidFormId = MOCK_FORM_ID - - const response = await session - .put(`/admin/forms/${invalidFormId}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(404) - expect(response.body).toEqual({ message: 'Form not found' }) - }) - - it('should return 422 when id of user adding twilio credentials is not found', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Delete user after login. - await dbHandler.clearCollection(UserModel.collection.name) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(422) - expect(response.body).toEqual({ message: 'User not found' }) - }) - - it('should return 500 when SecretsManagerError occurs whilst updating credentials', async () => { - const { form: formToUpdate, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - jest - .spyOn(MockAdminFormService, 'createTwilioCredentials') - .mockReturnValueOnce(errAsync(new SecretsManagerError())) - - const response = await session - .put(`/admin/forms/${formToUpdate._id}/twilio`) - .send(TWILIO_CREDENTIALS) - - expect(response.status).toEqual(500) - expect(response.body).toEqual({ - message: 'Something went wrong. Please try again.', - }) - }) - }) - - describe('DELETE /admin/forms/:formId/twilio', () => { - const MOCK_FORM_ID = new ObjectId() - const MOCK_MSG_SRVC_NAME = generateTwilioCredSecretKeyName( - MOCK_FORM_ID.toHexString(), - ) - - const MOCK_SUCCESSFUL_DELETE_RESPONSE = { - message: 'Successfully deleted Twilio credentials', - } - - it('should return 200 on successful twilio credentials deletion', async () => { - const { form, user } = await dbHandler.insertFormWithMsgSrvcName({ - formId: MOCK_FORM_ID, - msgSrvcName: MOCK_MSG_SRVC_NAME, - }) - const session = await createAuthedSession(user.email, request) - const msgSrvcName = form.msgSrvcName - - const formSpy = jest - .spyOn(FormModel.prototype, 'save') - .mockImplementationOnce(() => null) - - const twilioCacheSpy = jest - .spyOn(SmsService.twilioClientCache, 'del') - .mockReturnValueOnce(1) - - const deleteSecretSpy = jest - .spyOn(secretsManager, 'deleteSecret') - .mockImplementationOnce(() => { - return { - promise: () => { - return Promise.resolve({ - Name: msgSrvcName, - }) - }, - } as any - }) - - // Actual - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - // Assert - expect(twilioCacheSpy).toHaveBeenCalledWith(msgSrvcName) - expect(formSpy).toHaveBeenCalled() - expect(deleteSecretSpy).toHaveBeenCalledWith({ - SecretId: msgSrvcName, - }) - expect(response.status).toEqual(200) - expect(response.body).toEqual(MOCK_SUCCESSFUL_DELETE_RESPONSE) - }) - - it('should return 401 when user is not logged in', async () => { - const { form, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - await logoutSession(request) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(401) - expect(response.body).toEqual({ message: 'User is unauthorized.' }) - }) - - it('should return 403 when user does not have permissions to delete credentials', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - - // Create separate user - const collabUser = ( - await dbHandler.insertFormCollectionReqs({ - userId: new ObjectId(), - mailName: 'collab-user', - shortName: 'collabUser', - }) - ).user - - const randomForm = await FormModel.create({ - title: 'form that user has no write access to', - admin: collabUser._id, - publicKey: 'some random key', - // Current user only has read access. - permissionList: [{ email: user.email }], - _id: MOCK_FORM_ID, - }) - - const response = await session.delete( - `/admin/forms/${randomForm._id}/twilio`, - ) - - expect(response.status).toEqual(403) - expect(response.body).toEqual({ - message: `User ${user.email} not authorized to perform delete operation on Form ${randomForm._id} with title: ${randomForm.title}.`, - }) - }) - - it('should return 404 when form whose Twilio credentials should be deleted cannot be found', async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - const session = await createAuthedSession(user.email, request) - const invalidFormId = MOCK_FORM_ID - - const response = await session.delete( - `/admin/forms/${invalidFormId}/twilio`, - ) - - expect(response.status).toEqual(404) - expect(response.body).toEqual({ message: 'Form not found' }) - }) - - it('should return 422 when id of user adding twilio credentials is not found', async () => { - const { form, user } = await dbHandler.insertEmailForm() - const session = await createAuthedSession(user.email, request) - - // Delete user after login. - await dbHandler.clearCollection(UserModel.collection.name) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(422) - expect(response.body).toEqual({ message: 'User not found' }) - }) - - it('should return 500 when SecretsManagerError occurs whilst updating credentials', async () => { - const { form, user } = await dbHandler.insertFormWithMsgSrvcName() - const session = await createAuthedSession(user.email, request) - - jest - .spyOn(MockAdminFormService, 'deleteTwilioCredentials') - .mockReturnValueOnce(errAsync(new SecretsManagerError())) - - const response = await session.delete(`/admin/forms/${form._id}/twilio`) - - expect(response.status).toEqual(500) - expect(response.body).toEqual({ - message: 'Something went wrong. Please try again.', - }) - }) - }) -}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index a300e0591a..aca2c4ce0d 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -249,20 +249,6 @@ AdminFormsFormRouter.put( AdminFormController.handleUpdateStartPage, ) -/** - * Retrieves the free sms counts used by a form's administrator and the sms verification quota - * @security session - * - * @returns 200 with the free sms counts and the quota - * @returns 401 when user does not exist in session - * @returns 404 when the formId is not found in the database - * @returns 500 when a database error occurs during retrieval - */ -AdminFormsFormRouter.get( - '/:formId([a-fA-F0-9]{24})/verified-sms/count/free', - AdminFormController.handleGetFreeSmsCountForFormAdmin, -) - AdminFormsFormRouter.route('/feedback') /** * Submit an admin form creating feedback diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index d41fba4a81..5be6a6cfc2 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -16,7 +16,6 @@ import { AdminFormsPresignRouter } from './admin-forms.presign.routes' import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' -import { AdminFormsTwilioRouter } from './admin-forms.twilio.routes' export const AdminFormsRouter = Router() @@ -33,7 +32,6 @@ AdminFormsRouter.use(AdminFormsSubmissionsRouter) AdminFormsRouter.use(AdminFormsPreviewRouter) AdminFormsRouter.use(AdminFormsPresignRouter) AdminFormsRouter.use(AdminFormsLogicRouter) -AdminFormsRouter.use(AdminFormsTwilioRouter) AdminFormsRouter.use(AdminFormsPaymentsRouter) AdminFormsRouter.use(AdminFormsGoGovRouter) AdminFormsRouter.use(AdminFormsIssueRouter) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts deleted file mode 100644 index 6eb9640189..0000000000 --- a/src/app/routes/api/v3/admin/forms/admin-forms.twilio.routes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Router } from 'express' - -import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' - -export const AdminFormsTwilioRouter = Router() - -AdminFormsTwilioRouter.route('/:formId([a-fA-F0-9]{24})/twilio') - /** - * Update the specified form twilio credentials - * @route PUT /:formId/twilio - * @security session - * - * @returns 200 with twilio credentials succesfully updated - * @returns 400 with twilio credentials are invalid - * @returns 401 when user is not logged in - * @returns 403 when user does not have permissions to update the form - * @returns 404 when form to update cannot be found - * @returns 422 when id of user who is updating the form cannot be found - * @returns 500 when database error occurs - */ - .put(AdminFormController.handleUpdateTwilio) - /** - * @returns 200 when twilio credentials successfully deleted - * @returns 401 when user does not exist in session - * @returns 403 when user does not have permissions to delete credentials - * @returns 404 when form to delete credentials cannot be found - * @returns 422 when user in session cannot be retrieved from the database - * @returns 500 when database error occurs - */ - .delete(AdminFormController.handleDeleteTwilio) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts index a0baa88ff0..193f84a35a 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.verification.routes.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { setupApp } from '__tests__/integration/helpers/express-setup' -import MockTwilio from '__tests__/integration/helpers/twilio' import { generateDefaultField } from '__tests__/unit/backend/helpers/generate-form-data' import dbHandler from '__tests__/unit/backend/helpers/jest-db' import bcrypt from 'bcrypt' @@ -9,12 +8,11 @@ import { subMinutes, subYears } from 'date-fns' import { StatusCodes } from 'http-status-codes' import _ from 'lodash' import mongoose from 'mongoose' -import { okAsync } from 'neverthrow' +import { errAsync, okAsync } from 'neverthrow' import nodemailer from 'nodemailer' import Mail from 'nodemailer/lib/mailer' import session, { Session } from 'supertest-session' -import getFormModel from 'src/app/models/form.server.model' import { generateFieldParams, MOCK_HASHED_OTP, @@ -23,7 +21,7 @@ import { import getVerificationModel from 'src/app/modules/verification/verification.model' import MailService from 'src/app/services/mail/mail.service' import { SmsSendError } from 'src/app/services/postman-sms/postman-sms.errors' -import * as SmsService from 'src/app/services/sms/sms.service' +import PostmanSmsService from 'src/app/services/postman-sms/postman-sms.service' import * as OtpUtils from 'src/app/utils/otp' import { IVerificationSchema } from 'src/types' @@ -35,8 +33,6 @@ import { import { MOCK_OTP } from '../../../../../modules/verification/__tests__/verification.test.helpers' import { PublicFormsVerificationRouter } from '../public-forms.verification.routes' -const Form = getFormModel(mongoose) - const verificationApp = setupApp('/forms', PublicFormsVerificationRouter) const VerificationModel = getVerificationModel(mongoose) @@ -45,6 +41,8 @@ jest.mock('src/app/utils/limit-rate') // Avoid async refresh calls jest.mock('src/app/modules/spcp/spcp.oidc.client.ts') +jest.mock('src/app/services/postman-sms/postman-sms.service') +const MockPostmanSmsService = jest.mocked(PostmanSmsService) jest.mock('nodemailer', () => ({ createTransport: jest.fn().mockReturnValue({ @@ -258,10 +256,7 @@ describe('public-forms.verification.routes', () => { describe('POST /forms/:formId/fieldverifications/:transactionId/fields/:fieldId/otp/generate', () => { beforeEach(() => { - // @ts-ignore - MockTwilio.messages.create.mockResolvedValue({ - sid: 'mockSid', - }) + MockPostmanSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) }) it('should return 201 when parameters for email field are valid', async () => { @@ -315,50 +310,6 @@ describe('public-forms.verification.routes', () => { expect(response.body).toEqual(expectedResponse) }) - it('should return 400 when fieldType is mobile but the provided phone number is not valid', async () => { - // Arrange - const expectedResponse = { - message: - 'This phone number does not seem to be valid. Please try again with a valid phone number.', - } - - // Act - const response = await request - .post( - `/forms/${mockVerifiableFormId}/fieldverifications/${mockTransactionId}/fields/${mockMobileFieldId}/otp/generate`, - ) - .send({ - // 7 digits after +65 instead of 8 - answer: '+651234567', - }) - - // Assert - expect(response.status).toBe(StatusCodes.BAD_REQUEST) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 400 when otp data could not be retrieved from the form due to parameters being malformed', async () => { - // Arrange - // NOTE: This error is only thrown on interaction with the db, hence the db is mocked here - jest.spyOn(Form, 'getOtpData').mockResolvedValueOnce(null) - const expectedResponse = { - message: 'Sorry, something went wrong. Please refresh and try again.', - } - - // Act - const response = await request - .post( - `/forms/${mockVerifiableFormId}/fieldverifications/${mockTransactionId}/fields/${mockMobileFieldId}/otp/generate`, - ) - .send({ - answer: '+6512345678', - }) - - // Assert - expect(response.status).toBe(StatusCodes.BAD_REQUEST) - expect(response.body).toEqual(expectedResponse) - }) - it('should return 400 when the transaction has expired', async () => { // Arrange const { _id: expiredTransactionId } = await VerificationModel.create({ @@ -386,7 +337,10 @@ describe('public-forms.verification.routes', () => { it('should return 400 when the otp could not be sent and fieldType is mobile', async () => { // Arrange - MockTwilio.messages.create.mockRejectedValueOnce(new SmsSendError()) + + MockPostmanSmsService.sendVerificationOtp.mockReturnValue( + errAsync(new SmsSendError()), + ) const expectedResponse = { message: 'Sorry, something went wrong. Please refresh and try again.', } @@ -839,9 +793,7 @@ describe('public-forms.verification.routes', () => { const requestForSmsOtp = async (fieldId: string, answer: string) => { // Set that so no real mail is sent. - jest - .spyOn(SmsService, 'sendVerificationOtp') - .mockReturnValueOnce(okAsync(true)) + MockPostmanSmsService.sendVerificationOtp.mockReturnValueOnce(okAsync(true)) const response = await request .post( diff --git a/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts b/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts deleted file mode 100644 index aafb6c53c3..0000000000 --- a/src/app/routes/api/v3/notifications/__tests__/notifications.routes.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { setupApp } from '__tests__/integration/helpers/express-setup' -import supertest, { Session } from 'supertest-session' - -import { ITwilioSmsWebhookBody } from 'src/types' - -import { NotificationsRouter } from './../notifications.routes' - -// Prevent rate limiting. -jest.mock('src/app/utils/limit-rate') - -const app = setupApp('/notifications', NotificationsRouter, { - setupWithAuth: true, -}) - -describe('notifications.routes', () => { - let request: Session - beforeEach(async () => { - request = supertest(app) - }) - afterEach(async () => { - jest.clearAllMocks() - }) - - const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'delivered', - MessageStatus: 'delivered', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - MessagingServiceSid: 'MG123456', - From: '+12345678', - ApiVersion: '2011-11-01', - } - - const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = { - SmsSid: '12345', - SmsStatus: 'failed', - MessageStatus: 'failed', - To: '+12345678', - MessageSid: 'SM212312', - AccountSid: 'AC123456', - MessagingServiceSid: 'MG123456', - From: '+12345678', - ApiVersion: '2011-11-01', - ErrorCode: 30001, - ErrorMessage: 'Twilio is down!', - } - - const TWILIO_SIGNATURE_HEADER_KEY = 'x-twilio-signature' - const MOCK_TWILIO_SIGNATURE = 'mockSignature' - - describe('POST notifications/twilio', () => { - it('should return 200 on sending successful delivery status message', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_SUCCESSFUL_MESSAGE) - .set(TWILIO_SIGNATURE_HEADER_KEY, MOCK_TWILIO_SIGNATURE) - - expect(response.status).toEqual(200) - expect(response.body).toBeEmpty() - }) - - it('should return 200 on sending failed delivery status message', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_FAILED_MESSAGE) - .set(TWILIO_SIGNATURE_HEADER_KEY, MOCK_TWILIO_SIGNATURE) - - expect(response.status).toEqual(200) - expect(response.body).toBeEmpty() - }) - - it('should return 400 on sending successful delivery status message without wilio signature', async () => { - const response = await request - .post('/notifications/twilio') - .send(MOCK_SUCCESSFUL_MESSAGE) - - expect(response.status).toEqual(400) - }) - }) -}) diff --git a/src/app/routes/api/v3/notifications/notifications.routes.ts b/src/app/routes/api/v3/notifications/notifications.routes.ts index 1091cfddce..d5415a16f2 100644 --- a/src/app/routes/api/v3/notifications/notifications.routes.ts +++ b/src/app/routes/api/v3/notifications/notifications.routes.ts @@ -1,7 +1,6 @@ import { Router } from 'express' import { handleStripeEventUpdates } from '../../../../modules/payments/stripe.events.controller' -import { handleTwilioSmsUpdates } from '../../../../modules/twilio/twilio.controller' import { BouncesRouter } from './bounces' @@ -9,19 +8,6 @@ export const NotificationsRouter = Router() NotificationsRouter.use('/bounces', BouncesRouter) -/** - * Receives and logs all SMS delivery status updates from Twilio webhook - * - * Path here is required to be synced with statusCallbackRoute under - * sms.service#sendSms - * - * @route POST /api/v3/notifications/twilio - * - * @returns 200 when message succesfully received and logged - * @returns 400 when request is not coming from Twilio or request body s invalid - */ -NotificationsRouter.post('/twilio', handleTwilioSmsUpdates) - /** * Receives and logs all payment updates from Stripe webhook * diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index f0bbbbb9b2..49c8e8d10b 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1,15 +1,10 @@ -import ejs from 'ejs' import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' import { FormResponseMode, PaymentChannel } from 'shared/types' -import { extractFormLinkView } from 'src/app/modules/form/form.utils' -import { - MailGenerationError, - MailSendError, -} from 'src/app/services/mail/mail.errors' +import { MailSendError } from 'src/app/services/mail/mail.errors' import { MailService } from 'src/app/services/mail/mail.service' import { AutoreplySummaryRenderData, @@ -25,13 +20,7 @@ import { ISubmissionSchema, } from 'src/types' -import { - HASH_EXPIRE_AFTER_SECONDS, - stringifiedSmsWarningTiers, -} from '../../../../../shared/utils/verification' -import { smsConfig } from '../../../config/features/sms.config' -import * as FormService from '../../../modules/form/form.service' -import { formatAsPercentage } from '../../../utils/formatters' +import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../../shared/utils/verification' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -1348,306 +1337,6 @@ describe('mail.service', () => { }) }) - describe('sendSmsVerificationDisabledEmail', () => { - const MOCK_FORM_ID = 'mockFormId' - const MOCK_FORM_TITLE = 'You are all individuals!' - const MOCK_INVALID_EMAIL = 'something wrong@a' - - const MOCK_FORM: IPopulatedForm = { - permissionList: [ - { email: MOCK_VALID_EMAIL_2 }, - { email: MOCK_VALID_EMAIL_3 }, - ], - admin: { - email: MOCK_VALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { - permissionList: [], - admin: { - email: MOCK_INVALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const generateAdminExpectedMailOptions = async (admin: string) => { - const result = - await MailUtils.generateSmsVerificationDisabledHtmlForAdmin({ - forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - }).map((emailHtml) => { - return { - to: admin, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - const generateCollabExpectedMailOptions = async ( - admin: string, - collab: string[], - ) => { - const result = - await MailUtils.generateSmsVerificationDisabledHtmlForCollab({ - form: extractFormLinkView(MOCK_FORM, MOCK_APP_URL), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - }).map((emailHtml) => { - return { - to: admin, - cc: collab, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - it('should send verified sms disabled emails successfully', async () => { - // Arrange - // sendMail should return mocked success response - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - const expectedAdminMailOptions = - await generateAdminExpectedMailOptions(MOCK_VALID_EMAIL) - const expectedCollabMailOptions = await generateCollabExpectedMailOptions( - MOCK_VALID_EMAIL, - [MOCK_VALID_EMAIL_2, MOCK_VALID_EMAIL_3], - ) - - // Act - const actualResult = - await mailService.sendSmsVerificationDisabledEmail(MOCK_FORM) - - // Assert - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedAdminMailOptions) - expect(sendMailSpy).toHaveBeenCalledWith(expectedCollabMailOptions) - }) - - it('should return MailSendError when the provided email is invalid', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - - // Act - const actualResult = await mailService.sendSmsVerificationDisabledEmail( - MOCK_INVALID_EMAIL_FORM, - ) - - // Assert - expect(actualResult).toEqual( - err(new MailSendError('Invalid email error')), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - - it('should return MailGenerationError when the html template could not be created', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') - - // Act - const actualResult = await mailService.sendSmsVerificationDisabledEmail( - MOCK_INVALID_EMAIL_FORM, - ) - - // Assert - expect(actualResult).toEqual( - err( - new MailGenerationError( - 'Error occurred whilst rendering mail template', - ), - ), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - }) - - describe('sendSmsVerificationWarningEmail', () => { - const MOCK_FORM_ID = 'mockFormId' - const MOCK_FORM_TITLE = 'You are all individuals!' - const MOCK_INVALID_EMAIL = 'something wrong@a' - - const MOCK_FORM: IPopulatedForm = { - permissionList: [ - { email: MOCK_VALID_EMAIL_2 }, - { email: MOCK_VALID_EMAIL_3 }, - ], - admin: { - email: MOCK_VALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { - permissionList: [], - admin: { - email: MOCK_INVALID_EMAIL, - }, - title: MOCK_FORM_TITLE, - _id: MOCK_FORM_ID, - } as unknown as IPopulatedForm - - const generateExpectedAdminMailOptions = async ( - count: number, - admin: string, - ) => { - const result = await MailUtils.generateSmsVerificationWarningHtmlForAdmin( - { - forms: [extractFormLinkView(MOCK_FORM, MOCK_APP_URL)], - numAvailable: (smsConfig.smsVerificationLimit - count).toLocaleString( - 'en-US', - ), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - }, - ).map((emailHtml) => { - return { - to: admin, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - const generateExpectedCollabMailOptions = async ( - count: number, - admin: string, - collab: string[], - ) => { - const result = - await MailUtils.generateSmsVerificationWarningHtmlForCollab({ - form: extractFormLinkView(MOCK_FORM, MOCK_APP_URL), - percentageUsed: formatAsPercentage( - count / smsConfig.smsVerificationLimit, - ), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - }).map((emailHtml) => { - return { - to: admin, - cc: collab, - from: MOCK_SENDER_STRING, - html: emailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: MOCK_SENDER_EMAIL, - bcc: MOCK_SENDER_EMAIL, - } - }) - return result._unsafeUnwrap() - } - - it('should send verified sms warning emails successfully', async () => { - // Arrange - const MOCK_COUNT = 1000 - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - // sendMail should return mocked success response - sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') - const MOCK_FORM_COLLABS = MOCK_FORM.permissionList.map( - ({ email }) => email, - ) - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_FORM, - MOCK_COUNT, - ) - const expectedAdminMailOptions = await generateExpectedAdminMailOptions( - MOCK_COUNT, - MOCK_VALID_EMAIL, - ) - const expectedCollabMailOptions = await generateExpectedCollabMailOptions( - MOCK_COUNT, - MOCK_VALID_EMAIL, - MOCK_FORM_COLLABS, - ) - - // Assert - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(2) - expect(sendMailSpy).toHaveBeenCalledWith(expectedAdminMailOptions) - expect(sendMailSpy).toHaveBeenCalledWith(expectedCollabMailOptions) - }) - - it('should return MailSendError when the provided email is invalid', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_INVALID_EMAIL_FORM])) - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_INVALID_EMAIL_FORM, - 1000, - ) - - // Assert - expect(actualResult).toEqual( - err(new MailSendError('Invalid email error')), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - - it('should return MailGenerationError when the html template could not be created', async () => { - // Arrange - jest - .spyOn(FormService, 'retrievePublicFormsWithSmsVerification') - .mockReturnValueOnce(okAsync([MOCK_FORM])) - jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') - - // Act - const actualResult = await mailService.sendSmsVerificationWarningEmail( - MOCK_INVALID_EMAIL_FORM, - 1000, - ) - - // Assert - expect(actualResult).toEqual( - err( - new MailGenerationError( - 'Error occurred whilst rendering mail template', - ), - ), - ) - // Check arguments passed to sendNodeMail - expect(sendMailSpy).toHaveBeenCalledTimes(0) - }) - }) - describe('sendPaymentConfirmationEmail', () => { const MOCK_INVALID_EMAIL = 'hello@world' const MOCK_FORM_TITLE = 'Formally Information' diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index b193ddf5ec..60a8ec3e73 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -2,14 +2,7 @@ import { render } from '@react-email/render' import tracer from 'dd-trace' import { get, inRange, isEmpty } from 'lodash' import moment from 'moment-timezone' -import { - err, - errAsync, - fromPromise, - okAsync, - Result, - ResultAsync, -} from 'neverthrow' +import { err, errAsync, fromPromise, Result, ResultAsync } from 'neverthrow' import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' @@ -17,29 +10,18 @@ import validator from 'validator' import { FormResponseMode, PaymentChannel } from '../../../../shared/types' import { centsToDollars } from '../../../../shared/utils/payments' import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' -import { - HASH_EXPIRE_AFTER_SECONDS, - stringifiedSmsWarningTiers, -} from '../../../../shared/utils/verification' +import { HASH_EXPIRE_AFTER_SECONDS } from '../../../../shared/utils/verification' import { BounceType, EmailAdminDataField, - IFormDocument, IFormHasEmailSchema, IPopulatedEncryptedForm, IPopulatedForm, - IPopulatedUser, ISubmissionSchema, } from '../../../types' import config from '../../config/config' -import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' -import * as FormService from '../../modules/form/form.service' -import { - extractFormLinkView, - getAdminEmails, -} from '../../modules/form/form.utils' -import { formatAsPercentage } from '../../utils/formatters' +import { getAdminEmails } from '../../modules/form/form.utils' import { BounceNotification } from '../../views/templates/BounceNotification' import MrfWorkflowCompletionEmail, { QuestionAnswer, @@ -52,12 +34,8 @@ import MrfWorkflowEmail, { import { EMAIL_HEADERS, EmailType } from './mail.constants' import { MailGenerationError, MailSendError } from './mail.errors' import { - AdminSmsDisabledData, - AdminSmsWarningData, AutoreplySummaryRenderData, BounceNotificationHtmlData, - CollabSmsDisabledData, - CollabSmsWarningData, IssueReportedNotificationData, MailOptions, MailServiceParams, @@ -74,10 +52,6 @@ import { generateLoginOtpHtml, generatePaymentConfirmationHtml, generatePaymentOnboardingHtml, - generateSmsVerificationDisabledHtmlForAdmin, - generateSmsVerificationDisabledHtmlForCollab, - generateSmsVerificationWarningHtmlForAdmin, - generateSmsVerificationWarningHtmlForCollab, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -680,175 +654,6 @@ export class MailService { ) } - /** - * Sends a email to the admin and collaborators of the form when the verified sms feature will be disabled. - * This happens only when the admin has hit a certain limit of sms verifications on his account. - * - * Note that the email sent to the admin and collaborators will differ. - * This is because the admin will see all of their forms that are affected but collaborators - * only see forms which they are a part of. - * - * @param form The form whose admin and collaborators will be issued the email - * @returns ok(true) when mail sending is successful - * @returns err(MailGenerationError) when there was an error in generating the html data for the mail - * @returns err(MailSendError) when there was an error in sending the mail - */ - sendSmsVerificationDisabledEmail = ( - form: Pick, - ): ResultAsync => { - // Step 1: Retrieve all public forms of admin that have sms verification enabled - return FormService.retrievePublicFormsWithSmsVerification(form.admin._id) - .andThen((forms) => { - // Step 2: Send the mail containing all the active forms to the admin - return this.sendDisabledMailForAdmin(forms, form.admin).map(() => forms) - }) - .andThen((forms) => { - // Step 3: Send to each individual form - return ResultAsync.combine( - forms.map((f) => - // If there are no collaborators, do not send out the email. - // Admin would already have received a summary email from Step 2. - f.permissionList.length - ? this.sendDisabledMailForCollab(f, form.admin) - : okAsync(true), - ), - ) - }) - .map(() => true) - } - - // Helper method to send an email to all the collaborators of a given form that would be affected by - // Sms verifications being disabled for the form. - // Note that this method also emails the admin to notify them that the collaborators have been informed. - sendDisabledMailForCollab = ( - form: IFormDocument, - admin: IPopulatedUser, - ): ResultAsync => { - const formLink = extractFormLinkView(form, this.#appUrl) - const htmlData: CollabSmsDisabledData = { - form: formLink, - smsVerificationLimit: - // Formatted using localeString so that the displayed number has commas - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - } - const collaborators = form.permissionList.map(({ email }) => email) - const logMeta = { - form: formLink, - admin, - collaborators, - action: 'sendDisabledMailForCollab', - } - - return generateSmsVerificationDisabledHtmlForCollab(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - cc: collaborators, - from: this.#senderFromString, - html: mailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to email collaborators about form disabling', - meta: logMeta, - }) - - return this.#sendNodeMail(mailOptions, { - formId: form._id, - mailId: 'sendDisabledMailForCollab', - }) - }, - ) - } - - // Helper method to send an email to a form admin which contains a summary of - // which forms would be impacted by sms verifications being removed. - sendDisabledMailForAdmin = ( - forms: IPopulatedForm[], - admin: IPopulatedUser, - ): ResultAsync => { - const formLinks = forms.map((f) => extractFormLinkView(f, this.#appUrl)) - const logMeta = { - forms: formLinks, - admin, - action: 'sendDisabledMailForAdmin', - } - - const htmlData: AdminSmsDisabledData = { - forms: formLinks, - smsVerificationLimit: - // Formatted using localeString so that the displayed number has commas - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - smsWarningTiers: stringifiedSmsWarningTiers, - } - - return ( - // Step 1: Generate HTML data for admin - generateSmsVerificationDisabledHtmlForAdmin(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - from: this.#senderFromString, - html: mailHtml, - subject: 'Free Mobile Number Verification Disabled', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to email admin about form disabling', - meta: logMeta, - }) - - // Step 2: Send mail out to admin ONLY - return this.#sendNodeMail(mailOptions, { - mailId: 'sendDisabledMailForAdmin', - }) - }, - ) - ) - } - - /** - * Sends a warning email to the admin of the form when their current verified sms counts hits a limit - * @param form The form whose admin will be issued a warning - * @param smsVerifications The current total sms verifications for the form - * @returns ok(true) when mail sending is successful - * @returns err(MailGenerationError) when there was an error in generating the html data for the mail - * @returns err(MailSendError) when there was an error in sending the mail - */ - sendSmsVerificationWarningEmail = ( - form: Pick, - smsVerifications: number, - ): ResultAsync => { - // Step 1: Retrieve all public forms of admin that have sms verification enabled - return FormService.retrievePublicFormsWithSmsVerification(form.admin._id) - .andThen((forms) => { - // Step 2: Send the mail containing all the active forms to the admin - return this.sendWarningMailForAdmin( - forms, - form.admin, - smsVerifications, - ).map(() => forms) - }) - .andThen((forms) => { - // Step 3: Send to each individual form - return ResultAsync.combine( - forms.map((f) => - // If there are no collaborators, do not send out the email. - // Admin would already have received a summary email from Step 2. - f.permissionList.length - ? this.sendWarningMailForCollab(f, form.admin, smsVerifications) - : okAsync(true), - ), - ).map(() => true as const) - }) - } - /** * Sends a payment confirmation to a valid email * @param email the recipient email address @@ -919,110 +724,6 @@ export class MailService { return this.#sendNodeMail(mail, { mailId: 'paymentOnboarding' }) } - // Utility method to send a warning mail to the collaborators of a form. - // Note that this also sends the mail out to the admin of the form as well. - sendWarningMailForCollab = ( - form: IFormDocument, - admin: IPopulatedUser, - smsVerifications: number, - ): ResultAsync => { - const formLink = extractFormLinkView(form, this.#appUrl) - const percentageUsed = formatAsPercentage( - smsVerifications / smsConfig.smsVerificationLimit, - ) - const htmlData: CollabSmsWarningData = { - form: formLink, - percentageUsed, - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - } - const collaborators = form.permissionList.map(({ email }) => email) - const logMeta = { - form: formLink, - admin, - collaborators, - smsVerifications, - action: 'sendWarningMailForCollab', - } - - // Step 1: Generate HTML data for collab - return generateSmsVerificationWarningHtmlForCollab(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - cc: collaborators, - from: this.#senderFromString, - html: mailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to warn collaborators about sms limits', - meta: logMeta, - }) - - // Step 2: Send mail out to admin and collab - return this.#sendNodeMail(mailOptions, { - formId: form._id, - mailId: 'sendWarningMailForCollab', - }) - }, - ) - } - - // Utility method to send a warning mail to the admin of a form. - // This is triggered when the admin's sms verification counts hits a limit. - // This informs the admin of all forms that use sms verification - sendWarningMailForAdmin = ( - forms: IPopulatedForm[], - admin: IPopulatedUser, - smsVerifications: number, - ): ResultAsync => { - const formLinks = forms.map((f) => extractFormLinkView(f, this.#appUrl)) - const htmlData: AdminSmsWarningData = { - forms: formLinks, - numAvailable: ( - smsConfig.smsVerificationLimit - smsVerifications - ).toLocaleString('en-US'), - smsVerificationLimit: - smsConfig.smsVerificationLimit.toLocaleString('en-US'), - } - const logMeta = { - forms: formLinks, - admin, - smsVerifications, - action: 'sendWarningMailForAdmin', - } - - return ( - // Step 1: Generate HTML data for admin - generateSmsVerificationWarningHtmlForAdmin(htmlData).andThen( - (mailHtml) => { - const mailOptions: MailOptions = { - to: admin.email, - from: this.#senderFromString, - html: mailHtml, - subject: 'Mobile Number Verification - Free Tier Limit Alert', - replyTo: this.#officialMail, - bcc: this.#senderMail, - } - - logger.info({ - message: 'Attempting to warn admin about sms limits', - meta: logMeta, - }) - - // Step 2: Send mail out to admin ONLY - return this.#sendNodeMail(mailOptions, { - mailId: 'sendWarningMailForAdmin', - }) - }, - ) - ) - } - /** * Sends a notification email to the admin of the given form for issue * reported by the public users. diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index acb26f8808..343b88c55f 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -2,10 +2,8 @@ import Mail from 'nodemailer/lib/mailer' import { OperationOptions } from 'retry' import { AutoReplyOptions } from '../../../../shared/types' -import { SMS_WARNING_TIERS } from '../../../../shared/utils/verification' import { EmailAdminDataField, - FormLinkView, IFormSchema, IPopulatedForm, ISubmissionSchema, @@ -91,32 +89,6 @@ export type BounceNotificationHtmlData = { appName: string } -export type AdminSmsDisabledData = { - forms: FormLinkView[] -} & SmsVerificationTiers - -export type CollabSmsDisabledData = { - form: FormLinkView -} & SmsVerificationTiers - -export type AdminSmsWarningData = { - forms: FormLinkView[] - numAvailable: string - smsVerificationLimit: string -} - -export type CollabSmsWarningData = { - form: FormLinkView - percentageUsed: string - smsVerificationLimit: string -} - -type SmsVerificationTiers = { - smsVerificationLimit: string - // Ensure that all tiers are covered - smsWarningTiers: { [K in keyof typeof SMS_WARNING_TIERS]: string } -} - export type PaymentConfirmationData = { appName: string formTitle: string diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index 392b0a2880..622ac00b7a 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -11,13 +11,9 @@ import { generatePdfFromHtml } from '../../utils/convert-html-to-pdf' import { MailGenerationError, MailSendError } from './mail.errors' import { - AdminSmsDisabledData, - AdminSmsWarningData, AutoreplyHtmlData, AutoreplySummaryRenderData, BounceNotificationHtmlData, - CollabSmsDisabledData, - CollabSmsWarningData, IssueReportedNotificationData, PaymentConfirmationData, SubmissionToAdminHtmlData, @@ -206,70 +202,6 @@ export const isToFieldValid = (addresses: string | string[]): boolean => { return mails.every((addr) => validator.isEmail(addr)) } -export const generateSmsVerificationDisabledHtmlForAdmin = ( - htmlData: AdminSmsDisabledData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-admin.server.view.html` - logger.info({ - message: 'generateSmsVerificationDisabledHtmlForAdmin', - meta: { - action: 'generateSmsVerificationDisabledHtmlForAdmin', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationDisabledHtmlForCollab = ( - htmlData: CollabSmsDisabledData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled-collab.server.view.html` - logger.info({ - message: 'generateSmsVerificationDisabledHtmlForCollab', - meta: { - action: 'generateSmsVerificationDisabledHtmlForCollab', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationWarningHtmlForAdmin = ( - htmlData: AdminSmsWarningData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning-admin.view.html` - logger.info({ - message: 'generateSmsVerificationWarningHtmlForAdmin', - meta: { - action: 'generateSmsVerificationWarningHtmlForAdmin', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - -export const generateSmsVerificationWarningHtmlForCollab = ( - htmlData: CollabSmsWarningData, -): ResultAsync => { - const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning-collab.view.html` - logger.info({ - message: 'generateSmsVerificationWarningHtmlForCollab', - meta: { - action: 'generateSmsVerificationWarningHtmlForCollab', - pathToTemplate, - __dirname, - cwd: process.cwd(), - }, - }) - return safeRenderFile(pathToTemplate, htmlData) -} - export const generatePaymentConfirmationHtml = ({ htmlData, }: { diff --git a/src/app/services/sms/__tests__/sms.factory.spec.ts b/src/app/services/sms/__tests__/sms.factory.spec.ts deleted file mode 100644 index 812208febd..0000000000 --- a/src/app/services/sms/__tests__/sms.factory.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { okAsync } from 'neverthrow' -import Twilio from 'twilio' - -import { ISms } from 'src/app/config/features/sms.config' - -import { createSmsFactory } from '../sms.factory' -import * as SmsService from '../sms.service' -import { TwilioConfig } from '../sms.types' - -// This is hoisted and thus a const cannot be passed in. -jest.mock('twilio', () => - jest.fn().mockImplementation(() => ({ - mocked: 'this is mocked', - })), -) - -jest.mock('src/app/services/sms/sms.dev.prismclient', () => () => ({})) - -jest.mock('../sms.service') -const MockSmsService = jest.mocked(SmsService) - -const MOCKED_TWILIO = { - mocked: 'this is mocked', -} as unknown as Twilio.Twilio - -describe('sms.factory', () => { - beforeEach(() => jest.clearAllMocks()) - - const MOCK_SMS_FEATURE: ISms = { - twilioAccountSid: 'ACrandomTwilioSid', - twilioApiKey: 'SKrandomTwilioAPIKEY', - twilioApiSecret: 'this is a super secret', - twilioMsgSrvcSid: 'formsg-is-great-pleasehelpme', - smsVerificationLimit: 10000, - } - const expectedTwilioConfig: TwilioConfig = { - msgSrvcSid: MOCK_SMS_FEATURE.twilioMsgSrvcSid, - client: MOCKED_TWILIO, - } - const SmsFactory = createSmsFactory(MOCK_SMS_FEATURE) - - it('should call SmsService counterpart when invoking sendVerificationOtp', async () => { - // Arrange - MockSmsService.sendVerificationOtp.mockReturnValue(okAsync(true)) - - const mockArguments: Parameters = [ - 'mockRecipient', - 'mockOtp', - 'mockOtpPrefix', - 'mockUserId', - 'mockSenderIp', - ] - - // Act - await SmsFactory.sendVerificationOtp(...mockArguments) - - // Assert - expect(MockSmsService.sendVerificationOtp).toHaveBeenCalledTimes(1) - expect(MockSmsService.sendVerificationOtp).toHaveBeenCalledWith( - ...mockArguments, - expectedTwilioConfig, - ) - }) -}) diff --git a/src/app/services/sms/__tests__/sms.service.spec.ts b/src/app/services/sms/__tests__/sms.service.spec.ts deleted file mode 100644 index 771bef9055..0000000000 --- a/src/app/services/sms/__tests__/sms.service.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import mongoose from 'mongoose' - -import getFormModel from 'src/app/models/form.server.model' -import { - DatabaseError, - MalformedParametersError, -} from 'src/app/modules/core/core.errors' -import { getMongoErrorMessage } from 'src/app/utils/handle-mongo-error' -import { FormOtpData, IFormSchema, IUserSchema } from 'src/types' - -import { FormResponseMode } from '../../../../../shared/types' -import { InvalidNumberError } from '../../postman-sms/postman-sms.errors' -import * as SmsService from '../sms.service' -import { LogType, SmsType, TwilioConfig } from '../sms.types' -import getSmsCountModel from '../sms_count.server.model' - -const FormModel = getFormModel(mongoose) -const SmsCountModel = getSmsCountModel(mongoose) - -// Test numbers provided by Twilio: -// https://www.twilio.com/docs/iam/test-credentials -const TWILIO_TEST_NUMBER = '+15005550006' -const MOCK_MSG_SRVC_SID = 'mockMsgSrvcSid' -const MOCK_SENDER_IP = '200.000.000.000' - -const twilioSuccessSpy = jest.fn().mockResolvedValue({ - status: 'testStatus', - sid: 'testSid', -}) - -const MOCK_VALID_CONFIG = { - msgSrvcSid: MOCK_MSG_SRVC_SID, - client: { - messages: { - create: twilioSuccessSpy, - }, - }, -} as unknown as TwilioConfig - -const twilioFailureSpy = jest.fn().mockResolvedValue({ - status: 'testStatus', - sid: undefined, - errorCode: 21211, -}) - -const MOCK_INVALID_CONFIG = { - msgSrvcSid: MOCK_MSG_SRVC_SID, - client: { - messages: { - create: twilioFailureSpy, - }, - }, -} as unknown as TwilioConfig - -const smsCountSpy = jest.spyOn(SmsCountModel, 'logSms') - -describe('sms.service', () => { - let testUser: IUserSchema - - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => { - const { user } = await dbHandler.insertFormCollectionReqs() - testUser = user - jest.clearAllMocks() - }) - afterEach(async () => await dbHandler.clearDatabase()) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('sendVerificationOtp', () => { - let mockOtpData: FormOtpData - let testForm: IFormSchema - - beforeEach(async () => { - testForm = await FormModel.create({ - title: 'Test Form', - emails: [testUser.email], - admin: testUser._id, - responseMode: FormResponseMode.Email, - }) - - mockOtpData = { - form: testForm._id, - formAdmin: { - email: testUser.email, - userId: testUser._id, - }, - } - }) - - it('should return MalformedParametersError error when retrieved otpData is null', async () => { - // Arrange - // Return null on Form method - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(null) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_VALID_CONFIG, - ) - - // Assert - expect(actualResult._unsafeUnwrapErr()).toEqual( - new MalformedParametersError( - `Unable to retrieve otpData from ${testForm._id}`, - ), - ) - }) - - it('should log and send verification OTP when sending has no errors', async () => { - // Arrange - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(mockOtpData) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_VALID_CONFIG, - ) - - // Assert - expect(twilioSuccessSpy.mock.calls[0][0].statusCallback).toEqual( - expect.stringContaining('?senderIp'), - ) - - expect(actualResult._unsafeUnwrap()).toEqual(true) - // Logging should also have happened. - const expectedLogParams = { - smsData: mockOtpData, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType: SmsType.Verification, - logType: LogType.success, - } - expect(smsCountSpy).toHaveBeenCalledWith(expectedLogParams) - }) - - it('should log failure and return InvalidNumberError when verification OTP fails to send due to invalid number', async () => { - // Arrange - jest.spyOn(FormModel, 'getOtpData').mockResolvedValueOnce(mockOtpData) - - // Act - const actualResult = await SmsService.sendVerificationOtp( - /* recipient= */ TWILIO_TEST_NUMBER, - /* otp= */ '111111', - /* otpPrefix= */ 'ABC', - /* formId= */ testForm._id, - /* senderIp= */ MOCK_SENDER_IP, - /* defaultConfig= */ MOCK_INVALID_CONFIG, - ) - - // Assert - expect(actualResult._unsafeUnwrapErr()).toEqual(new InvalidNumberError()) - // Logging should also have happened. - const expectedLogParams = { - smsData: mockOtpData, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType: SmsType.Verification, - logType: LogType.failure, - } - expect(smsCountSpy).toHaveBeenCalledWith(expectedLogParams) - }) - }) - - describe('retrieveFreeSmsCounts', () => { - const VERIFICATION_SMS_COUNT = 3 - - it('should retrieve sms counts correctly for a specified user', async () => { - // Arrange - const retrieveSpy = jest.spyOn(SmsCountModel, 'retrieveFreeSmsCounts') - retrieveSpy.mockResolvedValueOnce(VERIFICATION_SMS_COUNT) - - // Act - const actual = await SmsService.retrieveFreeSmsCounts(testUser._id) - - // Assert - expect(actual._unsafeUnwrap()).toBe(VERIFICATION_SMS_COUNT) - }) - - it('should return a database error when retrieval fails', async () => { - // Arrange - const retrieveSpy = jest.spyOn(SmsCountModel, 'retrieveFreeSmsCounts') - retrieveSpy.mockRejectedValueOnce('ohno') - - // Act - const actual = await SmsService.retrieveFreeSmsCounts(testUser._id) - - // Assert - expect(actual._unsafeUnwrapErr()).toEqual( - new DatabaseError(getMongoErrorMessage('ohno')), - ) - }) - }) -}) diff --git a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts b/src/app/services/sms/__tests__/sms_count.server.model.spec.ts deleted file mode 100644 index 401774158e..0000000000 --- a/src/app/services/sms/__tests__/sms_count.server.model.spec.ts +++ /dev/null @@ -1,649 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import dbHandler from '__tests__/unit/backend/helpers/jest-db' -import { ObjectId } from 'bson' -import { cloneDeep, merge, omit } from 'lodash' -import mongoose from 'mongoose' - -import { smsConfig } from '../../../config/features/sms.config' -import { - IVerificationSmsCount, - IVerificationSmsCountSchema, - LogType, - SmsType, -} from '../sms.types' -import getSmsCountModel from '../sms_count.server.model' - -const SmsCount = getSmsCountModel(mongoose) - -const MOCK_SMSCOUNT_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'mockEmail@example.com', - userId: new ObjectId(), - }, -} - -const MOCK_BOUNCED_SUBMISSION_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'a@abc.com', - userId: new ObjectId(), - }, - collaboratorEmail: 'b@def.com', - recipientNumber: '+6581234567', - msgSrvcSid: 'mockMsgSrvcSid', - smsType: SmsType.BouncedSubmission, - logType: LogType.success, -} - -const MOCK_FORM_DEACTIVATED_PARAMS = { - form: new ObjectId(), - formAdmin: { - email: 'a@abc.com', - userId: new ObjectId(), - }, - collaboratorEmail: 'b@def.com', - recipientNumber: '+6581234567', - msgSrvcSid: 'mockMsgSrvcSid', - smsType: SmsType.DeactivatedForm, - logType: LogType.success, -} - -const MOCK_MSG_SRVC_SID = 'mockMsgSrvcSid' - -describe('SmsCount', () => { - beforeAll(async () => await dbHandler.connect()) - beforeEach(async () => await dbHandler.clearDatabase()) - afterAll(async () => await dbHandler.closeDatabase()) - - describe('FormDeactivatedSmsCountSchema', () => { - it('should create and save successfully', async () => { - const saved = await SmsCount.create(MOCK_FORM_DEACTIVATED_PARAMS) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(MOCK_FORM_DEACTIVATED_PARAMS) - }) - - it('should reject if form is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'form'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'formAdmin.email'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'formAdmin.userId'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'collaboratorEmail'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { - collaboratorEmail: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_FORM_DEACTIVATED_PARAMS, 'recipientNumber'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { - recipientNumber: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('BouncedSubmissionSmsCountSchema', () => { - it('should create and save successfully', async () => { - const saved = await SmsCount.create(MOCK_BOUNCED_SUBMISSION_PARAMS) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(MOCK_BOUNCED_SUBMISSION_PARAMS) - }) - - it('should reject if form is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'form'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'formAdmin.email'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'formAdmin.userId'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'collaboratorEmail'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if collaboratorEmail is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - collaboratorEmail: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is missing', async () => { - const invalidSmsCount = new SmsCount( - omit(MOCK_BOUNCED_SUBMISSION_PARAMS, 'recipientNumber'), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if recipientNumber is invalid', async () => { - const invalidSmsCount = new SmsCount( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - recipientNumber: 'invalid', - }), - ) - - await expect(invalidSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('VerificationCount Schema', () => { - const twilioMsgSrvcSid = smsConfig.twilioMsgSrvcSid - - beforeAll(() => { - smsConfig.twilioMsgSrvcSid = MOCK_MSG_SRVC_SID - }) - - afterAll(() => { - smsConfig.twilioMsgSrvcSid = twilioMsgSrvcSid - }) - - it('should create and save successfully', async () => { - // Arrange - const smsCountParams = createVerificationSmsCountParams() - const expected = merge(smsCountParams, { - isOnboardedAccount: false, - }) - - // Act - const validSmsCount = new SmsCount(smsCountParams) - const saved = await validSmsCount.save() - - // Assert - // All fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(expected) - }) - - it('should save successfully, but not save fields that is not defined in the schema', async () => { - // Arrange - const smsCountParamsWithExtra = merge( - createVerificationSmsCountParams(), - { - extra: 'somethingExtra', - }, - ) - const expected = merge(omit(smsCountParamsWithExtra, 'extra'), { - isOnboardedAccount: false, - }) - - // Act - const validSmsCount = new SmsCount(smsCountParamsWithExtra) - const saved = await validSmsCount.save() - - // Assert - // All defined fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - // Extra key should not be saved - expect(Object.keys(saved)).not.toContain('extra') - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual(expected) - }) - - it('should save successfully and set isOnboarded to true when the credentials are different from default', async () => { - // Arrange - const verificationParams = merge( - createVerificationSmsCountParams({ - logType: LogType.success, - smsType: SmsType.Verification, - }), - { msgSrvcSid: 'i am different' }, - ) - - // Act - const validSmsCount = new SmsCount(verificationParams) - const saved = await validSmsCount.save() - - // Assert - // All fields should exist - // Object Id should be defined when successfully saved to MongoDB. - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) as IVerificationSmsCountSchema - expect(omit(actualSavedObject, 'isOnboardedAccount')).toEqual( - verificationParams, - ) - expect(actualSavedObject.isOnboardedAccount).toBe(true) - }) - - it('should reject if form key is missing', async () => { - // Arrange - const malformedParams = omit(createVerificationSmsCountParams(), 'form') - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.email is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'formAdmin.email', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if formAdmin.userId is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'formAdmin.userId', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if logType is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'logType', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if logType is invalid', async () => { - // Arrange - const malformedParams = createVerificationSmsCountParams() - // @ts-ignore - malformedParams.logType = 'INVALID_LOG_TYPE' - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if smsType is missing', async () => { - // Arrange - const malformedParams = omit( - createVerificationSmsCountParams(), - 'smsType', - ) - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - - it('should reject if smsType is invalid', async () => { - // Arrange - const malformedParams = createVerificationSmsCountParams() - // @ts-ignore - malformedParams.smsType = 'INVALID_SMS_TYPE' - const malformedSmsCount = new SmsCount(malformedParams) - - // Act + Assert - await expect(malformedSmsCount.save()).rejects.toThrow( - mongoose.Error.ValidationError, - ) - }) - }) - - describe('Statics', () => { - describe('logSms', () => { - const MOCK_FORM_ID = MOCK_SMSCOUNT_PARAMS.form - - it('should correctly log bounced submission SMS successes', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.success, - smsType: SmsType.BouncedSubmission, - msgSrvcSid: MOCK_BOUNCED_SUBMISSION_PARAMS.msgSrvcSid, - smsData: omit(MOCK_BOUNCED_SUBMISSION_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - logType: LogType.success, - }), - ) - }) - - it('should correctly log bounced submission SMS failures', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.failure, - smsType: SmsType.BouncedSubmission, - msgSrvcSid: MOCK_BOUNCED_SUBMISSION_PARAMS.msgSrvcSid, - smsData: omit(MOCK_BOUNCED_SUBMISSION_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_BOUNCED_SUBMISSION_PARAMS, { - logType: LogType.failure, - }), - ) - }) - - it('should correctly log form deactivated SMS successes', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.success, - smsType: SmsType.DeactivatedForm, - msgSrvcSid: MOCK_FORM_DEACTIVATED_PARAMS.msgSrvcSid, - smsData: omit(MOCK_FORM_DEACTIVATED_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { logType: LogType.success }), - ) - }) - - it('should correctly log form deactivated SMS failures', async () => { - const saved = await SmsCount.logSms({ - logType: LogType.failure, - smsType: SmsType.DeactivatedForm, - msgSrvcSid: MOCK_FORM_DEACTIVATED_PARAMS.msgSrvcSid, - smsData: omit(MOCK_FORM_DEACTIVATED_PARAMS, [ - 'msgSrvcSid', - 'smsType', - 'logType', - ]), - }) - - expect(saved._id).toBeDefined() - expect(saved.createdAt).toBeInstanceOf(Date) - const actualSavedObject = omit(saved.toObject(), [ - '_id', - 'createdAt', - '__v', - ]) - expect(actualSavedObject).toEqual( - merge({}, MOCK_FORM_DEACTIVATED_PARAMS, { logType: LogType.failure }), - ) - }) - - it('should successfully log verification successes in the collection', async () => { - // Arrange - const initialCount = await SmsCount.countDocuments({}) - - // Act - const expectedLog = await logAndReturnExpectedLog({ - smsType: SmsType.Verification, - logType: LogType.success, - }) - - // Assert - const afterCount = await SmsCount.countDocuments({}) - // Should have 1 more document in the database since it is successful - expect(afterCount).toEqual(initialCount + 1) - - // Should contain OTP data and the correct sms/log type. - const actualLog = await SmsCount.findOne({ - form: MOCK_FORM_ID, - }).lean() - - expect(actualLog?._id).toBeDefined() - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(actualLog, ['_id', 'createdAt', '__v']) - expect(actualSavedObject).toEqual(expectedLog) - }) - - it('should successfully log verification failures in the collection', async () => { - // Arrange - const initialCount = await SmsCount.countDocuments({}) - - // Act - const expectedLog = await logAndReturnExpectedLog({ - smsType: SmsType.Verification, - logType: LogType.failure, - }) - - // Assert - const afterCount = await SmsCount.countDocuments({}) - // Should have 1 more document in the database since it is successful - expect(afterCount).toEqual(initialCount + 1) - - // Should contain OTP data and the correct sms/log type. - const actualLog = await SmsCount.findOne({ - form: MOCK_FORM_ID, - }).lean() - - expect(actualLog?._id).toBeDefined() - // Retrieve object and compare to params, remove indeterministic keys - const actualSavedObject = omit(actualLog, ['_id', 'createdAt', '__v']) - expect(actualSavedObject).toEqual(expectedLog) - }) - - it('should reject if smsType is invalid', async () => { - await expect( - logAndReturnExpectedLog({ - // @ts-ignore - smsType: 'INVALID', - logType: LogType.failure, - }), - ).rejects.toThrow(mongoose.Error.ValidationError) - }) - - it('should reject if logType is invalid', async () => { - await expect( - logAndReturnExpectedLog({ - smsType: SmsType.Verification, - // @ts-ignore - logType: 'INVALID', - }), - ).rejects.toThrow(mongoose.Error.ValidationError) - }) - }) - }) -}) - -const createVerificationSmsCountParams = ({ - logType = LogType.success, - smsType = SmsType.Verification, -}: { - logType?: LogType - smsType?: SmsType -} = {}) => { - const smsCountParams: Partial = - cloneDeep(MOCK_SMSCOUNT_PARAMS) - smsCountParams.logType = logType - smsCountParams.smsType = smsType - smsCountParams.msgSrvcSid = MOCK_MSG_SRVC_SID - return smsCountParams -} - -const logAndReturnExpectedLog = async ({ - logType, - smsType, -}: { - logType: LogType - smsType: SmsType -}) => { - await SmsCount.logSms({ - smsData: MOCK_SMSCOUNT_PARAMS, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType, - logType, - }) - - const expectedLog = { - ...MOCK_SMSCOUNT_PARAMS, - msgSrvcSid: MOCK_MSG_SRVC_SID, - smsType, - logType, - ...(smsType === SmsType.Verification && { - isOnboardedAccount: !(MOCK_MSG_SRVC_SID === smsConfig.twilioMsgSrvcSid), - }), - } - - return expectedLog -} diff --git a/src/app/services/sms/sms.dev.prismclient.ts b/src/app/services/sms/sms.dev.prismclient.ts deleted file mode 100644 index 01784abf37..0000000000 --- a/src/app/services/sms/sms.dev.prismclient.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { RequestClient } from 'twilio' - -import MailService from '../mail/mail.service' - -export class PrismClient extends RequestClient { - prismUrl: string - requestClient: InstanceType - constructor( - prismUrl: string, - requestClient: InstanceType, - ) { - super() - this.prismUrl = prismUrl - this.requestClient = requestClient - } - - #sendInternalMail = async (msg: string): Promise => { - await MailService.sendLocalDevMail('[mocktwilio] Captured SMS', msg) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(opts: any) { - opts.uri = opts.uri.replace(/^https:\/\/.*?\.twilio\.com/, this.prismUrl) - const resp = this.requestClient.request(opts) - - // eslint-disable-next-line no-console - this.#sendInternalMail(opts.data.Body).catch(console.error) - return resp - } -} diff --git a/src/app/services/sms/sms.factory.ts b/src/app/services/sms/sms.factory.ts deleted file mode 100644 index 6e299c3079..0000000000 --- a/src/app/services/sms/sms.factory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Twilio, { RequestClient } from 'twilio' - -import { useMockTwilio } from '../../config/config' -import { ISms, smsConfig } from '../../config/features/sms.config' - -import { PrismClient } from './sms.dev.prismclient' -import { sendVerificationOtp } from './sms.service' -import { TwilioConfig } from './sms.types' - -interface ISmsFactory { - sendVerificationOtp: ( - recipient: string, - otp: string, - otpPrefix: string, - formId: string, - senderIp: string, - ) => ReturnType -} - -// Exported for testing. -export const createSmsFactory = (smsConfig: ISms): ISmsFactory => { - const { twilioAccountSid, twilioApiKey, twilioApiSecret, twilioMsgSrvcSid } = - smsConfig - - const twilioClient = Twilio(twilioApiKey, twilioApiSecret, { - accountSid: twilioAccountSid, - httpClient: useMockTwilio - ? new PrismClient('http://127.0.0.1:4010', new RequestClient()) - : undefined, - }) - const twilioConfig: TwilioConfig = { - msgSrvcSid: twilioMsgSrvcSid, - client: twilioClient, - } - - return { - sendVerificationOtp: (recipient, otp, otpPrefix, formId, senderIp) => - sendVerificationOtp( - recipient, - otp, - otpPrefix, - formId, - senderIp, - twilioConfig, - ), - } -} - -export const SmsFactory = createSmsFactory(smsConfig) diff --git a/src/app/services/sms/sms.service.ts b/src/app/services/sms/sms.service.ts deleted file mode 100644 index 6a6397272b..0000000000 --- a/src/app/services/sms/sms.service.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { SecretsManager } from 'aws-sdk' -import mongoose from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' -import NodeCache from 'node-cache' -import Twilio from 'twilio' - -import { TwilioSmsStatsdTags } from 'src/types/twilio' - -import { isPhoneNumber } from '../../../../shared/utils/phone-num-validation' -import { AdminContactOtpData, FormOtpData } from '../../../types' -import config from '../../config/config' -import { createLoggerWithLabel } from '../../config/logger' -import getFormModel from '../../models/form.server.model' -import { - DatabaseError, - MalformedParametersError, - PossibleDatabaseError, -} from '../../modules/core/core.errors' -import { twilioStatsdClient } from '../../modules/twilio/twilio.statsd-client' -import { - getMongoErrorMessage, - transformMongoError, -} from '../../utils/handle-mongo-error' -import { - InvalidNumberError, - SmsSendError, -} from '../postman-sms/postman-sms.errors' -import { renderVerificationSms } from '../postman-sms/postman-sms.util' - -import { - LogSmsParams, - LogType, - SmsType, - TwilioConfig, - TwilioCredentials, -} from './sms.types' -import getSmsCountModel from './sms_count.server.model' - -const logger = createLoggerWithLabel(module) -const SmsCount = getSmsCountModel(mongoose) -const Form = getFormModel(mongoose) -const secretsManager = new SecretsManager({ region: config.aws.region }) -// The twilioClientCache is only initialized once even when sms.service.js is -// required by different files. -// Given that it is held in memory, when credentials are modified on -// secretsManager, the app will need to be redeployed to retrieve new -// credentials, or wait 10 seconds before. -export const twilioClientCache = new NodeCache({ - deleteOnExpire: true, - stdTTL: 10, -}) - -/** - * Retrieves credentials from secrets manager - * @param msgSrvcName The name of credential stored in the secret manager. - * @returns The credentials if available, null if secret does not exist or is malformed. - */ -const getCredentials = async ( - msgSrvcName: string, -): Promise => { - try { - const data = await secretsManager - .getSecretValue({ SecretId: msgSrvcName }) - .promise() - if (data.SecretString) { - const credentials = JSON.parse(data.SecretString) - if ( - credentials.accountSid && - credentials.apiKey && - credentials.apiSecret && - credentials.messagingServiceSid - ) { - return credentials - } - } - } catch (err) { - logger.error({ - message: 'Error retrieving credentials', - meta: { - action: 'getCredentials', - msgSrvcName, - }, - error: err, - }) - } - return null -} - -/** - * - * @param msgSrvcName The name of credential stored in the secret manager - * @returns A TwilioConfig containing the client and the sid linked to the msgSrvcName if defined, or the defaultConfig if not. - */ -const getTwilio = async ( - msgSrvcName: string | undefined, - defaultConfig: TwilioConfig, -): Promise => { - if (msgSrvcName) { - // Retrieve client and msgSrvcSid from cache - const cached = twilioClientCache.get(msgSrvcName) - if (cached !== undefined) { - return cached - } - // If not found in cache, retrieve credentials from AWS secret manager. - // Even if the msgSrvcName exists and a secret is returned, if the secret is - // malformed (missing required keys), null will still be returned. - // If null is returned, fallback to default Twilio config. - try { - const credentials = await getCredentials(msgSrvcName) - if (credentials !== null) { - const { accountSid, apiKey, apiSecret, messagingServiceSid } = - credentials - // Create twilioClient - const result: TwilioConfig = { - client: Twilio(apiKey, apiSecret, { accountSid }), - msgSrvcSid: messagingServiceSid, - } - // Add it to the cache - twilioClientCache.set(msgSrvcName, result) - logger.info({ - message: `Added ${msgSrvcName} to cache`, - meta: { - action: 'getTwilio', - msgSrvcName, - }, - }) - return result - } - } catch (err) { - logger.warn({ - message: - 'Failed to retrieve from cache. Defaulting to central Twilio client', - meta: { - action: 'getTwilio', - msgSrvcName, - }, - error: err, - }) - } - } - return defaultConfig -} - -const logSmsSend = (logParams: LogSmsParams) => { - return SmsCount.logSms(logParams).catch((error) => { - logger.error({ - message: 'Error logging sms count to database', - meta: { - action: 'logSmsSend', - ...logParams, - }, - error, - }) - }) -} - -/** - * Sends a message to a valid phone number - * @param twilioConfig The configuration used to send OTPs with - * @param twilioData.client The client to use - * @param twilioData.msgSrvcSid The message service sid to send from with. - * @param smsData The data for logging smsCount - * @param recipient The mobile number of the recipient - * @param message The message to send - * @param senderIp The ip address of the person triggering the SMS - */ -const sendSms = ( - twilioConfig: TwilioConfig, - smsData: FormOtpData | AdminContactOtpData, - recipient: string, - message: string, - smsType: SmsType, - senderIp?: string, -): ResultAsync => { - if (!isPhoneNumber(recipient)) { - logger.warn({ - message: `${recipient} is not a valid phone number`, - meta: { - action: 'send', - }, - }) - return errAsync(new InvalidNumberError()) - } - - const { client, msgSrvcSid } = twilioConfig - - const logMeta = { - action: 'send', - smsData, - smsType, - } - - const statusCallbackRoute = '/api/v3/notifications/twilio' - - const statusCallback = senderIp - ? `${config.app.appUrl}${statusCallbackRoute}?${encodeURI( - `senderIp=${senderIp}`, - )}` - : `${config.app.appUrl}${statusCallbackRoute}` - - return ResultAsync.fromPromise( - client.messages.create({ - to: recipient, - body: message, - from: msgSrvcSid, - forceDelivery: true, - statusCallback, - }), - (error) => { - logger.error({ - message: 'SMS send error', - meta: logMeta, - error, - }) - - return new SmsSendError('Error sending SMS to given number', { - originalError: error, - }) - }, - ) - .andThen(({ status, sid, errorCode, errorMessage }) => { - const ddTags: TwilioSmsStatsdTags = { - // msgSrvcSid not included to limit tag cardinality (for now?) - smsstatus: status, - errorcode: '0', - } - - if (!sid || errorCode) { - if (errorCode) { - ddTags.errorcode = `${errorCode}` - } - - logger.error({ - message: 'Encountered error code or missing sid after sending SMS', - meta: { - ...logMeta, - status, - errorCode, - errorMessage, - }, - }) - - twilioStatsdClient.increment('sms.send', 1, 1, ddTags) - - // Invalid number error code, throw a more reasonable error for error - // handling. - // See https://www.twilio.com/docs/api/errors/21211 - return errAsync( - errorCode === 21211 - ? new InvalidNumberError() - : new SmsSendError('Error sending SMS to given number', { - status, - errorCode, - errorMessage, - }), - ) - } - - twilioStatsdClient.increment('sms.send', 1, 1, ddTags) - - // No errors. - logger.info({ - message: 'Successfully sent sms', - meta: logMeta, - }) - - return okAsync(true as const) - }) - .map((result) => { - // Fire log sms success promise without waiting. - void logSmsSend({ - smsData, - smsType, - msgSrvcSid, - logType: LogType.success, - }) - - return result - }) - .mapErr((error) => { - // Fire log sms failure promise without waiting. - void logSmsSend({ - smsData, - smsType, - msgSrvcSid, - logType: LogType.failure, - }) - - return error - }) -} -/** - * Gets the correct twilio client for the form and sends an otp to a valid phonenumber - * @param recipient The phone number to send to - * @param otp The OTP to send - * @param otpPrefix The OTP Prefix to send - * @param formId Form id for retrieving otp data. - * @param senderIp The ip address of the person triggering the SMS - */ -export const sendVerificationOtp = ( - recipient: string, - otp: string, - otpPrefix: string, - formId: string, - senderIp: string, - defaultConfig: TwilioConfig, -): ResultAsync< - true, - DatabaseError | MalformedParametersError | SmsSendError | InvalidNumberError -> => { - logger.info({ - message: `Sending verification OTP for ${formId}`, - meta: { - action: 'sendVerificationOtp', - formId, - }, - }) - return ResultAsync.fromPromise(Form.getOtpData(formId), (error) => { - logger.error({ - message: `Database error occurred whilst retrieving form otp data`, - meta: { - action: 'sendVerificationOtp', - formId, - }, - error, - }) - - return new DatabaseError(getMongoErrorMessage(error)) - }).andThen((otpData) => { - if (!otpData) { - const errMsg = `Unable to retrieve otpData from ${formId}` - logger.error({ - message: errMsg, - meta: { - action: 'sendVerificationOtp', - formId, - }, - }) - - return errAsync(new MalformedParametersError(errMsg)) - } - - return ResultAsync.fromSafePromise< - TwilioConfig, - SmsSendError | InvalidNumberError - >(getTwilio(otpData.msgSrvcName, defaultConfig)).andThen((twilioConfig) => { - const message = renderVerificationSms(otp, otpPrefix) - - return sendSms( - twilioConfig, - otpData, - recipient, - message, - SmsType.Verification, - senderIp, - ) - }) - }) -} - -/** - * Retrieves the free sms count for a particular user - * @param userId The id of the user to retrieve the sms counts for - * @returns ok(count) when retrieval is successful - * @returns err(error) when retrieval fails due to a database error - */ -export const retrieveFreeSmsCounts = ( - userId: string, -): ResultAsync => { - return ResultAsync.fromPromise( - SmsCount.retrieveFreeSmsCounts(userId), - (error) => { - logger.error({ - message: `Retrieving free sms counts failed for ${userId}`, - meta: { - action: 'retrieveFreeSmsCounts', - userId, - error, - }, - }) - - return transformMongoError(error) - }, - ) -} diff --git a/src/app/services/sms/sms.types.ts b/src/app/services/sms/sms.types.ts deleted file mode 100644 index d0b9c674f8..0000000000 --- a/src/app/services/sms/sms.types.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Document, Model } from 'mongoose' -import { Twilio } from 'twilio' - -import { FormPermission } from '../../../../shared/types' -import { - AdminContactOtpData, - FormOtpData, - IFormSchema, - IUserSchema, -} from '../../../types' - -export enum SmsType { - Verification = 'VERIFICATION', - AdminContact = 'ADMIN_CONTACT', - DeactivatedForm = 'DEACTIVATED_FORM', - BouncedSubmission = 'BOUNCED_SUBMISSION', -} - -export enum LogType { - failure = 'FAILURE', - success = 'SUCCESS', -} - -export type FormDeactivatedSmsData = { - form: IFormSchema['_id'] - formAdmin: { - email: IUserSchema['email'] - userId: IUserSchema['_id'] - } - collaboratorEmail: FormPermission['email'] - recipientNumber: string -} - -export type BouncedSubmissionSmsData = FormDeactivatedSmsData - -export type LogSmsParams = { - smsData: - | FormOtpData - | AdminContactOtpData - | FormDeactivatedSmsData - | BouncedSubmissionSmsData - msgSrvcSid: string - smsType: SmsType - logType: LogType -} - -export interface ISmsCount { - // The Twilio SID used to send the SMS. Not to be confused with msgSrvcName. - msgSrvcSid: string - logType: LogType - smsType: SmsType - createdAt?: Date -} - -export interface ISmsCountSchema extends ISmsCount, Document {} - -export interface IVerificationSmsCount extends ISmsCount { - form: IFormSchema['_id'] - formAdmin: { - email: string - userId: IUserSchema['_id'] - } - isOnboardedAccount: boolean -} - -export interface IVerificationSmsCountSchema - extends IVerificationSmsCount, - ISmsCountSchema { - isOnboardedAccount: boolean -} - -export interface IAdminContactSmsCount extends ISmsCount { - admin: IUserSchema['_id'] -} - -export interface IAdminContactSmsCountSchema - extends IAdminContactSmsCount, - ISmsCountSchema {} - -export interface IFormDeactivatedSmsCount - extends ISmsCount, - FormDeactivatedSmsData {} - -export interface IFormDeactivatedSmsCountSchema - extends ISmsCountSchema, - FormDeactivatedSmsData {} - -export interface IBouncedSubmissionSmsCount - extends ISmsCount, - BouncedSubmissionSmsData {} - -export interface IBouncedSubmissionSmsCountSchema - extends ISmsCountSchema, - BouncedSubmissionSmsData {} - -export interface ISmsCountModel extends Model { - logSms: (logParams: LogSmsParams) => Promise - /** - * Counts the number of sms which an admin has sent using default (formSG) credentials. - * NOTE: This counts across all forms which an admin has. - */ - retrieveFreeSmsCounts: (userId: string) => Promise -} - -export type TwilioCredentials = { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string -} - -export class TwilioCredentialsData { - accountSid: string - apiKey: string - apiSecret: string - messagingServiceSid: string - - constructor(twilioCredentials: TwilioCredentials) { - const { accountSid, apiKey, apiSecret, messagingServiceSid } = - twilioCredentials - - this.accountSid = accountSid - this.apiKey = apiKey - this.apiSecret = apiSecret - this.messagingServiceSid = messagingServiceSid - } - - static fromString(credentials: string): TwilioCredentials | unknown { - try { - const twilioCredentials: TwilioCredentials = JSON.parse(credentials) - return new TwilioCredentialsData(twilioCredentials) - } catch (err) { - return err - } - } - - toString(): string { - const body: TwilioCredentials = { - accountSid: this.accountSid, - apiKey: this.apiKey, - apiSecret: this.apiSecret, - messagingServiceSid: this.messagingServiceSid, - } - return JSON.stringify(body) - } -} - -export type TwilioConfig = { - client: InstanceType - msgSrvcSid: string -} - -export interface BounceNotificationSmsParams { - recipient: string - recipientEmail: string - adminId: string - adminEmail: string - formId: string - formTitle: string -} diff --git a/src/app/services/sms/sms_count.server.model.ts b/src/app/services/sms/sms_count.server.model.ts deleted file mode 100644 index 234431b70c..0000000000 --- a/src/app/services/sms/sms_count.server.model.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { parsePhoneNumberFromString } from 'libphonenumber-js/mobile' -import { Mongoose, Schema } from 'mongoose' -import validator from 'validator' - -import { smsConfig } from '../../../app/config/features/sms.config' -import { FORM_SCHEMA_ID } from '../../models/form.server.model' -import { USER_SCHEMA_ID } from '../../models/user.server.model' - -import { - IAdminContactSmsCountSchema, - IBouncedSubmissionSmsCountSchema, - IFormDeactivatedSmsCountSchema, - ISmsCount, - ISmsCountModel, - ISmsCountSchema, - IVerificationSmsCountSchema, - LogSmsParams, - LogType, - SmsType, -} from './sms.types' - -const SMS_COUNT_SCHEMA_NAME = 'SmsCount' - -const VerificationSmsCountSchema = new Schema({ - form: { - type: Schema.Types.ObjectId, - ref: FORM_SCHEMA_ID, - required: true, - }, - formAdmin: { - email: { type: String, required: true }, - userId: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, - }, - isOnboardedAccount: { - type: Boolean, - }, -}) - -VerificationSmsCountSchema.pre( - 'save', - function (next) { - const formTwilioId = smsConfig.twilioMsgSrvcSid - this.isOnboardedAccount = !(this.msgSrvcSid === formTwilioId) - return next() - }, -) - -const AdminContactSmsCountSchema = new Schema({ - admin: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, -}) - -const bounceSmsCountSchema = { - form: { - type: Schema.Types.ObjectId, - ref: FORM_SCHEMA_ID, - required: true, - }, - formAdmin: { - email: { type: String, required: true }, - userId: { - type: Schema.Types.ObjectId, - ref: USER_SCHEMA_ID, - required: true, - }, - }, - collaboratorEmail: { - type: String, - validate: validator.isEmail, - required: true, - }, - recipientNumber: { - type: String, - validate: (value: string) => { - const phoneNumber = parsePhoneNumberFromString(value) - if (!phoneNumber) return false - return phoneNumber.isValid() - }, - required: true, - }, -} - -const FormDeactivatedSmsCountSchema = - new Schema(bounceSmsCountSchema) - -const BouncedSubmissionSmsCountSchema = - new Schema(bounceSmsCountSchema) - -const compileSmsCountModel = (db: Mongoose) => { - const SmsCountSchema = new Schema( - { - msgSrvcSid: { - type: String, - required: true, - }, - logType: { - type: String, - enum: Object.values(LogType), - required: true, - }, - smsType: { - type: String, - enum: Object.values(SmsType), - required: true, - }, - }, - { - timestamps: { - createdAt: true, - updatedAt: false, - }, - discriminatorKey: 'smsType', - }, - ) - - SmsCountSchema.statics.logSms = async function ({ - smsData, - msgSrvcSid, - smsType, - logType, - }: LogSmsParams) { - const schemaData: Omit = { - ...smsData, - msgSrvcSid, - smsType, - logType, - } - - const smsCount: ISmsCountSchema = new this(schemaData) - - return smsCount.save() - } - - SmsCountSchema.statics.retrieveFreeSmsCounts = async function ( - userId: string, - ) { - return this.countDocuments({ - 'formAdmin.userId': userId, - smsType: SmsType.Verification, - isOnboardedAccount: false, - }) - .read('secondary') - .exec() - } - - const SmsCountModel = db.model( - SMS_COUNT_SCHEMA_NAME, - SmsCountSchema, - ) - - // Adding Discriminators - SmsCountModel.discriminator(SmsType.Verification, VerificationSmsCountSchema) - SmsCountModel.discriminator(SmsType.AdminContact, AdminContactSmsCountSchema) - SmsCountModel.discriminator( - SmsType.DeactivatedForm, - FormDeactivatedSmsCountSchema, - ) - SmsCountModel.discriminator( - SmsType.BouncedSubmission, - BouncedSubmissionSmsCountSchema, - ) - - return SmsCountModel -} - -/** - * Retrieves the SmsCount model on the given Mongoose instance. If the model is - * not registered yet, the model will be registered and returned. - * @param db The mongoose instance to retrieve the SmsCount model from - * @returns The SmsCount model - */ -const getSmsCountModel = (db: Mongoose): ISmsCountModel => { - try { - return db.model(SMS_COUNT_SCHEMA_NAME) as ISmsCountModel - } catch { - return compileSmsCountModel(db) - } -} -export default getSmsCountModel diff --git a/src/app/utils/formatters.ts b/src/app/utils/formatters.ts deleted file mode 100644 index c2a197f162..0000000000 --- a/src/app/utils/formatters.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Transforms a number to a well formatted percentage to display -// Formats to integer precision -export const formatAsPercentage = (num: number): string => { - return `${Math.round(num * 100).toString()}%` -} diff --git a/src/app/views/templates/sms-verification-disabled-admin.server.view.html b/src/app/views/templates/sms-verification-disabled-admin.server.view.html deleted file mode 100644 index bd8c18c913..0000000000 --- a/src/app/views/templates/sms-verification-disabled-admin.server.view.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - -

Dear form admin,

-

- You own forms with free SMS OTP verification on a Mobile Number field: -

-
    - <% forms.forEach(function({title, link}) { %> -
  1. <%= title %>
  2. - <% }) %> -
-

- - As you have reached the free tier limit of <%= smsVerificationLimit %> - per account, SMS OTP verification has been automatically disabled on all - forms you own. - - Forms with Twilio already set up will not be affected. Respondents will - still be able to submit to your forms but will not be prompted to verify - their mobile numbers -

- -

- We would have previously notified all form admins upon your account - reaching <%= smsWarningTiers.LOW %>, <%= smsWarningTiers.MED %> and <%= - smsWarningTiers.HIGH %> free verifications. -

-

- If you need to send more SMS OTP verifications, please - - arrange advance billing with us. - -

-

FormSG Team

- - diff --git a/src/app/views/templates/sms-verification-disabled-collab.server.view.html b/src/app/views/templates/sms-verification-disabled-collab.server.view.html deleted file mode 100644 index 9c3b00e779..0000000000 --- a/src/app/views/templates/sms-verification-disabled-collab.server.view.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - -

Dear form admin,

-

- You are a collaborator on a form with free SMS OTP verification on a - Mobile Number field: - <%= form.title %>. -

- -

- - As the form owner has reached the account limit of <%= - smsVerificationLimit %> free verifications, SMS verification has been - automatically disabled on this form. - - Respondents will still be able to submit to this form but will not be - prompted to verify their mobile numbers. -

- -

- If you need to send more SMS OTP verifications, please - - arrange advance billing with us. - -

-

FormSG Team

- - diff --git a/src/app/views/templates/sms-verification-warning-admin.view.html b/src/app/views/templates/sms-verification-warning-admin.view.html deleted file mode 100644 index 7304a86283..0000000000 --- a/src/app/views/templates/sms-verification-warning-admin.view.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -

Dear form admin,

-

- You own forms with free SMS OTP verification enabled on the Mobile Number - field: -

-
    - <% forms.forEach(function({title, link}) { %> -
  1. <%= title %> .
  2. - <% }) %> -
-

- - Your account can use <%= numAvailable %> more free verifications until - free SMS verification is automatically disabled on all forms you own. - - Forms with Twilio already set up will not be affected. -

-

- If you need to send more than <%= smsVerificationLimit %> SMS - verifications, please - - arrange advance billing with us. - -

-

FormSG Team

- - diff --git a/src/app/views/templates/sms-verification-warning-collab.view.html b/src/app/views/templates/sms-verification-warning-collab.view.html deleted file mode 100644 index 4da056c73e..0000000000 --- a/src/app/views/templates/sms-verification-warning-collab.view.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - -

Dear form admin,

-

- You are a collaborator on a form with SMS OTP verification enabled on a - Mobile Number field: - <%= form.title %>. -

-

- The owner of this form has used up <%= percentageUsed %> of their free - tier limit for SMS verifications. - - SMS verification will be disabled for this form when the owner’s account - has reached <%= smsVerificationLimit %> verifications. - -

-

- If you need to send more than <%= smsVerificationLimit %> SMS - verifications, please - arrange advance billing with us. - -

-

FormSG Team

- - diff --git a/src/app/views/templates/sms-verification-warning.view.html b/src/app/views/templates/sms-verification-warning.view.html deleted file mode 100644 index cc92b0d82b..0000000000 --- a/src/app/views/templates/sms-verification-warning.view.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - -

Dear form admin,

-

- You own forms with free SMS OTP verification enabled on the Mobile Number - field: -

-
    - <% forms.forEach(function({title, link}) { %> -
  1. <%= title %>
  2. - <% }) %> -
-

- - Your account can use <%= numAvailable %> more free verifications until - free SMS OTP verification is automatically disabled for all owned forms. - - Forms with Twilio already set up will not be affected. -

-

- If you need to send more than <%= smsVerificationLimit %> SMS OTP - verifications, please - arrange advance billing with us. - -

-

FormSG Team

- - diff --git a/src/types/config.ts b/src/types/config.ts index e5a817ef72..5e6d9795ee 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -90,7 +90,6 @@ export type Config = { isTest: boolean isDevOrTest: boolean nodeEnv: Environment - useMockTwilio: boolean useMockPostmanSms: boolean port: number sessionSecret: string @@ -165,7 +164,6 @@ export interface IOptionalVarsSchema { otpLifeSpan: number submissionsTopUp: number nodeEnv: Environment - useMockTwilio: boolean useMockPostmanSms: boolean } banner: { diff --git a/src/types/form.ts b/src/types/form.ts index 2f8552dba7..920f0b5e1f 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -49,8 +49,6 @@ export type FormOtpData = { email: IUserSchema['email'] userId: IUserSchema['_id'] } - // Used for sending with the correct twilio - msgSrvcName?: string } /** @@ -256,22 +254,6 @@ export interface IFormSchema extends IForm, Document, PublicView { getDuplicateParams( overrideProps: OverrideProps, ): PickDuplicateForm & OverrideProps - - /** - * Updates the msgSrvcName of the form with the specified msgSrvcName - * @param msgSrvcName msgSrvcName to update the Form docuemnt with - * @param session transaction session in which update operation is a part of - */ - updateMsgSrvcName( - msgSrvcName: string, - session?: ClientSession, - ): Promise - - /** - * Deletes the msgSrvcName of the form - * @param session transaction session in which delete operation is a part of - */ - deleteMsgSrvcName(session?: ClientSession): Promise } /** @@ -392,15 +374,6 @@ export interface IFormModel extends Model { userId: IUserSchema['_id'], ): Promise - /** - * Retrieves all the public forms for a user which has sms verifications enabled - * @param userId The userId to retrieve the forms for - * @returns All public forms that have sms verifications enabled - */ - retrievePublicFormsWithSmsVerification( - userId: IUserSchema['_id'], - ): Promise - /** * Update the end page of form with given endpage object. * @param formId the id of the form to update @@ -486,11 +459,4 @@ export type IEmailFormModel = IFormModel & Model export type IMultirespondentFormModel = IFormModel & Model -export type IOnboardedForm = T & { - msgSrvcName: string -} - -export type FormLinkView = { - title: T['title'] - link: string -} +export type IOnboardedForm = T diff --git a/src/types/index.ts b/src/types/index.ts index 5fc43de979..0e5888db05 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,7 +19,6 @@ export * from './admin_verification' export * from './config' export * from './routing' export * from './email_mode_data' -export * from './twilio' export * from './workspace' export * from './payment' export * from './admin_feedback' diff --git a/src/types/twilio.ts b/src/types/twilio.ts deleted file mode 100644 index 969b7683ee..0000000000 --- a/src/types/twilio.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Based off Twilio documentation - https://www.twilio.com/docs/usage/webhooks/sms-webhooks -export interface ITwilioSmsWebhookBody { - SmsSid: string - SmsStatus: string - MessageStatus: string - To: string - MessageSid: string - AccountSid: string - MessagingServiceSid: string - From: string - ApiVersion: string - ErrorCode?: number // Only filled when it is 'failed' or 'undelivered' - ErrorMessage?: string // Only filled when it is 'failed' or 'undelivered' -} - -export type TwilioSmsStatsdTags = { - errorcode: string - smsstatus: string -}