diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index 7ee052f5..0d9f50f1 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -18,9 +18,10 @@ const BaseCommand = require('../../BaseCommand') const BuildCommand = require('./build') const webLib = require('@adobe/aio-lib-web') const { Flags } = require('@oclif/core') -const { createWebExportFilter, runInProcess, buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata } = require('../../lib/app-helper') +const { createWebExportFilter, runInProcess, buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, getCliInfo } = 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 PRE_DEPLOY_EVENT_REG = 'pre-deploy-event-reg' const POST_DEPLOY_EVENT_REG = 'post-deploy-event-reg' @@ -52,12 +53,11 @@ class Deploy extends BuildCommand { try { const aioConfig = (await this.getFullConfig()).aio + const cliDetails = await getCliInfo() // 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) { + if (aioConfig?.project?.workspace && flags['log-forwarding-update'] && flags.actions) { spinner.start('Updating log forwarding configuration') try { const lf = await LogForwarding.init(aioConfig) @@ -93,7 +93,15 @@ class Deploy extends BuildCommand { } } - // 3. deploy actions and web assets for each extension + // 3. send deploy log event + const logEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_DEPLOY') + if (logEvent) { + await sendAuditLogs(cliDetails.accessToken, logEvent, cliDetails.env) + } else { + this.log(chalk.red(chalk.bold('Warning: No valid config data found to send audit log event for deployment.'))) + } + + // 4. deploy actions and web assets for each extension // Possible improvements: // - parallelize // - break into smaller pieces deploy, allowing to first deploy all actions then all web assets @@ -101,6 +109,14 @@ class Deploy extends BuildCommand { const k = keys[i] const v = values[i] await this.deploySingleConfig(k, v, flags, spinner) + if (v.app.hasFrontend && flags['web-assets']) { + const opItems = getFilesCountWithExtension(v.web.distProd) + const assetDeployedLogEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_ASSETS_DEPLOYED') + if (assetDeployedLogEvent) { + assetDeployedLogEvent.data.opItems = opItems + await sendAuditLogs(cliDetails.accessToken, assetDeployedLogEvent, cliDetails.env) + } + } } // 4. deploy extension manifest @@ -205,6 +221,11 @@ class Deploy extends BuildCommand { } else { deployedFrontendUrl = await webLib.deployWeb(config, onProgress) spinner.succeed(chalk.green(message)) + const filesLogCount = getFilesCountWithExtension(config.web.distProd) + const filesDeployedMessage = `All static assets for the App Builder application in workspace: ${name} were successfully deployed to the CDN. Files deployed :` + const filesLogFormatted = filesLogCount?.map(file => ` • ${file}`).join('') + const finalMessage = chalk.green(`${filesDeployedMessage}\n${filesLogFormatted}`) + spinner.succeed(chalk.green(finalMessage)) } } catch (err) { spinner.fail(chalk.green(message)) diff --git a/src/commands/app/undeploy.js b/src/commands/app/undeploy.js index 25a477b6..f11d25ce 100644 --- a/src/commands/app/undeploy.js +++ b/src/commands/app/undeploy.js @@ -17,8 +17,9 @@ const { Flags } = require('@oclif/core') const BaseCommand = require('../../BaseCommand') const webLib = require('@adobe/aio-lib-web') -const { runInProcess, buildExtensionPointPayloadWoMetadata } = require('../../lib/app-helper') +const { runInProcess, buildExtensionPointPayloadWoMetadata, getCliInfo } = require('../../lib/app-helper') const rtLib = require('@adobe/aio-lib-runtime') +const { sendAuditLogs, getAuditLogEvent } = require('../../lib/audit-logger') class Undeploy extends BaseCommand { async run () { @@ -44,14 +45,29 @@ class Undeploy extends BaseCommand { const spinner = ora() try { + const aioConfig = (await this.getFullConfig()).aio + const cliDetails = await getCliInfo() + const logEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_UNDEPLOY') + + // 1.1. send audit log event for successful undeploy + if (logEvent) { + await sendAuditLogs(cliDetails.accessToken, logEvent, cliDetails.env) + } else { + this.log(chalk.red(chalk.bold('Warning: No valid config data found to send audit log event for deployment.'))) + } + for (let i = 0; i < keys.length; ++i) { const k = keys[i] const v = values[i] await this.undeployOneExt(k, v, flags, spinner) + const assetUndeployLogEvent = getAuditLogEvent(flags, aioConfig.project, 'AB_APP_ASSETS_UNDEPLOYED') + if (assetUndeployLogEvent) { + await sendAuditLogs(cliDetails.accessToken, assetUndeployLogEvent, cliDetails.env) + } } - // 2. unpublish extension manifest + + // 1.2. unpublish extension manifest if (flags.unpublish && !(keys.length === 1 && keys[0] === 'application')) { - const aioConfig = (await this.getFullConfig()).aio const payload = await this.unpublishExtensionPoints(libConsoleCLI, undeployConfigs, aioConfig, flags['force-unpublish']) this.log(chalk.blue(chalk.bold(`New Extension Point(s) in Workspace '${aioConfig.project.workspace.name}': '${Object.keys(payload.endpoints)}'`))) } else { @@ -110,6 +126,7 @@ class Undeploy extends BaseCommand { if (!script) { await webLib.undeployWeb(config, onProgress) } + spinner.succeed(chalk.green(`Un-Deploying web assets for ${extName}`)) } catch (err) { spinner.fail(chalk.green(`Un-Deploying web assets for ${extName}`)) diff --git a/src/lib/audit-logger.js b/src/lib/audit-logger.js new file mode 100644 index 00000000..24a1fd86 --- /dev/null +++ b/src/lib/audit-logger.js @@ -0,0 +1,144 @@ +/* +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 fetch = require('node-fetch') +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', // todo : remove after testing + AB_APP_ASSETS_DEPLOYED: 'ab_app_assets_deployed', + AB_APP_ASSETS_UNDEPLOYED: 'ab_app_assets_undeployed' +} + +const AUDIT_SERVICE_ENPOINTS = { + 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' +} + +/** + * 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 + */ +async function sendAuditLogs (accessToken, logEvent, env = 'prod') { + const url = AUDIT_SERVICE_ENPOINTS[env] + const payload = { + event: logEvent + } + const options = { + method: 'POST', + headers: { + Authorization: 'Bearer ' + accessToken, + 'Content-type': 'application/json' + }, + body: JSON.stringify(payload) + } + 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) + } +} + +/** + * + * @param {object} flags cli flags + * @param {object} project details + * @param {string} event log name + * @returns {object} logEvent + */ +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 - ` + } + + 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 + } + } + } + return logEvent +} + +/** + * + * @param {string} directory | path to assets directory + * @returns {Array} log | array of log messages + */ +function getFilesCountWithExtension (directory) { + const log = [] + + if (!fs.existsSync(directory)) { + this.log(chalk.red(chalk.bold(`Error: Directory ${directory} does not exist.`))) + return log + } + + const files = fs.readdirSync(directory) + + if (files.length === 0) { + this.log(chalk.red(chalk.bold(`Error: No files found in directory ${directory}.`))) + return log + } + + 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 + + if (ext === '.js') description = 'Javascript file(s)' + else if (ext === '.css') description = 'CSS file(s)' + else if (ext === '.html') description = 'HTML page(s)' + else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) description = 'image(s)' + else if (ext === 'no extension') description = 'file(s) without extension' + else description = `${ext} file(s)` + + log.push(`${count} ${description}\n`) + }) + + return log +} + +module.exports = { + sendAuditLogs, + getAuditLogEvent, + AUDIT_SERVICE_ENPOINTS, + getFilesCountWithExtension +} diff --git a/test/commands/app/deploy.test.js b/test/commands/app/deploy.test.js index 8ea2574c..e4ea014b 100644 --- a/test/commands/app/deploy.test.js +++ b/test/commands/app/deploy.test.js @@ -21,6 +21,9 @@ const mockBundleFunc = jest.fn() jest.mock('../../../src/lib/app-helper.js') const helpers = require('../../../src/lib/app-helper.js') +jest.mock('../../../src/lib/audit-logger.js') +const auditLogger = require('../../../src/lib/audit-logger.js') + const mockWebLib = require('@adobe/aio-lib-web') const mockRuntimeLib = require('@adobe/aio-lib-runtime') @@ -156,6 +159,7 @@ beforeEach(() => { helpers.buildExtensionPointPayloadWoMetadata.mockReset() helpers.buildExcShellViewExtensionMetadata.mockReset() helpers.createWebExportFilter.mockReset() + helpers.getCliInfo.mockReset() mockLogForwarding.isLocalConfigChanged.mockReset() mockLogForwarding.getLocalConfigWithSecrets.mockReset() mockLogForwarding.updateServerConfig.mockReset() @@ -164,7 +168,35 @@ 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) => { + return [ + '3 Javascript file(s)', + '2 CSS file(s)', + '5 image(s)', + '1 HTML page(s)' + ] + }) + helpers.getCliInfo.mockImplementation(() => { + return { + accessToken: 'mocktoken', + env: 'stage' + } + }) LogForwarding.init.mockResolvedValue(mockLogForwarding) }) @@ -1234,4 +1266,115 @@ describe('run', () => { expect(runHook).toHaveBeenCalledWith('post-deploy-event-reg', expect.any(Object)) expect(command.error).toHaveBeenCalledTimes(1) }) + + test('Send audit logs for successful app deploy', async () => { + const mockToken = 'mocktoken' + 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({ + aio: { + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + } + }) + 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.mock.calls.length).toBeLessThanOrEqual(2) + expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + }) + + test('Do not send audit logs for successful app deploy', async () => { + const mockToken = 'mocktoken' + 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({ + aio: { + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + } + }) + + auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => null) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + + await command.run() + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) + }) + + 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({ + aio: { + project: { + id: mockProject, + org: { + id: mockOrg + }, + workspace: { + id: mockWorkspaceId, + name: mockWorkspaceName + } + } + } + }) + + 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.getFilesCountWithExtension).toHaveBeenCalledTimes(2) + expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + }) }) diff --git a/test/commands/app/undeploy.test.js b/test/commands/app/undeploy.test.js index d8b73be1..ee40f37e 100644 --- a/test/commands/app/undeploy.test.js +++ b/test/commands/app/undeploy.test.js @@ -17,6 +17,9 @@ const dataMocks = require('../../data-mocks/config-loader') jest.mock('../../../src/lib/app-helper.js') const helpers = require('../../../src/lib/app-helper.js') +jest.mock('../../../src/lib/audit-logger.js') +const auditLogger = require('../../../src/lib/audit-logger.js') + const mockFS = require('fs-extra') jest.mock('fs-extra') @@ -66,6 +69,15 @@ beforeEach(() => { helpers.runInProcess.mockReset() mockFS.existsSync.mockReset() helpers.wrapError.mockImplementation(msg => msg) + auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => { + return { orgId: 'mockorg', projectId: 'mockproject', workspaceId: 'mockworkspaceid', workspaceName: 'mockworkspacename' } + }) + helpers.getCliInfo.mockImplementation(() => { + return { + accessToken: 'mocktoken', + env: 'stage' + } + }) jest.clearAllMocks() }) @@ -134,7 +146,15 @@ describe('run', () => { command.config = { runCommand: jest.fn(), runHook: jest.fn() } command.getLibConsoleCLI = jest.fn(() => mockLibConsoleCLI) command.getAppExtConfigs = jest.fn() - command.getFullConfig = jest.fn() + command.getFullConfig = jest.fn().mockReturnValue({ + aio: { + project: { + workspace: { + name: 'foo' + } + } + } + }) }) afterEach(() => { @@ -447,4 +467,68 @@ describe('run', () => { expect(runHook).toHaveBeenCalledWith('pre-undeploy-event-reg', expect.any(Object)) expect(command.error).toHaveBeenCalledTimes(1) }) + + test('Send audit logs for successful app undeploy', async () => { + const mockToken = 'mocktoken' + const mockEnv = 'stage' + 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 + } + } + } + }) + 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).toBeGreaterThan(1) + expect(auditLogger.sendAuditLogs).toHaveBeenCalledWith(mockToken, expect.objectContaining({ orgId: mockOrg, projectId: mockProject, workspaceId: mockWorkspaceId, workspaceName: mockWorkspaceName }), mockEnv) + }) + + test('Do not Send audit logs for successful app undeploy', 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 + } + } + } + }) + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + + auditLogger.getAuditLogEvent.mockImplementation((flags, project, event) => null) + + 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(command.log).toHaveBeenCalledWith(expect.stringMatching(/Warning: No valid config data found to send audit log event for deployment/)) + }) }) diff --git a/test/commands/lib/audit-logger.test.js b/test/commands/lib/audit-logger.test.js new file mode 100644 index 00000000..52f49472 --- /dev/null +++ b/test/commands/lib/audit-logger.test.js @@ -0,0 +1,296 @@ +/* +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') +/* eslint-disable no-unused-vars */ +const path = require('path') +/* eslint-disable no-unused-vars */ +const chalk = require('chalk') +const fetch = require('node-fetch') +jest.mock('node-fetch', () => jest.fn()) +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 mockResponse = Promise.resolve({ + ok: true, + status: 200, + text: () => { + return {} + } +}) + +const mockErrorResponse = Promise.resolve({ + ok: false, + status: 400, + text: () => { + return {} + } +}) + +beforeEach(() => { + fetch.mockReset() +}) + +test('sendAuditLogs with valid params', async () => { + fetch.mockReturnValue(mockResponse) + const options = { + method: 'POST', + headers: { + Authorization: 'Bearer ' + mockToken, + 'Content-type': 'application/json' + }, + body: JSON.stringify({ event: mockLogEvent }) + } + await auditLogger.sendAuditLogs(mockToken, mockLogEvent, mockEnv) + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENPOINTS[mockEnv], options) +}) + +test('sendAuditLogs with default params', async () => { + fetch.mockReturnValue(mockResponse) + const options = { + method: 'POST', + headers: { + Authorization: 'Bearer ' + mockToken, + 'Content-type': 'application/json' + }, + body: JSON.stringify({ event: mockLogEvent }) + } + await auditLogger.sendAuditLogs(mockToken, mockLogEvent) + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith(auditLogger.AUDIT_SERVICE_ENPOINTS.prod, options) +}) + +test('sendAuditLogs error response', async () => { + fetch.mockReturnValue(mockErrorResponse) + 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_ENPOINTS[mockEnv], options) +}) + +describe('getAuditLogEvent', () => { + const flags = { flag1: 'value1' } + const project = { + org: { id: 'org123' }, + id: 'proj456', + workspace: { id: 'ws789', name: 'testWorkspace' } + } + + 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 + } + }) + }) + + 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 + } + }) + }) + + 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' + } + }) + }) + + 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 - ' + } + }) + }) + + test('should return undefined if project or workspace is missing', () => { + const event = 'AB_APP_DEPLOY' + const result = auditLogger.getAuditLogEvent(flags, {}, event) + + expect(result).toBeUndefined() + }) + + 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 + } + }) + }) +}) + +describe('getFilesCountWithExtension', () => { + const directory = '__fixtures__/app/web-src' + + // Mock 'this.log' + const mockLog = jest.fn() + + beforeEach(() => { + mockLog.mockClear() // Clear mock between tests + }) + + it('should return an error message when directory does not exist', () => { + fs.existsSync.mockReturnValue(false) + + const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) + + expect(fs.existsSync).toHaveBeenCalledWith(directory) + expect(mockLog).toHaveBeenCalledWith( + 'Error: Directory __fixtures__/app/web-src does not exist.' + ) + expect(result).toEqual([]) + }) + + 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) + expect(mockLog).toHaveBeenCalledWith( + 'Error: No files found in directory __fixtures__/app/web-src.' + ) + expect(result).toEqual([]) + }) + + it('should return a count of different file types', () => { + fs.existsSync.mockReturnValue(true) + fs.readdirSync.mockReturnValue(['index.html', 'script.js', 'styles.css', 'image.png', 'readme']) + + const result = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) + + expect(result).toEqual([ + '1 HTML page(s)\n', + '1 Javascript file(s)\n', + '1 CSS file(s)\n', + '1 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 = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, 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 = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, 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 = auditLogger.getFilesCountWithExtension.call({ log: mockLog }, directory) + + expect(result).toEqual([ + '1 .json file(s)\n', + '1 .pdf file(s)\n' + ]) + }) +})