From f005af4719e0e91c3560fa6309eb9184d10c512a Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Fri, 16 Aug 2024 03:40:08 -0400 Subject: [PATCH 1/5] feat: expose plugin extension points into main package, developer docs + sample - update PluginLoader to use load-plugin's native Node module resolution. this library already handles loading of files vs modules in node_modules/ so let's use that directly - add configuration-based plugin loading and deprecate environment variables - new documentation page for plugin development. loading, writing, etc. - add PullActionPlugin + unify config on just "plugins" - add explicit exports to make importing @finos/git-proxy more straight forward for plugins In addition, this commit adds both CommonJS and ES module examples of plugins and refactors them into a more reflective package. It also includes updates to the site documentation, specifically in the Development section, which now includes details about plugins and contribution guidelines. - fix issue with json-schema-for-humans producing bad output due to post-processing (script step no longer required) - docs: add section on docs on how to update the config schema + re-generate reference doc - fix: don't package website with git-proxy, update .npmignore on npm pack/publish --- .npmignore | 4 +- config.schema.json | 7 + package.json | 4 + packages/git-proxy-notify-hello/index.js | 11 - plugins/README.md | 10 + plugins/git-proxy-plugin-samples/example.cjs | 45 +++ plugins/git-proxy-plugin-samples/index.js | 22 ++ plugins/git-proxy-plugin-samples/package.json | 21 ++ scripts/doc-schema.js | 1 - src/config/index.js | 10 + src/plugin.js | 275 +++++++++++------ src/proxy/actions/index.js | 10 +- src/proxy/chain.js | 66 ++-- src/proxy/index.js | 6 + src/proxy/routes/index.js | 4 +- test/baz.js | 4 + test/fixtures/test-package/default-export.js | 7 + test/fixtures/test-package/multiple-export.js | 13 + test/fixtures/test-package/package.json | 7 + test/fixtures/test-package/subclass.js | 14 + test/plugin.test.js | 78 +++++ website/docs/configuration/reference.mdx | 288 ++++++++++++++++-- website/docs/development/contributing.mdx | 43 +++ website/docs/development/plugins.mdx | 208 +++++++++++++ 24 files changed, 1007 insertions(+), 151 deletions(-) delete mode 100644 packages/git-proxy-notify-hello/index.js create mode 100644 plugins/README.md create mode 100644 plugins/git-proxy-plugin-samples/example.cjs create mode 100644 plugins/git-proxy-plugin-samples/index.js create mode 100644 plugins/git-proxy-plugin-samples/package.json create mode 100644 test/baz.js create mode 100644 test/fixtures/test-package/default-export.js create mode 100644 test/fixtures/test-package/multiple-export.js create mode 100644 test/fixtures/test-package/package.json create mode 100644 test/fixtures/test-package/subclass.js create mode 100644 test/plugin.test.js create mode 100644 website/docs/development/contributing.mdx create mode 100644 website/docs/development/plugins.mdx diff --git a/.npmignore b/.npmignore index 274f0b0b..27087e67 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,3 @@ -# This file required to override .gitignore when publishing to npm \ No newline at end of file +# This file required to override .gitignore when publishing to npm +website/ +plugins/ diff --git a/config.schema.json b/config.schema.json index 7f876bd8..0bcdfcb3 100644 --- a/config.schema.json +++ b/config.schema.json @@ -36,6 +36,13 @@ "description": "Flag to enable CSRF protections for UI", "type": "boolean" }, + "plugins": { + "type": "array", + "description": "List of plugins to integrate on Git Proxy's push or pull actions. Each value is either a file path or a module name.", + "items": { + "type": "string" + } + }, "authorisedList": { "description": "List of repositories that are authorised to be pushed to through the proxy.", "type": "array", diff --git a/package.json b/package.json index 458e3c6f..108d8670 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "git-proxy": "./index.js", "git-proxy-all": "concurrently 'npm run server' 'npm run client'" }, + "exports": { + "./plugin": "./src/plugin.js", + "./proxy/actions": "./src/proxy/actions/index.js" + }, "workspaces": [ "./packages/git-proxy-cli" ], diff --git a/packages/git-proxy-notify-hello/index.js b/packages/git-proxy-notify-hello/index.js deleted file mode 100644 index 93f0a14f..00000000 --- a/packages/git-proxy-notify-hello/index.js +++ /dev/null @@ -1,11 +0,0 @@ -const Step = require('@finos/git-proxy/src/proxy/actions').Step; -const plugin = require('@finos/git-proxy/src/plugin'); - -const helloPlugin = new plugin.ActionPlugin(async (req, action) => { - const step = new Step('HelloPlugin'); - console.log('This is a message from the HelloPlugin!'); - action.addStep(step); - return action; -}); - -module.exports.helloPlugin = helloPlugin; diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..337c9fa4 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,10 @@ +# Git Proxy plugins & samples +Git Proxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, Git Proxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by Git Proxy and use these objects to implement custom functionality. + +For detailed documentation, please refer to the [Git Proxy development resources on the project's site](https://git-proxy.finos.org/docs/development/plugins) + +## Included plugins +These plugins are maintained by the core Git Proxy team. As a future roadmap item, organizations can choose to omit +certain features of Git Proxy by simply removing the dependency from a deployed version of the application. + +- `git-proxy-plugin-samples`: "hello world" examples of the Git Proxy plugin system diff --git a/plugins/git-proxy-plugin-samples/example.cjs b/plugins/git-proxy-plugin-samples/example.cjs new file mode 100644 index 00000000..c88e3541 --- /dev/null +++ b/plugins/git-proxy-plugin-samples/example.cjs @@ -0,0 +1,45 @@ +/** + * This is a sample plugin that logs a message when the pull action is called. It is written using + * CommonJS modules to demonstrate the use of CommonJS in plugins. + */ + +// Peer dependencies; its expected that these deps exist on Node module path if you've installed @finos/git-proxy +const { PushActionPlugin } = require('@finos/git-proxy/plugin'); +const { Step } = require('@finos/git-proxy/proxy/actions'); +'use strict'; + +/** + * + * @param {object} req Express Request object + * @param {Action} action Git Proxy Action + * @return {Promise} Promise that resolves to an Action + */ +async function logMessage(req, action) { + const step = new Step('LogRequestPlugin'); + action.addStep(step); + console.log(`LogRequestPlugin: req url ${req.url}`); + console.log(`LogRequestPlugin: req user-agent ${req.header('User-Agent')}`); + console.log('LogRequestPlugin: action', JSON.stringify(action)); + return action; +} + +class LogRequestPlugin extends PushActionPlugin { + constructor() { + super(logMessage) + } +} + + +module.exports = { + // Plugins can be written inline as new instances of Push/PullActionPlugin + // A custom class is not required + hello: new PushActionPlugin(async (req, action) => { + const step = new Step('HelloPlugin'); + action.addStep(step); + console.log('Hello world from the hello plugin!'); + return action; + }), + // Sub-classing is fine too if you require more control over the plugin + logRequest: new LogRequestPlugin(), + someOtherValue: 'foo', // This key will be ignored by the plugin loader +}; \ No newline at end of file diff --git a/plugins/git-proxy-plugin-samples/index.js b/plugins/git-proxy-plugin-samples/index.js new file mode 100644 index 00000000..8aadb364 --- /dev/null +++ b/plugins/git-proxy-plugin-samples/index.js @@ -0,0 +1,22 @@ +/** + * This is a sample plugin that logs a message when the pull action is called. It is written using + * ES modules to demonstrate the use of ESM in plugins. + */ + +// Peer dependencies; its expected that these deps exist on Node module path if you've installed @finos/git-proxy +import { PullActionPlugin } from "@finos/git-proxy/plugin"; +import { Step } from "@finos/git-proxy/proxy/actions"; + +class RunOnPullPlugin extends PullActionPlugin { + constructor() { + super(function logMessage(req, action) { + const step = new Step('RunOnPullPlugin'); + action.addStep(step); + console.log('RunOnPullPlugin: Received fetch request', req.url); + return action; + }) + } +} + +// Default exports are supported and will be loaded by the plugin loader +export default new RunOnPullPlugin(); diff --git a/plugins/git-proxy-plugin-samples/package.json b/plugins/git-proxy-plugin-samples/package.json new file mode 100644 index 00000000..2d1b7365 --- /dev/null +++ b/plugins/git-proxy-plugin-samples/package.json @@ -0,0 +1,21 @@ +{ + "name": "@finos/git-proxy-plugin-samples", + "version": "0.1.0-alpha.0", + "description": "A set of sample (dummy) plugins for Git Proxy to demonstrate how plugins are authored.", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Thomas Cooper", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./index.js", + "./example": "./example.cjs" + }, + "dependencies": { + "express": "^4.18.2" + }, + "peerDependencies": { + "@finos/git-proxy": "1.3.5-alpha.5" + } +} diff --git a/scripts/doc-schema.js b/scripts/doc-schema.js index 9cfeab6c..bb74820a 100644 --- a/scripts/doc-schema.js +++ b/scripts/doc-schema.js @@ -20,7 +20,6 @@ try { console.log(genDocOutput); const schemaDoc = readFileSync(`${tempdir}${sep}schema.md`, 'utf-8') - .replace(/\n\n<\/summary>/g, '') .replace(/# GitProxy configuration file/g, '# Schema Reference'); // https://github.com/finos/git-proxy/pull/327#discussion_r1377343213 const docString = `--- title: Schema Reference diff --git a/src/config/index.js b/src/config/index.js index 8a7cc8ac..a1ebbceb 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -17,6 +17,7 @@ let _cookieSecret = defaultSettings.cookieSecret; let _sessionMaxAgeHours = defaultSettings.sessionMaxAgeHours; let _sslKeyPath = defaultSettings.sslKeyPemPath; let _sslCertPath = defaultSettings.sslCertPemPath; +let _plugins = defaultSettings.plugins; let _commitConfig = defaultSettings.commitConfig; let _attestationConfig = defaultSettings.attestationConfig; let _privateOrganizations = defaultSettings.privateOrganizations; @@ -160,6 +161,14 @@ const getCSRFProtection = () => { return _csrfProtection; }; +// Get loadable push plugins +const getPlugins = () => { + if (_userSettings && _userSettings.plugins) { + _plugins = _userSettings.plugins; + } + return _plugins; +} + const getSSLKeyPath = () => { if (_userSettings && _userSettings.sslKeyPemPath) { _sslKeyPath = _userSettings.sslKeyPemPath; @@ -195,5 +204,6 @@ exports.getPrivateOrganizations = getPrivateOrganizations; exports.getURLShortener = getURLShortener; exports.getContactEmail = getContactEmail; exports.getCSRFProtection = getCSRFProtection; +exports.getPlugins = getPlugins; exports.getSSLKeyPath = getSSLKeyPath; exports.getSSLCertPath = getSSLCertPath; diff --git a/src/plugin.js b/src/plugin.js index d2d3228c..9c5aee2e 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -1,96 +1,156 @@ -const path = require('path'); const lpModule = import('load-plugin'); ('use strict'); /** - * Finds, registers and loads plugins used by git-proxy + * Checks if the given object or any of its prototypes has the 'isGitProxyPlugin' property set to true. + * @param {Object} obj - The object to check. + * @param {string} propertyName - The property name to check for. Default is 'isGitProxyPlugin'. + * @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') { + while (obj != null) { + if (Object.prototype.hasOwnProperty.call(obj, propertyName) && + obj.isGitProxyPlugin && + Object.keys(obj).includes('exec')) { + return true; + } + obj = Object.getPrototypeOf(obj); + } + return false; +} + +/** + * @typedef PluginTypeResult + * @property {ProxyPlugin[]} pushPlugins - List of push plugins + * @property {ProxyPlugin[]} pullPlugins - List of pull 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.} names List of Node module/package names to load. - * @param {Array.} paths List of file paths to load modules from. + * @param {Array.} targets List of Node module package names or files to load. */ - constructor(names, paths) { - this.names = names; - this.paths = paths; + constructor(targets) { + this.targets = targets; /** - * @type {Array.} List of ProxyPlugin objects loaded. + * @type {ProxyPlugin[]} List of loaded ProxyPlugins * @public */ - this.plugins = []; + this.pushPlugins = []; + 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(); } - /** - * Load configured plugins as modules and set each concrete ProxyPlugin - * to this.plugins for use in proxying. - */ - load() { - const modulePromises = []; - for (const path of this.paths) { - modulePromises.push(this._loadFilePlugin(path)); - } - for (const name of this.names) { - modulePromises.push(this._loadPackagePlugin(name)); - } - Promise.all(modulePromises).then((vals) => { - const modules = vals; - console.log(`Found ${modules.length} plugin modules`); - const pluginObjPromises = []; - for (const mod of modules) { - pluginObjPromises.push(this._castToPluginObjects(mod)); + async _loadPlugins() { + try { + const modulePromises = this.targets.map(target => + this._loadPluginModule(target).catch(error => { + console.error(`Failed to load plugin: ${error}`); // TODO: log.error() + return Promise.reject(error); // Or return an error object to handle it later + }) + ); + + const moduleResults = await Promise.allSettled(modulePromises); + const loadedModules = moduleResults + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map(result => result.value); + + console.log(`Found ${loadedModules.length} plugin modules`); // TODO: log.debug() + + const pluginTypeResultPromises = loadedModules.map(mod => + this._getPluginObjects(mod).catch(error => { + console.error(`Failed to cast plugin objects: ${error}`); // TODO: log.error() + return Promise.reject(error); // Or return an error object to handle it later + }) + ); + + const settledPluginTypeResults = await Promise.allSettled(pluginTypeResultPromises); + 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) } - Promise.all(pluginObjPromises).then((vals) => { - for (const pluginObjs of vals) { - this.plugins = this.plugins.concat(pluginObjs); - } - console.log(`Loaded ${this.plugins.length} plugins`); + + const combinedPlugins = [...this.pushPlugins, ...this.pullPlugins]; + combinedPlugins.forEach(plugin => { + console.log(`Loaded plugin: ${plugin.constructor.name}`); }); - }); - } - /** - * Load a plugin module from a relative file path to the - * current working directory. - * @param {string} filepath - * @return {Module} - */ - async _loadFilePlugin(filepath) { - const lp = await lpModule; - const resolvedModuleFile = await lp.resolvePlugin(path.join(process.cwd(), filepath)); - return await lp.loadPlugin(resolvedModuleFile); + this.ready = Promise.resolve(); + } catch (error) { + console.error(`Error loading plugins: ${error}`); + this.ready = Promise.reject(error); + } } - /** - * Load a plugin module from the specified Node module. Only - * modules with the prefix "@finos" are supported. - * @param {string} packageName + * Load a plugin module from either a file path or a Node module. + * @param {string} target * @return {Module} */ - async _loadPackagePlugin(packageName) { + async _loadPluginModule(target) { const lp = await lpModule; - const resolvedPackageFile = await lp.resolvePlugin(packageName, { - prefix: '@finos', - }); - return await lp.loadPlugin(resolvedPackageFile); + const resolvedModuleFile = await lp.resolvePlugin(target); + return await lp.loadPlugin(resolvedModuleFile); } /** * Set a list of ProxyPlugin objects to this.plugins * from the keys exported by the passed in module. - * @param {Module} pluginModule - * @return {ProxyPlugin} + * @param {object} pluginModule + * @return {PluginTypeResult} - An object containing the loaded plugins classified by their type. */ - async _castToPluginObjects(pluginModule) { - const plugins = []; - // iterate over the module.exports keys - for (const key of Object.keys(pluginModule)) { - if ( - Object.prototype.hasOwnProperty.call(pluginModule, key) && - pluginModule[key] instanceof ProxyPlugin - ) { - plugins.push(pluginModule[key]); + async _getPluginObjects(pluginModule) { + const plugins = { + pushPlugins: [], + pullPlugins: [], + }; + // 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); + } else { + console.error(`Error: Object ${pluginModule.constructor.name} does not seem to be a compatible plugin type`); + } + } else { + // iterate over the module.exports keys if multiple arbitrary objects are exported + 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`); + } + } } } return plugins; @@ -101,44 +161,77 @@ class PluginLoader { * Parent class for all GitProxy plugins. New plugin types must inherit from * ProxyPlugin to be loaded by PluginLoader. */ -class ProxyPlugin {} +class ProxyPlugin { + constructor() { + this.isGitProxyPlugin = true; + } +} + +/** + * A plugin which executes a function when receiving a git push request. + */ +class PushActionPlugin extends ProxyPlugin { +/** + * Wrapper class which contains at least one function executed as part of the action chain for git push operations. + * The function must be called `exec` and take in two parameters: an Express Request (req) and the current Action + * executed in the chain (action). This function should return a Promise that resolves to an Action. + * + * Optionally, child classes which extend this can simply define the `exec` function as their own property. + * This is the preferred implementation when a custom plugin (subclass) has its own state or additional methods + * that are required. + * + * @param {function} exec - A function that: + * - Takes in an Express Request object as the first parameter (`req`). + * - Takes in an Action object as the second parameter (`action`). + * - Returns a Promise that resolves to an Action. + */ + constructor(exec) { + super(); + this.isGitProxyPushActionPlugin = true; + this.exec = exec; + } +} /** - * A plugin which executes a function when receiving a proxy request. + * A plugin which executes a function when receiving a git fetch request. */ -class ActionPlugin extends ProxyPlugin { +class PullActionPlugin extends ProxyPlugin { /** - * Custom function executed as part of the action chain. The function - * must take in two parameters, an {@link https://expressjs.com/en/4x/api.html#req Express Request} - * and the current Action executed in the chain. - * @param {Promise} exec A Promise that returns an Action & - * executes when a push is proxied. + * Wrapper class which contains at least one function executed as part of the action chain for git pull operations. + * The function must be called `exec` and take in two parameters: an Express Request (req) and the current Action + * executed in the chain (action). This function should return a Promise that resolves to an Action. + * + * Optionally, child classes which extend this can simply define the `exec` function as their own property. + * This is the preferred implementation when a custom plugin (subclass) has its own state or additional methods + * that are required. + * + * @param {function} exec - A function that: + * - Takes in an Express Request object as the first parameter (`req`). + * - Takes in an Action object as the second parameter (`action`). + * - Returns a Promise that resolves to an Action. */ constructor(exec) { super(); + this.isGitProxyPullActionPlugin = true; this.exec = exec; } } -const createLoader = async () => { - // Auto-register plugins that are part of git-proxy core - let names = []; - let files = []; - if (process.env.GITPROXY_PLUGIN_PACKAGES !== undefined) { - names = process.env.GITPROXY_PLUGIN_PACKAGES.split(','); - } - if (process.env.GITPROXY_PLUGIN_FILES !== undefined) { - files = process.env.GITPROXY_PLUGIN_FILES.split(','); - } - const loader = new PluginLoader(names, files); - if (names.length + files.length > 0) { - loader.load(); - } +/** + * + * @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.defaultLoader = createLoader(); -module.exports.ProxyPlugin = ProxyPlugin; -module.exports.ActionPlugin = ActionPlugin; -// exported for testing only -module.exports.createLoader = createLoader; +module.exports = { + createLoader, + PluginLoader, + PushActionPlugin, + PullActionPlugin, + isCompatiblePlugin, +} \ No newline at end of file diff --git a/src/proxy/actions/index.js b/src/proxy/actions/index.js index 6ccb06a8..173bb158 100644 --- a/src/proxy/actions/index.js +++ b/src/proxy/actions/index.js @@ -1,3 +1,7 @@ -exports.Action = require('./Action').Action; -exports.PushAction = require('./Action').PushAction; -exports.Step = require('./Step').Step; +const { Action } = require('./Action'); +const { Step } = require('./Step'); + +module.exports = { + Action, + Step, +} diff --git a/src/proxy/chain.js b/src/proxy/chain.js index c389a989..3bff31b3 100644 --- a/src/proxy/chain.js +++ b/src/proxy/chain.js @@ -1,12 +1,11 @@ const proc = require('./processors'); -const plugin = require('../plugin'); const pushActionChain = [ proc.push.parsePush, - proc.push.checkRepoInAuthorisedList, + proc.push.checkIfOnboardedRepo, proc.push.checkCommitMessages, proc.push.checkAuthorEmails, - proc.push.checkUserPushPermission, + // proc.push.checkIfPullRequestApproved, proc.push.checkIfWaitingAuth, proc.push.pullRemote, proc.push.writePack, @@ -16,9 +15,13 @@ const pushActionChain = [ proc.push.blockForAuth, ]; +const pullActionChain = [ + proc.push.checkIfOnboardedRepo, +]; + let pluginsLoaded = false; -const chain = async (req) => { +const executeChain = async (req) => { let action; try { action = await proc.pre.parseAction(req); @@ -43,27 +46,48 @@ const chain = async (req) => { return action; }; +/** + * The plugin loader used for the Git Proxy chain. + * + * @type {import('../plugin').PluginLoader} + */ +let chainPluginLoader; + const getChain = async (action) => { - if (action.type === 'pull') return [proc.push.checkRepoInAuthorisedList]; - if (action.type === 'push') { - // insert loaded plugins as actions - // this probably isn't the place to insert these functions - const loader = await plugin.defaultLoader; - const pluginActions = loader.plugins; - if (!pluginsLoaded && pluginActions.length > 0) { - console.log(`Found ${pluginActions.length}, inserting into proxy chain`); - for (const pluginAction of pluginActions) { - if (pluginAction instanceof plugin.ActionPlugin) { - console.log(`Inserting plugin ${pluginAction} into chain`); - // insert custom functions after parsePush but before other actions - pushActionChain.splice(1, 0, pluginAction.exec); - } - } - pluginsLoaded = true; + if (chainPluginLoader === undefined) { + console.error('Plugin loader was not initialized! Skipping any plugins...'); + pluginsLoaded = true; + } + if (!pluginsLoaded) { + 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 + pushActionChain.splice(1, 0, pluginObj.exec); + } + for (const pluginObj of chainPluginLoader.pullPlugins) { + console.log(`Inserting pull plugin ${pluginObj.constructor.name} into chain`); + // insert custom functions before other pull actions + 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; + } + if (action.type === 'pull') { + return pullActionChain; + }; + if (action.type === 'push') { return pushActionChain; } if (action.type === 'default') return []; }; -exports.exec = chain; +module.exports = { + set chainPluginLoader(loader) { + chainPluginLoader = loader; + }, + get chainPluginLoader() { + return chainPluginLoader; + }, + executeChain, +} \ No newline at end of file diff --git a/src/proxy/index.js b/src/proxy/index.js index 7d6d7874..59235c88 100644 --- a/src/proxy/index.js +++ b/src/proxy/index.js @@ -7,6 +7,8 @@ const path = require("path"); const router = require('./routes').router; const config = require('../config'); const db = require('../db'); +const { createLoader } = 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; @@ -23,6 +25,10 @@ proxyApp.use(bodyParser.raw(options)); proxyApp.use('/', router); const start = async () => { + const plugins = config.getPlugins(); + const pluginLoader = await createLoader(plugins); + await pluginLoader.load; + chain.chainPluginLoader = pluginLoader; // Check to see if the default repos are in the repo list const defaultAuthorisedRepoList = config.getAuthorisedList(); const allowedList = await db.getRepos(); diff --git a/src/proxy/routes/index.js b/src/proxy/routes/index.js index 50a5d53c..1ef84bcb 100644 --- a/src/proxy/routes/index.js +++ b/src/proxy/routes/index.js @@ -1,7 +1,7 @@ const express = require('express'); const proxy = require('express-http-proxy'); const router = new express.Router(); -const chain = require('../chain'); +const { executeChain } = require('../chain'); const config = require('../../config'); /** @@ -63,7 +63,7 @@ router.use( req.rawBody = req.body.toString('utf8'); } - const action = await chain.exec(req, res); + const action = await executeChain(req, res); console.log('action processed'); if (action.error || action.blocked) { diff --git a/test/baz.js b/test/baz.js new file mode 100644 index 00000000..6829bdac --- /dev/null +++ b/test/baz.js @@ -0,0 +1,4 @@ +module.exports = { + foo: 'bar', + baz: {}, +} \ No newline at end of file diff --git a/test/fixtures/test-package/default-export.js b/test/fixtures/test-package/default-export.js new file mode 100644 index 00000000..7f3fbdc9 --- /dev/null +++ b/test/fixtures/test-package/default-export.js @@ -0,0 +1,7 @@ +const { PushActionPlugin } = require('@osp0/finos-git-proxy/plugin'); + +// test default export +module.exports = new PushActionPlugin(async (req, action) => { + console.log('Dummy plugin: ', action); + return action; +}); diff --git a/test/fixtures/test-package/multiple-export.js b/test/fixtures/test-package/multiple-export.js new file mode 100644 index 00000000..3963c025 --- /dev/null +++ b/test/fixtures/test-package/multiple-export.js @@ -0,0 +1,13 @@ +const { PushActionPlugin, PullActionPlugin } = require('@osp0/finos-git-proxy/plugin'); + + +module.exports = { + foo: new PushActionPlugin(async (req, action) => { + console.log('PushActionPlugin: ', action); + return action; + }), + bar: new PullActionPlugin(async (req, action) => { + console.log('PullActionPlugin: ', action); + return action; + }), +} \ No newline at end of file diff --git a/test/fixtures/test-package/package.json b/test/fixtures/test-package/package.json new file mode 100644 index 00000000..1e122332 --- /dev/null +++ b/test/fixtures/test-package/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-package", + "version": "0.0.0", + "dependencies": { + "@osp0/finos-git-proxy": "file:../../.." + } +} diff --git a/test/fixtures/test-package/subclass.js b/test/fixtures/test-package/subclass.js new file mode 100644 index 00000000..ff98b9a2 --- /dev/null +++ b/test/fixtures/test-package/subclass.js @@ -0,0 +1,14 @@ +const { PushActionPlugin } = require('@osp0/finos-git-proxy/plugin'); + +class DummyPlugin extends PushActionPlugin { + constructor(exec) { + super(); + this.exec = exec; + } +} + +// test default export +module.exports = new DummyPlugin(async (req, action) => { + console.log('Dummy plugin: ', action); + return action; +}); diff --git a/test/plugin.test.js b/test/plugin.test.js new file mode 100644 index 00000000..4b9cb269 --- /dev/null +++ b/test/plugin.test.js @@ -0,0 +1,78 @@ +const chai = require('chai'); +const { + createLoader, + isCompatiblePlugin, + PullActionPlugin, + PushActionPlugin, +} = require('../src/plugin'); +const { spawnSync } = require('child_process'); +const { rmSync } = require('fs'); +const { join } = require('path'); + +chai.should(); + +const expect = chai.expect; + +const testPackagePath = join(__dirname, 'fixtures', 'test-package'); + +describe('creating a new PluginLoader and loading plugins', function () { + // eslint-disable-next-line no-invalid-this + this.timeout(10000); + + before(function () { + spawnSync('npm', ['install'], { cwd: testPackagePath, timeout: 5000 }); + }); + + 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; + expect(loader.pushPlugins.length).to.equal(1); + expect(loader.pushPlugins.every(p => isCompatiblePlugin(p))).to.be.true; + expect(loader.pushPlugins[0]) + .to.be.an.instanceOf(PushActionPlugin); + }).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; + expect(loader.pushPlugins.length).to.equal(1); + expect(loader.pullPlugins.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; + expect(loader.pullPlugins.every(p => isCompatiblePlugin(p, 'isGitProxyPullActionPlugin'))).to.be.true; + expect(loader.pushPlugins[0]).to.be.instanceOf(PushActionPlugin); + expect(loader.pullPlugins[0]).to.be.instanceOf(PullActionPlugin); + }).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; + 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; + expect(loader.pushPlugins[0]).to.be.instanceOf(PushActionPlugin); + }).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; + 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; + expect(loader.pushPlugins.length).to.equal(0); + expect(loader.pullPlugins.length).to.equal(0); + }).timeout(10000); + + after(function () { + rmSync(join(testPackagePath, 'node_modules'), { recursive: true }); + }); +}); diff --git a/website/docs/configuration/reference.mdx b/website/docs/configuration/reference.mdx index 9f624fde..4ed51acd 100644 --- a/website/docs/configuration/reference.mdx +++ b/website/docs/configuration/reference.mdx @@ -16,7 +16,205 @@ description: JSON schema reference documentation for GitProxy **Description:** Configuration for customizing git-proxy
- 1. [Optional] Property GitProxy configuration file > authorisedList + + 1. [Optional] Property GitProxy configuration file > proxyUrl + + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +
+
+ +
+ + 2. [Optional] Property GitProxy configuration file > cookieSecret + + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +
+
+ +
+ + 3. [Optional] Property GitProxy configuration file > sessionMaxAgeHours + + +
+ +| | | +| ------------ | -------- | +| **Type** | `number` | +| **Required** | No | + +
+
+ +
+ + 4. [Optional] Property GitProxy configuration file > api + + +
+ +| | | +| ------------------------- | ------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [[Any type: allowed]](# "Additional Properties of any type are allowed.") | + +**Description:** Third party APIs + +
+
+ +
+ + 5. [Optional] Property GitProxy configuration file > commitConfig + + +
+ +| | | +| ------------------------- | ------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [[Any type: allowed]](# "Additional Properties of any type are allowed.") | + +**Description:** Enforce rules and patterns on commits including e-mail and message + +
+
+ +
+ + 6. [Optional] Property GitProxy configuration file > attestationConfig + + +
+ +| | | +| ------------------------- | ------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [[Any type: allowed]](# "Additional Properties of any type are allowed.") | + +**Description:** Customisable questions to add to attestation form + +
+
+ +
+ + 7. [Optional] Property GitProxy configuration file > privateOrganizations + + +
+ +| | | +| ------------ | ------- | +| **Type** | `array` | +| **Required** | No | + +**Description:** Pattern searches for listed private organizations are disabled + +
+
+ +
+ + 8. [Optional] Property GitProxy configuration file > urlShortener + + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** Customisable URL shortener to share in proxy responses and warnings + +
+
+ +
+ + 9. [Optional] Property GitProxy configuration file > contactEmail + + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** Customisable e-mail address to share in proxy responses and warnings + +
+
+ +
+ + 10. [Optional] Property GitProxy configuration file > csrfProtection + + +
+ +| | | +| ------------ | --------- | +| **Type** | `boolean` | +| **Required** | No | + +**Description:** Flag to enable CSRF protections for UI + +
+
+ +
+ + 11. [Optional] Property GitProxy configuration file > plugins + + +
+ +| | | +| ------------ | ----------------- | +| **Type** | `array of string` | +| **Required** | No | + +**Description:** List of plugins to integrate on Git Proxy's push or pull actions. Each value is either a file path or a module name. + +| Each item of this array must be | Description | +| ------------------------------- | ----------- | +| [plugins items](#plugins_items) | - | + +### 11.1. GitProxy configuration file > plugins > plugins items + +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +
+
+ +
+ + 12. [Optional] Property GitProxy configuration file > authorisedList + +
| | | @@ -30,7 +228,7 @@ description: JSON schema reference documentation for GitProxy | --------------------------------------- | ----------- | | [authorisedRepo](#authorisedList_items) | - | -### 1.1. GitProxy configuration file > authorisedList > authorisedRepo +### 12.1. GitProxy configuration file > authorisedList > authorisedRepo | | | | ------------------------- | ------------------------------------------------------------------------- | @@ -40,7 +238,10 @@ description: JSON schema reference documentation for GitProxy | **Defined in** | #/definitions/authorisedRepo |
- 1.1.1. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > project + + 12.1.1. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > project + +
| | | @@ -52,7 +253,10 @@ description: JSON schema reference documentation for GitProxy
- 1.1.2. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > name + + 12.1.2. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > name + +
| | | @@ -64,7 +268,10 @@ description: JSON schema reference documentation for GitProxy
- 1.1.3. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > url + + 12.1.3. [Required] Property GitProxy configuration file > authorisedList > authorisedList items > url + +
| | | @@ -79,7 +286,10 @@ description: JSON schema reference documentation for GitProxy
- 2. [Optional] Property GitProxy configuration file > sink + + 13. [Optional] Property GitProxy configuration file > sink + +
| | | @@ -93,7 +303,7 @@ description: JSON schema reference documentation for GitProxy | ------------------------------- | ----------- | | [database](#sink_items) | - | -### 2.1. GitProxy configuration file > sink > database +### 13.1. GitProxy configuration file > sink > database | | | | ------------------------- | ------------------------------------------------------------------------- | @@ -103,7 +313,10 @@ description: JSON schema reference documentation for GitProxy | **Defined in** | #/definitions/database |
- 2.1.1. [Required] Property GitProxy configuration file > sink > sink items > type + + 13.1.1. [Required] Property GitProxy configuration file > sink > sink items > type + +
| | | @@ -115,7 +328,10 @@ description: JSON schema reference documentation for GitProxy
- 2.1.2. [Required] Property GitProxy configuration file > sink > sink items > enabled + + 13.1.2. [Required] Property GitProxy configuration file > sink > sink items > enabled + +
| | | @@ -127,7 +343,10 @@ description: JSON schema reference documentation for GitProxy
- 2.1.3. [Optional] Property GitProxy configuration file > sink > sink items > connectionString + + 13.1.3. [Optional] Property GitProxy configuration file > sink > sink items > connectionString + +
| | | @@ -139,7 +358,10 @@ description: JSON schema reference documentation for GitProxy
- 2.1.4. [Optional] Property GitProxy configuration file > sink > sink items > options + + 13.1.4. [Optional] Property GitProxy configuration file > sink > sink items > options + +
| | | @@ -152,7 +374,10 @@ description: JSON schema reference documentation for GitProxy
- 2.1.5. [Optional] Property GitProxy configuration file > sink > sink items > params + + 13.1.5. [Optional] Property GitProxy configuration file > sink > sink items > params + +
| | | @@ -168,7 +393,10 @@ description: JSON schema reference documentation for GitProxy
- 3. [Optional] Property GitProxy configuration file > authentication + + 14. [Optional] Property GitProxy configuration file > authentication + +
| | | @@ -182,7 +410,7 @@ description: JSON schema reference documentation for GitProxy | --------------------------------------- | ----------- | | [authentication](#authentication_items) | - | -### 3.1. GitProxy configuration file > authentication > authentication +### 14.1. GitProxy configuration file > authentication > authentication | | | | ------------------------- | ------------------------------------------------------------------------- | @@ -192,7 +420,10 @@ description: JSON schema reference documentation for GitProxy | **Defined in** | #/definitions/authentication |
- 3.1.1. [Required] Property GitProxy configuration file > authentication > authentication items > type + + 14.1.1. [Required] Property GitProxy configuration file > authentication > authentication items > type + +
| | | @@ -204,7 +435,10 @@ description: JSON schema reference documentation for GitProxy
- 3.1.2. [Required] Property GitProxy configuration file > authentication > authentication items > enabled + + 14.1.2. [Required] Property GitProxy configuration file > authentication > authentication items > enabled + +
| | | @@ -216,7 +450,10 @@ description: JSON schema reference documentation for GitProxy
- 3.1.3. [Optional] Property GitProxy configuration file > authentication > authentication items > options + + 14.1.3. [Optional] Property GitProxy configuration file > authentication > authentication items > options + +
| | | @@ -232,7 +469,10 @@ description: JSON schema reference documentation for GitProxy
- 4. [Optional] Property GitProxy configuration file > tempPassword + + 15. [Optional] Property GitProxy configuration file > tempPassword + +
| | | @@ -244,7 +484,10 @@ description: JSON schema reference documentation for GitProxy **Description:** Toggle the generation of temporary password for git-proxy admin user
- 4.1. [Optional] Property GitProxy configuration file > tempPassword > sendEmail + + 15.1. [Optional] Property GitProxy configuration file > tempPassword > sendEmail + +
| | | @@ -256,7 +499,10 @@ description: JSON schema reference documentation for GitProxy
- 4.2. [Optional] Property GitProxy configuration file > tempPassword > emailConfig + + 15.2. [Optional] Property GitProxy configuration file > tempPassword > emailConfig + +
| | | @@ -274,4 +520,4 @@ description: JSON schema reference documentation for GitProxy
---------------------------------------------------------------------------------------------------------------------------- -Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2023-12-10 at 15:42:45 -0500 +Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2024-07-22 at 10:42:24 -0400 diff --git a/website/docs/development/contributing.mdx b/website/docs/development/contributing.mdx new file mode 100644 index 00000000..a1782174 --- /dev/null +++ b/website/docs/development/contributing.mdx @@ -0,0 +1,43 @@ +--- +title: Contributing +--- + +Here's how to get setup for contributing to Git Proxy. + +## Setup +The Git Proxy project relies on the following pre-requisites: + +- [Node](https://nodejs.org/en/download) (16+) +- [npm](https://npmjs.com/) (8+) +- [git](https://git-scm.com/downloads) or equivalent Git client. It must support HTTP/S. + +Once you have the above tools installed & setup, clone the repository and run: + +```bash +$ npm install +``` + +This will install the full project's dependencies. Once complete, you can run the app locally: + +```bash +$ npm run start # Run both proxy server & dashboard UI +$ npm run server # Run only the proxy server +$ npm run client # Run only the UI +``` + +## Testing + + +## Configuration schema +The configuration for Git Proxy includes a JSON Schema ([`config.schema.json`](https://github.com/finos/git-proxy/blob/main/config.schema.json)) to define the expected properties used by the application. When adding new configuration properties to Git Proxy, ensure that the schema is updated with any new, removed or changed properties. See [JSON Schema docs for specific syntax](https://json-schema.org/docs). + +When updating the configuration schema, you must also re-generate the reference doc used here on the site. To generate the reference documentation, [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) is used to output the Markdown. + +1. Install [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans?tab=readme-ov-file#installation) (requires Python) +2. Run `npm run gen-schema-doc`. + +## Submitting a pull request + + +## About FINOS contributions + diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx new file mode 100644 index 00000000..c9db32e1 --- /dev/null +++ b/website/docs/development/plugins.mdx @@ -0,0 +1,208 @@ +--- +title: Plugins +--- + +## How plugins work +Git Proxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, Git Proxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by Git Proxy and use these objects to implement custom functionality. + +For each loaded "plugin object", it is inserted into Git Proxy's chain of actions which are triggered on a given Git action received by Git Proxy such as `git push` or `git fetch`. + +:::caution +The order that plugins are configured matters! Plugins execute _before_ Git Proxy's builtin steps and in the order that they are configured in `proxy.config.json`. If you wish to use a combination of features, ensure that your custom plugins do not conflict or interfere with later steps in the processing chain. +::: + +Git Proxy uses the [load-plugin package](https://www.npmjs.com/package/load-plugin) to provide the Node module resolution. + +## Limitations +- Plugins are only supported on the Git HTTP proxy server. There is no similar extensibility today for the dashboard UI or its backing API. +- Extensions are defined as JavaScript classes which are [quite limited](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain#inheritance_with_the_prototype_chain). Git Proxy has a rather naive system for determining if a provided module has any objects which it recognizes as "Git Proxy plugin types". A conversion of the project to TypeScript will provide more flexible options for enforcing API contracts via strict interfaces & types in a future release. [Use of TypeScript is a roadmap item](https://github.com/finos/git-proxy/issues/276). + +## Using plugins +The primary goals of the plugin system is to: + +- Allow users deploying Git Proxy to do so via the distributed binary package. ie. `npx -- @finos/git-proxy` +- Allow users to consume plugins that packaged using standard tools (npm) and optionally distributed via npm themselves. +- Reuse as much native Node functionality as possible for calling 3rd-party code (plugins) from the main application (git-proxy). + +The below instructions are using the [`@finos/git-proxy-plugin-samples`](https://www.npmjs.com/package/@finos/git-proxy-plugin-samples) package as an demonstrative set of plugins. + + +### via npm packages +#### Steps +1. Install both `@finos/git-proxy` and the `@finos/git-proxy-plugin-samples` package to the local `node_modules/` directory using the following command: + +```bash +$ npm install -g @finos/git-proxy@1.3.5 @finos/git-proxy-plugin-samples@0.1.0 +``` + +Alternatively, you can create local packages from source and install those instead. +```bash +$ git clone https://github.com/finos/git-proxy +$ cd git-proxy +$ npm pack +$ npm install -g ./finos-git-proxy-1.3.5.tgz +$ (cd plugins/git-proxy-plugin-samples && npm pack) +$ npm install -g plugins/git-proxy-plugin-samples/finos-git-proxy-plugin-samples-0.1.0.tgz +``` + +2. Create or edit an existing `proxy.config.json` file to configure the plugin(s) to load. You must include the full import path that would typically be used in a `import {}` (ESM) or `require()` (CJS) statement: + +```json +{ + "plugins": [ + "@finos/git-proxy-plugin-samples", + "@finos/git-proxy-plugin-samples/example.cjs" + ] +} +``` + +1. Run `git-proxy`. + +```bash +$ git-proxy +Service Listening on 8080 +HTTP Proxy Listening on 8000 +HTTPS Proxy Listening on 8443 +Found 2 plugin modules +Loaded plugin: RunOnPullPlugin +Loaded plugin: HelloPlugin +Loaded plugin: LogRequestPlugin +``` + +### via JavaScript file(s) +:::caution +This section is considered highly experimental and not recommended for general use. Even when authoring local plugins that are not distributed, it is best to use `node_modules/` and not rely on bespoke system setup or layout of files. Use `npm pack` and `npm install path/to/plugin.tgz` if you must use local plugin code to ensure Git Proxy's plugin manager can properly load your plugins. +::: + +Plugins written as standalone JavaScript files are used similarly to npm packages. The main difference is that file-based module loading requires additional steps to ensure that the given JavaScript files (written as Node CommonJS or ES modules) have all the necessary dependencies to be imported. Since Git Proxy plugin system relies on class-based inheritence, the module will require at least a dependency to `@finos/git-proxy` to be able to import the required classes. Plugins do not have to be distributed by NPM to be used in this fashion which may be advantageous in certain environments. + +1. To use a plugin that is written as a standalone JavaScript file, ensure that the JS code has all its necessary dependencies: + +```bash +$ cd path/to/plugindir +$ cat package.json +{ + "name": "foo-plugin", + ... + "dependencies": { + "@finos/git-proxy": "^1.3.5" + } +} +# Alternatively, add git-proxy that is cloned locally as a file-based dependency +$ cat package.json +{ + "name": "foo-plugin", + "dependencies": { + "@finos/git-proxy": "file:/path/to/checked/out/finos/git-proxy" + } +} +$ npm install +``` + +2. Create or edit an existing `proxy.config.json` file to configure the plugin(s) to load: + +```json +{ + "plugins": [ + "path/to/plugin/index.js" + ] +} +``` + +3. Run `git-proxy` + +```bash +$ git-proxy +Service Listening on 8080 +HTTP Proxy Listening on 8000 +HTTPS Proxy Listening on 8443 +Found 1 plugin modules +Loaded plugin: FooPlugin +``` + +## Developing new plugins + +To develop a new plugin, you must add `@finos/git-proxy` as a [peer dependency](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#peerdependencies). The main app (also known as the "host application") exports the following extension points: + +- `@finos/git-proxy/plugin/PushActionPlugin`: execute as an action in the proxy chain during a `git push` +- `@finos/git-proxy/plugin/PullActionPlugin`: execute as an action in the proxy chain during a `git fetch` +- `@finos/git-proxy/proxy/actions/Step` and `@finos/git-proxy/proxy/actions/Action`: internal classes which act as carriers for `git` state during proxying. Plugins should modify the passed in `action` for affecting any global state of the git operation and add its own custom `Step` object to capture the plugin's own internal state (logs, errored/blocked status, etc.) + +Git Proxy will load your plugin only if it extends one of the two plugin classes above. It is also important that your package has [`exports`](https://nodejs.org/api/packages.html#exports) defined for the plugin loader to properly load your module(s). + +Please see the [sample plugin package included in the repo](https://github.com/finos/git-proxy/tree/main/plugins/git-proxy-plugin-samples) for details on how to structure your plugin package. + +If your plugin relies on custom state, it is recommended to create subclasses in the following manner: + +```javascript +import { PushActionPlugin } from "@finos/git-proxy/plugin"; + +class FooPlugin extends PushActionPlugin { + constructor() { + super(); // don't pass any function to the parent class - let the parent's this.exec function be undefined + this.displayName = 'FooPlugin'; + // overwrite the parent's exec function which is executed as part of Git Proxy's action chain. + // use an arrow function if you require access to the instances's state (properties, methods, etc.) + this.exec = async (req, action) => { + console.log(this.displayName); + this.utilMethod(); + // do something + return action; + } + } + + utilMethod() { + // misc operations specific to FooPlugin + } +} +``` + +### Example + +```bash +$ npm init +# ... +$ npm install --save-peer @finos/git-proxy@1.3.5 +``` + +`package.json` + +```json +{ + "name": "bar", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "exports": { + ".": "./bar.js" + }, + "peerDependencies": { + "@finos/git-proxy": "^1.3.5-alpha.0" + } +} +``` + +`bar.js` +```javascript +import { PushActionPlugin } from "@finos/git-proxy/plugin"; +import { Step } from "@finos/git-proxy/proxy/actions"; + +//Note: Only use a default export if you do not rely on any state. Otherwise, create a sub-class of [Push/Pull]ActionPlugin +export default new PushActionPlugin(function(req, action) { + // the action parameter holds all the details about a push. + // see https://github.com/finos/git-proxy/blob/main/src/proxy/actions/Action.js + + // create a new Step to track any side effects of this plugin function (errors, blocking pushes, logs, etc) + // see https://github.com/finos/git-proxy/blob/main/src/proxy/actions/Step.js + const step = new Step('bar-plugin'); + action.addStep(step); + // add custom logic here. + // ... + return action; // always return the action - even in the case of errors +}); +``` From 7651deaf63cb9f2fdcaee80b65aab4825d64c076 Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Mon, 9 Sep 2024 12:13:00 -0400 Subject: [PATCH 2/5] fix: add missing sidebar entries for dev docs --- website/sidebars.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/website/sidebars.js b/website/sidebars.js index fb555b1d..79db3bb1 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -31,5 +31,19 @@ module.exports = { collapsed: false, items: ['configuration/overview', 'configuration/reference'], }, + { + type: 'category', + label: 'Development', + link: { + type: 'generated-index', + title: 'Development', + slug: '/category/development', + keywords: ['dev', 'development'], + image: '/img/github-mark.png', + }, + collapsible: true, + collapsed: false, + items: ['development/contributing', 'development/plugins'], + } ], }; From 0045fbb88e0566ffb74086c0547e327fc9ece153 Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Tue, 10 Sep 2024 09:51:37 -0400 Subject: [PATCH 3/5] fix: cli e2e tests by adding missing export, remove unused actions - remove actions which are used in internal fork from chain - add default config value for plugins (an empty array) --- package.json | 3 ++- proxy.config.json | 3 ++- src/proxy/chain.js | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 108d8670..007efb0f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ }, "exports": { "./plugin": "./src/plugin.js", - "./proxy/actions": "./src/proxy/actions/index.js" + "./proxy/actions": "./src/proxy/actions/index.js", + "./src/config/env": "./src/config/env.js" }, "workspaces": [ "./packages/git-proxy-cli" diff --git a/proxy.config.json b/proxy.config.json index 02f390a8..df082a2e 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -95,5 +95,6 @@ "privateOrganizations": [], "urlShortener": "", "contactEmail": "", - "csrfProtection": true + "csrfProtection": true, + "plugins": [] } diff --git a/src/proxy/chain.js b/src/proxy/chain.js index 3bff31b3..e1614229 100644 --- a/src/proxy/chain.js +++ b/src/proxy/chain.js @@ -2,10 +2,10 @@ const proc = require('./processors'); const pushActionChain = [ proc.push.parsePush, - proc.push.checkIfOnboardedRepo, + proc.push.checkRepoInAuthorisedList, proc.push.checkCommitMessages, proc.push.checkAuthorEmails, - // proc.push.checkIfPullRequestApproved, + proc.push.checkUserPushPermission, proc.push.checkIfWaitingAuth, proc.push.pullRemote, proc.push.writePack, @@ -16,7 +16,7 @@ const pushActionChain = [ ]; const pullActionChain = [ - proc.push.checkIfOnboardedRepo, + proc.push.checkRepoInAuthorisedList, ]; let pluginsLoaded = false; From b2dea6c0b5409d07597b2c150abc30bebcc5b55e Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Wed, 2 Oct 2024 13:22:52 -0400 Subject: [PATCH 4/5] chore: address review comments --- config.schema.json | 2 +- plugins/README.md | 12 ++++----- plugins/git-proxy-plugin-samples/example.cjs | 2 +- plugins/git-proxy-plugin-samples/package.json | 2 +- src/proxy/chain.js | 2 +- website/docs/configuration/reference.mdx | 4 +-- website/docs/development/contributing.mdx | 6 ++--- website/docs/development/plugins.mdx | 26 +++++++++---------- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/config.schema.json b/config.schema.json index 0bcdfcb3..a220099a 100644 --- a/config.schema.json +++ b/config.schema.json @@ -38,7 +38,7 @@ }, "plugins": { "type": "array", - "description": "List of plugins to integrate on Git Proxy's push or pull actions. Each value is either a file path or a module name.", + "description": "List of plugins to integrate on GitProxy's push or pull actions. Each value is either a file path or a module name.", "items": { "type": "string" } diff --git a/plugins/README.md b/plugins/README.md index 337c9fa4..66772287 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,10 +1,10 @@ -# Git Proxy plugins & samples -Git Proxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, Git Proxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by Git Proxy and use these objects to implement custom functionality. +# GitProxy plugins & samples +GitProxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, GitProxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by GitProxy and use these objects to implement custom functionality. -For detailed documentation, please refer to the [Git Proxy development resources on the project's site](https://git-proxy.finos.org/docs/development/plugins) +For detailed documentation, please refer to the [GitProxy development resources on the project's site](https://git-proxy.finos.org/docs/development/plugins) ## Included plugins -These plugins are maintained by the core Git Proxy team. As a future roadmap item, organizations can choose to omit -certain features of Git Proxy by simply removing the dependency from a deployed version of the application. +These plugins are maintained by the core GitProxy team. As a future roadmap item, organizations can choose to omit +certain features of GitProxy by simply removing the dependency from a deployed version of the application. -- `git-proxy-plugin-samples`: "hello world" examples of the Git Proxy plugin system +- `git-proxy-plugin-samples`: "hello world" examples of the GitProxy plugin system diff --git a/plugins/git-proxy-plugin-samples/example.cjs b/plugins/git-proxy-plugin-samples/example.cjs index c88e3541..42e6ea1b 100644 --- a/plugins/git-proxy-plugin-samples/example.cjs +++ b/plugins/git-proxy-plugin-samples/example.cjs @@ -11,7 +11,7 @@ const { Step } = require('@finos/git-proxy/proxy/actions'); /** * * @param {object} req Express Request object - * @param {Action} action Git Proxy Action + * @param {Action} action GitProxy Action * @return {Promise} Promise that resolves to an Action */ async function logMessage(req, action) { diff --git a/plugins/git-proxy-plugin-samples/package.json b/plugins/git-proxy-plugin-samples/package.json index 2d1b7365..923a0dba 100644 --- a/plugins/git-proxy-plugin-samples/package.json +++ b/plugins/git-proxy-plugin-samples/package.json @@ -1,7 +1,7 @@ { "name": "@finos/git-proxy-plugin-samples", "version": "0.1.0-alpha.0", - "description": "A set of sample (dummy) plugins for Git Proxy to demonstrate how plugins are authored.", + "description": "A set of sample (dummy) plugins for GitProxy to demonstrate how plugins are authored.", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/src/proxy/chain.js b/src/proxy/chain.js index e1614229..31b9943a 100644 --- a/src/proxy/chain.js +++ b/src/proxy/chain.js @@ -47,7 +47,7 @@ const executeChain = async (req) => { }; /** - * The plugin loader used for the Git Proxy chain. + * The plugin loader used for the GitProxy chain. * * @type {import('../plugin').PluginLoader} */ diff --git a/website/docs/configuration/reference.mdx b/website/docs/configuration/reference.mdx index 4ed51acd..599da8e6 100644 --- a/website/docs/configuration/reference.mdx +++ b/website/docs/configuration/reference.mdx @@ -194,7 +194,7 @@ description: JSON schema reference documentation for GitProxy | **Type** | `array of string` | | **Required** | No | -**Description:** List of plugins to integrate on Git Proxy's push or pull actions. Each value is either a file path or a module name. +**Description:** List of plugins to integrate on GitProxy's push or pull actions. Each value is either a file path or a module name. | Each item of this array must be | Description | | ------------------------------- | ----------- | @@ -520,4 +520,4 @@ description: JSON schema reference documentation for GitProxy
---------------------------------------------------------------------------------------------------------------------------- -Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2024-07-22 at 10:42:24 -0400 +Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2024-10-02 at 13:21:09 -0400 diff --git a/website/docs/development/contributing.mdx b/website/docs/development/contributing.mdx index a1782174..d1a22c78 100644 --- a/website/docs/development/contributing.mdx +++ b/website/docs/development/contributing.mdx @@ -2,10 +2,10 @@ title: Contributing --- -Here's how to get setup for contributing to Git Proxy. +Here's how to get setup for contributing to GitProxy. ## Setup -The Git Proxy project relies on the following pre-requisites: +The GitProxy project relies on the following pre-requisites: - [Node](https://nodejs.org/en/download) (16+) - [npm](https://npmjs.com/) (8+) @@ -29,7 +29,7 @@ $ npm run client # Run only the UI ## Configuration schema -The configuration for Git Proxy includes a JSON Schema ([`config.schema.json`](https://github.com/finos/git-proxy/blob/main/config.schema.json)) to define the expected properties used by the application. When adding new configuration properties to Git Proxy, ensure that the schema is updated with any new, removed or changed properties. See [JSON Schema docs for specific syntax](https://json-schema.org/docs). +The configuration for GitProxy includes a JSON Schema ([`config.schema.json`](https://github.com/finos/git-proxy/blob/main/config.schema.json)) to define the expected properties used by the application. When adding new configuration properties to GitProxy, ensure that the schema is updated with any new, removed or changed properties. See [JSON Schema docs for specific syntax](https://json-schema.org/docs). When updating the configuration schema, you must also re-generate the reference doc used here on the site. To generate the reference documentation, [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) is used to output the Markdown. diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx index c9db32e1..fa2610c4 100644 --- a/website/docs/development/plugins.mdx +++ b/website/docs/development/plugins.mdx @@ -3,24 +3,24 @@ title: Plugins --- ## How plugins work -Git Proxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, Git Proxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by Git Proxy and use these objects to implement custom functionality. +GitProxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, GitProxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by GitProxy and use these objects to implement custom functionality. -For each loaded "plugin object", it is inserted into Git Proxy's chain of actions which are triggered on a given Git action received by Git Proxy such as `git push` or `git fetch`. +For each loaded "plugin object", it is inserted into GitProxy's chain of actions which are triggered on a given Git action received by GitProxy such as `git push` or `git fetch`. :::caution -The order that plugins are configured matters! Plugins execute _before_ Git Proxy's builtin steps and in the order that they are configured in `proxy.config.json`. If you wish to use a combination of features, ensure that your custom plugins do not conflict or interfere with later steps in the processing chain. +The order that plugins are configured matters! Plugins execute _before_ GitProxy's builtin steps and in the order that they are configured in `proxy.config.json`. If you wish to use a combination of features, ensure that your custom plugins do not conflict or interfere with later steps in the processing chain. ::: -Git Proxy uses the [load-plugin package](https://www.npmjs.com/package/load-plugin) to provide the Node module resolution. +GitProxy uses the [load-plugin package](https://www.npmjs.com/package/load-plugin) to provide the Node module resolution. ## Limitations - Plugins are only supported on the Git HTTP proxy server. There is no similar extensibility today for the dashboard UI or its backing API. -- Extensions are defined as JavaScript classes which are [quite limited](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain#inheritance_with_the_prototype_chain). Git Proxy has a rather naive system for determining if a provided module has any objects which it recognizes as "Git Proxy plugin types". A conversion of the project to TypeScript will provide more flexible options for enforcing API contracts via strict interfaces & types in a future release. [Use of TypeScript is a roadmap item](https://github.com/finos/git-proxy/issues/276). +- Extensions are defined as JavaScript classes which are [quite limited](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain#inheritance_with_the_prototype_chain). GitProxy has a rather naive system for determining if a provided module has any objects which it recognizes as "GitProxy plugin types". A conversion of the project to TypeScript will provide more flexible options for enforcing API contracts via strict interfaces & types in a future release. [Use of TypeScript is a roadmap item](https://github.com/finos/git-proxy/issues/276). ## Using plugins The primary goals of the plugin system is to: -- Allow users deploying Git Proxy to do so via the distributed binary package. ie. `npx -- @finos/git-proxy` +- Allow users deploying GitProxy to do so via the distributed binary package. ie. `npx -- @finos/git-proxy` - Allow users to consume plugins that packaged using standard tools (npm) and optionally distributed via npm themselves. - Reuse as much native Node functionality as possible for calling 3rd-party code (plugins) from the main application (git-proxy). @@ -32,7 +32,7 @@ The below instructions are using the [`@finos/git-proxy-plugin-samples`](https:/ 1. Install both `@finos/git-proxy` and the `@finos/git-proxy-plugin-samples` package to the local `node_modules/` directory using the following command: ```bash -$ npm install -g @finos/git-proxy@1.3.5 @finos/git-proxy-plugin-samples@0.1.0 +$ npm install -g @finos/git-proxy@latest @finos/git-proxy-plugin-samples@0.1.0 ``` Alternatively, you can create local packages from source and install those instead. @@ -71,10 +71,10 @@ Loaded plugin: LogRequestPlugin ### via JavaScript file(s) :::caution -This section is considered highly experimental and not recommended for general use. Even when authoring local plugins that are not distributed, it is best to use `node_modules/` and not rely on bespoke system setup or layout of files. Use `npm pack` and `npm install path/to/plugin.tgz` if you must use local plugin code to ensure Git Proxy's plugin manager can properly load your plugins. +This section is considered highly experimental and not recommended for general use. Even when authoring local plugins that are not distributed, it is best to use `node_modules/` and not rely on bespoke system setup or layout of files. Use `npm pack` and `npm install path/to/plugin.tgz` if you must use local plugin code to ensure GitProxy's plugin manager can properly load your plugins. ::: -Plugins written as standalone JavaScript files are used similarly to npm packages. The main difference is that file-based module loading requires additional steps to ensure that the given JavaScript files (written as Node CommonJS or ES modules) have all the necessary dependencies to be imported. Since Git Proxy plugin system relies on class-based inheritence, the module will require at least a dependency to `@finos/git-proxy` to be able to import the required classes. Plugins do not have to be distributed by NPM to be used in this fashion which may be advantageous in certain environments. +Plugins written as standalone JavaScript files are used similarly to npm packages. The main difference is that file-based module loading requires additional steps to ensure that the given JavaScript files (written as Node CommonJS or ES modules) have all the necessary dependencies to be imported. Since GitProxy plugin system relies on class-based inheritence, the module will require at least a dependency to `@finos/git-proxy` to be able to import the required classes. Plugins do not have to be distributed by NPM to be used in this fashion which may be advantageous in certain environments. 1. To use a plugin that is written as a standalone JavaScript file, ensure that the JS code has all its necessary dependencies: @@ -128,7 +128,7 @@ To develop a new plugin, you must add `@finos/git-proxy` as a [peer dependency]( - `@finos/git-proxy/plugin/PullActionPlugin`: execute as an action in the proxy chain during a `git fetch` - `@finos/git-proxy/proxy/actions/Step` and `@finos/git-proxy/proxy/actions/Action`: internal classes which act as carriers for `git` state during proxying. Plugins should modify the passed in `action` for affecting any global state of the git operation and add its own custom `Step` object to capture the plugin's own internal state (logs, errored/blocked status, etc.) -Git Proxy will load your plugin only if it extends one of the two plugin classes above. It is also important that your package has [`exports`](https://nodejs.org/api/packages.html#exports) defined for the plugin loader to properly load your module(s). +GitProxy will load your plugin only if it extends one of the two plugin classes above. It is also important that your package has [`exports`](https://nodejs.org/api/packages.html#exports) defined for the plugin loader to properly load your module(s). Please see the [sample plugin package included in the repo](https://github.com/finos/git-proxy/tree/main/plugins/git-proxy-plugin-samples) for details on how to structure your plugin package. @@ -141,7 +141,7 @@ class FooPlugin extends PushActionPlugin { constructor() { super(); // don't pass any function to the parent class - let the parent's this.exec function be undefined this.displayName = 'FooPlugin'; - // overwrite the parent's exec function which is executed as part of Git Proxy's action chain. + // overwrite the parent's exec function which is executed as part of GitProxy's action chain. // use an arrow function if you require access to the instances's state (properties, methods, etc.) this.exec = async (req, action) => { console.log(this.displayName); @@ -162,7 +162,7 @@ class FooPlugin extends PushActionPlugin { ```bash $ npm init # ... -$ npm install --save-peer @finos/git-proxy@1.3.5 +$ npm install --save-peer @finos/git-proxy@latest ``` `package.json` @@ -182,7 +182,7 @@ $ npm install --save-peer @finos/git-proxy@1.3.5 ".": "./bar.js" }, "peerDependencies": { - "@finos/git-proxy": "^1.3.5-alpha.0" + "@finos/git-proxy": "^1.3.5" } } ``` From ab7c0ed473c215deb51893cc6fae26897a1a8e90 Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Mon, 21 Oct 2024 15:07:25 -0400 Subject: [PATCH 5/5] fix: add proper jsdoc, remove extraneous logic from plugin + tests - add test cases for getChain + executeChain to validate the processor execution - remove extraneous function to create a loader and just use the constructor - address review comments --- package-lock.json | 140 ++++++++++++++++++++ package.json | 1 + src/plugin.js | 126 +++++++++--------- src/proxy/chain.js | 35 +++-- src/proxy/index.js | 6 +- test/chain.test.js | 236 ++++++++++++++++++++++++++++++++++ test/{ => fixtures}/baz.js | 0 test/plugin.test.js | 54 +++++--- test/testPluginLoader.test.js | 29 ----- 9 files changed, 498 insertions(+), 129 deletions(-) create mode 100644 test/chain.test.js rename test/{ => fixtures}/baz.js (100%) delete mode 100644 test/testPluginLoader.test.js diff --git a/package-lock.json b/package-lock.json index b704b979..72058f04 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 007efb0f..8a1edfbb 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 9c5aee2e..b02e3bb8 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 31b9943a..11e6ae10 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 59235c88..3bd38740 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 00000000..33d5750a --- /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 4b9cb269..cba96fa7 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 ae6bbcf6..00000000 --- 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; - }); -});