From ab4212f26d74b4222aa3bc91c15a37a2b8ff603b Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:51:21 +0800 Subject: [PATCH 1/2] fix: add support for use_proxy_from_env_var needle option in openwhisk.js by runtime patching pin openwhisk lib version --- package.json | 4 ++- src/PatchedHttpsProxyAgent.js | 37 +++++++++++++++++++++++++ src/RuntimeAPI.js | 20 +++++++++++--- src/openwhisk-patch.js | 51 +++++++++++++++++++++++++++++++++++ src/utils.js | 19 +++++++++++++ 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 src/PatchedHttpsProxyAgent.js create mode 100644 src/openwhisk-patch.js diff --git a/package.json b/package.json index 5e06c6a6..ad0b7613 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "folder-hash": "^4.0.4", "fs-extra": "^11.3.0", "globby": "^11.0.1", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "js-yaml": "^4.1.0", "lodash.clonedeep": "^4.5.0", - "openwhisk": "^3.21.8", + "openwhisk": "3.21.8", "openwhisk-fqn": "0.0.2", "proxy-from-env": "^1.1.0", "sha1": "^1.1.1", diff --git a/src/PatchedHttpsProxyAgent.js b/src/PatchedHttpsProxyAgent.js new file mode 100644 index 00000000..c4d2b36d --- /dev/null +++ b/src/PatchedHttpsProxyAgent.js @@ -0,0 +1,37 @@ +/* +Copyright 2025 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 { HttpsProxyAgent } = require('https-proxy-agent') + +/** + * HttpsProxyAgent needs a patch for TLS connections. + * It doesn't pass in the original options during a SSL connect. + * + * See https://github.com/TooTallNate/proxy-agents/issues/89 + * @private + */ +class PatchedHttpsProxyAgent extends HttpsProxyAgent { + constructor (proxyUrl, opts) { + super(proxyUrl, opts) + this.savedOpts = opts + } + + async connect (req, opts) { + return super.connect(req, { + ...this.savedOpts, + keepAliveInitialDelay: 1000, + keepAlive: true, + ...opts + }) + } +} + +module.exports = PatchedHttpsProxyAgent diff --git a/src/RuntimeAPI.js b/src/RuntimeAPI.js index 90d33252..0934a854 100644 --- a/src/RuntimeAPI.js +++ b/src/RuntimeAPI.js @@ -17,6 +17,8 @@ const deepCopy = require('lodash.clonedeep') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-runtime:RuntimeAPI', { provider: 'debug', level: process.env.LOG_LEVEL }) const LogForwarding = require('./LogForwarding') const LogForwardingLocalDestinationsProvider = require('./LogForwardingLocalDestinationsProvider') +const { patchOWForTunnelingIssue } = require('./openwhisk-patch') +const { getProxyAgent } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete /* global OpenwhiskOptions, OpenwhiskClient */ // for linter @@ -35,6 +37,12 @@ class RuntimeAPI { */ async init (options) { aioLogger.debug(`init options: ${JSON.stringify(options, null, 2)}`) + + options.use_proxy_from_env_var = false // default, unless env var is set + if (process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR === 'true') { // legacy support + options.use_proxy_from_env_var = true + } + const clonedOptions = deepCopy(options) const initErrors = [] @@ -49,11 +57,17 @@ class RuntimeAPI { const sdkDetails = { clonedOptions } throw new codes.ERROR_SDK_INITIALIZATION({ sdkDetails, messageValues: `${initErrors.join(', ')}` }) } - + const proxyUrl = getProxyForUrl(clonedOptions.apihost) if (proxyUrl) { aioLogger.debug(`using proxy url: ${proxyUrl}`) - clonedOptions.proxy = proxyUrl + if (clonedOptions.use_proxy_from_env_var !== false) { + clonedOptions.proxy = proxyUrl + clonedOptions.agent = null + } else { + clonedOptions.proxy = null + clonedOptions.agent = getProxyAgent(clonedOptions.apihost, proxyUrl) + } } else { aioLogger.debug('proxy settings not found') } @@ -67,7 +81,7 @@ class RuntimeAPI { const shouldIgnoreCerts = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' clonedOptions.ignore_certs = clonedOptions.ignore_certs || shouldIgnoreCerts - this.ow = ow(clonedOptions) + this.ow = patchOWForTunnelingIssue(ow(clonedOptions), clonedOptions.use_proxy_from_env_var) const self = this return { diff --git a/src/openwhisk-patch.js b/src/openwhisk-patch.js new file mode 100644 index 00000000..6c8b88df --- /dev/null +++ b/src/openwhisk-patch.js @@ -0,0 +1,51 @@ +/* +Copyright 2025 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. +*/ + +/** + * This patches the Openwhisk client to handle a tunneling issue with openwhisk > v3.0.0 + * See https://github.com/tomas/needle/issues/406 + * + * Once openwhisk.js supports the use_proxy_from_env_var option (for needle), we can remove this patch. + * + * @param {object} ow + * @param {boolean} use_proxy_from_env_var + * @returns {object} the patched openwhisk object + */ +function patchOWForTunnelingIssue(ow, use_proxy_from_env_var) { + // we must set proxy to null here if agent is set, since it was already + // internally initialzed in Openwhisk with the proxy url from env vars + const agentIsSet = ow.actions.client.options.agent !== null + if (agentIsSet && use_proxy_from_env_var === false) { + ow.actions.client.options.proxy = undefined; + } + + // The issue is patching openwhisk.js to use use_proxy_from_env_var (a needle option) - the contribution process might take too long. + // monkey-patch client.params: patch one, all the rest should be patched (shared client) + // we wrap the original params to add the use_proxy_from_env_var boolean + const originalParams = ow.actions.client.params.bind(ow.actions.client) + ow.actions.client.params = function(...args) { + return originalParams(...args).then(params => { + params.use_proxy_from_env_var = use_proxy_from_env_var; + return params; + }).catch(err => { + console.error('Error patching openwhisk client params: ', err) + throw err + }) + } + + return ow +} + + +module.exports = { + patchOWForTunnelingIssue +} diff --git a/src/utils.js b/src/utils.js index 8f5df7ea..32eeb052 100644 --- a/src/utils.js +++ b/src/utils.js @@ -21,6 +21,8 @@ const path = require('path') const archiver = require('archiver') // this is a static list that comes from here: https://developer.adobe.com/runtime/docs/guides/reference/runtimes/ const SupportedRuntimes = ['sequence', 'blackbox', 'nodejs:10', 'nodejs:12', 'nodejs:14', 'nodejs:16', 'nodejs:18', 'nodejs:20', 'nodejs:22'] +const { HttpProxyAgent } = require('http-proxy-agent') +const PatchedHttpsProxyAgent = require('./PatchedHttpsProxyAgent.js') // must cover 'deploy-service[-region][.env].app-builder[.int|.corp].adp.adobe.io/runtime const SUPPORTED_ADOBE_ANNOTATION_ENDPOINT_REGEXES = [ @@ -2113,7 +2115,24 @@ async function getSupportedServerRuntimes (apihost) { return json.runtimes.nodejs.map(item => item.kind) } +/** + * Get the proxy agent for the given endpoint + * + * @param {string} endpoint - The endpoint to get the proxy agent for + * @param {string} proxyUrl - The proxy URL to use + * @param {Object} proxyOptions - The proxy options to use + * @returns {HttpsProxyAgent | HttpProxyAgent} - The proxy agent + */ +function getProxyAgent(endpoint, proxyUrl, proxyOptions = {}) { + if (endpoint.startsWith('https')) { + return new PatchedHttpsProxyAgent(proxyUrl, proxyOptions); + } else { + return new HttpProxyAgent(proxyUrl, proxyOptions); + } +} + module.exports = { + getProxyAgent, getSupportedServerRuntimes, checkOpenWhiskCredentials, getActionEntryFile, From 7cdd29b2d11529072fef0f8eb36f68b22fefd961 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Tue, 7 Oct 2025 19:04:12 +0800 Subject: [PATCH 2/2] add tests --- src/PatchedHttpsProxyAgent.js | 8 +- src/RuntimeAPI.js | 10 +- src/openwhisk-patch.js | 21 +- src/utils.js | 10 +- test/PatchedHttpsProxyAgent.test.js | 41 +++ test/RuntimeAPI.test.js | 318 ++++++++++++++++++++ test/index.test.js | 174 ++++++++++- test/openwhisk-patch.test.js | 222 ++++++++++++++ test/triggers.test.js | 445 ++++++++++++++++++++++++++++ test/utils.test.js | 49 +++ 10 files changed, 1272 insertions(+), 26 deletions(-) create mode 100644 test/PatchedHttpsProxyAgent.test.js create mode 100644 test/RuntimeAPI.test.js create mode 100644 test/openwhisk-patch.test.js create mode 100644 test/triggers.test.js diff --git a/src/PatchedHttpsProxyAgent.js b/src/PatchedHttpsProxyAgent.js index c4d2b36d..39b1c590 100644 --- a/src/PatchedHttpsProxyAgent.js +++ b/src/PatchedHttpsProxyAgent.js @@ -25,10 +25,10 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect (req, opts) { - return super.connect(req, { - ...this.savedOpts, - keepAliveInitialDelay: 1000, - keepAlive: true, + return super.connect(req, { + ...this.savedOpts, + keepAliveInitialDelay: 1000, + keepAlive: true, ...opts }) } diff --git a/src/RuntimeAPI.js b/src/RuntimeAPI.js index 0934a854..9b40a617 100644 --- a/src/RuntimeAPI.js +++ b/src/RuntimeAPI.js @@ -38,13 +38,13 @@ class RuntimeAPI { async init (options) { aioLogger.debug(`init options: ${JSON.stringify(options, null, 2)}`) - options.use_proxy_from_env_var = false // default, unless env var is set + const clonedOptions = deepCopy(options) + + clonedOptions.use_proxy_from_env_var = false // default, unless env var is set if (process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR === 'true') { // legacy support - options.use_proxy_from_env_var = true + clonedOptions.use_proxy_from_env_var = true } - const clonedOptions = deepCopy(options) - const initErrors = [] if (!clonedOptions || !clonedOptions.api_key) { initErrors.push('api_key') @@ -57,7 +57,7 @@ class RuntimeAPI { const sdkDetails = { clonedOptions } throw new codes.ERROR_SDK_INITIALIZATION({ sdkDetails, messageValues: `${initErrors.join(', ')}` }) } - + const proxyUrl = getProxyForUrl(clonedOptions.apihost) if (proxyUrl) { aioLogger.debug(`using proxy url: ${proxyUrl}`) diff --git a/src/openwhisk-patch.js b/src/openwhisk-patch.js index 6c8b88df..56a0b8a3 100644 --- a/src/openwhisk-patch.js +++ b/src/openwhisk-patch.js @@ -1,3 +1,5 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable camelcase */ /* Copyright 2025 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -13,29 +15,29 @@ governing permissions and limitations under the License. /** * This patches the Openwhisk client to handle a tunneling issue with openwhisk > v3.0.0 * See https://github.com/tomas/needle/issues/406 - * + * * Once openwhisk.js supports the use_proxy_from_env_var option (for needle), we can remove this patch. - * - * @param {object} ow - * @param {boolean} use_proxy_from_env_var + * + * @param {object} ow the Openwhisk object to patch + * @param {boolean} use_proxy_from_env_var the needle option to add * @returns {object} the patched openwhisk object */ -function patchOWForTunnelingIssue(ow, use_proxy_from_env_var) { +function patchOWForTunnelingIssue (ow, use_proxy_from_env_var) { // we must set proxy to null here if agent is set, since it was already // internally initialzed in Openwhisk with the proxy url from env vars const agentIsSet = ow.actions.client.options.agent !== null if (agentIsSet && use_proxy_from_env_var === false) { - ow.actions.client.options.proxy = undefined; + ow.actions.client.options.proxy = undefined } // The issue is patching openwhisk.js to use use_proxy_from_env_var (a needle option) - the contribution process might take too long. // monkey-patch client.params: patch one, all the rest should be patched (shared client) // we wrap the original params to add the use_proxy_from_env_var boolean const originalParams = ow.actions.client.params.bind(ow.actions.client) - ow.actions.client.params = function(...args) { + ow.actions.client.params = function (...args) { return originalParams(...args).then(params => { - params.use_proxy_from_env_var = use_proxy_from_env_var; - return params; + params.use_proxy_from_env_var = use_proxy_from_env_var + return params }).catch(err => { console.error('Error patching openwhisk client params: ', err) throw err @@ -45,7 +47,6 @@ function patchOWForTunnelingIssue(ow, use_proxy_from_env_var) { return ow } - module.exports = { patchOWForTunnelingIssue } diff --git a/src/utils.js b/src/utils.js index 32eeb052..e71c6082 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2120,14 +2120,14 @@ async function getSupportedServerRuntimes (apihost) { * * @param {string} endpoint - The endpoint to get the proxy agent for * @param {string} proxyUrl - The proxy URL to use - * @param {Object} proxyOptions - The proxy options to use - * @returns {HttpsProxyAgent | HttpProxyAgent} - The proxy agent + * @param {object} proxyOptions - The proxy options to use + * @returns {PatchedHttpsProxyAgent | HttpProxyAgent} - The proxy agent */ -function getProxyAgent(endpoint, proxyUrl, proxyOptions = {}) { +function getProxyAgent (endpoint, proxyUrl, proxyOptions = {}) { if (endpoint.startsWith('https')) { - return new PatchedHttpsProxyAgent(proxyUrl, proxyOptions); + return new PatchedHttpsProxyAgent(proxyUrl, proxyOptions) } else { - return new HttpProxyAgent(proxyUrl, proxyOptions); + return new HttpProxyAgent(proxyUrl, proxyOptions) } } diff --git a/test/PatchedHttpsProxyAgent.test.js b/test/PatchedHttpsProxyAgent.test.js new file mode 100644 index 00000000..c7151d53 --- /dev/null +++ b/test/PatchedHttpsProxyAgent.test.js @@ -0,0 +1,41 @@ +/* +Copyright 2025 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 { HttpsProxyAgent } = require('https-proxy-agent') +const PatchedHttpsProxyAgent = require('../src/PatchedHttpsProxyAgent') + +jest.mock('https-proxy-agent') + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('constructor', () => { + test('should call parent constructor with proxyUrl and opts', () => { + const proxyUrl = 'https://proxy.example.com:8080' + const req = { url: 'https://example.com' } + const constructorOpts = { hostname: 'example.com', port: 443 } + const connectOpts = { some: 'value' } + + const patchedAgent = new PatchedHttpsProxyAgent(proxyUrl, constructorOpts) + + patchedAgent.connect(req, connectOpts) + expect(patchedAgent.savedOpts).toBe(constructorOpts) + + expect(HttpsProxyAgent.prototype.connect).toHaveBeenCalledWith(req, { + ...constructorOpts, + keepAliveInitialDelay: 1000, + keepAlive: true, + ...connectOpts + }) + }) +}) diff --git a/test/RuntimeAPI.test.js b/test/RuntimeAPI.test.js new file mode 100644 index 00000000..b512e154 --- /dev/null +++ b/test/RuntimeAPI.test.js @@ -0,0 +1,318 @@ +/* +Copyright 2020 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 RuntimeAPI = require('../src/RuntimeAPI') +const { codes } = require('../src/SDKErrors') +const Triggers = require('../src/triggers') +const LogForwarding = require('../src/LogForwarding') +const LogForwardingLocalDestinationsProvider = require('../src/LogForwardingLocalDestinationsProvider') +const { patchOWForTunnelingIssue } = require('../src/openwhisk-patch') +const { getProxyAgent } = require('../src/utils') + +// Mock dependencies +jest.mock('openwhisk') +jest.mock('../src/triggers') +jest.mock('../src/LogForwarding') +jest.mock('../src/LogForwardingLocalDestinationsProvider') +jest.mock('../src/openwhisk-patch') +jest.mock('../src/utils') +jest.mock('proxy-from-env') +jest.mock('lodash.clonedeep') + +const ow = require('openwhisk') +const { getProxyForUrl } = require('proxy-from-env') +const deepCopy = require('lodash.clonedeep') + +describe('RuntimeAPI', () => { + let runtimeAPI + let mockOWClient + let clonedOptionsRef + + beforeEach(() => { + runtimeAPI = new RuntimeAPI() + + // Reset all mocks + jest.clearAllMocks() + + // Mock OpenWhisk client + mockOWClient = { + actions: { mock: 'actions' }, + activations: { mock: 'activations' }, + namespaces: { mock: 'namespaces' }, + packages: { mock: 'packages' }, + rules: { mock: 'rules' }, + triggers: { mock: 'triggers' }, + routes: { mock: 'routes' } + } + + ow.mockReturnValue(mockOWClient) + patchOWForTunnelingIssue.mockReturnValue(mockOWClient) + + // Create a spy that tracks modifications to the cloned object + deepCopy.mockImplementation((obj) => { + clonedOptionsRef = { ...obj } + return clonedOptionsRef + }) + + // Reset environment variables + delete process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + }) + + afterEach(() => { + // Clean up environment variables + delete process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + }) + + describe('init', () => { + const validOptions = { + api_key: 'test-api-key', + apihost: 'https://test-host.com', + namespace: 'test-namespace' + } + + test('should initialize successfully with valid options', async () => { + const result = await runtimeAPI.init(validOptions) + + expect(result).toHaveProperty('actions', mockOWClient.actions) + expect(result).toHaveProperty('activations', mockOWClient.activations) + expect(result).toHaveProperty('namespaces', mockOWClient.namespaces) + expect(result).toHaveProperty('packages', mockOWClient.packages) + expect(result).toHaveProperty('rules', mockOWClient.rules) + expect(result).toHaveProperty('triggers') + expect(result).toHaveProperty('routes', mockOWClient.routes) + expect(result).toHaveProperty('logForwarding') + expect(result).toHaveProperty('initOptions') + }) + + test('should throw error when api_key is missing', async () => { + const invalidOptions = { apihost: 'https://test-host.com' } + + await expect(runtimeAPI.init(invalidOptions)) + .rejects.toThrow(codes.ERROR_SDK_INITIALIZATION) + }) + + test('should throw error when apihost is missing', async () => { + const invalidOptions = { api_key: 'test-api-key' } + + await expect(runtimeAPI.init(invalidOptions)) + .rejects.toThrow(codes.ERROR_SDK_INITIALIZATION) + }) + + test('should throw error when both api_key and apihost are missing', async () => { + const invalidOptions = {} + + await expect(runtimeAPI.init(invalidOptions)) + .rejects.toThrow(codes.ERROR_SDK_INITIALIZATION) + }) + + test('should throw error when options is null', async () => { + await expect(runtimeAPI.init(null)) + .rejects.toThrow(codes.ERROR_SDK_INITIALIZATION) + }) + + test('should throw error when options is undefined', async () => { + await expect(runtimeAPI.init(undefined)) + .rejects.toThrow(codes.ERROR_SDK_INITIALIZATION) + }) + + test('should set use_proxy_from_env_var to true when NEEDLE_USE_PROXY_FROM_ENV_VAR is true', async () => { + process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR = 'true' + + await runtimeAPI.init(validOptions) + + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should set use_proxy_from_env_var to false by default', async () => { + await runtimeAPI.init(validOptions) + + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should configure proxy when proxy URL is found and NEEDLE_USE_PROXY_FROM_ENV_VAR is true', async () => { + const proxyUrl = 'http://proxy.example.com:8080' + getProxyForUrl.mockReturnValue(proxyUrl) + process.env.NEEDLE_USE_PROXY_FROM_ENV_VAR = 'true' + + await runtimeAPI.init(validOptions) + + // Verify that deepCopy was called with the original options + expect(deepCopy).toHaveBeenCalledWith(validOptions) + + // Verify that the cloned options were modified with proxy settings + expect(clonedOptionsRef.proxy).toBe(proxyUrl) + expect(clonedOptionsRef.agent).toBe(null) + }) + + test('should configure proxy agent when use_proxy_from_env_var is explicitly true but NEEDLE_USE_PROXY_FROM_ENV_VAR is not set', async () => { + const proxyUrl = 'http://proxy.example.com:8080' + const mockAgent = { mock: 'agent' } + getProxyForUrl.mockReturnValue(proxyUrl) + getProxyAgent.mockReturnValue(mockAgent) + + const options = { ...validOptions, use_proxy_from_env_var: true } + + await runtimeAPI.init(options) + + // Verify that deepCopy was called with the original options + expect(deepCopy).toHaveBeenCalledWith(options) + + // The code overrides use_proxy_from_env_var to false by default, so it uses proxy agent + expect(clonedOptionsRef.proxy).toBe(null) + expect(clonedOptionsRef.agent).toBe(mockAgent) + expect(getProxyAgent).toHaveBeenCalledWith(validOptions.apihost, proxyUrl) + }) + + test('should configure proxy agent when proxy URL is found and use_proxy_from_env_var is false', async () => { + const proxyUrl = 'http://proxy.example.com:8080' + const mockAgent = { mock: 'agent' } + getProxyForUrl.mockReturnValue(proxyUrl) + getProxyAgent.mockReturnValue(mockAgent) + + const options = { ...validOptions, use_proxy_from_env_var: false } + + await runtimeAPI.init(options) + + expect(getProxyAgent).toHaveBeenCalledWith(validOptions.apihost, proxyUrl) + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(options) + }) + + test('should not configure proxy when no proxy URL is found', async () => { + getProxyForUrl.mockReturnValue(null) + + await runtimeAPI.init(validOptions) + + expect(getProxyAgent).not.toHaveBeenCalled() + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should set default retry options when retry is undefined', async () => { + await runtimeAPI.init(validOptions) + + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should not override existing retry options', async () => { + const customRetry = { retries: 5, minTimeout: 1000 } + const options = { ...validOptions, retry: customRetry } + + await runtimeAPI.init(options) + + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(options) + }) + + test('should set ignore_certs to true when NODE_TLS_REJECT_UNAUTHORIZED is 0', async () => { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + await runtimeAPI.init(validOptions) + + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should not set ignore_certs when NODE_TLS_REJECT_UNAUTHORIZED is not 0', async () => { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1' + + await runtimeAPI.init(validOptions) + + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(validOptions) + }) + + test('should preserve existing ignore_certs option', async () => { + const options = { ...validOptions, ignore_certs: true } + + await runtimeAPI.init(options) + + // deepCopy is called with the original options before modifications + expect(deepCopy).toHaveBeenCalledWith(options) + }) + + test('should call patchOWForTunnelingIssue with correct parameters', async () => { + await runtimeAPI.init(validOptions) + + expect(patchOWForTunnelingIssue).toHaveBeenCalledWith( + mockOWClient, + false + ) + }) + + test('should create LogForwarding instance with correct parameters', async () => { + const options = { + ...validOptions, + auth_handler: 'test-auth-handler' + } + + await runtimeAPI.init(options) + + expect(LogForwarding).toHaveBeenCalledWith( + options.namespace, + options.apihost, + options.api_key, + expect.any(LogForwardingLocalDestinationsProvider), + options.auth_handler + ) + }) + + test('should create LogForwarding instance without auth_handler when not provided', async () => { + await runtimeAPI.init(validOptions) + + expect(LogForwarding).toHaveBeenCalledWith( + validOptions.namespace, + validOptions.apihost, + validOptions.api_key, + expect.any(LogForwardingLocalDestinationsProvider), + undefined + ) + }) + + test('should return triggers proxy that delegates to Triggers class', async () => { + const mockTriggersInstance = { + create: jest.fn(), + delete: jest.fn(), + someMethod: jest.fn() + } + Triggers.mockImplementation(() => mockTriggersInstance) + + const result = await runtimeAPI.init(validOptions) + + // Test that triggers proxy delegates to Triggers instance + expect(result.triggers.create).toBe(mockTriggersInstance.create) + expect(result.triggers.delete).toBe(mockTriggersInstance.delete) + expect(result.triggers.someMethod).toBe(mockTriggersInstance.someMethod) + }) + + test('should return triggers proxy that falls back to original triggers for unknown methods', async () => { + const mockTriggersInstance = {} + Triggers.mockImplementation(() => mockTriggersInstance) + + const result = await runtimeAPI.init(validOptions) + + // Test that unknown methods fall back to original triggers + expect(result.triggers.mock).toBe('triggers') + }) + + test('should include initOptions in returned object', async () => { + const result = await runtimeAPI.init(validOptions) + + expect(result.initOptions).toBeDefined() + expect(result.initOptions).toHaveProperty('api_key', validOptions.api_key) + expect(result.initOptions).toHaveProperty('apihost', validOptions.apihost) + expect(result.initOptions).toHaveProperty('namespace', validOptions.namespace) + }) + }) +}) diff --git a/test/index.test.js b/test/index.test.js index 8b3afb2a..f6082575 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -11,11 +11,24 @@ governing permissions and limitations under the License. const sdk = require('../src') const { codes } = require('../src/SDKErrors') -const Triggers = require('../src/triggers') -const ow = require('openwhisk')() const { getProxyForUrl } = require('proxy-from-env') jest.mock('proxy-from-env') +jest.mock('openwhisk') +jest.mock('../src/RuntimeAPI') +jest.mock('../src/triggers') +jest.mock('../src/LogForwarding') +jest.mock('../src/LogForwardingLocalDestinationsProvider') +jest.mock('../src/openwhisk-patch') +jest.mock('../src/utils') + +const ow = require('openwhisk') +const RuntimeAPI = require('../src/RuntimeAPI') +const Triggers = require('../src/triggers') +const LogForwarding = require('../src/LogForwarding') +const LogForwardingLocalDestinationsProvider = require('../src/LogForwardingLocalDestinationsProvider') +const { patchOWForTunnelingIssue } = require('../src/openwhisk-patch') +const { getProxyAgent } = require('../src/utils') // ///////////////////////////////////////////// @@ -40,6 +53,163 @@ const createSdkClient = async (options) => { beforeEach(() => { getProxyForUrl.mockReset() + + // Mock OpenWhisk client with proper structure + const mockOWClient = { + actions: { + client: { + options: { + agent: null, + proxy: undefined + }, + params: jest.fn().mockResolvedValue({}), + mockResolved: jest.fn(), + mockRejected: jest.fn() + } + }, + activations: { mock: 'activations' }, + namespaces: { mock: 'namespaces' }, + packages: { mock: 'packages' }, + rules: { mock: 'rules' }, + triggers: { + mock: 'triggers', + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn() + }, + routes: { mock: 'routes' } + } + + // Add mockResolved and mockRejected methods to the ow mock + ow.mockResolved = jest.fn().mockImplementation((method, value) => { + const mockFn = jest.fn().mockResolvedValue(value) + // Mock the specific method on the OpenWhisk client + if (method === 'triggers.create') { + mockOWClient.triggers.create = mockFn + } else if (method === 'triggers.delete') { + mockOWClient.triggers.delete = mockFn + } else if (method === 'triggers.get') { + mockOWClient.triggers.get = mockFn + } else if (method === 'triggers.list') { + mockOWClient.triggers.list = mockFn + } else if (method === 'feeds.create') { + mockOWClient.feeds = mockOWClient.feeds || {} + mockOWClient.feeds.create = mockFn + } else if (method === 'feeds.delete') { + mockOWClient.feeds = mockOWClient.feeds || {} + mockOWClient.feeds.delete = mockFn + } + return mockFn + }) + ow.mockRejected = jest.fn().mockImplementation((method, error) => { + const mockFn = jest.fn().mockRejectedValue(error) + // Mock the specific method on the OpenWhisk client + if (method === 'triggers.create') { + mockOWClient.triggers.create = mockFn + } else if (method === 'triggers.delete') { + mockOWClient.triggers.delete = mockFn + } else if (method === 'triggers.get') { + mockOWClient.triggers.get = mockFn + } else if (method === 'triggers.list') { + mockOWClient.triggers.list = mockFn + } else if (method === 'feeds.create') { + mockOWClient.feeds = mockOWClient.feeds || {} + mockOWClient.feeds.create = mockFn + } else if (method === 'feeds.delete') { + mockOWClient.feeds = mockOWClient.feeds || {} + mockOWClient.feeds.delete = mockFn + } + return mockFn + }) + ow.mockReturnValue(mockOWClient) + patchOWForTunnelingIssue.mockReturnValue(mockOWClient) + + // Mock RuntimeAPI with proper behavior + const mockRuntimeAPI = { + init: jest.fn().mockImplementation(async (options) => { + // Simulate the actual RuntimeAPI behavior + if (!options || !options.api_key) { + throw new codes.ERROR_SDK_INITIALIZATION({ messageValues: 'api_key' }) + } + if (!options || !options.apihost) { + throw new codes.ERROR_SDK_INITIALIZATION({ messageValues: 'apihost' }) + } + + // Simulate the ignore_certs logic from RuntimeAPI.js + const shouldIgnoreCerts = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' + const ignoreCerts = options.ignore_certs || shouldIgnoreCerts + + // Create a mock triggers proxy that delegates to Triggers class + const mockTriggersProxy = new Proxy(mockOWClient.triggers, { + get (target, property) { + return property in mockTriggersInstance ? mockTriggersInstance[property] : target[property] + } + }) + + // Return the mock client with initOptions + return { + ...mockOWClient, + triggers: mockTriggersProxy, + initOptions: { + ...options, + retry: options.retry || { retries: 2, minTimeout: 200 }, + ignore_certs: ignoreCerts + } + } + }) + } + RuntimeAPI.mockImplementation(() => mockRuntimeAPI) + + // Mock other dependencies + const mockTriggersInstance = { + create: jest.fn().mockImplementation(async (options) => { + if (!options) { + throw new Error('No args provided') + } + // Call the underlying OpenWhisk client method + const result = await mockOWClient.triggers.create(options) + if (options && options.trigger && options.trigger.feed) { + // Simulate the feed creation logic from the actual Triggers class + try { + try { + await mockOWClient.feeds.delete({ name: options.trigger.feed, trigger: options.name }) + } catch (err) { + // Ignore + } + await mockOWClient.feeds.create({ name: options.trigger.feed, trigger: options.name }) + } catch (err) { + // If feed creation fails, delete the trigger that was created + await mockOWClient.triggers.delete(options) + throw err + } + } + return result + }), + delete: jest.fn().mockImplementation(async (options) => { + const trigger = await mockOWClient.triggers.get(options) + if (trigger && trigger.annotations) { + const feedAnnotation = trigger.annotations.find(ann => ann.key === 'feed') + if (feedAnnotation) { + await mockOWClient.feeds.delete({ name: feedAnnotation.value, trigger: options.name }) + } + } + return await mockOWClient.triggers.delete(options) + }), + list: jest.fn().mockImplementation(async () => { + return await mockOWClient.triggers.list() + }), + get: jest.fn().mockImplementation(async (options) => { + return await mockOWClient.triggers.get(options) + }) + } + + // Make Triggers.prototype.create point to our mock function + Triggers.prototype.create = mockTriggersInstance.create + Triggers.mockImplementation(() => mockTriggersInstance) + LogForwarding.mockImplementation(() => ({})) + LogForwardingLocalDestinationsProvider.mockImplementation(() => ({})) + getProxyAgent.mockReturnValue({ mock: 'agent' }) }) test('sdk init test', async () => { diff --git a/test/openwhisk-patch.test.js b/test/openwhisk-patch.test.js new file mode 100644 index 00000000..17f6c513 --- /dev/null +++ b/test/openwhisk-patch.test.js @@ -0,0 +1,222 @@ +/* +Copyright 2025 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 { patchOWForTunnelingIssue } = require('../src/openwhisk-patch') + +describe('openwhisk-patch', () => { + let mockOWClient + let originalConsoleError + + beforeEach(() => { + // Mock console.error to avoid noise in tests + originalConsoleError = console.error + console.error = jest.fn() + + // Create a mock OpenWhisk client with the expected structure + mockOWClient = { + actions: { + client: { + options: { + agent: null, + proxy: 'http://proxy.example.com:8080' + }, + params: jest.fn() + } + } + } + }) + + afterEach(() => { + // Restore console.error + console.error = originalConsoleError + jest.clearAllMocks() + }) + + describe('patchOWForTunnelingIssue', () => { + test('should return the same OpenWhisk client object', () => { + const result = patchOWForTunnelingIssue(mockOWClient, false) + expect(result).toBe(mockOWClient) + }) + + test('should set proxy to undefined when agent is set and use_proxy_from_env_var is false', () => { + // Set agent to a non-null value + mockOWClient.actions.client.options.agent = { mock: 'agent' } + + patchOWForTunnelingIssue(mockOWClient, false) + + expect(mockOWClient.actions.client.options.proxy).toBeUndefined() + }) + + test('should not modify proxy when agent is null', () => { + // agent is already null by default + const originalProxy = mockOWClient.actions.client.options.proxy + + patchOWForTunnelingIssue(mockOWClient, false) + + expect(mockOWClient.actions.client.options.proxy).toBe(originalProxy) + }) + + test('should not modify proxy when agent is set but use_proxy_from_env_var is true', () => { + // Set agent to a non-null value + mockOWClient.actions.client.options.agent = { mock: 'agent' } + const originalProxy = mockOWClient.actions.client.options.proxy + + patchOWForTunnelingIssue(mockOWClient, true) + + expect(mockOWClient.actions.client.options.proxy).toBe(originalProxy) + }) + + test('should not modify proxy when agent is set and use_proxy_from_env_var is undefined', () => { + // Set agent to a non-null value + mockOWClient.actions.client.options.agent = { mock: 'agent' } + const originalProxy = mockOWClient.actions.client.options.proxy + + patchOWForTunnelingIssue(mockOWClient, undefined) + + expect(mockOWClient.actions.client.options.proxy).toBe(originalProxy) + }) + + test('should patch params function to add use_proxy_from_env_var parameter', async () => { + const mockParams = { existing: 'param' } + const mockOriginalParams = jest.fn().mockResolvedValue(mockParams) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, true) + + // Call the patched params function + const result = await mockOWClient.actions.client.params('arg1', 'arg2') + + // Verify original params was called with correct arguments + expect(mockOriginalParams).toHaveBeenCalledWith('arg1', 'arg2') + + // Verify the result includes the use_proxy_from_env_var parameter + expect(result).toEqual({ + existing: 'param', + use_proxy_from_env_var: true + }) + }) + + test('should patch params function with use_proxy_from_env_var set to false', async () => { + const mockParams = { existing: 'param' } + const mockOriginalParams = jest.fn().mockResolvedValue(mockParams) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, false) + + // Call the patched params function + const result = await mockOWClient.actions.client.params() + + // Verify the result includes the use_proxy_from_env_var parameter set to false + expect(result).toEqual({ + existing: 'param', + use_proxy_from_env_var: false + }) + }) + + test('should handle params function error and re-throw it', async () => { + const mockError = new Error('Original params error') + const mockOriginalParams = jest.fn().mockRejectedValue(mockError) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, true) + + // Call the patched params function and expect it to throw + await expect(mockOWClient.actions.client.params()).rejects.toThrow('Original params error') + + // Verify console.error was called + expect(console.error).toHaveBeenCalledWith('Error patching openwhisk client params: ', mockError) + }) + + test('should preserve original params function binding', () => { + const originalParams = jest.fn().mockResolvedValue({}) + mockOWClient.actions.client.params = originalParams + + patchOWForTunnelingIssue(mockOWClient, true) + + // Verify the params function is now a different function (patched) + expect(mockOWClient.actions.client.params).not.toBe(originalParams) + expect(typeof mockOWClient.actions.client.params).toBe('function') + }) + + test('should handle multiple calls to patchOWForTunnelingIssue', () => { + // First call + const firstResult = patchOWForTunnelingIssue(mockOWClient, false) + const firstPatchedParams = mockOWClient.actions.client.params + + // Second call + const secondResult = patchOWForTunnelingIssue(mockOWClient, true) + const secondPatchedParams = mockOWClient.actions.client.params + + // Both calls should return the same client object + expect(firstResult).toBe(secondResult) + expect(firstResult).toBe(mockOWClient) + + // The params function should be patched (different from original) + expect(typeof firstPatchedParams).toBe('function') + expect(typeof secondPatchedParams).toBe('function') + }) + + test('should handle edge case with undefined use_proxy_from_env_var', async () => { + const mockParams = { existing: 'param' } + const mockOriginalParams = jest.fn().mockResolvedValue(mockParams) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, undefined) + + const result = await mockOWClient.actions.client.params() + + expect(result).toEqual({ + existing: 'param', + use_proxy_from_env_var: undefined + }) + }) + + test('should handle edge case with null use_proxy_from_env_var', async () => { + const mockParams = { existing: 'param' } + const mockOriginalParams = jest.fn().mockResolvedValue(mockParams) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, null) + + const result = await mockOWClient.actions.client.params() + + expect(result).toEqual({ + existing: 'param', + use_proxy_from_env_var: null + }) + }) + + test('should handle complex params object', async () => { + const complexParams = { + nested: { + object: { + with: 'values' + } + }, + array: [1, 2, 3], + boolean: true, + number: 42 + } + const mockOriginalParams = jest.fn().mockResolvedValue(complexParams) + mockOWClient.actions.client.params = mockOriginalParams + + patchOWForTunnelingIssue(mockOWClient, true) + + const result = await mockOWClient.actions.client.params() + + expect(result).toEqual({ + ...complexParams, + use_proxy_from_env_var: true + }) + }) + }) +}) diff --git a/test/triggers.test.js b/test/triggers.test.js new file mode 100644 index 00000000..9b95ac79 --- /dev/null +++ b/test/triggers.test.js @@ -0,0 +1,445 @@ +/* +Copyright 2020 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 Triggers = require('../src/triggers') +const { createKeyValueObjectFromArray } = require('../src/utils') + +// Mock dependencies +jest.mock('../src/utils') +jest.mock('lodash.clonedeep') + +const cloneDeep = require('lodash.clonedeep') + +describe('Triggers', () => { + let triggers + let mockOWClient + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + // Create mock OpenWhisk client + mockOWClient = { + triggers: { + create: jest.fn(), + delete: jest.fn(), + get: jest.fn() + }, + feeds: { + create: jest.fn(), + delete: jest.fn() + } + } + + // Create Triggers instance + triggers = new Triggers(mockOWClient) + + // Mock cloneDeep to return a modified copy + cloneDeep.mockImplementation((obj) => { + if (!obj) return obj + return JSON.parse(JSON.stringify(obj)) + }) + + // Mock createKeyValueObjectFromArray + createKeyValueObjectFromArray.mockImplementation((arr) => { + if (!arr) return {} + const result = {} + arr.forEach(item => { + if (item && item.key !== undefined && item.key !== null && item.key !== '') { + result[item.key] = item.value + } + }) + return result + }) + }) + + describe('constructor', () => { + test('should store the OpenWhisk client', () => { + expect(triggers.owclient).toBe(mockOWClient) + }) + }) + + describe('create', () => { + test('should create a trigger without feed', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger' + } + } + + const expectedResult = { name: 'testTrigger', created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + + const result = await triggers.create(options) + + expect(cloneDeep).toHaveBeenCalledWith(options) + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(options) + expect(result).toBe(expectedResult) + }) + + test('should create a trigger with feed', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm', + parameters: [ + { key: 'cron', value: '0 0 1 * *' }, + { key: 'trigger_payload', value: 'test' } + ] + } + } + + const expectedResult = { name: 'testTrigger', created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.feeds.create.mockResolvedValue({}) + + const result = await triggers.create(options) + + // Verify cloneDeep was called + expect(cloneDeep).toHaveBeenCalledWith(options) + + // Verify trigger creation with feed annotation + const expectedTriggerOptions = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm', + parameters: [ + { key: 'cron', value: '0 0 1 * *' }, + { key: 'trigger_payload', value: 'test' } + ], + annotations: [ + { key: 'feed', value: '/whisk.system/alarms/alarm' } + ] + } + } + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(expectedTriggerOptions) + + // Verify feed operations + expect(mockOWClient.feeds.delete).toHaveBeenCalledWith({ + name: '/whisk.system/alarms/alarm', + trigger: 'testTrigger' + }) + expect(mockOWClient.feeds.create).toHaveBeenCalledWith({ + name: '/whisk.system/alarms/alarm', + trigger: 'testTrigger', + params: { cron: '0 0 1 * *', trigger_payload: 'test' } + }) + + expect(result).toBe(expectedResult) + }) + + test('should create a trigger with feed and existing annotations', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm', + annotations: [ + { key: 'existing', value: 'annotation' } + ] + } + } + + const expectedResult = { name: 'testTrigger', created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.feeds.create.mockResolvedValue({}) + + const result = await triggers.create(options) + + // Verify trigger creation with both existing and feed annotations + const expectedTriggerOptions = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm', + annotations: [ + { key: 'existing', value: 'annotation' }, + { key: 'feed', value: '/whisk.system/alarms/alarm' } + ] + } + } + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(expectedTriggerOptions) + + expect(result).toBe(expectedResult) + }) + + test('should handle feed creation error and clean up trigger', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm' + } + } + + const triggerResult = { name: 'testTrigger', created: true } + const feedError = new Error('Feed creation failed') + + mockOWClient.triggers.create.mockResolvedValue(triggerResult) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.feeds.create.mockRejectedValue(feedError) + mockOWClient.triggers.delete.mockResolvedValue({}) + + await expect(triggers.create(options)).rejects.toThrow('Feed creation failed') + + // Verify trigger was created + expect(mockOWClient.triggers.create).toHaveBeenCalledTimes(1) + + // Verify feed operations + expect(mockOWClient.feeds.delete).toHaveBeenCalledTimes(1) + expect(mockOWClient.feeds.create).toHaveBeenCalledTimes(1) + + // Verify trigger cleanup + const expectedCleanupOptions = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm', + annotations: [ + { key: 'feed', value: '/whisk.system/alarms/alarm' } + ] + } + } + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(expectedCleanupOptions) + }) + + test('should handle feed delete error gracefully', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger', + feed: '/whisk.system/alarms/alarm' + } + } + + const expectedResult = { name: 'testTrigger', created: true } + const deleteError = new Error('Feed delete failed') + + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + mockOWClient.feeds.delete.mockRejectedValue(deleteError) + mockOWClient.feeds.create.mockResolvedValue({}) + + const result = await triggers.create(options) + + // Verify feed delete was attempted but error was ignored + expect(mockOWClient.feeds.delete).toHaveBeenCalledTimes(1) + + // Verify feed create still succeeded + expect(mockOWClient.feeds.create).toHaveBeenCalledTimes(1) + + // Verify result is returned + expect(result).toBe(expectedResult) + }) + + test('should handle null/undefined options', async () => { + const expectedResult = { created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + + const result = await triggers.create(null) + + expect(cloneDeep).toHaveBeenCalledWith(null) + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(null) + expect(result).toBe(expectedResult) + }) + + test('should handle options without trigger property', async () => { + const options = { + name: 'testTrigger' + } + + const expectedResult = { name: 'testTrigger', created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + + const result = await triggers.create(options) + + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(options) + expect(result).toBe(expectedResult) + }) + + test('should handle trigger without feed property', async () => { + const options = { + name: 'testTrigger', + trigger: { + name: 'testTrigger' + } + } + + const expectedResult = { name: 'testTrigger', created: true } + mockOWClient.triggers.create.mockResolvedValue(expectedResult) + + const result = await triggers.create(options) + + expect(mockOWClient.triggers.create).toHaveBeenCalledWith(options) + expect(result).toBe(expectedResult) + }) + }) + + describe('delete', () => { + test('should delete a trigger without feed annotations', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger', + annotations: [ + { key: 'other', value: 'annotation' } + ] + } + const deleteResult = { deleted: true } + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.triggers.delete.mockResolvedValue(deleteResult) + + const result = await triggers.delete(options) + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).not.toHaveBeenCalled() + expect(result).toBe(deleteResult) + }) + + test('should delete a trigger with feed annotations', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger', + annotations: [ + { key: 'other', value: 'annotation' }, + { key: 'feed', value: '/whisk.system/alarms/alarm' }, + { key: 'another', value: 'annotation' } + ] + } + const deleteResult = { deleted: true } + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.triggers.delete.mockResolvedValue(deleteResult) + + const result = await triggers.delete(options) + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).toHaveBeenCalledWith({ + name: '/whisk.system/alarms/alarm', + trigger: 'testTrigger' + }) + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(options) + expect(result).toBe(deleteResult) + }) + + test('should delete a trigger with multiple feed annotations', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger', + annotations: [ + { key: 'feed', value: '/whisk.system/alarms/alarm1' }, + { key: 'other', value: 'annotation' }, + { key: 'feed', value: '/whisk.system/alarms/alarm2' } + ] + } + const deleteResult = { deleted: true } + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.triggers.delete.mockResolvedValue(deleteResult) + + const result = await triggers.delete(options) + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).toHaveBeenCalledTimes(2) + expect(mockOWClient.feeds.delete).toHaveBeenCalledWith({ + name: '/whisk.system/alarms/alarm1', + trigger: 'testTrigger' + }) + expect(mockOWClient.feeds.delete).toHaveBeenCalledWith({ + name: '/whisk.system/alarms/alarm2', + trigger: 'testTrigger' + }) + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(options) + expect(result).toBe(deleteResult) + }) + + test('should delete a trigger without annotations', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger' + } + const deleteResult = { deleted: true } + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.triggers.delete.mockResolvedValue(deleteResult) + + const result = await triggers.delete(options) + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).not.toHaveBeenCalled() + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(options) + expect(result).toBe(deleteResult) + }) + + test('should handle trigger get error', async () => { + const options = { name: 'testTrigger' } + const getError = new Error('Trigger not found') + + mockOWClient.triggers.get.mockRejectedValue(getError) + + await expect(triggers.delete(options)).rejects.toThrow('Trigger not found') + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).not.toHaveBeenCalled() + expect(mockOWClient.triggers.delete).not.toHaveBeenCalled() + }) + + test('should handle feed delete error', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger', + annotations: [ + { key: 'feed', value: '/whisk.system/alarms/alarm' } + ] + } + const feedError = new Error('Feed delete failed') + const deleteResult = { deleted: true } + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.feeds.delete.mockRejectedValue(feedError) + mockOWClient.triggers.delete.mockResolvedValue(deleteResult) + + await expect(triggers.delete(options)).rejects.toThrow('Feed delete failed') + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).toHaveBeenCalledTimes(1) + expect(mockOWClient.triggers.delete).not.toHaveBeenCalled() + }) + + test('should handle trigger delete error', async () => { + const options = { name: 'testTrigger' } + const triggerInfo = { + name: 'testTrigger', + annotations: [ + { key: 'feed', value: '/whisk.system/alarms/alarm' } + ] + } + const deleteError = new Error('Trigger delete failed') + + mockOWClient.triggers.get.mockResolvedValue(triggerInfo) + mockOWClient.feeds.delete.mockResolvedValue({}) + mockOWClient.triggers.delete.mockRejectedValue(deleteError) + + await expect(triggers.delete(options)).rejects.toThrow('Trigger delete failed') + + expect(mockOWClient.triggers.get).toHaveBeenCalledWith(options) + expect(mockOWClient.feeds.delete).toHaveBeenCalledTimes(1) + expect(mockOWClient.triggers.delete).toHaveBeenCalledWith(options) + }) + }) +}) diff --git a/test/utils.test.js b/test/utils.test.js index fd924965..55955851 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1751,6 +1751,11 @@ describe('createKeyValueObjectFromArray', () => { // falsy value, empty string expect(() => utils.createKeyValueObjectFromArray([{ key: 'a', value: '' }])).not.toThrow() }) + + test('handles default parameter (empty array)', () => { + const res = utils.createKeyValueObjectFromArray() + expect(res).toEqual({}) + }) }) describe('createKeyValueArrayFromFlag', () => { @@ -2660,3 +2665,47 @@ describe('getSupportedServerRuntimes', () => { .rejects.toThrow() }) }) + +describe('getProxyAgent', () => { + const { HttpProxyAgent } = require('http-proxy-agent') + const PatchedHttpsProxyAgent = require('../src/PatchedHttpsProxyAgent.js') + + test('returns PatchedHttpsProxyAgent for HTTPS endpoint', () => { + const endpoint = 'https://example.com' + const proxyUrl = 'http://proxy.example.com:8080' + const proxyOptions = { timeout: 5000 } + + const result = utils.getProxyAgent(endpoint, proxyUrl, proxyOptions) + + expect(result).toBeInstanceOf(PatchedHttpsProxyAgent) + }) + + test('returns HttpProxyAgent for HTTP endpoint', () => { + const endpoint = 'http://example.com' + const proxyUrl = 'http://proxy.example.com:8080' + const proxyOptions = { timeout: 5000 } + + const result = utils.getProxyAgent(endpoint, proxyUrl, proxyOptions) + + expect(result).toBeInstanceOf(HttpProxyAgent) + }) + + test('returns HttpProxyAgent for non-HTTPS endpoint', () => { + const endpoint = 'ws://example.com' + const proxyUrl = 'http://proxy.example.com:8080' + const proxyOptions = {} + + const result = utils.getProxyAgent(endpoint, proxyUrl, proxyOptions) + + expect(result).toBeInstanceOf(HttpProxyAgent) + }) + + test('handles default proxyOptions parameter', () => { + const endpoint = 'https://example.com' + const proxyUrl = 'http://proxy.example.com:8080' + + const result = utils.getProxyAgent(endpoint, proxyUrl) + + expect(result).toBeInstanceOf(PatchedHttpsProxyAgent) + }) +})