diff --git a/package-lock.json b/package-lock.json index b704b979e..72058f04b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "mocha": "^10.2.0", "nyc": "^17.0.0", "prettier": "^3.0.0", + "sinon": "^19.0.2", "vite": "^4.4.2" }, "optionalDependencies": { @@ -2863,6 +2864,55 @@ "util": "^0.12.4" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", + "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@smithy/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", @@ -8848,6 +8898,13 @@ "node": ">=4.0" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9090,6 +9147,13 @@ "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -9830,6 +9894,30 @@ "node": ">= 0.6" } }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -11545,6 +11633,58 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", diff --git a/package.json b/package.json index 007efb0f9..8a1edfbb9 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "mocha": "^10.2.0", "nyc": "^17.0.0", "prettier": "^3.0.0", + "sinon": "^19.0.2", "vite": "^4.4.2" }, "optionalDependencies": { diff --git a/src/plugin.js b/src/plugin.js index 9c5aee2eb..b02e3bb8e 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -8,6 +8,9 @@ const lpModule = import('load-plugin'); * @return {boolean} - True if the object or any of its prototypes has the 'isGitProxyPlugin' property set to true, false otherwise. */ function isCompatiblePlugin(obj, propertyName = 'isGitProxyPlugin') { + // loop through the prototype chain to check if the object is a ProxyPlugin + // valid plugin objects will have the appropriate property set to true + // if the prototype chain is exhausted, return false while (obj != null) { if (Object.prototype.hasOwnProperty.call(obj, propertyName) && obj.isGitProxyPlugin && @@ -21,46 +24,45 @@ function isCompatiblePlugin(obj, propertyName = 'isGitProxyPlugin') { /** * @typedef PluginTypeResult - * @property {ProxyPlugin[]} pushPlugins - List of push plugins - * @property {ProxyPlugin[]} pullPlugins - List of pull plugins + * @property {PushActionPlugin[]} pushAction - List of push action plugins + * @property {PullActionPlugin[]} pullAction - List of pull action plugins */ /** * Registers and loads plugins used by git-proxy */ class PluginLoader { - /** - * @property {Promise} load - A Promise that begins loading plugins from a list of modules. Callers must run `await loader.load` to load plugins. - */ - load; - /** - * This property is not used in production code. It is exposed for testing purposes. - * @property {Promise} ready - A Promise that resolves when all plugins have been loaded. - */ - ready; - /** - * Initialize PluginLoader with candidates modules (node_modules or relative - * file paths). - * @param {Array.} targets List of Node module package names or files to load. - */ constructor(targets) { + /** + * List of Node module specifiers to load as plugins. It can be a relative path, an + * absolute path, or a module name (which can include scoped packages like '@bar/baz'). + * @type {string[]} + * @public + */ this.targets = targets; /** - * @type {ProxyPlugin[]} List of loaded ProxyPlugins + * List of loaded PushActionPlugin objects. + * @type {PushActionPlugin[]} * @public */ this.pushPlugins = []; + /** + * List of loaded PullActionPlugin objects. + * @type {PullActionPlugin[]} + * @public + */ this.pullPlugins = []; if (this.targets.length === 0) { console.log('No plugins configured'); // TODO: log.debug() - this.ready = Promise.resolve(); - this.load = () => Promise.resolve(); // Ensure this.load is always defined - return; } - this.load = this._loadPlugins(); } - async _loadPlugins() { + /** + * Load all plugins specified in the `targets` property. This method must complete before a PluginLoader instance + * can be used to retrieve plugins. + * @return {Promise} A Promise that resolves when all plugins have been loaded. + */ + async load() { try { const modulePromises = this.targets.map(target => this._loadPluginModule(target).catch(error => { @@ -84,30 +86,31 @@ class PluginLoader { ); const settledPluginTypeResults = await Promise.allSettled(pluginTypeResultPromises); + /** + * @type {PluginTypeResult[]} List of resolved PluginTypeResult objects + */ const pluginTypeResults = settledPluginTypeResults .filter(result => result.status === 'fulfilled' && result.value !== null) .map(result => result.value); for (const result of pluginTypeResults) { - this.pushPlugins.push(...result.pushPlugins) - this.pullPlugins.push(...result.pullPlugins) + this.pushPlugins.push(...result.pushAction) + this.pullPlugins.push(...result.pullAction) } const combinedPlugins = [...this.pushPlugins, ...this.pullPlugins]; combinedPlugins.forEach(plugin => { console.log(`Loaded plugin: ${plugin.constructor.name}`); }); - - this.ready = Promise.resolve(); } catch (error) { console.error(`Error loading plugins: ${error}`); - this.ready = Promise.reject(error); } } + /** - * Load a plugin module from either a file path or a Node module. - * @param {string} target - * @return {Module} + * Resolve & load a Node module from either a given specifier (file path, import specifier or package name) using load-plugin. + * @param {string} target The module specifier to load + * @return {Promise} A resolved & loaded Module */ async _loadPluginModule(target) { const lp = await lpModule; @@ -116,40 +119,39 @@ class PluginLoader { } /** - * Set a list of ProxyPlugin objects to this.plugins - * from the keys exported by the passed in module. - * @param {object} pluginModule - * @return {PluginTypeResult} - An object containing the loaded plugins classified by their type. + * Checks for known compatible plugin objects in a Module and returns them classified by their type. + * @param {Module} pluginModule The module to extract plugins from + * @return {Promise} An object containing the loaded plugins classified by their type. */ async _getPluginObjects(pluginModule) { const plugins = { - pushPlugins: [], - pullPlugins: [], + pushAction: [], + pullAction: [], }; - // handles the case where the `module.exports = new ProxyPlugin()` or `exports default new ProxyPlugin()` - if (isCompatiblePlugin(pluginModule)) { - if (isCompatiblePlugin(pluginModule, 'isGitProxyPushActionPlugin')) { - console.log('found push plugin', pluginModule.constructor.name); - plugins.pushPlugins.push(pluginModule); - } else if (isCompatiblePlugin(pluginModule, 'isGitProxyPullActionPlugin')) { - console.log('found pull plugin', pluginModule.constructor.name); - plugins.pullPlugins.push(pluginModule); + + function handlePlugin(potentialModule) { + if (isCompatiblePlugin(potentialModule, 'isGitProxyPushActionPlugin')) { + console.log('found push plugin', potentialModule.constructor.name); + plugins.pushAction.push(potentialModule); + } else if (isCompatiblePlugin(potentialModule, 'isGitProxyPullActionPlugin')) { + console.log('found pull plugin', potentialModule.constructor.name); + plugins.pullAction.push(potentialModule); } else { - console.error(`Error: Object ${pluginModule.constructor.name} does not seem to be a compatible plugin type`); + console.error(`Error: Object ${potentialModule.constructor.name} does not seem to be a compatible plugin type`); } + } + + // handles the default export case + // `module.exports = new ProxyPlugin()` in CJS or `exports default new ProxyPlugin()` in ESM + // the "module" is a single object that could be a plugin + if (isCompatiblePlugin(pluginModule)) { + handlePlugin(pluginModule) } else { - // iterate over the module.exports keys if multiple arbitrary objects are exported + // handle the typical case of a module which exports multiple objects + // module.exports = { x, y } (CJS) or multiple `export ...` statements (ESM) for (const key of Object.keys(pluginModule)) { if (isCompatiblePlugin(pluginModule[key])) { - if (isCompatiblePlugin(pluginModule[key], 'isGitProxyPushActionPlugin')) { - console.log('found push plugin', pluginModule[key].constructor.name); - plugins.pushPlugins.push(pluginModule[key]); - } else if (isCompatiblePlugin(pluginModule[key], 'isGitProxyPullActionPlugin')) { - console.log('found pull plugin', pluginModule[key].constructor.name); - plugins.pullPlugins.push(pluginModule[key]); - } else { - console.error(`Error: Object ${pluginModule.constructor.name} does not seem to be a compatible plugin type`); - } + handlePlugin(pluginModule[key]); } } } @@ -217,21 +219,9 @@ class PullActionPlugin extends ProxyPlugin { } } -/** - * - * @param {Array} targets A list of loadable targets for plugin modules. - * @return {PluginLoader} - */ -const createLoader = async (targets) => { - const loadTargets = targets; - const loader = new PluginLoader(loadTargets); - return loader; -}; - module.exports = { - createLoader, PluginLoader, PushActionPlugin, PullActionPlugin, isCompatiblePlugin, -} \ No newline at end of file +} diff --git a/src/proxy/chain.js b/src/proxy/chain.js index 31b9943a3..11e6ae106 100644 --- a/src/proxy/chain.js +++ b/src/proxy/chain.js @@ -15,11 +15,9 @@ const pushActionChain = [ proc.push.blockForAuth, ]; -const pullActionChain = [ - proc.push.checkRepoInAuthorisedList, -]; +const pullActionChain = [proc.push.checkRepoInAuthorisedList]; -let pluginsLoaded = false; +let pluginsInserted = false; const executeChain = async (req) => { let action; @@ -48,18 +46,21 @@ const executeChain = async (req) => { /** * The plugin loader used for the GitProxy chain. - * * @type {import('../plugin').PluginLoader} */ let chainPluginLoader; const getChain = async (action) => { if (chainPluginLoader === undefined) { - console.error('Plugin loader was not initialized! Skipping any plugins...'); - pluginsLoaded = true; + console.error( + 'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers. Skipping plugins...', + ); + pluginsInserted = true; } - if (!pluginsLoaded) { - console.log(`Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`); + if (!pluginsInserted) { + console.log( + `Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`, + ); for (const pluginObj of chainPluginLoader.pushPlugins) { console.log(`Inserting push plugin ${pluginObj.constructor.name} into chain`); // insert custom functions after parsePush but before other actions @@ -71,11 +72,11 @@ const getChain = async (action) => { pullActionChain.splice(0, 0, pluginObj.exec); } // This is set to true so that we don't re-insert the plugins into the chain - pluginsLoaded = true; + pluginsInserted = true; } if (action.type === 'pull') { return pullActionChain; - }; + } if (action.type === 'push') { return pushActionChain; } @@ -89,5 +90,15 @@ module.exports = { get chainPluginLoader() { return chainPluginLoader; }, + get pluginsInserted() { + return pluginsInserted; + }, + get pushActionChain() { + return pushActionChain; + }, + get pullActionChain() { + return pullActionChain; + }, executeChain, -} \ No newline at end of file + getChain, +}; diff --git a/src/proxy/index.js b/src/proxy/index.js index 59235c88d..3bd387406 100644 --- a/src/proxy/index.js +++ b/src/proxy/index.js @@ -7,7 +7,7 @@ const path = require("path"); const router = require('./routes').router; const config = require('../config'); const db = require('../db'); -const { createLoader } = require('../plugin'); +const { PluginLoader } = require('../plugin'); const chain = require('./chain'); const { GIT_PROXY_SERVER_PORT: proxyHttpPort } = require('../config/env').Vars; const { GIT_PROXY_HTTPS_SERVER_PORT: proxyHttpsPort } = require('../config/env').Vars; @@ -26,8 +26,8 @@ proxyApp.use('/', router); const start = async () => { const plugins = config.getPlugins(); - const pluginLoader = await createLoader(plugins); - await pluginLoader.load; + const pluginLoader = new PluginLoader(plugins); + await pluginLoader.load(); chain.chainPluginLoader = pluginLoader; // Check to see if the default repos are in the repo list const defaultAuthorisedRepoList = config.getAuthorisedList(); diff --git a/test/chain.test.js b/test/chain.test.js new file mode 100644 index 000000000..33d5750ac --- /dev/null +++ b/test/chain.test.js @@ -0,0 +1,236 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const { PluginLoader } = require('../src/plugin'); + +chai.should(); +const expect = chai.expect; + +const mockLoader = { + pushPlugins: [ + { exec: Object.assign(async () => console.log('foo'), { displayName: 'foo.exec' }) }, + ], + pullPlugins: [ + { exec: Object.assign(async () => console.log('foo'), { displayName: 'bar.exec' }) }, + ], +}; + +const mockPushProcessors = { + parsePush: sinon.stub(), + audit: sinon.stub(), + checkRepoInAuthorisedList: sinon.stub(), + checkCommitMessages: sinon.stub(), + checkAuthorEmails: sinon.stub(), + checkUserPushPermission: sinon.stub(), + checkIfWaitingAuth: sinon.stub(), + pullRemote: sinon.stub(), + writePack: sinon.stub(), + getDiff: sinon.stub(), + clearBareClone: sinon.stub(), + scanDiff: sinon.stub(), + blockForAuth: sinon.stub(), +}; +mockPushProcessors.parsePush.displayName = 'parsePush'; +mockPushProcessors.audit.displayName = 'audit'; +mockPushProcessors.checkRepoInAuthorisedList.displayName = 'checkRepoInAuthorisedList'; +mockPushProcessors.checkCommitMessages.displayName = 'checkCommitMessages'; +mockPushProcessors.checkAuthorEmails.displayName = 'checkAuthorEmails'; +mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermission'; +mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth'; +mockPushProcessors.pullRemote.displayName = 'pullRemote'; +mockPushProcessors.writePack.displayName = 'writePack'; +mockPushProcessors.getDiff.displayName = 'getDiff'; +mockPushProcessors.clearBareClone.displayName = 'clearBareClone'; +mockPushProcessors.scanDiff.displayName = 'scanDiff'; +mockPushProcessors.blockForAuth.displayName = 'blockForAuth'; + +const mockPreProcessors = { + parseAction: sinon.stub(), +}; + +describe('proxy chain', function () { + let processors; + let chain; + + beforeEach(() => { + // Re-require the processors module after clearing the cache + processors = require('../src/proxy/processors'); + + // Mock the processors module + sinon.stub(processors, 'pre').value(mockPreProcessors); + + sinon.stub(processors, 'push').value(mockPushProcessors); + + // Re-require the chain module after stubbing processors + chain = require('../src/proxy/chain'); + + chain.chainPluginLoader = new PluginLoader([]) + }); + + afterEach(() => { + // Clear the module from the cache after each test + delete require.cache[require.resolve('../src/proxy/processors')]; + delete require.cache[require.resolve('../src/proxy/chain')]; + sinon.reset(); + }); + + it('getChain should set pluginLoaded if loader is undefined', async function () { + chain.chainPluginLoader = undefined; + const actual = await chain.getChain({ type: 'push' }); + expect(actual).to.deep.equal(chain.pushActionChain); + expect(chain.chainPluginLoader).to.be.undefined; + expect(chain.pluginsInserted).to.be.true; + }); + + it('getChain should load plugins from an initialized PluginLoader', async function () { + chain.chainPluginLoader = mockLoader; + const initialChain = [...chain.pushActionChain]; + const actual = await chain.getChain({ type: 'push' }); + expect(actual.length).to.be.greaterThan(initialChain.length); + expect(chain.pluginsInserted).to.be.true; + }); + + it('getChain should load pull plugins from an initialized PluginLoader', async function () { + chain.chainPluginLoader = mockLoader; + const initialChain = [...chain.pullActionChain]; + const actual = await chain.getChain({ type: 'pull' }); + expect(actual.length).to.be.greaterThan(initialChain.length); + expect(chain.pluginsInserted).to.be.true; + }); + + it('executeChain should stop executing if action has continue returns false', async function () { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.resolves({ type: 'push' }); + mockPushProcessors.parsePush.resolves(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); + mockPushProcessors.checkCommitMessages.resolves(continuingAction); + mockPushProcessors.checkAuthorEmails.resolves(continuingAction); + mockPushProcessors.checkUserPushPermission.resolves(continuingAction); + + // this stops the chain from further execution + mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => false, allowPush: false }); + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction.called).to.be.true; + expect(mockPushProcessors.parsePush.called).to.be.true; + expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; + expect(mockPushProcessors.checkCommitMessages.called).to.be.true; + expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; + expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; + expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; + expect(mockPushProcessors.pullRemote.called).to.be.false; + expect(mockPushProcessors.audit.called).to.be.true; + + expect(result.type).to.equal('push'); + expect(result.allowPush).to.be.false; + expect(result.continue).to.be.a('function'); + }); + + it('executeChain should stop executing if action has allowPush is set to true', async function () { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.resolves({ type: 'push' }); + mockPushProcessors.parsePush.resolves(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); + mockPushProcessors.checkCommitMessages.resolves(continuingAction); + mockPushProcessors.checkAuthorEmails.resolves(continuingAction); + mockPushProcessors.checkUserPushPermission.resolves(continuingAction); + // this stops the chain from further execution + mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => true, allowPush: true }); + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction.called).to.be.true; + expect(mockPushProcessors.parsePush.called).to.be.true; + expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; + expect(mockPushProcessors.checkCommitMessages.called).to.be.true; + expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; + expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; + expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; + expect(mockPushProcessors.pullRemote.called).to.be.false; + expect(mockPushProcessors.audit.called).to.be.true; + + expect(result.type).to.equal('push'); + expect(result.allowPush).to.be.true; + expect(result.continue).to.be.a('function'); + }); + + it('executeChain should execute all steps if all actions succeed', async function () { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.resolves({ type: 'push' }); + mockPushProcessors.parsePush.resolves(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); + mockPushProcessors.checkCommitMessages.resolves(continuingAction); + mockPushProcessors.checkAuthorEmails.resolves(continuingAction); + mockPushProcessors.checkUserPushPermission.resolves(continuingAction); + mockPushProcessors.checkIfWaitingAuth.resolves(continuingAction); + mockPushProcessors.pullRemote.resolves(continuingAction); + mockPushProcessors.writePack.resolves(continuingAction); + mockPushProcessors.getDiff.resolves(continuingAction); + mockPushProcessors.clearBareClone.resolves(continuingAction); + mockPushProcessors.scanDiff.resolves(continuingAction); + mockPushProcessors.blockForAuth.resolves(continuingAction); + + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction.called).to.be.true; + expect(mockPushProcessors.parsePush.called).to.be.true; + expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; + expect(mockPushProcessors.checkCommitMessages.called).to.be.true; + expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; + expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; + expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; + expect(mockPushProcessors.pullRemote.called).to.be.true; + expect(mockPushProcessors.writePack.called).to.be.true; + expect(mockPushProcessors.getDiff.called).to.be.true; + expect(mockPushProcessors.clearBareClone.called).to.be.true; + expect(mockPushProcessors.scanDiff.called).to.be.true; + expect(mockPushProcessors.blockForAuth.called).to.be.true; + expect(mockPushProcessors.audit.called).to.be.true; + + expect(result.type).to.equal('push'); + expect(result.allowPush).to.be.false; + expect(result.continue).to.be.a('function'); + }); + + it('executeChain should run the expected steps for pulls', async function () { + const req = {}; + const continuingAction = { type: 'pull', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.resolves({ type: 'pull' }); + mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); + const result = await chain.executeChain(req); + + expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; + expect(mockPushProcessors.parsePush.called).to.be.false; + expect(result.type).to.equal('pull'); + }); + + it('executeChain should handle errors and still call audit', async function () { + const req = {}; + const action = { type: 'push', continue: () => true, allowPush: true }; + + processors.pre.parseAction.resolves(action); + mockPushProcessors.parsePush.rejects(new Error('Audit error')); + + try { + await chain.executeChain(req); + } catch (e) { + // Ignore the error + } + + expect(mockPushProcessors.audit.called).to.be.true; + }); + + it('executeChain should run no actions if not a push or pull', async function () { + const req = {}; + const action = { type: 'foo', continue: () => true, allowPush: true }; + + processors.pre.parseAction.resolves(action); + + const result = await chain.executeChain(req); + + expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.false; + expect(mockPushProcessors.parsePush.called).to.be.false; + expect(result).to.deep.equal(action); + }) +}); diff --git a/test/baz.js b/test/fixtures/baz.js similarity index 100% rename from test/baz.js rename to test/fixtures/baz.js diff --git a/test/plugin.test.js b/test/plugin.test.js index 4b9cb2695..cba96fa7a 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -1,9 +1,9 @@ const chai = require('chai'); const { - createLoader, isCompatiblePlugin, PullActionPlugin, PushActionPlugin, + PluginLoader, } = require('../src/plugin'); const { spawnSync } = require('child_process'); const { rmSync } = require('fs'); @@ -15,7 +15,7 @@ const expect = chai.expect; const testPackagePath = join(__dirname, 'fixtures', 'test-package'); -describe('creating a new PluginLoader and loading plugins', function () { +describe('loading plugins from packages', function () { // eslint-disable-next-line no-invalid-this this.timeout(10000); @@ -24,9 +24,8 @@ describe('creating a new PluginLoader and loading plugins', function () { }); it('should load plugins that are the default export (module.exports = pluginObj)', async function () { - const loader = await createLoader([join(testPackagePath, 'default-export.js')]); - await loader.load; - await loader.ready; + const loader = new PluginLoader([join(testPackagePath, 'default-export.js')]); + await loader.load(); expect(loader.pushPlugins.length).to.equal(1); expect(loader.pushPlugins.every(p => isCompatiblePlugin(p))).to.be.true; expect(loader.pushPlugins[0]) @@ -34,9 +33,8 @@ describe('creating a new PluginLoader and loading plugins', function () { }).timeout(10000); it('should load multiple plugins from a module that match the plugin class (module.exports = { pluginFoo, pluginBar })', async function () { - const loader = await createLoader([join(testPackagePath, 'multiple-export.js')]); - await loader.load; - await loader.ready; + const loader = new PluginLoader([join(testPackagePath, 'multiple-export.js')]); + await loader.load(); expect(loader.pushPlugins.length).to.equal(1); expect(loader.pullPlugins.length).to.equal(1); expect(loader.pushPlugins.every(p => isCompatiblePlugin(p))).to.be.true; @@ -47,9 +45,8 @@ describe('creating a new PluginLoader and loading plugins', function () { }).timeout(10000); it('should load plugins that are subclassed from plugin classes', async function () { - const loader = await createLoader([join(testPackagePath, 'subclass.js')]); - await loader.load; - await loader.ready; + const loader = new PluginLoader([join(testPackagePath, 'subclass.js')]); + await loader.load(); expect(loader.pushPlugins.length).to.equal(1); expect(loader.pushPlugins.every(p => isCompatiblePlugin(p))).to.be.true; expect(loader.pushPlugins.every(p => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))).to.be.true; @@ -57,17 +54,15 @@ describe('creating a new PluginLoader and loading plugins', function () { }).timeout(10000); it('should not load plugins that are not valid modules', async function () { - const loader = await createLoader([join(__dirname, './dummy.js')]); - await loader.load; - await loader.ready; + const loader = new PluginLoader([join(__dirname, './dummy.js')]); + await loader.load(); expect(loader.pushPlugins.length).to.equal(0); expect(loader.pullPlugins.length).to.equal(0); }).timeout(10000); it('should not load plugins that are not extended from plugin objects', async function () { - const loader = await createLoader([join(__dirname, './baz.js')]); - await loader.load; - await loader.ready; + const loader = new PluginLoader([join(__dirname, './fixtures/baz.js')]); + await loader.load(); expect(loader.pushPlugins.length).to.equal(0); expect(loader.pullPlugins.length).to.equal(0); }).timeout(10000); @@ -76,3 +71,28 @@ describe('creating a new PluginLoader and loading plugins', function () { rmSync(join(testPackagePath, 'node_modules'), { recursive: true }); }); }); + +describe('plugin functions', function () { + it('should return true for isCompatiblePlugin', function () { + const plugin = new PushActionPlugin(); + expect(isCompatiblePlugin(plugin)).to.be.true; + expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).to.be.true; + }); + + it('should return false for isCompatiblePlugin', function () { + const plugin = {}; + expect(isCompatiblePlugin(plugin)).to.be.false; + }); + + it('should return true for isCompatiblePlugin with a custom type', function () { + class CustomPlugin extends PushActionPlugin { + constructor() { + super(); + this.isCustomPlugin = true; + } + } + const plugin = new CustomPlugin(); + expect(isCompatiblePlugin(plugin)).to.be.true; + expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).to.be.true; + }); +}); diff --git a/test/testPluginLoader.test.js b/test/testPluginLoader.test.js deleted file mode 100644 index ae6bbcf6f..000000000 --- a/test/testPluginLoader.test.js +++ /dev/null @@ -1,29 +0,0 @@ -const originalEnv = process.env; -const chai = require('chai'); -const plugin = require('../src/plugin'); - -chai.should(); - -const expect = chai.expect; - -describe('creating a new PluginLoader and loading plugins', function () { - before(function () { - process.env.GITPROXY_PLUGIN_FILES = './packages/git-proxy-notify-hello/index.js'; - }); - - it('should load file-based plugins when set from env var', async function () { - plugin.createLoader().then((loader) => { - expect(loader.paths).to.eql(['./packages/git-proxy-notify-hello/index.js']); - expect(loader.names).to.be.empty; - expect(loader.plugins.length).to.equal(1); - expect(loader.plugins[0]) - .to.be.an.instanceOf(plugin.ProxyPlugin) - .and.to.be.an.instanceOf(plugin.ActionPlugin); - }); - }); - - after(function () { - // prevent potential side-effects in other tests - process.env = originalEnv; - }); -});