From 9a414683090cb3edcac61d9a782c4d136f18c6f6 Mon Sep 17 00:00:00 2001 From: Mariusz Nowak Date: Thu, 27 Oct 2022 14:09:23 +0200 Subject: [PATCH] feat(Console): External Console integration --- lib/cli/interactive-setup/console-login.js | 34 +-- .../interactive-setup/console-resolve-org.js | 85 ++++++ .../console-setup-iam-role.js | 178 ++++++++++++ lib/cli/interactive-setup/dashboard-login.js | 4 +- .../interactive-setup/dashboard-set-org.js | 4 +- lib/cli/interactive-setup/deploy.js | 7 +- lib/cli/interactive-setup/index.js | 24 +- lib/cli/interactive-setup/utils.js | 11 +- lib/plugins/aws/info/display.js | 52 +++- lib/utils/telemetry/generate-payload.js | 4 + package.json | 2 +- scripts/serverless.js | 19 +- .../.serverlessrc | 11 - .../serverless.yml | 5 - .../.serverlessrc | 11 - .../serverless.yml | 5 - .../interactive-setup/console-login.test.js | 45 +--- .../console-resolve-org.test.js | 218 +++++++++++++++ .../console-setup-iam-role.test.js | 254 ++++++++++++++++++ .../interactive-setup/dashboard-login.test.js | 4 +- .../lib/cli/interactive-setup/index.test.js | 6 +- .../utils/telemetry/generate-payload.test.js | 10 + 22 files changed, 878 insertions(+), 115 deletions(-) create mode 100644 lib/cli/interactive-setup/console-resolve-org.js create mode 100644 lib/cli/interactive-setup/console-setup-iam-role.js delete mode 100644 test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc delete mode 100644 test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml delete mode 100644 test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc delete mode 100644 test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml create mode 100644 test/unit/lib/cli/interactive-setup/console-resolve-org.test.js create mode 100644 test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js diff --git a/lib/cli/interactive-setup/console-login.js b/lib/cli/interactive-setup/console-login.js index 37d74e3e2f2..5e731973ce3 100644 --- a/lib/cli/interactive-setup/console-login.js +++ b/lib/cli/interactive-setup/console-login.js @@ -1,20 +1,13 @@ 'use strict'; -const _ = require('lodash'); const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); const login = require('../../commands/login/console'); const { showOnboardingWelcome } = require('./utils'); const loginOrRegisterQuestion = async (context) => { - let message; - if (context.initial.isInServiceContext) { - message = 'Press [Enter] to create a free Serverless Console account'; - } else { - message = 'Do you want to login/register to Serverless Console?'; - } return promptWithHistory({ - message, + message: 'Press [Enter] to login to Serverless Console.', type: 'confirm', name: 'shouldLoginOrRegister', stepHistory: context.stepHistory, @@ -23,48 +16,29 @@ const loginOrRegisterQuestion = async (context) => { const steps = { loginOrRegister: async (context) => { - const shouldLoginOrRegister = - context.options.org || context.configuration.org || (await loginOrRegisterQuestion(context)); + const shouldLoginOrRegister = await loginOrRegisterQuestion(context); if (shouldLoginOrRegister) await login({ clientOriginCommand: 'onboarding' }); }, }; module.exports = { async isApplicable(context) { - const { isConsole, configuration, serviceDir } = context; + const { isConsole } = context; if (!isConsole) { context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; return false; } - if (!serviceDir) { - context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; - return false; - } - if (await resolveAuthMode()) { context.inapplicabilityReasonCode = 'ALREADY_LOGGED_IN'; return false; } - if ( - _.get(configuration, 'provider') !== 'aws' && - _.get(configuration, 'provider.name') !== 'aws' - ) { - context.inapplicabilityReasonCode = 'NON_AWS_PROVIDER'; - return false; - } - - const runtime = _.get(configuration.provider, 'runtime') || 'nodejs14.x'; - if (!runtime.startsWith('nodejs')) { - context.inapplicabilityReasonCode = 'UNSUPPORTED_RUNTIME'; - return false; - } return true; }, async run(context) { - if (context.initial.isInServiceContext) showOnboardingWelcome(context); + showOnboardingWelcome(context); return steps.loginOrRegister(context); }, diff --git a/lib/cli/interactive-setup/console-resolve-org.js b/lib/cli/interactive-setup/console-resolve-org.js new file mode 100644 index 00000000000..156b1d0d2f2 --- /dev/null +++ b/lib/cli/interactive-setup/console-resolve-org.js @@ -0,0 +1,85 @@ +'use strict'; + +const { log } = require('@serverless/utils/log'); +const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); +const apiRequest = require('@serverless/utils/api-request'); +const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); +const { showOnboardingWelcome } = require('./utils'); + +const orgsChoice = async (orgs, stepHistory) => + promptWithHistory({ + message: 'What org do you want to add this service to?', + type: 'list', + name: 'orgName', + choices: [ + ...orgs.map((org) => ({ name: org.orgName, value: org })), + { name: '[Skip]', value: '_skip_' }, + ], + stepHistory, + }); + +const resolveOrgs = async () => { + const { userId } = await apiRequest('/api/identity/me'); + return (await apiRequest(`/api/identity/users/${userId}/orgs`)).orgs; +}; + +module.exports = { + async isApplicable(context) { + const { options, isConsole } = context; + + if (!isConsole) { + context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; + return false; + } + + if (!(await resolveAuthMode())) { + context.inapplicabilityReasonCode = 'NOT_LOGGED_IN'; + return false; + } + + const orgs = await resolveOrgs(); + + const orgName = options.org; + if (!orgs.length) { + context.inapplicabilityReasonCode = 'NO_ORGS_AVAILABLE'; + return false; + } + + log.notice(); + showOnboardingWelcome(context); + + if (orgName) { + const org = orgs.find((someOrg) => someOrg.orgName === orgName); + if (org) { + context.org = org; + context.inapplicabilityReasonCode = 'RESOLVED_FROM_OPTIONS'; + return false; + } + + log.error( + 'Passed value for "--org" doesn\'t seem to correspond to account with which ' + + "you're logged in with. Please choose applicable org" + ); + + return { orgs, isOrgMismatch: true }; + } else if (orgs.length === 1) { + context.org = orgs[0]; + context.inapplicabilityReasonCode = 'ONLY_ORG'; + return false; + } + return { orgs }; + }, + async run(context, stepData) { + const { stepHistory } = context; + + const org = await orgsChoice(stepData.orgs, stepHistory); + + if (org === '_skip_') { + log.error('Console integraton aborted'); + context.isConsole = false; + return; + } + context.org = org; + }, + configuredQuestions: ['orgName'], +}; diff --git a/lib/cli/interactive-setup/console-setup-iam-role.js b/lib/cli/interactive-setup/console-setup-iam-role.js new file mode 100644 index 00000000000..2ed681f806b --- /dev/null +++ b/lib/cli/interactive-setup/console-setup-iam-role.js @@ -0,0 +1,178 @@ +'use strict'; + +const wait = require('timers-ext/promise/sleep'); +const { log, style, progress } = require('@serverless/utils/log'); +const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); +const apiRequest = require('@serverless/utils/api-request'); +const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); +const { awsRequest } = require('./utils'); + +const iamRoleStackName = 'Serverless-Inc-Role-Stack'; +const cloudFormationServiceConfig = { name: 'CloudFormation', params: { region: 'us-east-1' } }; + +const waitUntilStackIsCreated = async (context) => { + await wait(2000); + const stackEvents = ( + await awsRequest(context, cloudFormationServiceConfig, 'describeStackEvents', { + StackName: iamRoleStackName, + }) + ).StackEvents; + const failedStatusReasons = stackEvents + .filter(({ ResourceStatus: status }) => { + return status && status.endsWith('_FAILED'); + }) + .map(({ ResourceStatusReason: reason }) => reason); + + if (failedStatusReasons.length) { + log.error(`Creating IAM Role failed:\n - ${failedStatusReasons.join('\n - ')}`); + return false; + } + const statusEvent = stackEvents.find( + ({ ResourceType: resourceType }) => resourceType === 'AWS::CloudFormation::Stack' + ); + const status = statusEvent ? statusEvent.ResourceStatus : null; + if (status && status.endsWith('_COMPLETE')) { + if (status === 'CREATE_COMPLETE') return true; + log.error('Creating IAM Role failed'); + return false; + } + return waitUntilStackIsCreated(context); +}; + +const waitUntilIntegrationIsReady = async (context) => { + await wait(2000); + const { integrations } = await apiRequest(`/api/integrations/?orgId=${context.org.orgId}`, { + urlName: 'integrationsBackend', + }); + const integration = integrations.find( + ({ vendorAccount }) => vendorAccount === context.awsAccountId + ); + if (integration && integration.status === 'alive' && integration.syncStatus !== 'pending') return; + await waitUntilIntegrationIsReady(context); + return; +}; + +module.exports = { + async isApplicable(context) { + const { isConsole } = context; + + if (!isConsole) { + context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; + return false; + } + + if (!(await resolveAuthMode())) { + context.inapplicabilityReasonCode = 'NOT_LOGGED_IN'; + return false; + } + + if (!context.org) { + context.inapplicabilityReasonCode = 'UNRESOLVED_ORG'; + return false; + } + + const { integrations } = await apiRequest(`/api/integrations/?orgId=${context.org.orgId}`, { + urlName: 'integrationsBackend', + }); + + const integration = integrations.find( + ({ vendorAccount }) => vendorAccount === context.awsAccountId + ); + + if (integration) { + if (integration.status !== 'alive' || integration.syncStatus === 'pending') { + const integrationSetupProgress = progress.get('integration-setup'); + try { + integrationSetupProgress.notice('Setting up Serverless Console Integration'); + await waitUntilIntegrationIsReady(context); + } finally { + integrationSetupProgress.remove(); + } + } + + log.notice(); + log.notice.success('Your AWS account is integrated with Serverless Console'); + context.inapplicabilityReasonCode = 'INTEGRATED'; + return false; + } + + try { + await awsRequest(context, cloudFormationServiceConfig, 'describeStacks', { + StackName: iamRoleStackName, + }); + log.warning( + 'Cannot integrate with Serverless Console: ' + + 'AWS account seems already integrated with other org' + ); + context.inapplicabilityReasonCode = 'AWS_ACCOUNT_ALREADY_INTEGRATED'; + return false; + } catch (error) { + if (error.Code === 'ValidationError') return true; + if (error.providerErrorCodeExtension === 'VALIDATION_ERROR') return true; + throw error; + } + }, + + async run(context) { + const { stepHistory } = context; + + if ( + !(await promptWithHistory({ + message: `Press [Enter] to enable Serverless Console's next-generation monitoring.\n\n${style.aside( + [ + 'This will create an IAM Role in your AWS account with the following permissions:', + '- Subscribe to CloudWatch logs and metrics', + '- Update Lambda layers and env vars to add tracing and real-time logging', + '- Read resource info for security alerts', + `See the IAM Permissions transparently here: ${style.link( + 'https://slss.io/iam-role-permissions' + )}`, + ] + )}`, + type: 'confirm', + name: 'shouldSetupConsoleIamRole', + stepHistory, + })) + ) { + return false; + } + + log.notice(); + + const integrationSetupProgress = progress.get('integration-setup'); + integrationSetupProgress.notice('Creating IAM Role for Serverless Console'); + + try { + const { cfnTemplateUrl, params } = await apiRequest( + `/api/integrations/aws/initial?orgId=${context.org.orgId}`, + { urlName: 'integrationsBackend' } + ); + + await awsRequest(context, cloudFormationServiceConfig, 'createStack', { + Capabilities: ['CAPABILITY_NAMED_IAM'], + StackName: iamRoleStackName, + TemplateURL: cfnTemplateUrl, + Parameters: [ + { ParameterKey: 'AccountId', ParameterValue: params.accountId }, + { ParameterKey: 'ReportServiceToken', ParameterValue: params.reportServiceToken }, + { ParameterKey: 'ExternalId', ParameterValue: params.externalId }, + { ParameterKey: 'Version', ParameterValue: params.version }, + ], + }); + + if (!(await waitUntilStackIsCreated(context))) return false; + + integrationSetupProgress.notice( + 'Setting up Serverless Console Integration (this may take 5-10 minutes)' + ); + + await waitUntilIntegrationIsReady(context); + + log.notice.success('Your AWS account is integrated with Serverless Console'); + } finally { + integrationSetupProgress.remove(); + } + return true; + }, + configuredQuestions: ['shouldSetupConsoleIamRole'], +}; diff --git a/lib/cli/interactive-setup/dashboard-login.js b/lib/cli/interactive-setup/dashboard-login.js index bdb36261461..968059a7460 100644 --- a/lib/cli/interactive-setup/dashboard-login.js +++ b/lib/cli/interactive-setup/dashboard-login.js @@ -25,9 +25,9 @@ const steps = { module.exports = { async isApplicable(context) { - const { isConsole, configuration, options, serviceDir } = context; + const { configuration, options, serviceDir } = context; - if (isConsole) { + if (options.console) { context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; return false; } diff --git a/lib/cli/interactive-setup/dashboard-set-org.js b/lib/cli/interactive-setup/dashboard-set-org.js index 3e2e5c0c024..7568bdb0a4c 100644 --- a/lib/cli/interactive-setup/dashboard-set-org.js +++ b/lib/cli/interactive-setup/dashboard-set-org.js @@ -193,14 +193,14 @@ const steps = { module.exports = { async isApplicable(context) { - const { configuration, options, serviceDir, isConsole } = context; + const { configuration, options, serviceDir } = context; if (!serviceDir) { context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } - if (isConsole) { + if (options.console) { context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; return false; } diff --git a/lib/cli/interactive-setup/deploy.js b/lib/cli/interactive-setup/deploy.js index a9a8d23f5e0..c1d2b29f040 100644 --- a/lib/cli/interactive-setup/deploy.js +++ b/lib/cli/interactive-setup/deploy.js @@ -23,12 +23,17 @@ const printMessage = () => { module.exports = { async isApplicable(context) { - const { configuration, serviceDir, options } = context; + const { configuration, serviceDir, options, initial } = context; if (!serviceDir) { context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } + if (options.console && initial.isInServiceContext) { + context.inapplicabilityReasonCode = 'CONSOLE_INTEGRATION'; + return false; + } + if ( _.get(configuration, 'provider') !== 'aws' && _.get(configuration, 'provider.name') !== 'aws' diff --git a/lib/cli/interactive-setup/index.js b/lib/cli/interactive-setup/index.js index 6cd52f0d075..2ab17d45545 100644 --- a/lib/cli/interactive-setup/index.js +++ b/lib/cli/interactive-setup/index.js @@ -4,16 +4,27 @@ const inquirer = require('@serverless/utils/inquirer'); const { StepHistory } = require('@serverless/utils/telemetry'); const log = require('@serverless/utils/log').log.get('onboarding'); const { resolveInitialContext } = require('./utils'); +const { awsRequest } = require('./utils'); const steps = { service: require('./service'), consoleLogin: require('./console-login'), dashboardLogin: require('./dashboard-login'), + consoleResolveOrg: require('./console-resolve-org'), + consoleSetupIamRole: require('./console-setup-iam-role'), dashboardSetOrg: require('./dashboard-set-org'), awsCredentials: require('./aws-credentials'), deploy: require('./deploy'), }; +const resolveAwsAccountId = async (context) => { + try { + return (await awsRequest(context, 'STS', 'getCallerIdentity')).Account; + } catch { + return null; + } +}; + module.exports = async (context) => { const stepsDetails = new Map( Object.entries(steps).map(([stepName, step]) => { @@ -39,7 +50,18 @@ module.exports = async (context) => { const initialContext = resolveInitialContext(context); commandUsage.initialContext = initialContext; context.initial = initialContext; - context.isConsole = Boolean(options.console); + context.awsAccountId = await resolveAwsAccountId(context); + + if (options.console) { + if (!context.awsAccountId) { + log.error( + 'We’re unable to connect Console via the CLI - No local AWS credentials found\n' + + 'Visit https://console.serverless.com/ to set up Console from the web' + ); + } else { + context.isConsole = true; + } + } for (const [stepName, step] of Object.entries(steps)) { delete context.stepHistory; diff --git a/lib/cli/interactive-setup/utils.js b/lib/cli/interactive-setup/utils.js index 561ef87ffee..cad88eea398 100644 --- a/lib/cli/interactive-setup/utils.js +++ b/lib/cli/interactive-setup/utils.js @@ -7,6 +7,7 @@ const { log, style } = require('@serverless/utils/log'); const resolveProviderCredentials = require('@serverless/dashboard-plugin/lib/resolve-provider-credentials'); const isAuthenticated = require('@serverless/dashboard-plugin/lib/is-authenticated'); const hasLocalCredentials = require('../../aws/has-local-credentials'); +const awsRequest = require('../../aws/request'); const fsp = require('fs').promises; @@ -48,6 +49,14 @@ module.exports = { return Boolean(result); }, + awsRequest: async ({ serverless }, serviceConfig, method, params) => { + const awsProvider = serverless && serverless.getProvider('aws'); + if (awsProvider) { + // This method supports only direct service name input + return awsProvider.request(serviceConfig.name || serviceConfig, method, params); + } + return awsRequest(serviceConfig, method, params); + }, resolveInitialContext: ({ configuration, serviceDir }) => { return { isInServiceContext: Boolean(serviceDir), @@ -105,8 +114,8 @@ module.exports = { log.notice(style.aside('Learn more at https://serverless.com/console')); } else { log.notice(`Onboarding "${context.configuration.service}" to the Serverless Dashboard`); + log.notice(); } - log.notice(); }, { length: 0 } ), diff --git a/lib/plugins/aws/info/display.js b/lib/plugins/aws/info/display.js index 32ee7c1df0f..992e91c275a 100644 --- a/lib/plugins/aws/info/display.js +++ b/lib/plugins/aws/info/display.js @@ -1,16 +1,64 @@ 'use strict'; -const filesize = require('../../../utils/filesize'); const { isVerboseMode, style } = require('@serverless/utils/log'); +const apiRequest = require('@serverless/utils/api-request'); +const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); +const urls = require('@serverless/utils/lib/auth/urls'); + +const filesize = require('../../../utils/filesize'); module.exports = { - displayServiceInfo() { + async resolveConsoleUrl() { + if (!(await resolveAuthMode())) return null; + const awsAccountId = await (async () => { + try { + return (await this.provider.request('STS', 'getCallerIdentity')).Account; + } catch { + return null; + } + })(); + if (!awsAccountId) return null; + + const { userId } = await apiRequest('/api/identity/me'); + const { orgs } = await apiRequest(`/api/identity/users/${userId}/orgs`); + + const org = ( + await Promise.all( + orgs.map(async (orgCandidate) => { + const orgData = await (async () => { + try { + return await apiRequest(`/api/integrations/?orgId=${orgCandidate.orgId}`, { + urlName: 'integrationsBackend', + }); + } catch { + return null; + } + })(); + if (!orgData) return null; + return orgData.integrations.some(({ vendorAccount }) => vendorAccount === awsAccountId) + ? orgCandidate + : null; + }) + ) + ).filter(Boolean)[0]; + + if (!org) return false; + return `${urls.frontend}/${ + org.orgName + }/metrics/awsLambda?globalEnvironments=${this.provider.getStage()}&globalNamespace=${ + this.serverless.service.service + }&globalRegions=${this.provider.getRegion()}&globalScope=awsLambda&globalTimeFrame=15m`; + }, + + async displayServiceInfo() { if (this.serverless.processedInput.commands.join(' ') === 'info') { this.serverless.serviceOutputs.set('service', this.serverless.service.service); this.serverless.serviceOutputs.set('stage', this.provider.getStage()); this.serverless.serviceOutputs.set('region', this.provider.getRegion()); this.serverless.serviceOutputs.set('stack', this.provider.naming.getStackName()); } + const consoleUrl = await this.resolveConsoleUrl(); + if (consoleUrl) this.serverless.serviceOutputs.set('console', consoleUrl); }, displayApiKeys() { diff --git a/lib/utils/telemetry/generate-payload.js b/lib/utils/telemetry/generate-payload.js index 812f8027b31..e8500d01b0c 100644 --- a/lib/utils/telemetry/generate-payload.js +++ b/lib/utils/telemetry/generate-payload.js @@ -138,6 +138,7 @@ module.exports = ({ serverless, commandUsage, variableSources, + isConsoleAuthenticated, }) => { let commandDurationMs; @@ -199,6 +200,9 @@ module.exports = ({ cliName: 'serverless', command, commandOptionNames, + console: { + isAuthenticated: isConsoleAuthenticated, + }, dashboard: { userId, }, diff --git a/package.json b/package.json index 1c1d022a535..a9e0205ed6a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@serverless/dashboard-plugin": "^6.2.2", "@serverless/platform-client": "^4.3.2", - "@serverless/utils": "^6.7.0", + "@serverless/utils": "^6.8.1", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", "archiver": "^5.3.1", diff --git a/scripts/serverless.js b/scripts/serverless.js index 3a03a2ec7f4..333adf0aa19 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -22,6 +22,7 @@ const { const generateTelemetryPayload = require('../lib/utils/telemetry/generate-payload'); const isTelemetryDisabled = require('../lib/utils/telemetry/are-disabled'); const logDeprecation = require('../lib/utils/log-deprecation'); +const resolveConsoleAuthMode = require('@serverless/utils/auth/resolve-mode'); let command; let isHelpRequest; @@ -30,6 +31,7 @@ let commandSchema; let serviceDir = null; let configuration = null; let serverless; +let isConsoleAuthenticated = false; const commandUsage = {}; const variableSourcesInConfig = new Set(); @@ -57,7 +59,15 @@ const finalize = async ({ error, shouldBeSync, telemetryData, shouldSendTelemetr clearTimeout(keepAliveTimer); progress.clear(); if (error) ({ telemetryData } = await handleError(error, { serverless })); - if (!shouldBeSync) await logDeprecation.printSummary(); + if (!shouldBeSync) { + await logDeprecation.printSummary(); + await resolveConsoleAuthMode().then( + (mode) => { + isConsoleAuthenticated = Boolean(mode); + }, + () => {} + ); + } if (isTelemetryDisabled || !commandSchema) return null; if (!error && isHelpRequest) return null; storeTelemetryLocally({ @@ -70,6 +80,7 @@ const finalize = async ({ error, shouldBeSync, telemetryData, shouldSendTelemetr serverless, commandUsage, variableSources: variableSourcesInConfig, + isConsoleAuthenticated, }), ...telemetryData, }); @@ -95,6 +106,12 @@ processSpanPromise = (async () => { const wait = require('timers-ext/promise/sleep'); await wait(); // Ensure access to "processSpanPromise" + resolveConsoleAuthMode().then( + (mode) => { + isConsoleAuthenticated = Boolean(mode); + }, + () => {} + ); require('signal-exit/signals').forEach((signal) => { process.once(signal, () => { processLog.debug('exit signal %s', signal); diff --git a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc b/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc deleted file mode 100644 index 43b8223bc1c..00000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "frameworkId": "00000000-0000-0000-0000-000000000000", - "meta": { - "created_at": 1560000000, - "updated_at": 1560000000 - }, - "auth": { - "refreshToken": "foo", - "idToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTTFMiLCJpYXQiOjE2MTE3NDAzNTgsImV4cCI6MTk1ODgwOTE1OCwiYXVkIjoic2xzIiwic3ViIjoic2xzQHNscy5jb20ifQ.fy_DY4cWWADDREVYrSy3U5-p7cKT4evEOCjQtQJl9ww" - } -} diff --git a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml b/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml deleted file mode 100644 index fbae0701fe2..00000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml +++ /dev/null @@ -1,5 +0,0 @@ -service: 'some-aws-service' -provider: 'aws' - -org: 'testinteractivecli' -console: true diff --git a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc b/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc deleted file mode 100644 index 43b8223bc1c..00000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "frameworkId": "00000000-0000-0000-0000-000000000000", - "meta": { - "created_at": 1560000000, - "updated_at": 1560000000 - }, - "auth": { - "refreshToken": "foo", - "idToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTTFMiLCJpYXQiOjE2MTE3NDAzNTgsImV4cCI6MTk1ODgwOTE1OCwiYXVkIjoic2xzIiwic3ViIjoic2xzQHNscy5jb20ifQ.fy_DY4cWWADDREVYrSy3U5-p7cKT4evEOCjQtQJl9ww" - } -} diff --git a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml b/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml deleted file mode 100644 index a1d8adc90ff..00000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml +++ /dev/null @@ -1,5 +0,0 @@ -service: 'some-aws-service' -provider: 'aws' - -org: 'some-other' -console: true diff --git a/test/unit/lib/cli/interactive-setup/console-login.test.js b/test/unit/lib/cli/interactive-setup/console-login.test.js index d50fd5a8ee7..525f615ccda 100644 --- a/test/unit/lib/cli/interactive-setup/console-login.test.js +++ b/test/unit/lib/cli/interactive-setup/console-login.test.js @@ -4,6 +4,7 @@ const chai = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire'); const overrideCwd = require('process-utils/override-cwd'); +const requireUncached = require('ncjsm/require-uncached'); const configureInquirerStub = require('@serverless/test/configure-inquirer-stub'); const { StepHistory } = require('@serverless/utils/telemetry'); const inquirer = require('@serverless/utils/inquirer'); @@ -31,40 +32,6 @@ describe('test/unit/lib/cli/interactive-setup/console-login.test.js', function ( expect(context.inapplicabilityReasonCode).to.equal('NON_CONSOLE_CONTEXT'); }); - it('Should be ineffective, when not at service path', async () => { - const context = { isConsole: true }; - expect(await step.isApplicable(context)).to.be.false; - expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); - }); - - it('Should be ineffective, when not at AWS service path', async () => { - const context = { - isConsole: true, - serviceDir: process.cwd(), - configuration: {}, - configurationFilename: 'serverless.yml', - options: { console: true }, - initial: {}, - inquirer, - }; - expect(await step.isApplicable(context)).to.equal(false); - expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER'); - }); - - it('Should be ineffective, when not at supported runtime service path', async () => { - const context = { - isConsole: true, - serviceDir: process.cwd(), - configuration: { provider: { name: 'aws', runtime: 'java8' } }, - configurationFilename: 'serverless.yml', - options: { console: true }, - initial: {}, - inquirer, - }; - expect(await step.isApplicable(context)).to.equal(false); - expect(context.inapplicabilityReasonCode).to.equal('UNSUPPORTED_RUNTIME'); - }); - it('Should be ineffective, when logged in', async () => { const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( 'aws-loggedin-console-service' @@ -78,9 +45,13 @@ describe('test/unit/lib/cli/interactive-setup/console-login.test.js', function ( initial: {}, inquirer, }; - expect(await overrideCwd(serviceDir, async () => await step.isApplicable(context))).to.equal( - false - ); + expect( + await overrideCwd(serviceDir, async () => + requireUncached(async () => + require('../../../../../lib/cli/interactive-setup/console-login').isApplicable(context) + ) + ) + ).to.equal(false); expect(context.inapplicabilityReasonCode).to.equal('ALREADY_LOGGED_IN'); }); diff --git a/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js b/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js new file mode 100644 index 00000000000..b844508eb65 --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const overrideCwd = require('process-utils/override-cwd'); +const overrideEnv = require('process-utils/override-env'); +const configureInquirerStub = require('@serverless/test/configure-inquirer-stub'); +const { StepHistory } = require('@serverless/utils/telemetry'); +const inquirer = require('@serverless/utils/inquirer'); + +const fixtures = require('../../../../fixtures/programmatic'); + +const { expect } = chai; + +chai.use(require('chai-as-promised')); + +describe('test/unit/lib/cli/interactive-setup/console-resolve-org.test.js', function () { + this.timeout(1000 * 60 * 3); + + let step; + let authMode = 'user'; + let mockOrgNames = []; + + before(() => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-resolve-org', { + '@serverless/utils/auth/resolve-mode': async () => authMode, + '@serverless/utils/api-request': async (pathname) => { + if (pathname === '/api/identity/me') return { userId: 'user' }; + if (pathname === '/api/identity/users/user/orgs') { + return { orgs: mockOrgNames.map((orgName) => ({ orgName })) }; + } + throw new Error(`Unexpected pathname "${pathname}"`); + }, + }); + }); + + after(() => { + sinon.restore(); + }); + + afterEach(async () => { + mockOrgNames = []; + authMode = 'user'; + sinon.reset(); + }); + + it('Should be ineffective, when --console not passed', async () => { + const context = { + initial: {}, + options: {}, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_CONSOLE_CONTEXT'); + }); + + it('Should be ineffective, when not logged in', async () => { + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + authMode = null; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('NOT_LOGGED_IN'); + }); + + it('Should be ineffective, when no orgs are resolved', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('NO_ORGS_AVAILABLE'); + }); + + describe('Monitoring setup', () => { + it('Should recognize and skip, when single org is assigned to the account', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + mockOrgNames = ['testinteractivecli']; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('ONLY_ORG'); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should ask for org if passed in one is invalid', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'testinteractivecli' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true, org: 'foo' }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + mockOrgNames = ['testinteractivecli']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + expect(Array.from(context.stepHistory.valuesMap())).to.deep.equal( + Array.from(new Map([['orgName', '_user_choice_']])) + ); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should setup monitoring for chosen org', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'testinteractivecli' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + + mockOrgNames = ['testinteractivecli', 'someotherorg']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + expect(context.stepHistory.valuesMap()).to.deep.equal( + new Map([['orgName', '_user_choice_']]) + ); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should setup monitoring for org based on access key', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'fromaccesskey' }, + }); + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + authMode = 'org'; + // TODO: Decide whether that test needs to stay + mockOrgNames = ['fromaccesskey']; + await overrideEnv({ variables: { SLS_ORG_TOKEN: 'token' } }, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('ONLY_ORG'); + expect(context.org).to.deep.equal({ orgName: 'fromaccesskey' }); + }); + + it('Should allow to skip setting monitoring when selecting org', async () => { + configureInquirerStub(inquirer, { + list: { orgName: '_skip_' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + mockOrgNames = ['testinteractivecli', 'someother']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + + expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['orgName', '_skip_']])); + expect(context).to.not.have.property('org'); + }); + }); +}); diff --git a/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js b/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js new file mode 100644 index 00000000000..f6da2e10e1a --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js @@ -0,0 +1,254 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const wait = require('timers-ext/promise/sleep'); +const overrideCwd = require('process-utils/override-cwd'); +const inquirer = require('@serverless/utils/inquirer'); +const { StepHistory } = require('@serverless/utils/telemetry'); +const configureInquirerStub = require('@serverless/test/configure-inquirer-stub'); + +const fixtures = require('../../../../fixtures/programmatic'); + +const { expect } = chai; + +chai.use(require('chai-as-promised')); + +describe('test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js', function () { + this.timeout(1000 * 60 * 3); + + let step; + let authMode = 'user'; + let mockOrgNames = []; + let isIntegrated = false; + let stackCreationOutcome = 'success'; + let stackAlreadyExists = false; + + before(() => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-setup-iam-role', { + '@serverless/utils/auth/resolve-mode': async () => authMode, + '@serverless/utils/api-request': async (pathname) => { + if (pathname === '/api/identity/me') return { userId: 'user' }; + if (pathname === '/api/identity/users/user/orgs') { + return { orgs: mockOrgNames.map((orgName) => ({ orgName })) }; + } + if (pathname === '/api/integrations/?orgId=integrated') { + return { + integrations: [{ vendorAccount: '12345', status: 'alive', syncStatus: 'running' }], + }; + } + if (pathname === '/api/integrations/?orgId=tobeintegrated') { + return { + integrations: isIntegrated + ? [{ vendorAccount: '12345', status: 'alive', syncStatus: 'running' }] + : [], + }; + } + if (pathname.startsWith('/api/integrations/aws/initial?orgId=')) { + return { cfnTemplateUrl: 'someUrl', params: {} }; + } + + throw new Error(`Unexpected pathname "${pathname}"`); + }, + './utils': { + awsRequest: async (ingore, serviceConfig, method) => { + switch (method) { + case 'createStack': + return {}; + + case 'describeStacks': + if (stackAlreadyExists) return {}; + throw Object.assign(new Error('Stack with id test does not exist'), { + Code: 'ValidationError', + }); + case 'describeStackEvents': + switch (stackCreationOutcome) { + case 'success': + return { + StackEvents: [ + { + EventId: 'fdc5ed10-4a41-11ed-b15b-12151bc3f4d1', + StackName: 'test', + LogicalResourceId: 'test', + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + }, + ], + }; + case 'failure': + return { + StackEvents: [ + { + EventId: 'fdc5ed10-4a41-11ed-b15b-12151bc3f4d1', + StackName: 'test', + LogicalResourceId: 'test', + ResourceType: 'AWS::Lambda::Function', + ResourceStatus: 'CREATE_FAILED', + ResourceStatusReason: 'Cannot create due to some reason', + }, + ], + }; + default: + throw new Error(`Unexpexted stack creation outcome: ${stackCreationOutcome}`); + } + default: + throw new Error(`Unexpexted AWS method: ${method}`); + } + }, + }, + }); + }); + + after(() => { + sinon.restore(); + }); + + afterEach(async () => { + mockOrgNames = []; + authMode = 'user'; + stackCreationOutcome = 'success'; + isIntegrated = false; + stackAlreadyExists = false; + sinon.reset(); + }); + + it('Should be ineffective, when --console not passed', async () => { + const context = { + initial: {}, + options: {}, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_CONSOLE_CONTEXT'); + }); + + it('Should be ineffective, when not logged in', async () => { + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + authMode = null; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('NOT_LOGGED_IN'); + }); + + it('Should be ineffective, when no org is resolved', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('UNRESOLVED_ORG'); + }); + + it('Should be ineffective, when logged in account is already integrated', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'integrated' }, + awsAccountId: '12345', + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('INTEGRATED'); + }); + + it('Should be ineffective, when CF stack of given name is already deployed', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'tobeintegrated' }, + awsAccountId: '12345', + }; + stackAlreadyExists = true; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('AWS_ACCOUNT_ALREADY_INTEGRATED'); + }); + + it('Should setup integration', async () => { + configureInquirerStub(inquirer, { + confirm: { shouldSetupConsoleIamRole: true }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'tobeintegrated' }, + awsAccountId: '12345', + stepHistory: new StepHistory(), + }; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + const deferredRun = step.run(context, stepData); + await wait(1000); + isIntegrated = true; + expect(await deferredRun).to.be.true; + }); + }); + + it('Should abort gently if CF deployment fails', async () => { + configureInquirerStub(inquirer, { + confirm: { shouldSetupConsoleIamRole: true }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'tobeintegrated' }, + awsAccountId: '12345', + stepHistory: new StepHistory(), + }; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + stackCreationOutcome = 'failure'; + expect(await step.run(context, stepData)).to.be.false; + }); + }); +}); diff --git a/test/unit/lib/cli/interactive-setup/dashboard-login.test.js b/test/unit/lib/cli/interactive-setup/dashboard-login.test.js index 99d24bb1e9f..f51ea2c613f 100644 --- a/test/unit/lib/cli/interactive-setup/dashboard-login.test.js +++ b/test/unit/lib/cli/interactive-setup/dashboard-login.test.js @@ -54,13 +54,13 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function }); it('Should be ineffective in console context', async () => { - const context = { isConsole: true }; + const context = { isConsole: true, options: { console: true } }; expect(await step.isApplicable(context)).to.be.false; expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_CONTEXT'); }); it('Should be ineffective, when not at service path', async () => { - const context = {}; + const context = { options: {} }; expect(await step.isApplicable(context)).to.be.false; expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); }); diff --git a/test/unit/lib/cli/interactive-setup/index.test.js b/test/unit/lib/cli/interactive-setup/index.test.js index 685eb063153..69a1b0405a2 100644 --- a/test/unit/lib/cli/interactive-setup/index.test.js +++ b/test/unit/lib/cli/interactive-setup/index.test.js @@ -12,7 +12,7 @@ describe('test/unit/lib/cli/interactive-setup/index.test.js', () => { it('should configure interactive setup flow', async () => { const slsProcessPromise = spawn( 'node', - [serverlessPath, '--console', '--template-path', path.join(fixturesPath, 'aws')], + [serverlessPath, '--template-path', path.join(fixturesPath, 'aws')], { env: { ...process.env, @@ -31,9 +31,9 @@ describe('test/unit/lib/cli/interactive-setup/index.test.js', () => { input: 'interactive-setup-test', }, - // console-login + // dashboard-login { - instructionString: 'Do you want to login/register to Serverless Console?', + instructionString: 'Do you want to login/register to Serverless Dashboard?', input: 'n', // Move cursor down by one line }, diff --git a/test/unit/lib/utils/telemetry/generate-payload.test.js b/test/unit/lib/utils/telemetry/generate-payload.test.js index 87baef31b5e..4fb5c57dc51 100644 --- a/test/unit/lib/utils/telemetry/generate-payload.test.js +++ b/test/unit/lib/utils/telemetry/generate-payload.test.js @@ -127,6 +127,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => { delete payload.timestamp; expect(payload).to.have.property('dashboard'); delete payload.dashboard; + expect(payload).to.have.property('console'); + delete payload.console; expect(payload).to.have.property('timezone'); delete payload.timezone; expect(payload).to.have.property('ciName'); @@ -196,6 +198,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => { delete payload.timestamp; expect(payload).to.have.property('dashboard'); delete payload.dashboard; + expect(payload).to.have.property('console'); + delete payload.console; expect(payload).to.have.property('timezone'); delete payload.timezone; expect(payload).to.have.property('ciName'); @@ -254,6 +258,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => { delete payload.timestamp; expect(payload).to.have.property('dashboard'); delete payload.dashboard; + expect(payload).to.have.property('console'); + delete payload.console; expect(payload).to.have.property('timezone'); delete payload.timezone; expect(payload).to.have.property('ciName'); @@ -292,6 +298,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => { delete payload.timestamp; expect(payload).to.have.property('dashboard'); delete payload.dashboard; + expect(payload).to.have.property('console'); + delete payload.console; expect(payload).to.have.property('timezone'); delete payload.timezone; expect(payload).to.have.property('ciName'); @@ -345,6 +353,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => { delete payload.timestamp; expect(payload).to.have.property('dashboard'); delete payload.dashboard; + expect(payload).to.have.property('console'); + delete payload.console; expect(payload).to.have.property('timezone'); delete payload.timezone; expect(payload).to.have.property('ciName');