diff --git a/jest.config.js b/jest.config.js index 05c8f8675..a8176778c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,7 +27,8 @@ module.exports = { global: { branches: 100, lines: 100, - statements: 100 + statements: 100, + functions: 100 } } } diff --git a/package.json b/package.json index b5b0cf3d6..376a4cc37 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,13 @@ "bugs": "https://github.com/adobe/aio-cli-plugin-app/issues", "dependencies": { "@adobe/aio-cli-lib-app-config": "^4.0.3", - "@adobe/aio-cli-lib-console": "^5", + "@adobe/aio-cli-lib-console": "^5.0.3", "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", "@adobe/aio-lib-core-networking": "^5", "@adobe/aio-lib-env": "^3", "@adobe/aio-lib-ims": "^7", - "@adobe/aio-lib-runtime": "^7.1.0", + "@adobe/aio-lib-runtime": "^7.1.2", "@adobe/aio-lib-templates": "^3", "@adobe/aio-lib-web": "^7", "@adobe/generator-aio-app": "^9", diff --git a/src/commands/app/config/get/log-forwarding.js b/src/commands/app/config/get/log-forwarding.js index 9ad7ab62e..4e962df2a 100644 --- a/src/commands/app/config/get/log-forwarding.js +++ b/src/commands/app/config/get/log-forwarding.js @@ -11,10 +11,16 @@ governing permissions and limitations under the License. const BaseCommand = require('../../../../BaseCommand') const LogForwarding = require('../../../../lib/log-forwarding') +const { setRuntimeApiHostAndAuthHandler } = require('../../../../lib/auth-helper') class LogForwardingCommand extends BaseCommand { async run () { - const lf = await LogForwarding.init((await this.getFullConfig()).aio) + let aioConfig = (await this.getFullConfig()).aio + // TODO: remove this check once the deploy service is enabled by default + if (process.env.IS_DEPLOY_SERVICE_ENABLED === 'true') { + aioConfig = setRuntimeApiHostAndAuthHandler(aioConfig) + } + const lf = await LogForwarding.init(aioConfig) const localConfig = lf.getLocalConfig() const serverConfig = await lf.getServerConfig() diff --git a/src/commands/app/config/get/log-forwarding/errors.js b/src/commands/app/config/get/log-forwarding/errors.js index bc0bfba43..327941fcc 100644 --- a/src/commands/app/config/get/log-forwarding/errors.js +++ b/src/commands/app/config/get/log-forwarding/errors.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. const BaseCommand = require('../../../../../BaseCommand') const rtLib = require('@adobe/aio-lib-runtime') const ora = require('ora') +const { setRuntimeApiHostAndAuthHandler } = require('../../../../../lib/auth-helper') class ErrorsCommand extends BaseCommand { async run () { @@ -30,7 +31,13 @@ class ErrorsCommand extends BaseCommand { } async getLogForwarding () { - const runtimeConfig = (await this.getFullConfig()).aio.runtime + let aioConfig = (await this.getFullConfig()).aio + // TODO: remove this check once the deploy service is enabled by default + if (process.env.IS_DEPLOY_SERVICE_ENABLED === 'true') { + aioConfig = setRuntimeApiHostAndAuthHandler(aioConfig) + } + + const runtimeConfig = aioConfig.runtime rtLib.utils.checkOpenWhiskCredentials({ ow: runtimeConfig }) const rt = await rtLib.init({ ...runtimeConfig, diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index a8914d998..2ae040542 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -18,10 +18,10 @@ const BaseCommand = require('../../BaseCommand') const BuildCommand = require('./build') const webLib = require('@adobe/aio-lib-web') const { Flags } = require('@oclif/core') -const { runInProcess, buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, getCliInfo } = require('../../lib/app-helper') +const { runInProcess, buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, getCliInfo, getFilesCountWithExtension } = require('../../lib/app-helper') const rtLib = require('@adobe/aio-lib-runtime') const LogForwarding = require('../../lib/log-forwarding') -const { sendAuditLogs, getAuditLogEvent, getFilesCountWithExtension } = require('../../lib/audit-logger') +const { sendAppAssetsDeployedAuditLog, sendAppDeployAuditLog } = require('../../lib/audit-logger') const { setRuntimeApiHostAndAuthHandler } = require('../../lib/auth-helper') const logActions = require('../../lib/log-actions') @@ -53,15 +53,43 @@ class Deploy extends BuildCommand { const spinner = ora() try { - const aioConfig = (await this.getFullConfig()).aio + const { aio: aioConfig, packagejson: packageJson } = await this.getFullConfig() const cliDetails = await getCliInfo(flags.publish) + const appInfo = { + name: packageJson.name, + version: packageJson.version, + project: aioConfig?.project, + runtimeNamespace: aioConfig?.runtime?.namespace + } + + if (cliDetails?.accessToken) { + try { + // send audit log at start (don't wait for deployment to finish) + await sendAppDeployAuditLog({ + accessToken: cliDetails?.accessToken, + cliCommandFlags: flags, + appInfo, + env: cliDetails.env + }) + } catch (error) { + if (flags.verbose) { + this.warn('Error: Audit Log Service Error: Failed to send audit log event for deployment.') + this.warn(error.message) + } + } + } // 1. update log forwarding configuration // note: it is possible that .aio file does not exist, which means there is no local lg config if (aioConfig?.project?.workspace && flags['log-forwarding-update'] && flags.actions) { spinner.start('Updating log forwarding configuration') try { - const lf = await LogForwarding.init(aioConfig) + let lfConfig = aioConfig + if (process.env.IS_DEPLOY_SERVICE_ENABLED === 'true') { + lfConfig = setRuntimeApiHostAndAuthHandler(aioConfig) + } + + const lf = await LogForwarding.init(lfConfig) if (lf.isLocalConfigChanged()) { const lfConfig = lf.getLocalConfigWithSecrets() if (lfConfig.isDefined()) { @@ -101,22 +129,24 @@ class Deploy extends BuildCommand { // - break into smaller pieces deploy, allowing to first deploy all actions then all web assets for (let i = 0; i < keys.length; ++i) { const k = keys[i] - const v = setRuntimeApiHostAndAuthHandler(values[i]) + const v = process.env.IS_DEPLOY_SERVICE_ENABLED === 'true' ? setRuntimeApiHostAndAuthHandler(values[i]) : values[i] await this.deploySingleConfig(k, v, flags, spinner) - if (v.app.hasFrontend && flags['web-assets']) { + if (cliDetails?.accessToken && v.app.hasFrontend && flags['web-assets']) { const opItems = getFilesCountWithExtension(v.web.distProd) - const assetDeployedLogEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_ASSETS_DEPLOYED') - if (assetDeployedLogEvent && cliDetails?.accessToken) { - assetDeployedLogEvent.data.opItems = opItems - try { - // only send logs in case of web-assets deployment - await sendAuditLogs(cliDetails.accessToken, assetDeployedLogEvent, cliDetails.env) - } catch (error) { - if (flags.verbose) { - this.warn('Error: Audit Log Service Error: Failed to send audit log event for deployment.') - this.warn(error.message) - } + try { + // only send logs in case of web-assets deployment + await sendAppAssetsDeployedAuditLog({ + accessToken: cliDetails?.accessToken, + cliCommandFlags: flags, + opItems, + appInfo, + env: cliDetails.env + }) + } catch (error) { + if (flags.verbose) { + this.warn('Error: Audit Log Service Error: Failed to send audit log event for deployment.') + this.warn(error.message) } } } diff --git a/src/commands/app/undeploy.js b/src/commands/app/undeploy.js index d83ad879c..1e5968a62 100644 --- a/src/commands/app/undeploy.js +++ b/src/commands/app/undeploy.js @@ -19,7 +19,7 @@ const BaseCommand = require('../../BaseCommand') const webLib = require('@adobe/aio-lib-web') const { runInProcess, buildExtensionPointPayloadWoMetadata, getCliInfo } = require('../../lib/app-helper') const rtLib = require('@adobe/aio-lib-runtime') -const { sendAuditLogs, getAuditLogEvent } = require('../../lib/audit-logger') +const { sendAppAssetsUndeployedAuditLog, sendAppUndeployAuditLog } = require('../../lib/audit-logger') const { setRuntimeApiHostAndAuthHandler } = require('../../lib/auth-helper') class Undeploy extends BaseCommand { @@ -51,8 +51,31 @@ class Undeploy extends BaseCommand { const spinner = ora() try { - const aioConfig = (await this.getFullConfig()).aio + const { aio: aioConfig, packagejson: packageJson } = await this.getFullConfig() const cliDetails = await getCliInfo(flags.unpublish) + const appInfo = { + name: packageJson.name, + version: packageJson.version, + project: aioConfig?.project, + runtimeNamespace: aioConfig?.runtime?.namespace + } + + if (cliDetails?.accessToken) { + try { + // send audit log at start (don't wait for deployment to finish) + await sendAppUndeployAuditLog({ + accessToken: cliDetails?.accessToken, + cliCommandFlags: flags, + appInfo, + env: cliDetails.env + }) + } catch (error) { + if (flags.verbose) { + this.warn('Error: Audit Log Service Error: Failed to send audit log event for deployment.') + this.warn(error.message) + } + } + } for (let i = 0; i < keys.length; ++i) { const k = keys[i] @@ -60,11 +83,15 @@ class Undeploy extends BaseCommand { const v = process.env.IS_DEPLOY_SERVICE_ENABLED === 'true' ? setRuntimeApiHostAndAuthHandler(values[i]) : values[i] await this.undeployOneExt(k, v, flags, spinner) - const assetUndeployLogEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_ASSETS_UNDEPLOYED') - // send logs for case of web-assets undeployment - if (assetUndeployLogEvent && cliDetails?.accessToken) { + if (cliDetails?.accessToken) { + // send logs for case of web-assets undeployment try { - await sendAuditLogs(cliDetails.accessToken, assetUndeployLogEvent, cliDetails.env) + await sendAppAssetsUndeployedAuditLog({ + accessToken: cliDetails?.accessToken, + cliCommandFlags: flags, + appInfo, + env: cliDetails.env + }) } catch (error) { this.warn('Warning: Audit Log Service Error: Failed to send audit log event for un-deployment.') } diff --git a/src/lib/app-helper.js b/src/lib/app-helper.js index dd5025233..4b236e900 100644 --- a/src/lib/app-helper.js +++ b/src/lib/app-helper.js @@ -11,7 +11,7 @@ governing permissions and limitations under the License. const execa = require('execa') const fs = require('fs-extra') -const path = require('path') +const path = require('node:path') const which = require('which') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:lib-app-helper', { provider: 'debug' }) const { getToken, context } = require('@adobe/aio-lib-ims') @@ -473,6 +473,67 @@ function getObjectValue (obj, key) { return keys.filter(o => o.trim()).reduce((o, i) => o && getObjectProp(o, i), obj) } +/** + * Counts files by extension in a directory + * + * @param {string} directory Path to assets directory + * @returns {Array} Array of formatted log messages + */ +function getFilesCountWithExtension (directory) { + const log = [] + + if (!fs.existsSync(directory)) { + throw new Error(`Error: Directory ${directory} does not exist.`) + } + + const files = fs.readdirSync(directory, { recursive: true }) + if (files.length === 0) { + throw new Error(`Error: No files found in directory ${directory}.`) + } + + const fileTypeCounts = {} + files.forEach(file => { + const ext = path.extname(file).toLowerCase() || 'no extension' + if (fileTypeCounts[ext]) { + fileTypeCounts[ext]++ + } else { + fileTypeCounts[ext] = 1 + } + }) + + Object.keys(fileTypeCounts).forEach(ext => { + const count = fileTypeCounts[ext] + let description + switch (ext) { + case '.js': + description = 'Javascript file(s)' + break + case '.css': + description = 'CSS file(s)' + break + case '.html': + description = 'HTML page(s)' + break + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.svg': + case '.webp': + description = `${ext} image(s)` + break + case 'no extension': + description = 'file(s) without extension' + break + default: + description = `${ext} file(s)` + } + log.push(`${count} ${description}\n`) + }) + + return log +} + module.exports = { getObjectValue, getObjectProp, @@ -496,5 +557,6 @@ module.exports = { buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, atLeastOne, - deleteUserConfig + deleteUserConfig, + getFilesCountWithExtension } diff --git a/src/lib/audit-logger.js b/src/lib/audit-logger.js index 022ab4eac..078f7fd75 100644 --- a/src/lib/audit-logger.js +++ b/src/lib/audit-logger.js @@ -8,30 +8,85 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -const fs = require('fs') -const path = require('path') -const chalk = require('chalk') const OPERATIONS = { AB_APP_DEPLOY: 'ab_app_deploy', AB_APP_UNDEPLOY: 'ab_app_undeploy', - AB_APP_TEST: 'ab_app_test', AB_APP_ASSETS_DEPLOYED: 'ab_app_assets_deployed', AB_APP_ASSETS_UNDEPLOYED: 'ab_app_assets_undeployed' } const AUDIT_SERVICE_ENDPOINTS = { - stage: 'https://adp-auditlog-service-stage.adobeioruntime.net/api/v1/web/audit-log-api/event-post', - prod: 'https://adp-auditlog-service-prod.adobeioruntime.net/api/v1/web/audit-log-api/event-post' + stage: process.env.AUDIT_SERVICE_ENDPOINT_STAGE ?? 'https://adp-auditlog-service-stage.adobeioruntime.net/api/v1/web/audit-log-api/event-post', + prod: process.env.AUDIT_SERVICE_ENDPOINT_PROD ?? 'https://adp-auditlog-service-prod.adobeioruntime.net/api/v1/web/audit-log-api/event-post' } /** - * Send audit log events to audit service - * @param {string} accessToken valid access token - * @param {object} logEvent logEvent details - * @param {string} env valid env stage|prod + * @typedef {object} AppInfo + * @property {string} name - Application name + * @property {string} version - Application version + * @property {object} project - Project details containing org and workspace information + * @property {object} namespace - the runtime namespace */ -async function sendAuditLogs (accessToken, logEvent, env = 'prod') { + +/** + * @typedef {object} AuditLogParams + * @property {string} accessToken - Valid access token for authentication + * @property {object} cliCommandFlags - CLI command flags and options + * @property {AppInfo} appInfo - Application information including project details, name, and version + * @property {Array} [opItems] - List of deployed files (only for assets deployment) + * @property {string} [env='prod'] - Environment to use: 'stage' or 'prod' + */ + +/** + * @typedef {object} PublishAuditLogParams + * @property {string} accessToken - Valid access token for authentication + * @property {object} logEvent - Audit log event details to be published + * @property {string} [env='prod'] - Environment to use: 'stage' or 'prod' + */ + +/** + * @typedef {object} GetAuditLogEventParams + * @property {object} cliCommandFlags - CLI command flags and options + * @property {AppInfo} appInfo - Application information containing project details, name, version, and optional runtime namespace + * @property {string} operation - Operation type: 'ab_app_deploy', 'ab_app_undeploy', 'ab_app_assets_deployed', or 'ab_app_assets_undeployed' + */ + +/** + * Checks for environment variable overrides of audit service endpoints and logs warnings if found. + * + * This function checks for the following environment variables: + * - AUDIT_SERVICE_ENDPOINT_STAGE: Override for the stage environment endpoint + * - AUDIT_SERVICE_ENDPOINT_PROD: Override for the production environment endpoint + * + * If any of these variables are set, a warning will be logged to the console indicating + * which variables are being overridden and their values. + * + * @function checkOverrides + * @returns {void} + */ +function checkOverrides () { + const toCheck = ['AUDIT_SERVICE_ENDPOINT_STAGE', 'AUDIT_SERVICE_ENDPOINT_PROD'] + const overrides = toCheck.filter((toCheck) => process.env[toCheck]) + + if (overrides.length > 0) { + console.warn('Audit Service overrides detected:') + overrides.forEach((override) => { + console.warn(` ${override}: ${process.env[override]}`) + }) + } +} + +/** + * Publish audit log events to audit service + * + * @param {PublishAuditLogParams} params - Parameters object containing access token, log event, and environment + * @returns {Promise} Promise that resolves when the audit log is sent successfully + * @throws {Error} If the audit log request fails + */ +async function publishAuditLogs ({ accessToken, logEvent, env = 'prod' }) { + checkOverrides() + const url = AUDIT_SERVICE_ENDPOINTS[env] ?? AUDIT_SERVICE_ENDPOINTS.prod const payload = { event: logEvent @@ -39,7 +94,7 @@ async function sendAuditLogs (accessToken, logEvent, env = 'prod') { const options = { method: 'POST', headers: { - Authorization: 'Bearer ' + accessToken, + Authorization: `Bearer ${accessToken}`, 'Content-type': 'application/json' }, body: JSON.stringify(payload) @@ -47,111 +102,117 @@ async function sendAuditLogs (accessToken, logEvent, env = 'prod') { const response = await fetch(url, options) if (response.status !== 200) { const err = await response.text() - throw new Error('Failed to send audit log - ' + response.status + ' ' + err) + throw new Error(`Failed to send audit log - ${response.status} ${err}`) } } /** + * Creates an audit log event object * - * @param {object} flags cli flags - * @param {object} project details - * @param {string} event log name - * @returns {object} logEvent + * @param {GetAuditLogEventParams} params - Parameters object containing CLI flags, operation type, and app info + * @returns {object} Log event object containing audit log details + * @throws {Error} If project is missing, or if operation is invalid */ -function getAuditLogEvent (flags, project, event) { - let logEvent, logStrMsg - if (project && project.org && project.workspace) { - if (event === 'AB_APP_DEPLOY') { - logStrMsg = `Starting deployment for the App Builder application in workspace ${project.workspace.name}` - } else if (event === 'AB_APP_UNDEPLOY') { - logStrMsg = `Starting undeployment for the App Builder application in workspace ${project.workspace.name}` - } else if (event === 'AB_APP_ASSETS_UNDEPLOYED') { - logStrMsg = `All static assets for the App Builder application in workspace: ${project.workspace.name} were successfully undeployed from the CDN` - } else if (event === 'AB_APP_ASSETS_DEPLOYED') { - logStrMsg = `All static assets for the App Builder application in workspace: ${project.workspace.name} were successfully deployed to the CDN.\n Files deployed - ` - } +function getAuditLogEvent ({ cliCommandFlags, operation, appInfo }) { + const { project, runtimeNamespace } = appInfo + + if (!project) { + throw new Error('Project is required') + } - logEvent = { - orgId: project.org.id, - projectId: project.id, - workspaceId: project.workspace.id, - workspaceName: project.workspace.name, - operation: event in OPERATIONS ? OPERATIONS[event] : OPERATIONS.AB_APP_TEST, - timestamp: new Date().valueOf(), - data: { - cliCommandFlags: flags, - opDetailsStr: logStrMsg - } + if (!project.org) { + throw new Error('Project org is required') + } + if (!project.workspace) { + throw new Error('Project workspace is required') + } + + if (!runtimeNamespace) { + throw new Error('Runtime namespace is required') + } + + if (!Object.values(OPERATIONS).find((op) => op === operation)) { + throw new Error(`Invalid operation: ${operation}`) + } + const orgId = project.org.id + const projectId = project.id + const workspaceId = project.workspace.id + const workspaceName = project.workspace.name + + const logEvent = { + orgId, + projectId, + workspaceId, + workspaceName, + operation, + objectRef: appInfo.name, + objectRev: appInfo.version, + objectName: appInfo.name, + timestamp: new Date().valueOf(), + runtimeNamespace, + data: { + cliCommandFlags } } return logEvent } /** + * Send audit log event for app deployment * - * @param {string} directory | path to assets directory - * @returns {Array} log | array of log messages + * @param {AuditLogParams} params - Parameters object containing access token, CLI flags, app info, and environment + * @returns {Promise} Promise that resolves when the audit log is sent successfully + * @throws {Error} If the audit log request fails */ -function getFilesCountWithExtension (directory) { - const log = [] - - if (!fs.existsSync(directory)) { - this.log(chalk.red(chalk.bold(`Error: Directory ${directory} does not exist.`))) - return log - } +async function sendAppDeployAuditLog ({ accessToken, cliCommandFlags, appInfo, env }) { + const logEvent = getAuditLogEvent({ cliCommandFlags, appInfo, operation: OPERATIONS.AB_APP_DEPLOY }) + return publishAuditLogs({ accessToken, logEvent, env }) +} - const files = fs.readdirSync(directory, { recursive: true }) +/** + * Send audit log event for app undeployment + * + * @param {AuditLogParams} params - Parameters object containing access token, CLI flags, app info, and environment + * @returns {Promise} Promise that resolves when the audit log is sent successfully + * @throws {Error} If the audit log request fails + */ +async function sendAppUndeployAuditLog ({ accessToken, cliCommandFlags, appInfo, env }) { + const logEvent = getAuditLogEvent({ cliCommandFlags, appInfo, operation: OPERATIONS.AB_APP_UNDEPLOY }) + return publishAuditLogs({ accessToken, logEvent, env }) +} - if (files.length === 0) { - this.log(chalk.red(chalk.bold(`Error: No files found in directory ${directory}.`))) - return log - } +/** + * Send audit log event for app assets deployment + * + * @param {AuditLogParams} params - Parameters object containing access token, CLI flags, app info, operation items, and environment + * @returns {Promise} Promise that resolves when the audit log is sent successfully + * @throws {Error} If the audit log request fails + */ +async function sendAppAssetsDeployedAuditLog ({ accessToken, cliCommandFlags, appInfo, opItems, env }) { + const logEvent = getAuditLogEvent({ cliCommandFlags, appInfo, operation: OPERATIONS.AB_APP_ASSETS_DEPLOYED }) + logEvent.data.opItems = opItems + return publishAuditLogs({ accessToken, logEvent, env }) +} - const fileTypeCounts = {} - files.forEach(file => { - const ext = path.extname(file).toLowerCase() || 'no extension' - if (fileTypeCounts[ext]) { - fileTypeCounts[ext]++ - } else { - fileTypeCounts[ext] = 1 - } - }) - - Object.keys(fileTypeCounts).forEach(ext => { - const count = fileTypeCounts[ext] - let description - switch (ext) { - case '.js': - description = 'Javascript file(s)' - break - case '.css': - description = 'CSS file(s)' - break - case '.html': - description = 'HTML page(s)' - break - case '.png': - case '.jpg': - case '.jpeg': - case '.gif': - case '.svg': - case '.webp': - description = `${ext} image(s)` - break - case 'no extension': - description = 'file(s) without extension' - break - default: - description = `${ext} file(s)` - } - log.push(`${count} ${description}\n`) - }) - return log +/** + * Send audit log event for app assets undeployment + * + * @param {AuditLogParams} params - Parameters object containing access token, CLI flags, app info, and environment + * @returns {Promise} Promise that resolves when the audit log is sent successfully + * @throws {Error} If the audit log request fails + */ +async function sendAppAssetsUndeployedAuditLog ({ accessToken, cliCommandFlags, appInfo, env }) { + const logEvent = getAuditLogEvent({ cliCommandFlags, appInfo, operation: OPERATIONS.AB_APP_ASSETS_UNDEPLOYED }) + return publishAuditLogs({ accessToken, logEvent, env }) } module.exports = { - sendAuditLogs, - getAuditLogEvent, + OPERATIONS, AUDIT_SERVICE_ENDPOINTS, - getFilesCountWithExtension + getAuditLogEvent, + sendAppDeployAuditLog, + sendAppUndeployAuditLog, + sendAppAssetsDeployedAuditLog, + sendAppAssetsUndeployedAuditLog, + checkOverrides } diff --git a/test/commands/app/config/get/log-forwarding.test.js b/test/commands/app/config/get/log-forwarding.test.js index 07105f207..af5532a82 100644 --- a/test/commands/app/config/get/log-forwarding.test.js +++ b/test/commands/app/config/get/log-forwarding.test.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. const { stdout } = require('stdout-stderr') const TheCommand = require('../../../../../src/commands/app/config/get/log-forwarding.js') const LogForwarding = require('../../../../../src/lib/log-forwarding') +const { setRuntimeApiHostAndAuthHandler } = require('../../../../../src/lib/auth-helper') jest.mock('../../../../../src/lib/log-forwarding', () => { const orig = jest.requireActual('../../../../../src/lib/log-forwarding') @@ -22,6 +23,10 @@ jest.mock('../../../../../src/lib/log-forwarding', () => { } }) +jest.mock('../../../../../src/lib/auth-helper', () => ({ + setRuntimeApiHostAndAuthHandler: jest.fn(config => config) +})) + let command, lf beforeEach(async () => { command = new TheCommand([]) @@ -189,3 +194,26 @@ test('failed to get log forwarding settings', async () => { lf.getServerConfig.mockRejectedValue(new Error('mocked error')) await expect(command.run()).rejects.toThrow('mocked error') }) + +test('get log forwarding settings with deploy service enabled', async () => { + process.env.IS_DEPLOY_SERVICE_ENABLED = 'true' + const localConfig = new LogForwarding.LogForwardingConfig() + const serverConfig = new LogForwarding.LogForwardingConfig() + + lf.getLocalConfig.mockReturnValue(localConfig) + lf.getServerConfig.mockResolvedValue(serverConfig) + + await command.run() + expect(setRuntimeApiHostAndAuthHandler).toHaveBeenCalledWith(command.appConfig.aio) + expect(LogForwarding.init).toHaveBeenCalledWith(command.appConfig.aio) + + delete process.env.IS_DEPLOY_SERVICE_ENABLED +}) + +test('command aliases are set correctly', () => { + expect(TheCommand.aliases).toEqual(['app:config:get:log-forwarding', 'app:config:get:lf']) +}) + +test('command description is set correctly', () => { + expect(TheCommand.description).toBe('Get log forwarding destination configuration') +}) diff --git a/test/commands/app/config/get/log-forwarding/errors.test.js b/test/commands/app/config/get/log-forwarding/errors.test.js index be3b17ce2..47ad8d3ec 100644 --- a/test/commands/app/config/get/log-forwarding/errors.test.js +++ b/test/commands/app/config/get/log-forwarding/errors.test.js @@ -10,13 +10,23 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const TheCommand = require('../../../../../../src/commands/app/config/get/log-forwarding/errors') -const RuntimeLib = require('@adobe/aio-lib-runtime') -const ora = require('ora') +const { stdout } = require('stdout-stderr') +const TheCommand = require('../../../../../../src/commands/app/config/get/log-forwarding/errors.js') +const rtLib = require('@adobe/aio-lib-runtime') +const { setRuntimeApiHostAndAuthHandler } = require('../../../../../../src/lib/auth-helper') -jest.mock('ora') +jest.mock('@adobe/aio-lib-runtime', () => ({ + init: jest.fn(), + utils: { + checkOpenWhiskCredentials: jest.fn() + } +})) + +jest.mock('../../../../../../src/lib/auth-helper', () => ({ + setRuntimeApiHostAndAuthHandler: jest.fn(config => config) +})) -let command, rtLib, spinner +let command, logForwarding beforeEach(async () => { command = new TheCommand([]) command.appConfig = { @@ -30,79 +40,70 @@ beforeEach(async () => { } } } - rtLib = await RuntimeLib.init({ apihost: 'https://adobeioruntime.net', api_key: 'fake:auth' }) - RuntimeLib.utils.checkOpenWhiskCredentials = jest.fn() - rtLib.logForwarding.getErrors = jest.fn() - spinner = ora() + logForwarding = { + getErrors: jest.fn() + } + rtLib.init.mockResolvedValue({ logForwarding }) }) -test('app:config:get:log-forwarding:errors command', async () => { - return new Promise(resolve => { - rtLib.logForwarding.getErrors.mockResolvedValue({ - configured_forwarder: 'destination', - errors: [ - 'error 1', - 'error 2' - ] - }) - - return command.run() - .then(() => { - expect(spinner.succeed) - .toHaveBeenCalledWith("Log forwarding errors for the last configured destination 'destination':\nerror 1\nerror 2") - resolve() - }) +test('get log forwarding errors with errors', async () => { + const errors = ['Error 1', 'Error 2'] + logForwarding.getErrors.mockResolvedValue({ + errors, + configured_forwarder: 'test-destination' }) + + await command.run() + expect(stdout.output).toContain('Log forwarding errors for the last configured destination \'test-destination\':') + expect(stdout.output).toContain('Error 1') + expect(stdout.output).toContain('Error 2') }) -test('app:config:get:log-forwarding:errors command - no destination returned from the server', async () => { - return new Promise(resolve => { - rtLib.logForwarding.getErrors.mockResolvedValue({ - errors: [ - 'error 1', - 'error 2' - ] - }) - - return command.run() - .then(() => { - expect(spinner.succeed).toHaveBeenCalledWith('Log forwarding errors:\nerror 1\nerror 2') - resolve() - }) +test('get log forwarding errors without errors', async () => { + logForwarding.getErrors.mockResolvedValue({ + errors: [], + configured_forwarder: 'test-destination' }) + + await command.run() + expect(stdout.output).toContain('No log forwarding errors for the last configured destination \'test-destination\'') }) -test('app:config:get:log-forwarding:errors command - no errors', async () => { - return new Promise(resolve => { - rtLib.logForwarding.getErrors.mockResolvedValue({ - configured_forwarder: 'destination', - errors: [] - }) - - return command.run() - .then(() => { - expect(spinner.succeed) - .toHaveBeenCalledWith("No log forwarding errors for the last configured destination 'destination'") - resolve() - }) +test('get log forwarding errors without configured forwarder', async () => { + logForwarding.getErrors.mockResolvedValue({ + errors: ['Error 1'] }) + + await command.run() + expect(stdout.output).toContain('Log forwarding errors:') + expect(stdout.output).toContain('Error 1') }) -test('app:config:get:log-forwarding:errors command - no errors and no destination returned from the server', async () => { - return new Promise(resolve => { - rtLib.logForwarding.getErrors.mockResolvedValue({ - errors: [] - }) - - return command.run() - .then(() => { - expect(spinner.succeed).toHaveBeenCalledWith('No log forwarding errors') - resolve() - }) +test('get log forwarding errors with deploy service enabled', async () => { + process.env.IS_DEPLOY_SERVICE_ENABLED = 'true' + logForwarding.getErrors.mockResolvedValue({ + errors: [] }) + + await command.run() + expect(setRuntimeApiHostAndAuthHandler).toHaveBeenCalledWith(command.appConfig.aio) + expect(rtLib.init).toHaveBeenCalledWith({ + ...command.appConfig.aio.runtime, + api_key: command.appConfig.aio.runtime.auth + }) + + delete process.env.IS_DEPLOY_SERVICE_ENABLED }) -test('app:config:get:log-forwarding:errors command - failed response from server', async () => { - rtLib.logForwarding.getErrors.mockRejectedValue(new Error('mocked error')) +test('failed to get log forwarding errors', async () => { + logForwarding.getErrors.mockRejectedValue(new Error('mocked error')) await expect(command.run()).rejects.toThrow('mocked error') }) + +test('command aliases are set correctly', () => { + expect(TheCommand.aliases).toEqual(['app:config:get:log-forwarding:errors', 'app:config:get:lf:errors']) +}) + +test('command description is set correctly', () => { + expect(TheCommand.description).toBe('Get log forwarding errors') +}) diff --git a/test/commands/app/deploy.test.js b/test/commands/app/deploy.test.js index e0fed79d5..e6ac8887a 100644 --- a/test/commands/app/deploy.test.js +++ b/test/commands/app/deploy.test.js @@ -67,16 +67,19 @@ const createAppConfig = (aioConfig = {}, appFixtureName = 'legacy-app') => { return appConfig } -const mockExtRegExcShellPayload = () => { +const mockExtRegExcShellPayload = ({ excShellView = true } = {}) => { const payload = { - endpoints: { - 'dx/excshell/1': { - view: [ - { metadata: {} } - ] - } + endpoints: {} + } + + if (excShellView) { + payload.endpoints['dx/excshell/1'] = { + view: [ + { metadata: {} } + ] } } + helpers.buildExtensionPointPayloadWoMetadata.mockReturnValueOnce(payload) mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites.mockReturnValueOnce(payload) mockLibConsoleCLI.updateExtensionPoints.mockReturnValueOnce(payload) @@ -156,6 +159,8 @@ afterAll(() => { jest.resetAllMocks() }) +let command + beforeEach(() => { helpers.writeConfig.mockReset() helpers.runInProcess.mockReset() @@ -171,22 +176,7 @@ beforeEach(() => { helpers.wrapError.mockImplementation(msg => msg) helpers.createWebExportFilter.mockImplementation(filterValue => helpersActual.createWebExportFilter(filterValue)) - auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => { - return { - orgId: 'mockorg', - projectId: 'mockproject', - workspaceId: 'mockworkspaceid', - workspaceName: 'mockworkspacename', - operation: 'AB_APP_ASSETS_DEPLOYED'.toLowerCase(), - timestamp: new Date().valueOf(), - data: { - cliCommandFlags: flags, - opDetailsStr: 'logStrMsg', - opItems: [] - } - } - }) - auditLogger.getFilesCountWithExtension.mockImplementation((dir) => { + helpers.getFilesCountWithExtension.mockImplementation((dir) => { return [ '3 Javascript file(s)', '2 CSS file(s)', @@ -202,6 +192,37 @@ beforeEach(() => { } }) LogForwarding.init.mockResolvedValue(mockLogForwarding) + + command = new TheCommand([]) + command.error = jest.fn() + command.log = jest.fn() + command.appConfig = cloneDeep(mockConfigData) + command.appConfig.actions = { dist: 'actions' } + command.appConfig.web.distProd = 'dist' + command.config = { runCommand: jest.fn(), runHook: jest.fn() } + command.buildOneExt = jest.fn() + command.getFullConfig = jest.fn().mockResolvedValue({ + aio: { + project: { + workspace: { + name: 'test-workspace' + } + } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' + } + }) + command.getAppExtConfigs = jest.fn().mockResolvedValue(createAppConfig(command.appConfig)) + command.getLibConsoleCLI = jest.fn(() => mockLibConsoleCLI) + + mockRuntimeLib.deployActions.mockResolvedValue({ actions: [] }) + mockWebLib.bundle.mockResolvedValue({ run: mockBundleFunc }) + + mockLogForwarding.isLocalConfigChanged.mockReturnValue(true) + const config = new LogForwarding.LogForwardingConfig('destination', { field: 'value' }) + mockLogForwarding.getLocalConfigWithSecrets.mockReturnValue(config) }) test('exports', async () => { @@ -274,61 +295,46 @@ test('flags', async () => { }) describe('run', () => { - let command - - beforeEach(() => { - command = new TheCommand([]) - command.error = jest.fn() - command.log = jest.fn() - command.appConfig = cloneDeep(mockConfigData) - command.appConfig.actions = { dist: 'actions' } - command.appConfig.web.distProd = 'dist' - command.config = { runCommand: jest.fn(), runHook: jest.fn() } - command.buildOneExt = jest.fn() - command.getAppExtConfigs = jest.fn() - command.getLibConsoleCLI = jest.fn(() => mockLibConsoleCLI) - command.getFullConfig = jest.fn().mockReturnValue({ - aio: { - project: { - workspace: { - name: 'foo' - } + test('build & deploy an App with no flags', async () => { + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig, 'app-exc-nui')) + helpers.buildExtensionPointPayloadWoMetadata.mockReturnValueOnce({ + endpoints: { + 'dx/excshell/1': { + view: [{ + metadata: {} + }] } } }) - mockRuntimeLib.deployActions.mockResolvedValue({ actions: [] }) - mockWebLib.bundle.mockResolvedValue({ run: mockBundleFunc }) - - mockLogForwarding.isLocalConfigChanged.mockReturnValue(true) - const config = new LogForwarding.LogForwardingConfig('destination', { field: 'value' }) - mockLogForwarding.getLocalConfigWithSecrets.mockReturnValue(config) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - test('build & deploy an App with no flags', async () => { - command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) - + mockExtRegExcShellPayload() await command.run() expect(command.error).toHaveBeenCalledTimes(0) - expect(command.buildOneExt).toHaveBeenCalledTimes(1) - expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) - expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) + expect(command.buildOneExt).toHaveBeenCalledTimes(3) + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(3) // 3 extensions + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(2) }) test('build & deploy an App verbose', async () => { - const appConfig = createAppConfig(command.appConfig) + const appConfig = createAppConfig(command.appConfig, 'app-exc-nui') command.getAppExtConfigs.mockResolvedValueOnce(appConfig) + helpers.buildExtensionPointPayloadWoMetadata.mockReturnValueOnce({ + endpoints: { + 'dx/excshell/1': { + view: [{ + metadata: {} + }] + } + } + }) + mockExtRegExcShellPayload() command.argv = ['-v'] await command.run() expect(command.error).toHaveBeenCalledTimes(0) - expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) - expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(command.buildOneExt).toHaveBeenCalledTimes(1) + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(3) // 3 extensions + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(2) + expect(command.buildOneExt).toHaveBeenCalledTimes(3) expect(command.buildOneExt).toHaveBeenCalledWith('application', appConfig.application, expect.objectContaining({ verbose: true, 'force-build': true }), @@ -365,6 +371,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) @@ -391,6 +401,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) @@ -590,8 +604,6 @@ describe('run', () => { [['--no-actions']], [['--no-log-forwarding-update', '--no-actions']] ])('no log forwarding update due to %s arg(s) specified', async (args) => { - const appConfig = createAppConfig(command.appConfig) - command.getAppExtConfigs.mockResolvedValueOnce(appConfig) command.argv = args await command.run() expect(command.error).toHaveBeenCalledTimes(0) @@ -754,6 +766,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) const noScriptFound = undefined @@ -783,6 +799,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellPayload() @@ -791,13 +811,13 @@ describe('run', () => { .mockResolvedValueOnce(noScriptFound) // pre-app-deploy .mockResolvedValueOnce(noScriptFound) // post-app-deploy - command.argv = ['--no-actions', '--no-web-assets'] + command.argv = ['--no-actions', '--no-web-assets', '--force-publish'] await command.run() expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(0) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(0) - expect(mockLibConsoleCLI.updateExtensionPoints).toHaveBeenCalledTimes(0) - expect(mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites).toHaveBeenCalledTimes(1) + expect(mockLibConsoleCLI.updateExtensionPoints).toHaveBeenCalledTimes(1) + expect(mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites).toHaveBeenCalledTimes(0) }) test('deploy (--no-actions)', async () => { @@ -922,6 +942,15 @@ describe('run', () => { mockGetProject() command.getFullConfig.mockResolvedValue({ aio: { + project: { + workspace: { + name: 'foo' + } + } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellPayload() @@ -950,6 +979,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellPayload() @@ -977,6 +1010,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellPayload() @@ -1006,6 +1043,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellPayloadFailure() @@ -1033,6 +1074,10 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) command.argv = [] @@ -1060,9 +1105,13 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) - mockExtRegExcShellPayload() + mockExtRegExcShellPayload({ excShellView: false }) await command.run() expect(mockLibConsoleCLI.getProject).toHaveBeenCalledTimes(1) @@ -1087,107 +1136,19 @@ describe('run', () => { id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) mockExtRegExcShellAndNuiPayload() command.argv = [] await command.run() expect(command.error).toHaveBeenCalledTimes(0) - expect(helpers.buildExcShellViewExtensionMetadata).toHaveBeenCalledTimes(1) - expect(mockLibConsoleCLI.updateExtensionPoints).toHaveBeenCalledTimes(0) - expect(mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites).toHaveBeenCalledTimes(1) - }) - - test('publish phase (no force, nui payload + no view operation)', async () => { - mockGetProject() - mockGetExtensionPointsRetractedApp() - command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig, 'app-exc-nui')) - command.getFullConfig.mockResolvedValue({ - aio: { - project: { - workspace: { - name: 'foo' - }, - org: { - id: '1111' - } - } - } - }) - const payload = { - endpoints: { - 'dx/excshell/1': { - another: [ - ] - } - } - } - helpers.buildExtensionPointPayloadWoMetadata.mockReturnValueOnce(payload) - mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites.mockReturnValueOnce(payload) - mockLibConsoleCLI.updateExtensionPoints.mockReturnValueOnce(payload) - command.argv = [] - await command.run() - expect(command.error).toHaveBeenCalledTimes(0) - expect(helpers.buildExcShellViewExtensionMetadata).toHaveBeenCalledTimes(0) - expect(mockLibConsoleCLI.updateExtensionPoints).toHaveBeenCalledTimes(0) - expect(mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites).toHaveBeenCalledTimes(1) - }) - - test('publish phase (--force-publish, exc+nui payload)', async () => { - mockGetProject() - mockGetExtensionPointsRetractedApp() - command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig, 'exc')) - command.getFullConfig.mockResolvedValue({ - aio: { - project: { - workspace: { - name: 'foo' - }, - org: { - id: '1111' - } - } - } - }) - - mockExtRegExcShellAndNuiPayload() - command.argv = ['--force-publish'] - await command.run() - expect(command.error).toHaveBeenCalledTimes(0) - expect(helpers.buildExcShellViewExtensionMetadata).toHaveBeenCalledTimes(1) - expect(mockLibConsoleCLI.updateExtensionPoints).toHaveBeenCalledTimes(1) - expect(mockLibConsoleCLI.updateExtensionPointsWithoutOverwrites).toHaveBeenCalledTimes(0) - }) - - test('app hook sequence', async () => { - const appConfig = createAppConfig(command.appConfig) - command.getAppExtConfigs.mockResolvedValueOnce(appConfig) - - // set hooks (command the same as hook name, for easy reference) - appConfig.application.hooks = { - 'pre-app-deploy': 'pre-app-deploy', - 'deploy-actions': 'deploy-actions', - 'deploy-static': 'deploy-static', - 'post-app-deploy': 'post-app-deploy' - } - - const scriptSequence = [] - helpers.runInProcess.mockImplementation(script => { - scriptSequence.push(script) - }) - - command.argv = [] - await command.run() - expect(command.error).toHaveBeenCalledTimes(0) - expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) - - expect(helpers.runInProcess).toHaveBeenCalledTimes(4) - expect(scriptSequence.length).toEqual(4) - expect(scriptSequence[0]).toEqual('pre-app-deploy') - expect(scriptSequence[1]).toEqual('deploy-actions') - expect(scriptSequence[2]).toEqual('deploy-static') - expect(scriptSequence[3]).toEqual('post-app-deploy') + // For app-exc-nui config, we have 2 extensions that need web assets deployed + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(2) + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(3) // 3 extensions }) test('should update log forwarding on server when local config is defined', async () => { @@ -1309,7 +1270,7 @@ describe('run', () => { accessToken: mockToken, env: mockEnv }) - command.getFullConfig = jest.fn().mockReturnValue({ + const fullConfig = { aio: { project: { id: mockProject, @@ -1321,17 +1282,60 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } - }) + } + command.getFullConfig = jest.fn().mockReturnValue(fullConfig) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(1) - expect(authHelper.setRuntimeApiHostAndAuthHandler).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledTimes(1) + expect(authHelper.setRuntimeApiHostAndAuthHandler).toHaveBeenCalledTimes(2) // once in logforwarding, once when deploying + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledWith({ + accessToken: mockToken, + appInfo: { + name: 'test-app', + version: '1.0.0', + runtimeNamespace: undefined, + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + }, + cliCommandFlags: { + actions: true, + build: true, + 'content-hash': true, + 'force-build': true, + 'force-deploy': false, + 'force-events': false, + 'force-publish': false, + 'log-forwarding-update': true, + open: false, + publish: false, + 'web-assets': true, + 'web-optimize': false + }, + env: mockEnv, + opItems: [ + '3 Javascript file(s)', + '2 CSS file(s)', + '5 image(s)', + '1 HTML page(s)' + ] + }) process.env.IS_DEPLOY_SERVICE_ENABLED = false }) @@ -1346,7 +1350,8 @@ describe('run', () => { accessToken: mockToken, env: mockEnv }) - command.getFullConfig = jest.fn().mockReturnValue({ + + const fullConfig = { aio: { project: { id: mockProject, @@ -1358,30 +1363,75 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } - }) + } + command.getFullConfig = jest.fn().mockReturnValue(fullConfig) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledTimes(1) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledWith({ + accessToken: mockToken, + appInfo: { + name: 'test-app', + version: '1.0.0', + runtimeNamespace: undefined, + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + }, + cliCommandFlags: { + actions: true, + build: true, + 'content-hash': true, + 'force-build': true, + 'force-deploy': false, + 'force-events': false, + 'force-publish': false, + 'log-forwarding-update': true, + open: false, + publish: false, + 'web-assets': true, + 'web-optimize': false + }, + env: mockEnv, + opItems: [ + '3 Javascript file(s)', + '2 CSS file(s)', + '5 image(s)', + '1 HTML page(s)' + ] + }) }) - test('Do not send audit logs for successful app deploy, if no logevent is present', async () => { - const mockToken = 'mocktoken' + test('Do not send audit logs for successful app deploy, if case of no token', async () => { + const mockToken = null const mockEnv = 'stage' const mockOrg = 'mockorg' const mockProject = 'mockproject' const mockWorkspaceId = 'mockworkspaceid' const mockWorkspaceName = 'mockworkspacename' + helpers.getCliInfo.mockResolvedValueOnce({ accessToken: mockToken, env: mockEnv }) - command.getFullConfig = jest.fn().mockReturnValue({ + + const fullConfig = { aio: { project: { id: mockProject, @@ -1393,34 +1443,38 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } - }) - - auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => null) - + } + command.getFullConfig = jest.fn().mockReturnValue(fullConfig) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(0) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledTimes(0) }) - test('Do not send audit logs for successful app deploy, if case of no token', async () => { - const mockToken = null + test('Send audit logs for successful app deploy + web assets', async () => { + const mockToken = 'mocktoken' const mockEnv = 'stage' const mockOrg = 'mockorg' const mockProject = 'mockproject' const mockWorkspaceId = 'mockworkspaceid' const mockWorkspaceName = 'mockworkspacename' + command.argv = ['--web-assets'] + helpers.getCliInfo.mockResolvedValueOnce({ accessToken: mockToken, env: mockEnv }) - command.getFullConfig = jest.fn().mockReturnValue({ + const fullConfig = { aio: { project: { id: mockProject, @@ -1432,27 +1486,68 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } - }) + } + command.getFullConfig = jest.fn().mockReturnValue(fullConfig) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(0) + expect(helpers.getFilesCountWithExtension).toHaveBeenCalledTimes(2) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledWith({ + accessToken: mockToken, + appInfo: { + name: 'test-app', + version: '1.0.0', + runtimeNamespace: undefined, + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + }, + cliCommandFlags: { + actions: true, + build: true, + 'content-hash': true, + 'force-build': true, + 'force-deploy': false, + 'force-events': false, + 'force-publish': false, + 'log-forwarding-update': true, + open: false, + publish: false, + 'web-assets': true, + 'web-optimize': false + }, + env: mockEnv, + opItems: [ + '3 Javascript file(s)', + '2 CSS file(s)', + '5 image(s)', + '1 HTML page(s)' + ] + }) }) - test('Send audit logs for successful app deploy + web assets', async () => { + test('Should deploy successfully even if (app deploy) Audit log service is unavailable (--verbose)', async () => { const mockToken = 'mocktoken' const mockEnv = 'stage' const mockOrg = 'mockorg' const mockProject = 'mockproject' const mockWorkspaceId = 'mockworkspaceid' const mockWorkspaceName = 'mockworkspacename' - - command.argv = ['--web-assets'] - helpers.getCliInfo.mockResolvedValueOnce({ accessToken: mockToken, env: mockEnv @@ -1470,20 +1565,36 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) + auditLogger.sendAppDeployAuditLog.mockRejectedValue({ + message: 'Internal Server Error', + status: 500 + }) + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + command.argv = ['--verbose'] await command.run() + expect(command.log).toHaveBeenCalledWith( + expect.stringContaining('skipping publish phase...') + ) + + expect(command.log).toHaveBeenCalledWith( + expect.stringContaining('Successful deployment 🏄') + ) + expect(auditLogger.sendAppDeployAuditLog).toHaveBeenCalledTimes(1) expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.getFilesCountWithExtension).toHaveBeenCalledTimes(2) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) }) - test('Should deploy successfully even if Audit log service is unavailable', async () => { + test('Should deploy successfully even if (app assets deploy)Audit log service is unavailable', async () => { const mockToken = 'mocktoken' const mockEnv = 'stage' const mockOrg = 'mockorg' @@ -1494,6 +1605,7 @@ describe('run', () => { accessToken: mockToken, env: mockEnv }) + command.getFullConfig = jest.fn().mockReturnValue({ aio: { project: { @@ -1506,10 +1618,14 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) - auditLogger.sendAuditLogs.mockRejectedValue({ + auditLogger.sendAppAssetsDeployedAuditLog.mockRejectedValue({ message: 'Internal Server Error', status: 500 }) @@ -1524,13 +1640,13 @@ describe('run', () => { expect(command.log).toHaveBeenCalledWith( expect.stringContaining('Successful deployment 🏄') ) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(1) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledTimes(1) expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) }) - test('Should deploy successfully even if Audit log service is unavailable (--verbose)', async () => { + test('Should deploy successfully even if Audit log service is unavailable (--verbose', async () => { const mockToken = 'mocktoken' const mockEnv = 'stage' const mockOrg = 'mockorg' @@ -1541,6 +1657,7 @@ describe('run', () => { accessToken: mockToken, env: mockEnv }) + command.getFullConfig = jest.fn().mockReturnValue({ aio: { project: { @@ -1553,10 +1670,14 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) - auditLogger.sendAuditLogs.mockRejectedValue({ + auditLogger.sendAppAssetsDeployedAuditLog.mockRejectedValue({ message: 'Internal Server Error', status: 500 }) @@ -1572,7 +1693,7 @@ describe('run', () => { expect(command.log).toHaveBeenCalledWith( expect.stringContaining('Successful deployment 🏄') ) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(1) + expect(auditLogger.sendAppAssetsDeployedAuditLog).toHaveBeenCalledTimes(1) expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) diff --git a/test/commands/app/undeploy.test.js b/test/commands/app/undeploy.test.js index fd96e91aa..bc9deb56d 100644 --- a/test/commands/app/undeploy.test.js +++ b/test/commands/app/undeploy.test.js @@ -13,6 +13,7 @@ governing permissions and limitations under the License. const TheCommand = require('../../../src/commands/app/undeploy') const BaseCommand = require('../../../src/BaseCommand') const dataMocks = require('../../data-mocks/config-loader') +const cloneDeep = require('lodash.clonedeep') jest.mock('../../../src/lib/app-helper.js') const helpers = require('../../../src/lib/app-helper.js') @@ -30,6 +31,9 @@ const mockConfigData = { app: { hasFrontend: true, hasBackend: true + }, + web: { + distProd: 'dist' } } @@ -146,22 +150,29 @@ describe('run', () => { command = new TheCommand([]) command.error = jest.fn() command.log = jest.fn() - command.appConfig = mockConfigData - command.config = { - runCommand: jest.fn(), - runHook: jest.fn() - } - command.getLibConsoleCLI = jest.fn(() => mockLibConsoleCLI) - command.getAppExtConfigs = jest.fn() - command.getFullConfig = jest.fn().mockReturnValue({ + command.appConfig = cloneDeep(mockConfigData) + command.appConfig.actions = { dist: 'actions' } + command.appConfig.web.distProd = 'dist' + command.config = { runCommand: jest.fn(), runHook: jest.fn() } + command.buildOneExt = jest.fn() + command.getFullConfig = jest.fn().mockResolvedValue({ aio: { project: { workspace: { - name: 'foo' + name: 'Production' + }, + org: { + id: '1111' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) + command.getLibConsoleCLI = jest.fn(() => mockLibConsoleCLI) + command.getAppExtConfigs = jest.fn() }) afterEach(() => { @@ -245,24 +256,40 @@ describe('run', () => { expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(0) }) - test('undeploy skip web assets', async () => { + test('should handle audit log error with verbose flag', async () => { command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig()) + command.warn = jest.fn() + command.argv = ['-v'] + + // Mock audit logger to throw an error + auditLogger.sendAppUndeployAuditLog.mockRejectedValueOnce(new Error('Audit log error')) - command.argv = ['--no-web-assets'] await command.run() - expect(command.error).toHaveBeenCalledTimes(0) + + // Verify error was logged with verbose flag + expect(command.warn).toHaveBeenCalledWith('Error: Audit Log Service Error: Failed to send audit log event for deployment.') + expect(command.warn).toHaveBeenCalledWith('Audit log error') + + // Verify deployment still continues expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) - expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(0) + expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) }) - test('undeploy skip static verbose', async () => { + test('should handle audit log error without verbose flag', async () => { command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig()) + command.warn = jest.fn() + + // Mock audit logger to throw an error + auditLogger.sendAppUndeployAuditLog.mockRejectedValueOnce(new Error('Audit log error')) - command.argv = ['--no-web-assets', '-v'] await command.run() - expect(command.error).toHaveBeenCalledTimes(0) + + // Verify error was not logged without verbose flag + expect(command.warn).not.toHaveBeenCalled() + + // Verify deployment still continues expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) - expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(0) + expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) }) test('undeploy an app with no backend', async () => { @@ -348,6 +375,10 @@ describe('run', () => { name: 'foo' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) const payload = { @@ -379,6 +410,10 @@ describe('run', () => { name: 'foo' } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) @@ -496,6 +531,10 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) @@ -516,7 +555,7 @@ describe('run', () => { const mockWorkspaceId = 'mockworkspaceid' const mockWorkspaceName = 'mockworkspacename' - command.getFullConfig = jest.fn().mockReturnValue({ + const fullConfig = { aio: { project: { id: mockProject, @@ -528,16 +567,46 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } - }) + } + command.getFullConfig = jest.fn().mockReturnValue(fullConfig) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs.mock.calls.length).toBe(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + expect(auditLogger.sendAppAssetsUndeployedAuditLog.mock.calls.length).toBe(1) + expect(auditLogger.sendAppAssetsUndeployedAuditLog).toHaveBeenCalledWith({ + accessToken: mockToken, + appInfo: { + name: 'test-app', + version: '1.0.0', + runtimeNamespace: undefined, + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + }, + cliCommandFlags: { + actions: true, + events: true, + 'force-unpublish': false, + unpublish: false, + 'web-assets': true + }, + env: mockEnv + }) }) test('Do not Send audit logs for successful app undeploy if case of no-token', async () => { @@ -558,6 +627,10 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) @@ -567,10 +640,10 @@ describe('run', () => { expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs.mock.calls.length).toBe(0) + expect(auditLogger.sendAppAssetsUndeployedAuditLog.mock.calls.length).toBe(0) }) - test('Do not Send audit logs for successful app undeploy, if no logevent is present', async () => { + test('Should app undeploy successfully even if Audit Log Service is not available', async () => { const mockOrg = 'mockorg' const mockProject = 'mockproject' const mockWorkspaceId = 'mockworkspaceid' @@ -588,20 +661,27 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) - auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => null) + auditLogger.sendAppAssetsUndeployedAuditLog.mockRejectedValue({ + message: 'Internal Server Error', + status: 500 + }) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs.mock.calls.length).toBe(0) + expect(auditLogger.sendAppAssetsUndeployedAuditLog).toHaveBeenCalledTimes(1) }) - test('Should app undeploy successfully even if Audit Log Service is not available', async () => { + test('Should app undeploy successfully even if Audit Log Service returns 503', async () => { const mockOrg = 'mockorg' const mockProject = 'mockproject' const mockWorkspaceId = 'mockworkspaceid' @@ -619,19 +699,61 @@ describe('run', () => { name: mockWorkspaceName } } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' } }) command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) - auditLogger.sendAuditLogs.mockRejectedValue({ - message: 'Internal Server Error', - status: 500 + auditLogger.sendAppAssetsUndeployedAuditLog.mockRejectedValue({ + message: 'Service Unavailable', + status: 503 + }) + + await command.run() + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) + expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) + expect(auditLogger.sendAppAssetsUndeployedAuditLog).toHaveBeenCalledTimes(1) + }) + + test('Should app undeploy successfully even if Audit Log Service returns 504', async () => { + const mockOrg = 'mockorg' + const mockProject = 'mockproject' + const mockWorkspaceId = 'mockworkspaceid' + const mockWorkspaceName = 'mockworkspacename' + + command.getFullConfig = jest.fn().mockReturnValue({ + aio: { + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + }, + packagejson: { + name: 'test-app', + version: '1.0.0' + } + }) + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + + auditLogger.sendAppAssetsUndeployedAuditLog.mockRejectedValue({ + message: 'Gateway Timeout', + status: 504 }) await command.run() expect(command.error).toHaveBeenCalledTimes(0) expect(mockRuntimeLib.undeployActions).toHaveBeenCalledTimes(1) expect(mockWebLib.undeployWeb).toHaveBeenCalledTimes(1) - expect(auditLogger.sendAuditLogs).toHaveBeenCalledTimes(1) + expect(auditLogger.sendAppAssetsUndeployedAuditLog).toHaveBeenCalledTimes(1) }) }) diff --git a/test/commands/lib/app-helper.test.js b/test/commands/lib/app-helper.test.js index 1e6af12f9..f7fd53ec6 100644 --- a/test/commands/lib/app-helper.test.js +++ b/test/commands/lib/app-helper.test.js @@ -17,7 +17,7 @@ const mockFetch = jest.fn() jest.mock('@adobe/aio-lib-core-config') jest.mock('execa') -jest.mock('path') +jest.mock('node:path') jest.mock('fs-extra') // do not touch the real fs jest.mock('@adobe/aio-lib-env') jest.mock('@adobe/aio-lib-ims') @@ -25,7 +25,7 @@ jest.mock('@adobe/aio-lib-ims') const mockLogger = require('@adobe/aio-lib-core-logging') const which = require('which') -const path = require('path') +const path = require('node:path') const fs = require('fs-extra') const execa = require('execa') const appHelper = require('../../../src/lib/app-helper') @@ -34,6 +34,9 @@ const libEnv = require('@adobe/aio-lib-env') const libIms = require('@adobe/aio-lib-ims') beforeEach(() => { + // use actual implementation + path.extname.mockImplementation(jest.requireActual('node:path').extname) + Object.defineProperty(process, 'platform', { value: 'linux' }) execa.mockReset() execa.command.mockReset() @@ -806,3 +809,67 @@ describe('object values', () => { expect(appHelper.getObjectValue(obj)).toEqual(obj) }) }) + +describe('getFilesCountWithExtension', () => { + const directory = '__fixtures__/app/web-src' + + it('should return an error message when directory does not exist', () => { + fs.existsSync.mockReturnValue(false) + + expect(() => appHelper.getFilesCountWithExtension(directory)).toThrow(`Error: Directory ${directory} does not exist.`) + expect(fs.existsSync).toHaveBeenCalledWith(directory) + }) + + it('should return an error message when directory is empty', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue([]) + + expect(() => appHelper.getFilesCountWithExtension(directory)).toThrow(`Error: No files found in directory ${directory}.`) + expect(fs.existsSync).toHaveBeenCalledWith(directory) + expect(fs.readdirSync).toHaveBeenCalledWith(directory, { recursive: true }) + }) + + it('should return a count of different file types', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue(['index.html', 'script.js', 'styles.css', 'image.png', 'image.jpg', 'readme']) + + const result = appHelper.getFilesCountWithExtension(directory) + // this really should be 2 image(s) but there is a side effect in the code that makes it split by ext + // and this makes more sense than seeing 1 image(s), 1 image(s) + expect(result).toEqual([ + '1 HTML page(s)\n', + '1 Javascript file(s)\n', + '1 CSS file(s)\n', + '1 .png image(s)\n', + '1 .jpg image(s)\n', + '1 file(s) without extension\n' + ]) + }) + + it('should handle directories with files of the same type', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue(['script1.js', 'script2.js', 'script3.js']) + + const result = appHelper.getFilesCountWithExtension(directory) + expect(result).toEqual(['3 Javascript file(s)\n']) + }) + + it('should handle files with no extension', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue(['readme', 'LICENSE']) + + const result = appHelper.getFilesCountWithExtension(directory) + expect(result).toEqual(['2 file(s) without extension\n']) + }) + + it('should handle files with other extensions', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue(['data.json', 'document.pdf']) + + const result = appHelper.getFilesCountWithExtension(directory) + expect(result).toEqual([ + '1 .json file(s)\n', + '1 .pdf file(s)\n' + ]) + }) +}) diff --git a/test/commands/lib/audit-logger.test.js b/test/commands/lib/audit-logger.test.js index 7e34f8b49..c0b2c4298 100644 --- a/test/commands/lib/audit-logger.test.js +++ b/test/commands/lib/audit-logger.test.js @@ -3,290 +3,322 @@ Copyright 2024 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -const fs = require('fs') -const auditLogger = require('../../../src/lib/audit-logger') - -jest.mock('fs') -jest.mock('chalk', () => ({ - red: jest.fn((text) => text), - bold: jest.fn((text) => text) -})) - -const OPERATIONS = { - AB_APP_DEPLOY: 'ab_app_deploy', - AB_APP_UNDEPLOY: 'ab_app_undeploy', - AB_APP_ASSETS_UNDEPLOYED: 'ab_app_assets_undeployed', - AB_APP_ASSETS_DEPLOYED: 'ab_app_assets_deployed', - AB_APP_TEST: 'ab_app_test' -} - -const mockToken = 'mocktoken' -const mockEnv = 'stage' -const mockLogEvent = { - projectId: 'mockproject', - orgId: 'mockorg' -} +const { + OPERATIONS, + AUDIT_SERVICE_ENDPOINTS, + getAuditLogEvent, + sendAppDeployAuditLog, + sendAppUndeployAuditLog, + sendAppAssetsDeployedAuditLog, + sendAppAssetsUndeployedAuditLog, + checkOverrides +} = require('../../../src/lib/audit-logger') beforeEach(() => { setFetchMock(true, 200, {}) }) -test('sendAuditLogs with valid params', async () => { - setFetchMock(true, 200, {}) - const options = { - method: 'POST', - headers: { - Authorization: 'Bearer ' + mockToken, - 'Content-type': 'application/json' - }, - body: JSON.stringify({ event: mockLogEvent }) +describe('audit-logger', () => { + const mockAccessToken = 'fake-token' + const mockProject = { + org: { id: 'fake-org-id' }, + id: 'fake-project-id', + workspace: { + id: 'fake-workspace-id', + name: 'fake-workspace' + } } - await auditLogger.sendAuditLogs(mockToken, mockLogEvent, mockEnv) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENDPOINTS[mockEnv], options) -}) - -// NOTE: this test is blocked until the audit service is available in prod -test('sendAuditLogs with default params', async () => { - setFetchMock(true, 200, {}) - const options = { - method: 'POST', - headers: { - Authorization: 'Bearer ' + mockToken, - 'Content-type': 'application/json' - }, - body: JSON.stringify({ event: mockLogEvent }) + const mockAppInfo = { + name: 'test-app', + version: '1.0.0', + project: mockProject, + runtimeNamespace: 'fake-namespace' } - await auditLogger.sendAuditLogs(mockToken, mockLogEvent) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENDPOINTS.prod, options) -}) + const mockCliFlags = { flag1: 'value1' } -test('should take prod endpoint if calling sendAuditLogs with non-exisiting env', async () => { - setFetchMock(true, 200, {}) - const options = { - method: 'POST', - headers: { - Authorization: 'Bearer ' + mockToken, - 'Content-type': 'application/json' - }, - body: JSON.stringify({ event: mockLogEvent }) - } - await auditLogger.sendAuditLogs(mockToken, mockLogEvent, 'dev') - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENDPOINTS.prod, options) -}) + describe('checkOverrides', () => { + const originalEnv = process.env -test('sendAuditLogs error response', async () => { - setFetchMock(false, 400, {}) - const options = { - method: 'POST', - headers: { - Authorization: 'Bearer ' + mockToken, - 'Content-type': 'application/json' - }, - body: JSON.stringify({ event: mockLogEvent }) - } - await expect(auditLogger.sendAuditLogs(mockToken, mockLogEvent, mockEnv)).rejects.toThrow('Failed to send audit log - 400') - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENDPOINTS[mockEnv], options) -}) + beforeEach(() => { + jest.resetModules() // Clears any cached modules + process.env = { ...originalEnv } // Copies the original environment variables + jest.spyOn(console, 'warn').mockImplementation() + }) -describe('getAuditLogEvent', () => { - const flags = { flag1: 'value1' } - const project = { - org: { id: 'org123' }, - id: 'proj456', - workspace: { id: 'ws789', name: 'testWorkspace' } - } + afterEach(() => { + process.env = originalEnv // Restores the original environment variables + console.warn.mockRestore() + }) - const mockDeployMessage = 'Starting deployment for the App Builder application in workspace testWorkspace' - const mockUndeployMessage = 'Starting undeployment for the App Builder application in workspace testWorkspace' - - test('should return correct log event for AB_APP_DEPLOY event', () => { - const event = 'AB_APP_DEPLOY' - const result = auditLogger.getAuditLogEvent(flags, project, event) - - expect(result).toEqual({ - orgId: 'org123', - projectId: 'proj456', - workspaceId: 'ws789', - workspaceName: 'testWorkspace', - operation: OPERATIONS.AB_APP_DEPLOY, - timestamp: expect.any(Number), - data: { - cliCommandFlags: flags, - opDetailsStr: mockDeployMessage - } + it('should not log warnings when no environment variables are set', () => { + checkOverrides() + expect(console.warn).not.toHaveBeenCalled() }) - }) - test('should return correct log event for AB_APP_UNDEPLOY event', () => { - const event = 'AB_APP_UNDEPLOY' - const result = auditLogger.getAuditLogEvent(flags, project, event) - - expect(result).toEqual({ - orgId: 'org123', - projectId: 'proj456', - workspaceId: 'ws789', - workspaceName: 'testWorkspace', - operation: OPERATIONS.AB_APP_UNDEPLOY, - timestamp: expect.any(Number), - data: { - cliCommandFlags: flags, - opDetailsStr: mockUndeployMessage - } + it('should log warning when only stage endpoint is overridden', () => { + process.env.AUDIT_SERVICE_ENDPOINT_STAGE = 'https://custom-stage-endpoint.com' + checkOverrides() + expect(console.warn).toHaveBeenCalledWith('Audit Service overrides detected:') + expect(console.warn).toHaveBeenCalledWith(' AUDIT_SERVICE_ENDPOINT_STAGE: https://custom-stage-endpoint.com') }) - }) - test('should return correct log event for AB_APP_ASSETS_UNDEPLOYED event', () => { - const event = 'AB_APP_ASSETS_UNDEPLOYED' - const result = auditLogger.getAuditLogEvent(flags, project, event) - - expect(result).toEqual({ - orgId: 'org123', - projectId: 'proj456', - workspaceId: 'ws789', - workspaceName: 'testWorkspace', - operation: OPERATIONS.AB_APP_ASSETS_UNDEPLOYED, - timestamp: expect.any(Number), - data: { - cliCommandFlags: flags, - opDetailsStr: 'All static assets for the App Builder application in workspace: testWorkspace were successfully undeployed from the CDN' - } + it('should log warning when only prod endpoint is overridden', () => { + process.env.AUDIT_SERVICE_ENDPOINT_PROD = 'https://custom-prod-endpoint.com' + checkOverrides() + expect(console.warn).toHaveBeenCalledWith('Audit Service overrides detected:') + expect(console.warn).toHaveBeenCalledWith(' AUDIT_SERVICE_ENDPOINT_PROD: https://custom-prod-endpoint.com') }) - }) - test('should return correct log event for AB_APP_ASSETS_DEPLOYED event', () => { - const event = 'AB_APP_ASSETS_DEPLOYED' - const result = auditLogger.getAuditLogEvent(flags, project, event) - - expect(result).toEqual({ - orgId: 'org123', - projectId: 'proj456', - workspaceId: 'ws789', - workspaceName: 'testWorkspace', - operation: OPERATIONS.AB_APP_ASSETS_DEPLOYED, - timestamp: expect.any(Number), - data: { - cliCommandFlags: flags, - opDetailsStr: 'All static assets for the App Builder application in workspace: testWorkspace were successfully deployed to the CDN.\n Files deployed - ' - } + it('should log warnings when both endpoints are overridden', () => { + process.env.AUDIT_SERVICE_ENDPOINT_STAGE = 'https://custom-stage-endpoint.com' + process.env.AUDIT_SERVICE_ENDPOINT_PROD = 'https://custom-prod-endpoint.com' + checkOverrides() + expect(console.warn).toHaveBeenCalledWith('Audit Service overrides detected:') + expect(console.warn).toHaveBeenCalledWith(' AUDIT_SERVICE_ENDPOINT_STAGE: https://custom-stage-endpoint.com') + expect(console.warn).toHaveBeenCalledWith(' AUDIT_SERVICE_ENDPOINT_PROD: https://custom-prod-endpoint.com') }) }) - test('should return undefined if project or workspace is missing', () => { - const event = 'AB_APP_DEPLOY' - const result = auditLogger.getAuditLogEvent(flags, {}, event) + describe('getAuditLogEvent', () => { + it('should create a valid audit log event for app deploy', () => { + const event = getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_DEPLOY, + appInfo: mockAppInfo + }) + + expect(event).toEqual({ + orgId: 'fake-org-id', + projectId: 'fake-project-id', + workspaceId: 'fake-workspace-id', + workspaceName: 'fake-workspace', + operation: OPERATIONS.AB_APP_DEPLOY, + objectRef: 'test-app', + objectRev: '1.0.0', + objectName: 'test-app', + timestamp: expect.any(Number), + runtimeNamespace: 'fake-namespace', + data: { + cliCommandFlags: mockCliFlags + } + }) + }) - expect(result).toBeFalsy() - }) + it('should create a valid audit log event for app undeploy', () => { + const event = getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_UNDEPLOY, + appInfo: mockAppInfo + }) + + expect(event).toEqual({ + orgId: 'fake-org-id', + projectId: 'fake-project-id', + workspaceId: 'fake-workspace-id', + workspaceName: 'fake-workspace', + operation: OPERATIONS.AB_APP_UNDEPLOY, + objectRef: 'test-app', + objectRev: '1.0.0', + objectName: 'test-app', + timestamp: expect.any(Number), + runtimeNamespace: 'fake-namespace', + data: { + cliCommandFlags: mockCliFlags + } + }) + }) - test('should default operation to APP_TEST if event is not found in OPERATIONS', () => { - const event = 'UNKNOWN_EVENT' - const result = auditLogger.getAuditLogEvent(flags, project, event) - - expect(result).toEqual({ - orgId: 'org123', - projectId: 'proj456', - workspaceId: 'ws789', - workspaceName: 'testWorkspace', - operation: OPERATIONS.AB_APP_TEST, - timestamp: expect.any(Number), - data: { - cliCommandFlags: flags, - opDetailsStr: undefined + it('should throw error if project is not provided', () => { + const invalidAppInfo = { + name: 'test-app', + version: '1.0.0' } + expect(() => getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_DEPLOY, + appInfo: invalidAppInfo + })).toThrow('Project is required') }) - }) -}) - -describe('getFilesCountWithExtension', () => { - const directory = '__fixtures__/app/web-src' - // Mock 'this.log' - const mockLog = jest.fn() - - beforeEach(() => { - mockLog.mockClear() // Clear mock between tests - }) + it('should throw error if project org is missing', () => { + const invalidAppInfo = { + ...mockAppInfo, + project: { ...mockProject, org: null } + } + expect(() => getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_DEPLOY, + appInfo: invalidAppInfo + })).toThrow('Project org is required') + }) - it('should return an error message when directory does not exist', () => { - fs.existsSync.mockReturnValue(false) + it('should throw error if project workspace is missing', () => { + const invalidAppInfo = { + ...mockAppInfo, + project: { ...mockProject, workspace: null } + } + expect(() => getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_DEPLOY, + appInfo: invalidAppInfo + })).toThrow('Project workspace is required') + }) - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) + it('should throw error if runtime namespace is missing', () => { + const invalidAppInfo = { + ...mockAppInfo, + runtimeNamespace: null, + project: { ...mockProject } + } + expect(() => getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: OPERATIONS.AB_APP_DEPLOY, + appInfo: invalidAppInfo + })).toThrow('Runtime namespace is required') + }) - expect(fs.existsSync).toHaveBeenCalledWith(directory) - expect(mockLog).toHaveBeenCalledWith( - 'Error: Directory __fixtures__/app/web-src does not exist.' - ) - expect(result).toEqual([]) + it('should throw error for invalid operation', () => { + expect(() => getAuditLogEvent({ + cliCommandFlags: mockCliFlags, + operation: 'invalid_operation', + appInfo: mockAppInfo + })).toThrow('Invalid operation: invalid_operation') + }) }) - it('should return an error message when directory is empty', () => { - fs.existsSync.mockReturnValue(true) - fs.readdirSync.mockReturnValue([]) - - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) - - expect(fs.readdirSync).toHaveBeenCalledWith(directory, { recursive: true }) - expect(mockLog).toHaveBeenCalledWith( - 'Error: No files found in directory __fixtures__/app/web-src.' - ) - expect(result).toEqual([]) + describe('sendAppDeployAuditLog', () => { + it('should send app deploy audit log successfully', async () => { + setFetchMock(true, 200, {}) + + await sendAppDeployAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo + }) + + expect(fetch).toHaveBeenCalledWith( + AUDIT_SERVICE_ENDPOINTS.prod, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockAccessToken}`, + 'Content-type': 'application/json' + } + }) + ) + }) }) - it('should return a count of different file types', () => { - fs.existsSync.mockReturnValue(true) - fs.readdirSync.mockReturnValue(['index.html', 'script.js', 'styles.css', 'image.png', 'image.jpg', 'readme']) - - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) - // this really should be 2 image(s) but there is a side effect in the code that makes it split by ext - // and this makes more sense than seeing 1 image(s), 1 image(s) - expect(result).toEqual([ - '1 HTML page(s)\n', - '1 Javascript file(s)\n', - '1 CSS file(s)\n', - '1 .png image(s)\n', - '1 .jpg image(s)\n', - '1 file(s) without extension\n' - ]) + describe('sendAppUndeployAuditLog', () => { + it('should send app undeploy audit log successfully', async () => { + setFetchMock(true, 200, {}) + + await sendAppUndeployAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo + }) + + expect(fetch).toHaveBeenCalledWith( + AUDIT_SERVICE_ENDPOINTS.prod, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockAccessToken}`, + 'Content-type': 'application/json' + } + }) + ) + }) }) - it('should handle directories with files of the same type', () => { - fs.existsSync.mockReturnValue(true) - fs.readdirSync.mockReturnValue(['script1.js', 'script2.js', 'script3.js']) - - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) - - expect(result).toEqual(['3 Javascript file(s)\n']) + describe('sendAppAssetsDeployedAuditLog', () => { + it('should send app assets deployed audit log successfully', async () => { + setFetchMock(true, 200, {}) + + const mockOpItems = ['file1.js', 'file2.css'] + + await sendAppAssetsDeployedAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo, + opItems: mockOpItems + }) + + expect(fetch).toHaveBeenCalledWith( + AUDIT_SERVICE_ENDPOINTS.prod, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockAccessToken}`, + 'Content-type': 'application/json' + }, + body: expect.stringContaining(JSON.stringify(mockOpItems)) + }) + ) + }) }) - it('should handle files with no extension', () => { - fs.existsSync.mockReturnValue(true) - fs.readdirSync.mockReturnValue(['readme', 'LICENSE']) - - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) - - expect(result).toEqual(['2 file(s) without extension\n']) + describe('sendAppAssetsUndeployedAuditLog', () => { + it('should send app assets undeployed audit log successfully', async () => { + setFetchMock(true, 200, {}) + + await sendAppAssetsUndeployedAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo + }) + + expect(fetch).toHaveBeenCalledWith( + AUDIT_SERVICE_ENDPOINTS.prod, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockAccessToken}`, + 'Content-type': 'application/json' + } + }) + ) + }) }) - it('should handle files with other extensions', () => { - fs.existsSync.mockReturnValue(true) - fs.readdirSync.mockReturnValue(['data.json', 'document.pdf']) + describe('error handling', () => { + it('should throw error when audit service returns non-200 status', async () => { + setFetchMock(true, 500, 'Internal Server Error') - const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) + await expect(sendAppDeployAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo + })).rejects.toThrow('Failed to send audit log - 500 Internal Server Error') + }) - expect(result).toEqual([ - '1 .json file(s)\n', - '1 .pdf file(s)\n' - ]) + it('should use prod endpoint when invalid environment is provided', async () => { + fetch.mockResolvedValueOnce({ status: 200 }) + + await sendAppDeployAuditLog({ + accessToken: mockAccessToken, + cliCommandFlags: mockCliFlags, + appInfo: mockAppInfo, + env: 'invalid-env' + }) + + expect(fetch).toHaveBeenCalledWith( + AUDIT_SERVICE_ENDPOINTS.prod, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${mockAccessToken}`, + 'Content-type': 'application/json' + } + }) + ) + }) }) })