Skip to content

Commit

Permalink
Merge 6f9ab31 into 8db59d2
Browse files Browse the repository at this point in the history
  • Loading branch information
KenLSM committed May 23, 2024
2 parents 8db59d2 + 6f9ab31 commit b52f4b2
Show file tree
Hide file tree
Showing 17 changed files with 727 additions and 17 deletions.
3 changes: 2 additions & 1 deletion .template-env
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,5 @@ 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_TWILIO=
# USE_MOCK_POSTMAN_SMS=
3 changes: 3 additions & 0 deletions __tests__/setup/.test-env
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,6 @@ SSM_ENV_SITE_NAME=test

# Public API env vars
API_KEY_VERSION=v1
POSTMAN_MOP_CAMPAIGN_ID=campaign_tesst
POSTMAN_MOP_CAMPAIGN_API_KEY=key_test_123
POSTMAN_BASE_URL=https://test.postman.gov.sg/api/v2
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ services:
- WOGAA_START_ENDPOINT
- WOGAA_SUBMIT_ENDPOINT
- WOGAA_FEEDBACK_ENDPOINT

- POSTMAN_MOP_CAMPAIGN_ID
- POSTMAN_MOP_CAMPAIGN_API_KEY
- POSTMAN_BASE_URL

mockpass:
build: https://github.com/opengovsg/mockpass.git#v4.0.4
Expand Down
1 change: 1 addition & 0 deletions shared/constants/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const featureFlags = {
myinfoSgid: 'myinfo-sgid' as const,
chartsMaxResponseCount: 'charts-max-response-count' as const,
addingTwilioDisabled: 'adding-twilio-disabled' as const,
postmanSms: 'postmanSms' as const,
}
1 change: 1 addition & 0 deletions shared/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const UserBase = z.object({
.object({
payment: z.boolean().optional(),
children: z.boolean().optional(),
postmanSms: z.boolean().optional(),
})
.optional(),
flags: z
Expand Down
2 changes: 2 additions & 0 deletions src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const isDev =
basicVars.core.nodeEnv === Environment.Test
const nodeEnv = isDev ? basicVars.core.nodeEnv : Environment.Prod
const useMockTwilio = basicVars.core.useMockTwilio
const useMockPostmanSms = basicVars.core.useMockPostmanSms

// Load and validate configuration values which are compulsory only in production
// If environment variables are not present, an error will be thrown
Expand Down Expand Up @@ -235,6 +236,7 @@ const config: Config = {
cookieSettings,
isDev,
useMockTwilio,
useMockPostmanSms,
nodeEnv,
formsgSdkMode: basicVars.formsgSdkMode,
customCloudWatchGroup: basicVars.awsConfig.customCloudWatchGroup,
Expand Down
32 changes: 32 additions & 0 deletions src/app/config/features/postman-sms.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import convict, { Schema } from 'convict'

export interface ISms {
mopCampaignId: string
mopCampaignApiKey: string
postmanBaseUrl: string
}

const postmanSmsSchema: Schema<ISms> = {
mopCampaignId: {
doc: 'Postman SMS messaging campaign ID',
format: String,
default: null,
env: 'POSTMAN_MOP_CAMPAIGN_ID',
},
mopCampaignApiKey: {
doc: 'Postman SMS messaging campaign ID',
format: String,
default: null,
env: 'POSTMAN_MOP_CAMPAIGN_API_KEY',
},
postmanBaseUrl: {
doc: 'Postman base URL',
format: String,
default: null,
env: 'POSTMAN_BASE_URL',
},
}

export const postmanSmsConfig = convict(postmanSmsSchema)
.validate({ allowed: 'strict' })
.getProperties()
7 changes: 7 additions & 0 deletions src/app/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,19 @@ export const optionalVarsSchema: Schema<IOptionalVarsSchema> = {
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',
default: false,
env: 'USE_MOCK_POSTMAN_SMS',
},
},
rateLimit: {
submissions: {
Expand Down
1 change: 1 addition & 0 deletions src/app/models/user.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const compileUserModel = (db: Mongoose) => {
betaFlags: {
payment: Boolean,
children: Boolean,
postmanSms: Boolean,
},
flags: {
lastSeenFeatureUpdateVersion: Number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import getMockLogger from '__tests__/unit/backend/helpers/jest-logger'
import { ObjectId } from 'bson'
import { addHours, subHours, subMinutes, subSeconds } from 'date-fns'
import mongoose from 'mongoose'
import { errAsync, okAsync } from 'neverthrow'
import { err, errAsync, ok, okAsync } from 'neverthrow'

// These need to be mocked first before the rest of the test
import * as LoggerModule from 'src/app/config/logger'
Expand All @@ -30,14 +30,21 @@ import {
} from 'src/app/modules/verification/verification.errors'
import { MailSendError } from 'src/app/services/mail/mail.errors'
import MailService from 'src/app/services/mail/mail.service'
import PostmanSmsService from 'src/app/services/postman-sms/postman-sms.service'
import { SmsSendError } from 'src/app/services/sms/sms.errors'
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 {
IFormSchema,
IUserSchema,
IVerificationSchema,
UpdateFieldData,
} from 'src/types'

import { BasicField } from '../../../../../shared/types'
import { DatabaseError } from '../../core/core.errors'
import { FormNotFoundError } from '../../form/form.errors'
import * as AdminFormUtils from '../../form/admin-form/admin-form.utils'
import { ForbiddenFormError, FormNotFoundError } from '../../form/form.errors'
import {
FieldNotFoundInTransactionError,
TransactionExpiredError,
Expand Down Expand Up @@ -311,6 +318,10 @@ describe('Verification service', () => {
.spyOn(VerificationModel, 'updateHashForField')
.mockResolvedValue(mockTransactionSuccessful)
MockFormService.retrieveFormById.mockReturnValue(okAsync(mockForm))

jest
.spyOn(AdminFormUtils, 'verifyUserBetaflag')
.mockReturnValue(err(new ForbiddenFormError('ForbiddenFormError')))
})

it('should send OTP and update hashes when parameters are valid', async () => {
Expand All @@ -337,6 +348,45 @@ describe('Verification service', () => {
expect(result._unsafeUnwrap()).toEqual(mockTransactionSuccessful)
})

it('should send OTP with postman if admin has feature flag on', async () => {
jest
.spyOn(AdminFormUtils, 'verifyUserBetaflag')
.mockReturnValue(ok(true as unknown as IUserSchema))

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 admin has feature flag on', async () => {
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,
Expand Down
43 changes: 31 additions & 12 deletions src/app/modules/verification/verification.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import mongoose from 'mongoose'
import { errAsync, okAsync, ResultAsync } from 'neverthrow'

import { PAYMENT_CONTACT_FIELD_ID } from '../../../../shared/constants'
import {
featureFlags,
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'
Expand All @@ -14,6 +17,7 @@ import formsgSdk from '../../config/formsg-sdk'
import { createLoggerWithLabel } from '../../config/logger'
import { MailSendError } from '../../services/mail/mail.errors'
import MailService from '../../services/mail/mail.service'
import PostmanSmsService from '../../services/postman-sms/postman-sms.service'
import { InvalidNumberError, SmsSendError } from '../../services/sms/sms.errors'
import { SmsFactory } from '../../services/sms/sms.factory'
import { transformMongoError } from '../../utils/handle-mongo-error'
Expand All @@ -23,6 +27,7 @@ import {
MalformedParametersError,
PossibleDatabaseError,
} from '../core/core.errors'
import { verifyUserBetaflag } from '../form/admin-form/admin-form.utils'
import { FormNotFoundError } from '../form/form.errors'
import * as FormService from '../form/form.service'

Expand Down Expand Up @@ -452,18 +457,32 @@ const sendOtpForField = (
return fieldId
? FormService.retrieveFormById(formId)
// check if we should allow public user to request for otp
.andThen((form) => {
return okAsync(form)
})
.andThen((form) =>
shouldGenerateMobileOtp(form, fieldId, recipient),
)
// call sms - it should validate the recipient
.andThen(() =>
SmsFactory.sendVerificationOtp(
recipient,
otp,
otpPrefix,
formId,
senderIp,
),
shouldGenerateMobileOtp(form, fieldId, recipient).andThen(() => {
const postmanFlag = verifyUserBetaflag(
form.admin,
featureFlags.postmanSms,
)
if (postmanFlag.isErr()) {
return SmsFactory.sendVerificationOtp(
recipient,
otp,
otpPrefix,
formId,
senderIp,
)
}
return PostmanSmsService.sendVerificationOtp(
recipient,
otp,
otpPrefix,
formId,
senderIp,
)
}),
)
: errAsync(new MalformedParametersError('Field id not present'))
case BasicField.Email:
Expand Down
Loading

0 comments on commit b52f4b2

Please sign in to comment.