diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index cdffff6b..7e4e24cc 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -18,7 +18,11 @@ const BaseCommand = require('../../BaseCommand') const BuildCommand = require('./build') const webLib = require('@adobe/aio-lib-web') const { Flags } = require('@oclif/core') -const { runInProcess, buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, getFilesCountWithExtension } = require('../../lib/app-helper') +const { + rewriteActionUrlInEntities, runInProcess, + buildExtensionPointPayloadWoMetadata, buildExcShellViewExtensionMetadata, + getFilesCountWithExtension +} = require('../../lib/app-helper') const rtLib = require('@adobe/aio-lib-runtime') const LogForwarding = require('../../lib/log-forwarding') const { sendAppAssetsDeployedAuditLog, sendAppDeployAuditLog } = require('../../lib/audit-logger') @@ -128,7 +132,7 @@ class Deploy extends BuildCommand { const k = keys[i] const v = setRuntimeApiHostAndAuthHandler(values[i]) - await this.deploySingleConfig(k, v, flags, spinner) + await this.deploySingleConfig({ name: k, config: v, originalConfig: values[i], flags, spinner }) if (cliDetails?.accessToken && v.app.hasFrontend && flags['web-assets']) { const opItems = getFilesCountWithExtension(v.web.distProd) try { @@ -167,7 +171,7 @@ class Deploy extends BuildCommand { this.log(chalk.green(chalk.bold('Successful deployment 🏄'))) } - async deploySingleConfig (name, config, flags, spinner) { + async deploySingleConfig ({ name, config, originalConfig, flags, spinner }) { const onProgress = !flags.verbose ? info => { spinner.text = info @@ -267,7 +271,8 @@ class Deploy extends BuildCommand { // log deployed resources if (deployedRuntimeEntities.actions && deployedRuntimeEntities.actions.length > 0) { - await logActions({ entities: deployedRuntimeEntities, log: (...rest) => this.log(chalk.bold(chalk.blue(...rest))) }) + const entities = await rewriteActionUrlInEntities({ entities: deployedRuntimeEntities, config: originalConfig }) + await logActions({ entities, log: (...rest) => this.log(chalk.bold(chalk.blue(...rest))) }) } // TODO urls should depend on extension point, exc shell only for exc shell extension point - use a post-app-deploy hook ? diff --git a/src/lib/app-helper.js b/src/lib/app-helper.js index 0fd45748..d31b0e99 100644 --- a/src/lib/app-helper.js +++ b/src/lib/app-helper.js @@ -504,7 +504,49 @@ function getFilesCountWithExtension (directory) { return log } +/** + * Rewrites action URLs in deployed runtime entities using URLs from the manifest configuration. + * + * This function takes deployed runtime entities and updates the URL property of each action + * with the corresponding URL from the runtime manifest configuration. It creates a deep copy + * of the entities to avoid mutating the original object. + * + * @param {object} params - Parameters object + * @param {object} params.entities - The deployed runtime entities object + * @param {object} params.config - The application configuration object containing runtime manifest + * @returns {Promise} A promise that resolves to a deep copy of the entities object with updated action URLs + * @example + * const entities = { + * actions: [ + * { name: 'my-action', url: 'old-url' }, + * { name: 'another-action', url: 'another-old-url' } + * ] + * } + * const config = { + * actions: { devRemote: false }, + * // ... other config properties + * } + * + * const rewrittenEntities = await rewriteActionUrlInEntities({ entities, config }) + * // rewrittenEntities.actions will have updated URLs from the manifest + */ +async function rewriteActionUrlInEntities ({ entities, config }) { + const actionUrlsFromManifest = RuntimeLib.utils.getActionUrls(config, config.actions.devRemote) + const rewrittenEntities = structuredClone(entities) + + rewrittenEntities.actions = rewrittenEntities.actions?.map(action => { + const retAction = structuredClone(action) + const url = actionUrlsFromManifest[action.name] + if (url) { + retAction.url = url + } + return retAction + }) + return rewrittenEntities +} + module.exports = { + rewriteActionUrlInEntities, getObjectValue, getObjectProp, createWebExportFilter, diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 32b09f31..acad265e 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -64,7 +64,8 @@ const bearerAuthHandler = { } } -const setRuntimeApiHostAndAuthHandler = (config) => { +const setRuntimeApiHostAndAuthHandler = (_config) => { + const config = structuredClone(_config) const aioConfig = (config && 'runtime' in config) ? config : null if (aioConfig) { const apiEndpoint = process.env.AIO_DEPLOY_SERVICE_URL ?? defaultDeployServiceUrl diff --git a/test/commands/app/config/get/log-forwarding.test.js b/test/commands/app/config/get/log-forwarding.test.js index b6ea5a64..840c224f 100644 --- a/test/commands/app/config/get/log-forwarding.test.js +++ b/test/commands/app/config/get/log-forwarding.test.js @@ -52,7 +52,13 @@ test('get log forwarding settings (expect init to be passed a config)', async () lf.getServerConfig.mockResolvedValue(serverConfig) await command.run() - expect(LogForwarding.init).toHaveBeenCalledWith(command.appConfig.aio) + // config should be deploy service settings + const modifiedConfig = structuredClone(command.appConfig.aio) + modifiedConfig.runtime.apihost = 'https://deploy-service.app-builder.adp.adobe.io/runtime' + modifiedConfig.runtime.auth_handler = { + getAuthHeader: expect.any(Function) + } + expect(LogForwarding.init).toHaveBeenCalledWith(modifiedConfig) }) test('get log forwarding settings (local and server are the same)', async () => { diff --git a/test/commands/app/deploy.test.js b/test/commands/app/deploy.test.js index 4d05c2c4..223ad57f 100644 --- a/test/commands/app/deploy.test.js +++ b/test/commands/app/deploy.test.js @@ -167,6 +167,7 @@ beforeEach(() => { helpers.buildExtensionPointPayloadWoMetadata.mockReset() helpers.buildExcShellViewExtensionMetadata.mockReset() helpers.createWebExportFilter.mockReset() + helpers.rewriteActionUrlInEntities.mockReset() mockLogForwarding.isLocalConfigChanged.mockReset() mockLogForwarding.getLocalConfigWithSecrets.mockReset() mockLogForwarding.updateServerConfig.mockReset() @@ -183,6 +184,11 @@ beforeEach(() => { '1 HTML page(s)' ] }) + + helpers.rewriteActionUrlInEntities.mockImplementation(async ({ entities }) => { + return entities + }) + authHelper.setRuntimeApiHostAndAuthHandler.mockImplementation((aioConfig) => aioConfig) authHelper.getAccessToken.mockImplementation(() => { return { diff --git a/test/commands/lib/app-helper.test.js b/test/commands/lib/app-helper.test.js index ca69cf74..28ed4655 100644 --- a/test/commands/lib/app-helper.test.js +++ b/test/commands/lib/app-helper.test.js @@ -847,3 +847,223 @@ describe('getFilesCountWithExtension', () => { ]) }) }) + +describe('rewriteActionUrlInEntities', () => { + const RuntimeLib = require('@adobe/aio-lib-runtime') + + beforeEach(() => { + RuntimeLib.utils.getActionUrls = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('should rewrite action URLs with URLs from manifest', async () => { + const entities = { + actions: [ + { name: 'action1', url: 'old-url-1' }, + { name: 'action2', url: 'old-url-2' } + ] + } + const config = { + actions: { devRemote: false } + } + const mockActionUrls = { + action1: 'https://example.com/api/v1/web/action1', + action2: 'https://example.com/api/v1/web/action2' + } + + RuntimeLib.utils.getActionUrls.mockReturnValue(mockActionUrls) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(RuntimeLib.utils.getActionUrls).toHaveBeenCalledWith(config, false) + expect(result.actions).toEqual([ + { name: 'action1', url: 'https://example.com/api/v1/web/action1' }, + { name: 'action2', url: 'https://example.com/api/v1/web/action2' } + ]) + }) + + test('should use devRemote flag when calling getActionUrls', async () => { + const entities = { actions: [] } + const config = { + actions: { devRemote: true } + } + + RuntimeLib.utils.getActionUrls.mockReturnValue({}) + + await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(RuntimeLib.utils.getActionUrls).toHaveBeenCalledWith(config, true) + }) + + test('should preserve original action when no URL found in manifest', async () => { + const entities = { + actions: [ + { name: 'action1', url: 'original-url' }, + { name: 'action2', url: 'another-original-url' } + ] + } + const config = { + actions: { devRemote: false } + } + const mockActionUrls = { + action1: 'https://example.com/api/v1/web/action1' + // action2 is missing from manifest URLs + } + + RuntimeLib.utils.getActionUrls.mockReturnValue(mockActionUrls) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions).toEqual([ + { name: 'action1', url: 'https://example.com/api/v1/web/action1' }, + { name: 'action2', url: 'another-original-url' } // Original URL preserved + ]) + }) + + test('should handle entities with no actions array', async () => { + const entities = {} + const config = { + actions: { devRemote: false } + } + + RuntimeLib.utils.getActionUrls.mockReturnValue({}) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions).toBeUndefined() + expect(RuntimeLib.utils.getActionUrls).toHaveBeenCalledWith(config, false) + }) + + test('should handle entities with undefined actions', async () => { + const entities = { actions: undefined } + const config = { + actions: { devRemote: false } + } + + RuntimeLib.utils.getActionUrls.mockReturnValue({}) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions).toBeUndefined() + }) + + test('should handle empty actions array', async () => { + const entities = { actions: [] } + const config = { + actions: { devRemote: false } + } + + RuntimeLib.utils.getActionUrls.mockReturnValue({}) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions).toEqual([]) + }) + + test('should create deep copy and not mutate original entities', async () => { + const originalEntities = { + actions: [ + { name: 'action1', url: 'original-url', otherProp: 'value' } + ], + otherProp: 'test' + } + const config = { + actions: { devRemote: false } + } + const mockActionUrls = { + action1: 'https://example.com/api/v1/web/action1' + } + + RuntimeLib.utils.getActionUrls.mockReturnValue(mockActionUrls) + + const result = await appHelper.rewriteActionUrlInEntities({ entities: originalEntities, config }) + + // Original should be unchanged + expect(originalEntities.actions[0].url).toBe('original-url') + expect(originalEntities.otherProp).toBe('test') + + // Result should have the new URL + expect(result.actions[0].url).toBe('https://example.com/api/v1/web/action1') + expect(result.actions[0].otherProp).toBe('value') + expect(result.otherProp).toBe('test') + + // Should be different objects + expect(result).not.toBe(originalEntities) + expect(result.actions).not.toBe(originalEntities.actions) + expect(result.actions[0]).not.toBe(originalEntities.actions[0]) + }) + + test('should preserve all other action properties', async () => { + const entities = { + actions: [ + { + name: 'action1', + url: 'old-url', + version: '1.0.0', + annotations: { 'web-export': true }, + parameters: { key: 'value' }, + exec: { kind: 'nodejs:18' } + } + ] + } + const config = { + actions: { devRemote: false } + } + const mockActionUrls = { + action1: 'https://example.com/api/v1/web/action1' + } + + RuntimeLib.utils.getActionUrls.mockReturnValue(mockActionUrls) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions[0]).toEqual({ + name: 'action1', + url: 'https://example.com/api/v1/web/action1', + version: '1.0.0', + annotations: { 'web-export': true }, + parameters: { key: 'value' }, + exec: { kind: 'nodejs:18' } + }) + }) + + test('should handle empty action URLs from manifest', async () => { + const entities = { + actions: [ + { name: 'action1', url: 'original-url' } + ] + } + const config = { + actions: { devRemote: false } + } + + RuntimeLib.utils.getActionUrls.mockReturnValue({}) // Empty object + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions[0].url).toBe('original-url') // Should preserve original + }) + + test('should handle null/undefined URL from manifest', async () => { + const entities = { + actions: [ + { name: 'action1', url: 'original-url' } + ] + } + const config = { + actions: { devRemote: false } + } + const mockActionUrls = { + action1: null // Null URL + } + + RuntimeLib.utils.getActionUrls.mockReturnValue(mockActionUrls) + + const result = await appHelper.rewriteActionUrlInEntities({ entities, config }) + + expect(result.actions[0].url).toBe('original-url') // Should preserve original when URL is falsy + }) +})