From 3dbb3bfc199fdd7faac68cdee236d2625d6fb1ea Mon Sep 17 00:00:00 2001 From: Yathi <511386+yuth@users.noreply.github.com> Date: Thu, 20 May 2021 16:38:38 -0700 Subject: [PATCH] feat: prep work for Cognito SMS Sandbox #2 (#7338) * feat: display SNS sandbox status * chore: SNS API error handling * chore: add support for SMS Sandbox check * chore: auth add show sandbox warning * chore: add some tests * chore: address review comments and remove unused code --- .../utils/message-printer.test.ts | 29 +++++ .../handlers/resource-handlers.ts | 64 +++++----- .../utils/message-printer.ts | 19 +++ .../__tests__/display-helpful-urls.test.ts | 116 ++++++++++++++++++ .../src/aws-utils/aws-sns.ts | 34 +++++ .../src/display-helpful-urls.js | 53 ++++++-- 6 files changed, 276 insertions(+), 39 deletions(-) create mode 100644 packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/utils/message-printer.test.ts create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/display-helpful-urls.test.ts create mode 100644 packages/amplify-provider-awscloudformation/src/aws-utils/aws-sns.ts diff --git a/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/utils/message-printer.test.ts b/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/utils/message-printer.test.ts new file mode 100644 index 00000000000..c7c86cee3b1 --- /dev/null +++ b/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/utils/message-printer.test.ts @@ -0,0 +1,29 @@ +import { printSMSSandboxWarning } from '../../../../provider-utils/awscloudformation/utils/message-printer'; +import { BannerMessage } from 'amplify-cli-core'; +jest.mock('amplify-cli-core'); +const printMock = { + info: jest.fn(), +}; + +describe('printSMSSandboxWarning', () => { + const mockedGetMessage = jest.spyOn(BannerMessage, 'getMessage'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should print warning when the message is present', async () => { + const message = 'BannerMessage'; + mockedGetMessage.mockResolvedValueOnce(message); + await printSMSSandboxWarning(printMock); + expect(printMock.info).toHaveBeenCalledWith(`${message}\n`); + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_CATEGORY_AUTH_ADD_OR_UPDATE_INFO'); + }); + + it('should not print warning when the banner message is missing', async () => { + mockedGetMessage.mockResolvedValueOnce(undefined); + await printSMSSandboxWarning(printMock); + expect(printMock.info).not.toHaveBeenCalled(); + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_CATEGORY_AUTH_ADD_OR_UPDATE_INFO'); + }); +}); diff --git a/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts b/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts index 07e3880bf8e..08bed5f9eeb 100644 --- a/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts +++ b/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts @@ -2,8 +2,9 @@ import { ServiceQuestionsResult } from '../service-walkthrough-types'; import { getAddAuthDefaultsApplier, getUpdateAuthDefaultsApplier } from '../utils/auth-defaults-appliers'; import { getResourceSynthesizer, getResourceUpdater } from '../utils/synthesize-resources'; import { getPostAddAuthMetaUpdater, getPostUpdateAuthMetaUpdater } from '../utils/amplify-meta-updaters'; -import { getPostAddAuthMessagePrinter, getPostUpdateAuthMessagePrinter } from '../utils/message-printer'; +import { getPostAddAuthMessagePrinter, getPostUpdateAuthMessagePrinter, printSMSSandboxWarning } from '../utils/message-printer'; import { supportedServices } from '../../supported-services'; +import { doesConfigurationIncludeSMS } from '../utils/auth-sms-workflow-helper'; /** * Factory function that returns a ServiceQuestionsResult consumer that handles all of the resource generation logic. @@ -13,43 +14,48 @@ import { supportedServices } from '../../supported-services'; export const getAddAuthHandler = (context: any) => async (request: ServiceQuestionsResult) => { const serviceMetadata = supportedServices[request.serviceName]; const { cfnFilename, defaultValuesFilename, provider } = serviceMetadata; + let projectName = context.amplify.getProjectConfig().projectName.toLowerCase(); const disallowedChars = /[^A-Za-z0-9]+/g; projectName = projectName.replace(disallowedChars, ''); + const requestWithDefaults = await getAddAuthDefaultsApplier(context, defaultValuesFilename, projectName)(request); - await getResourceSynthesizer( - context, - cfnFilename, - provider, - )(requestWithDefaults) - .then(req => req.resourceName!) - .then(getPostAddAuthMetaUpdater(context, { service: requestWithDefaults.serviceName, providerName: provider })) - .then(getPostAddAuthMessagePrinter(context.print)) - .catch(err => { - context.print.info(err.stack); - context.print.error('There was an error adding the auth resource'); - context.usageData.emitError(err); - process.exitCode = 1; - }); + + try { + await getResourceSynthesizer(context, cfnFilename, provider)(requestWithDefaults); + await getPostAddAuthMetaUpdater(context, { service: requestWithDefaults.serviceName, providerName: provider })( + requestWithDefaults.resourceName!, + ); + await getPostAddAuthMessagePrinter(context.print)(requestWithDefaults.resourceName!); + + if (doesConfigurationIncludeSMS(request)) { + await printSMSSandboxWarning(context.print); + } + } catch (err) { + context.print.info(err.stack); + context.print.error('There was an error adding the auth resource'); + context.usageData.emitError(err); + process.exitCode = 1; + } return requestWithDefaults.resourceName!; }; export const getUpdateAuthHandler = (context: any) => async (request: ServiceQuestionsResult) => { const { cfnFilename, defaultValuesFilename, provider } = supportedServices[request.serviceName]; const requestWithDefaults = await getUpdateAuthDefaultsApplier(context, defaultValuesFilename, context.updatingAuth)(request); - await getResourceUpdater( - context, - cfnFilename, - provider, - )(requestWithDefaults) - .then(req => req.resourceName!) - .then(getPostUpdateAuthMetaUpdater(context)) - .then(getPostUpdateAuthMessagePrinter(context.print)) - .catch(err => { - context.print.info(err.stack); - context.print.error('There was an error updating the auth resource'); - context.usageData.emitError(err); - process.exitCode = 1; - }); + try { + await getResourceUpdater(context, cfnFilename, provider)(requestWithDefaults); + await getPostUpdateAuthMetaUpdater(context)(requestWithDefaults.resourceName!); + await getPostUpdateAuthMessagePrinter(context.print)(requestWithDefaults.resourceName!); + + if (doesConfigurationIncludeSMS(requestWithDefaults)) { + await printSMSSandboxWarning(context.print); + } + } catch (err) { + context.print.info(err.stack); + context.print.error('There was an error updating the auth resource'); + context.usageData.emitError(err); + process.exitCode = 1; + } return requestWithDefaults.resourceName!; }; diff --git a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/message-printer.ts b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/message-printer.ts index 662bfc97f18..654766742bd 100644 --- a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/message-printer.ts +++ b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/message-printer.ts @@ -1,3 +1,5 @@ +import { ServiceQuestionsResult } from '../service-walkthrough-types'; +import { BannerMessage } from 'amplify-cli-core'; /** * A factory function that returns a function that prints the "success message" after adding auth * @param print The amplify print object @@ -25,3 +27,20 @@ const printCommonText = (print: any) => { ); print.info(''); }; + +export const printSMSSandboxWarning = async (print: any) => { + const postAddUpdateSMSSandboxInfo = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_CATEGORY_AUTH_ADD_OR_UPDATE_INFO'); + postAddUpdateSMSSandboxInfo && print.info(`${postAddUpdateSMSSandboxInfo}\n`); +}; + +export const doesConfigurationIncludeSMS = (request: ServiceQuestionsResult): boolean => { + if ((request.mfaConfiguration === 'OPTIONAL' || request.mfaConfiguration === 'ON') && request.mfaTypes?.includes('SMS Text Message')) { + return true; + } + + if (request.usernameAttributes?.includes('phone_number')) { + return true; + } + + return false; +}; diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/display-helpful-urls.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/display-helpful-urls.test.ts new file mode 100644 index 00000000000..5115f0d0124 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/display-helpful-urls.test.ts @@ -0,0 +1,116 @@ +import { showSMSSandboxWarning } from '../display-helpful-urls'; +import { BannerMessage } from 'amplify-cli-core'; +import { SNS } from '../aws-utils/aws-sns'; +import { AWSError } from 'aws-sdk'; + +jest.mock('../aws-utils/aws-sns'); +jest.mock('amplify-cli-core'); + +describe('showSMSSandBoxWarning', () => { + const mockedGetMessage = jest.spyOn(BannerMessage, 'getMessage'); + const mockedSNSClientInstance = { + isInSandboxMode: jest.fn(), + }; + + let mockedSNSClass; + const context = { + print: { + warning: jest.fn(), + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + mockedSNSClass = jest.spyOn(SNS, 'getInstance').mockResolvedValue((mockedSNSClientInstance as unknown) as SNS); + }); + + describe('when API is missing in SDK', () => { + beforeEach(() => { + mockedSNSClientInstance.isInSandboxMode.mockRejectedValue(new TypeError()); + }); + + it('should not show warning when SNS client is missing sandbox api and there is no banner message associated', async () => { + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); + expect(context.print.warning).not.toHaveBeenCalled(); + }); + + it('should show warning when SNS Client is missing sandbox API and there is a banner message associated', async () => { + const message = 'UPGRADE YOUR CLI!!!!'; + mockedGetMessage.mockImplementation(async messageId => (messageId === 'COGNITO_SMS_SANDBOX_UPDATE_WARNING' ? message : undefined)); + + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); + expect(context.print.warning).toHaveBeenCalledWith(message); + }); + }); + + describe('when IAM user is missing sandbox permission', () => { + beforeEach(() => { + const authError = new Error() as AWSError; + authError.code = 'AuthorizationError'; + mockedSNSClientInstance.isInSandboxMode.mockRejectedValue(authError); + }); + it('should not show any warning if there is no message associated', async () => { + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_MISSING_PERMISSION'); + expect(context.print.warning).not.toHaveBeenCalled(); + }); + + it('should show any warning if there is no message associated', async () => { + const message = 'UPDATE YOUR PROFILE USER WITH SANDBOX PERMISSION'; + + mockedGetMessage.mockImplementation(async messageId => { + switch (messageId) { + case 'COGNITO_SMS_SANDBOX_MISSING_PERMISSION': + return message; + case 'COGNITO_SMS_SANDBOX_UPDATE_WARNING': + return 'enabled'; + } + }); + + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_MISSING_PERMISSION'); + expect(context.print.warning).toHaveBeenCalledWith(message); + }); + }); + + describe('it should not show any warning message when the SNS API is not deployed', () => { + beforeEach(() => { + const resourceNotFoundError = new Error() as AWSError; + resourceNotFoundError.code = 'ResourceNotFound'; + mockedSNSClientInstance.isInSandboxMode.mockRejectedValue(resourceNotFoundError); + }); + it('should not print error', async () => { + const message = 'UPGRADE YOUR CLI!!!!'; + mockedGetMessage.mockImplementation(async messageId => (messageId === 'COGNITO_SMS_SANDBOX_UPDATE_WARNING' ? message : undefined)); + + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); + expect(context.print.warning).not.toHaveBeenCalledWith(message); + }); + }); + + describe('it should not show any warning message when there is a network error', () => { + beforeEach(() => { + const networkError = new Error() as AWSError; + networkError.code = 'UnknownEndpoint'; + mockedSNSClientInstance.isInSandboxMode.mockRejectedValue(networkError); + }); + + it('should not print error', async () => { + const message = 'UPGRADE YOUR CLI!!!!'; + mockedGetMessage.mockImplementation(async messageId => (messageId === 'COGNITO_SMS_SANDBOX_UPDATE_WARNING' ? message : undefined)); + + await showSMSSandboxWarning(context); + + expect(mockedGetMessage).toHaveBeenCalledWith('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); + expect(context.print.warning).not.toHaveBeenCalledWith(message); + }); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/aws-utils/aws-sns.ts b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-sns.ts new file mode 100644 index 00000000000..8196f759011 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-sns.ts @@ -0,0 +1,34 @@ +import { $TSAny, $TSContext } from 'amplify-cli-core'; +import { loadConfiguration } from '../configuration-manager'; +import aws from './aws.js'; + +export class SNS { + private static instance: SNS; + private readonly sns: AWS.SNS; + + static async getInstance(context: $TSContext, options = {}): Promise { + if (!SNS.instance) { + let cred = {}; + try { + cred = await loadConfiguration(context); + } catch (e) { + // ignore missing config + } + + SNS.instance = new SNS(context, cred, options); + } + return SNS.instance; + } + + private constructor(context: $TSContext, cred: $TSAny, options = {}) { + this.sns = new aws.SNS({ ...cred, ...options }); + } + + public async isInSandboxMode(): Promise { + // AWS SDK still does not have getSMSSandboxAccountStatus. Casting sns to any to avoid compile error + // Todo: remove any casting once aws-sdk is updated + const snsClient = this.sns as any; + const result = await snsClient.getSMSSandboxAccountStatus().promise(); + return result.IsInSandbox; + } +} diff --git a/packages/amplify-provider-awscloudformation/src/display-helpful-urls.js b/packages/amplify-provider-awscloudformation/src/display-helpful-urls.js index 475048d26ec..384653d3834 100644 --- a/packages/amplify-provider-awscloudformation/src/display-helpful-urls.js +++ b/packages/amplify-provider-awscloudformation/src/display-helpful-urls.js @@ -2,6 +2,7 @@ const chalk = require('chalk'); const { BannerMessage } = require('amplify-cli-core'); const { fileLogger } = require('./utils/aws-logger'); +const { SNS } = require('./aws-utils/aws-sns'); const logger = fileLogger('display-helpful-urls'); @@ -171,11 +172,6 @@ function showHostedUIURLs(context, resourcesToBeCreated) { } async function showCognitoSandBoxMessage(context, resources) { - const smsSandBoxMessage = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); - if (!smsSandBoxMessage) { - return; - } - const cognitoResource = resources.filter(resource => resource.service === 'Cognito'); if (cognitoResource.length > 0) { @@ -187,13 +183,11 @@ async function showCognitoSandBoxMessage(context, resources) { cognitoResource[0].resourceName, ]); if (smsWorkflowEnabled) { - context.print.warning(smsSandBoxMessage); - return; + await showSMSSandboxWarning(context); } } catch (e) { - if (e.name !== 'MethodNotFound') { - log(e); - } + log(e); + throw e; } } } @@ -221,6 +215,45 @@ async function showRekognitionURLS(context, resourcesToBeCreated) { } } +async function showSMSSandboxWarning(context) { + const log = logger('showSMSSandBoxWarning', []); + + // This message will be set only after SNS Sandbox Sandbox API is available and AWS SDK gets updated + const cliUpdateWarning = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_UPDATE_WARNING'); + const smsSandBoxMissingPermissionWarning = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_MISSING_PERMISSION'); + const sandboxModeWarning = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_SANDBOXED_MODE_WARNING'); + const productionModeInfo = await BannerMessage.getMessage('COGNITO_SMS_SANDBOX_PRODUCTION_MODE_INFO'); + if (!cliUpdateWarning) { + return; + } + + try { + const snsClient = await SNS.getInstance(context); + const sandboxStatus = await snsClient.isInSandboxMode(); + + if (sandboxStatus) { + sandboxModeWarning && context.print.warning(sandboxModeWarning); + } else { + productionModeInfo && context.print.warning(productionModeInfo); + } + } catch (e) { + if (e.code === 'AuthorizationError') { + smsSandBoxMissingPermissionWarning && context.print.warning(smsSandBoxMissingPermissionWarning); + } else if (e instanceof TypeError) { + context.print.warning(cliUpdateWarning); + } else if (e.code === 'ResourceNotFound') { + // API is not public yet. Ignore it for now. This error should not occur as `COGNITO_SMS_SANDBOX_UPDATE_WARNING` will not be set + } else if (e.code === 'UnknownEndpoint') { + // Network error. Sandbox status is for informational purpose and should not stop deployment + log(e); + } else { + log(e); + throw e; + } + } +} + module.exports = { displayHelpfulURLs, + showSMSSandboxWarning, };