diff --git a/config.schema.json b/config.schema.json index 4539cb5b2..945c419c3 100644 --- a/config.schema.json +++ b/config.schema.json @@ -5,7 +5,11 @@ "description": "Configuration for customizing git-proxy", "type": "object", "properties": { - "proxyUrl": { "type": "string" }, + "proxyUrl": { + "type": "string", + "description": "Used in early versions of git proxy to configure the remote host that traffic is proxied to. In later versions, the repository URL is used to determine the domain proxied, allowing multiple hosts to be proxied by one instance.", + "deprecated": true + }, "cookieSecret": { "type": "string" }, "sessionMaxAgeHours": { "type": "number" }, "api": { diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index b627412d0..92d02734e 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -10,7 +10,7 @@ describe('Repo', () => { describe('Code button for repo row', () => { it('Opens tooltip with correct content and can copy', () => { - const cloneURL = 'http://localhost:8000/finos/git-proxy.git'; + const cloneURLRegex = /http:\/\/localhost:8000\/(?:[^\/]+\/).+\.git/; const tooltipQuery = 'div[role="tooltip"]'; cy @@ -19,10 +19,8 @@ describe('Repo', () => { .should('not.exist'); cy - // find the entry for finos/git-proxy - .get('a[href="/dashboard/repo/git-proxy"]') - // take it's parent row - .closest('tr') + // find a table row for a repo (any will do) + .get('table#RepoListTable>tbody>tr') // find the nearby span containing Code we can click to open the tooltip .find('span') .contains('Code') @@ -35,7 +33,7 @@ describe('Repo', () => { .should('exist') .find('span') // check it contains the url we expect - .contains(cloneURL) + .contains(cloneURLRegex) .should('exist') .parent() // find the adjacent span that contains the svg diff --git a/index.ts b/index.ts index 29da39b44..cc4c744ba 100755 --- a/index.ts +++ b/index.ts @@ -6,7 +6,7 @@ import { hideBin } from 'yargs/helpers'; import * as fs from 'fs'; import { configFile, setConfigFile, validate } from './src/config/file'; import { initUserConfig } from './src/config'; -import proxy from './src/proxy'; +import Proxy from './src/proxy'; import service from './src/service'; const argv = yargs(hideBin(process.argv)) @@ -30,7 +30,7 @@ const argv = yargs(hideBin(process.argv)) .strict() .parseSync(); -setConfigFile(argv.c as string || ""); +setConfigFile((argv.c as string) || ''); initUserConfig(); if (argv.v) { @@ -48,7 +48,8 @@ if (argv.v) { validate(); +const proxy = new Proxy(); proxy.start(); -service.start(); +service.start(proxy); export { proxy, service }; diff --git a/packages/git-proxy-cli/test/testCli.test.js b/packages/git-proxy-cli/test/testCli.test.js index 70a445cbe..aa0056d06 100644 --- a/packages/git-proxy-cli/test/testCli.test.js +++ b/packages/git-proxy-cli/test/testCli.test.js @@ -221,13 +221,13 @@ describe('test git-proxy-cli', function () { before(async function () { await helper.addRepoToDb(TEST_REPO_CONFIG); await helper.addUserToDb(TEST_USER, TEST_PASSWORD, TEST_EMAIL, TEST_GIT_ACCOUNT); - await helper.addGitPushToDb(pushId, TEST_USER, TEST_EMAIL, TEST_REPO); + await helper.addGitPushToDb(pushId, TEST_REPO_CONFIG.url, TEST_USER, TEST_EMAIL); }); after(async function () { await helper.removeGitPushFromDb(pushId); await helper.removeUserFromDb(TEST_USER); - await helper.removeRepoFromDb(TEST_REPO_CONFIG.name); + await helper.removeRepoFromDb(TEST_REPO_CONFIG.url); }); it('attempt to authorise should fail when server is down', async function () { @@ -304,7 +304,7 @@ describe('test git-proxy-cli', function () { after(async function () { await helper.removeGitPushFromDb(pushId); await helper.removeUserFromDb(TEST_USER); - await helper.removeRepoFromDb(TEST_REPO_CONFIG.name); + await helper.removeRepoFromDb(TEST_REPO_CONFIG.url); }); it('attempt to cancel should fail when server is down', async function () { @@ -421,13 +421,13 @@ describe('test git-proxy-cli', function () { before(async function () { await helper.addRepoToDb(TEST_REPO_CONFIG); await helper.addUserToDb(TEST_USER, TEST_PASSWORD, TEST_EMAIL, TEST_GIT_ACCOUNT); - await helper.addGitPushToDb(pushId, TEST_USER, TEST_EMAIL, TEST_REPO); + await helper.addGitPushToDb(pushId, TEST_REPO_CONFIG.url, TEST_USER, TEST_EMAIL); }); after(async function () { await helper.removeGitPushFromDb(pushId); await helper.removeUserFromDb(TEST_USER); - await helper.removeRepoFromDb(TEST_REPO_CONFIG.name); + await helper.removeRepoFromDb(TEST_REPO_CONFIG.url); }); it('attempt to reject should fail when server is down', async function () { @@ -498,19 +498,13 @@ describe('test git-proxy-cli', function () { before(async function () { await helper.addRepoToDb(TEST_REPO_CONFIG); await helper.addUserToDb(TEST_USER, TEST_PASSWORD, TEST_EMAIL, TEST_GIT_ACCOUNT); - await helper.addGitPushToDb(pushId, TEST_REPO, TEST_USER, TEST_EMAIL); + await helper.addGitPushToDb(pushId, TEST_REPO_CONFIG.url, TEST_USER, TEST_EMAIL); }); after(async function () { await helper.removeGitPushFromDb(pushId); await helper.removeUserFromDb(TEST_USER); - await helper.removeRepoFromDb(TEST_REPO_CONFIG.name); - }); - - after(async function () { - await helper.removeUserFromDb('testuser1'); - await helper.removeGitPushFromDb(pushId); - await helper.removeRepoFromDb(TEST_REPO_CONFIG.name); + await helper.removeRepoFromDb(TEST_REPO_CONFIG.url); }); it('attempt to ls should list existing push', async function () { diff --git a/packages/git-proxy-cli/test/testCliUtils.js b/packages/git-proxy-cli/test/testCliUtils.js index b6a9b5be7..aec7961ba 100644 --- a/packages/git-proxy-cli/test/testCliUtils.js +++ b/packages/git-proxy-cli/test/testCliUtils.js @@ -30,7 +30,7 @@ async function runCli( expectedExitCode = 0, expectedMessages = null, expectedErrorMessages = null, - debug = false, + debug = true, ) { try { console.log(`cli: '${cli}'`); @@ -152,8 +152,9 @@ async function addRepoToDb(newRepo, debug = false) { const found = repos.find((y) => y.project === newRepo.project && newRepo.name === y.name); if (!found) { await db.createRepo(newRepo); - await db.addUserCanPush(newRepo.name, 'admin'); - await db.addUserCanAuthorise(newRepo.name, 'admin'); + const repo = await db.getRepoByUrl(newRepo.url); + await db.addUserCanPush(repo._id, 'admin'); + await db.addUserCanAuthorise(repo._id, 'admin'); if (debug) { console.log(`New repo added to database: ${newRepo}`); } @@ -166,27 +167,28 @@ async function addRepoToDb(newRepo, debug = false) { /** * Removes a repo from the DB. - * @param {string} repoName The name of the repo to remove. + * @param {string} repoUrl The url of the repo to remove. */ -async function removeRepoFromDb(repoName) { - await db.deleteRepo(repoName); +async function removeRepoFromDb(repoUrl) { + const repo = await db.getRepoByUrl(repoUrl); + await db.deleteRepo(repo._id); } /** * Add a new git push record to the database. * @param {string} id The ID of the git push. - * @param {string} repo The repository of the git push. + * @param {string} repoUrl The repository URL of the git push. * @param {string} user The user who pushed the git push. * @param {string} userEmail The email of the user who pushed the git push. * @param {boolean} debug Flag to enable logging for debugging. */ -async function addGitPushToDb(id, repo, user = null, userEmail = null, debug = false) { +async function addGitPushToDb(id, repoUrl, user = null, userEmail = null, debug = false) { const action = new actions.Action( id, 'push', // type 'get', // method Date.now(), // timestamp - repo, + repoUrl, ); action.user = user; action.userEmail = userEmail; diff --git a/proxy.config.json b/proxy.config.json index 6b2970c30..bdaedff4f 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -1,5 +1,4 @@ { - "proxyUrl": "https://github.com", "cookieSecret": "cookie secret", "sessionMaxAgeHours": 12, "rateLimit": { diff --git a/src/config/index.ts b/src/config/index.ts index 7ffb6a969..570652b4d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -28,7 +28,6 @@ let _database: Database[] = defaultSettings.sink; let _authentication: Authentication[] = defaultSettings.authentication; let _apiAuthentication: Authentication[] = defaultSettings.apiAuthentication; let _tempPassword: TempPasswordConfig = defaultSettings.tempPassword; -let _proxyUrl = defaultSettings.proxyUrl; let _api: Record = defaultSettings.api; let _cookieSecret: string = serverConfig.GIT_PROXY_COOKIE_SECRET || defaultSettings.cookieSecret; let _sessionMaxAgeHours: number = defaultSettings.sessionMaxAgeHours; @@ -54,15 +53,6 @@ let _config = { ...defaultSettings, ...(_userSettings || {}) } as Configuration; // Create config loader instance const configLoader = new ConfigLoader(_config); -// Get configured proxy URL -export const getProxyUrl = () => { - if (_userSettings !== null && _userSettings.proxyUrl) { - _proxyUrl = _userSettings.proxyUrl; - } - - return _proxyUrl; -}; - // Gets a list of authorised repositories export const getAuthorisedList = () => { if (_userSettings !== null && _userSettings.authorisedList) { diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 6ac1c2088..c41227b84 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -2,29 +2,27 @@ import * as users from './users'; import * as repo from './repo'; import * as pushes from './pushes'; -export const { - getPushes, - writeAudit, - getPush, - deletePush, - authorise, - cancel, - reject, - canUserCancelPush, - canUserApproveRejectPush, -} = pushes; +export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes; export const { getRepos, getRepo, + getRepoByUrl, + getRepoById, createRepo, addUserCanPush, addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, deleteRepo, - isUserPushAllowed, - canUserApproveRejectPushRepo, } = repo; -export const { findUser, findUserByOIDC, getUsers, createUser, deleteUser, updateUser } = users; +export const { + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + createUser, + deleteUser, + updateUser, +} = users; diff --git a/src/db/file/pushes.ts b/src/db/file/pushes.ts index c1b251944..10cc2a4fd 100644 --- a/src/db/file/pushes.ts +++ b/src/db/file/pushes.ts @@ -2,8 +2,7 @@ import fs from 'fs'; import _ from 'lodash'; import Datastore from '@seald-io/nedb'; import { Action } from '../../proxy/actions/Action'; -import { toClass, trimTrailingDotGit } from '../helper'; -import * as repo from './repo'; +import { toClass } from '../helper'; import { PushQuery } from '../types'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -15,7 +14,14 @@ if (!fs.existsSync('./.data')) fs.mkdirSync('./.data'); if (!fs.existsSync('./.data/db')) fs.mkdirSync('./.data/db'); const db = new Datastore({ filename: './.data/db/pushes.db', autoload: true }); -db.ensureIndex({ fieldName: 'id', unique: true }); +try { + db.ensureIndex({ fieldName: 'id', unique: true }); +} catch (e) { + console.error( + 'Failed to build a unique index of push id values. Please check your database file for duplicate entries or delete the duplicate through the UI and restart. ', + e, + ); +} db.setAutocompactionInterval(COMPACTION_INTERVAL); const defaultPushQuery: PushQuery = { @@ -25,7 +31,7 @@ const defaultPushQuery: PushQuery = { authorised: false, }; -export const getPushes = (query: PushQuery) => { +export const getPushes = (query: PushQuery): Promise => { if (!query) query = defaultPushQuery; return new Promise((resolve, reject) => { db.find(query, (err: Error, docs: Action[]) => { @@ -44,7 +50,7 @@ export const getPushes = (query: PushQuery) => { }); }; -export const getPush = async (id: string) => { +export const getPush = async (id: string): Promise => { return new Promise((resolve, reject) => { db.findOne({ id: id }, (err, doc) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -62,7 +68,7 @@ export const getPush = async (id: string) => { }); }; -export const deletePush = async (id: string) => { +export const deletePush = async (id: string): Promise => { return new Promise((resolve, reject) => { db.remove({ id }, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -76,7 +82,7 @@ export const deletePush = async (id: string) => { }); }; -export const writeAudit = async (action: Action) => { +export const writeAudit = async (action: Action): Promise => { return new Promise((resolve, reject) => { const options = { multi: false, upsert: true }; db.update({ id: action.id }, action, options, (err) => { @@ -85,13 +91,13 @@ export const writeAudit = async (action: Action) => { if (err) { reject(err); } else { - resolve(null); + resolve(); } }); }); }; -export const authorise = async (id: string, attestation: any) => { +export const authorise = async (id: string, attestation: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -105,7 +111,7 @@ export const authorise = async (id: string, attestation: any) => { return { message: `authorised ${id}` }; }; -export const reject = async (id: string) => { +export const reject = async (id: string, attestation: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -114,11 +120,12 @@ export const reject = async (id: string) => { action.authorised = false; action.canceled = false; action.rejected = true; + action.attestation = attestation; await writeAudit(action); return { message: `reject ${id}` }; }; -export const cancel = async (id: string) => { +export const cancel = async (id: string): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -129,37 +136,3 @@ export const cancel = async (id: string) => { await writeAudit(action); return { message: `cancel ${id}` }; }; - -export const canUserCancelPush = async (id: string, user: string) => { - return new Promise(async (resolve) => { - const pushDetail = await getPush(id); - if (!pushDetail) { - resolve(false); - return; - } - - const repoName = trimTrailingDotGit(pushDetail.repoName); - const isAllowed = await repo.isUserPushAllowed(repoName, user); - - if (isAllowed) { - resolve(true); - } else { - resolve(false); - } - }); -}; - -export const canUserApproveRejectPush = async (id: string, user: string) => { - return new Promise(async (resolve) => { - const action = await getPush(id); - if (!action) { - resolve(false); - return; - } - - const repoName = trimTrailingDotGit(action.repoName); - const isAllowed = await repo.canUserApproveRejectPushRepo(repoName, user); - - resolve(isAllowed); - }); -}; diff --git a/src/db/file/repo.ts b/src/db/file/repo.ts index fd7218c15..584339f82 100644 --- a/src/db/file/repo.ts +++ b/src/db/file/repo.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import Datastore from '@seald-io/nedb'; import { Repo } from '../types'; +import { toClass } from '../helper'; +import _ from 'lodash'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -11,14 +13,20 @@ if (!fs.existsSync('./.data')) fs.mkdirSync('./.data'); if (!fs.existsSync('./.data/db')) fs.mkdirSync('./.data/db'); const db = new Datastore({ filename: './.data/db/repos.db', autoload: true }); + +try { + db.ensureIndex({ fieldName: 'url', unique: true }); +} catch (e) { + console.error( + 'Failed to build a unique index of Repository URLs. Please check your database file for duplicate entries or delete the duplicate through the UI and restart. ', + e, + ); +} + db.ensureIndex({ fieldName: 'name', unique: false }); db.setAutocompactionInterval(COMPACTION_INTERVAL); -const isBlank = (str: string) => { - return !str || /^\s*$/.test(str); -}; - -export const getRepos = async (query: any = {}) => { +export const getRepos = async (query: any = {}): Promise => { if (query?.name) { query.name = query.name.toLowerCase(); } @@ -29,13 +37,17 @@ export const getRepos = async (query: any = {}) => { if (err) { reject(err); } else { - resolve(docs); + resolve( + _.chain(docs) + .map((x) => toClass(x, Repo.prototype)) + .value(), + ); } }); }); }; -export const getRepo = async (name: string) => { +export const getRepo = async (name: string): Promise => { return new Promise((resolve, reject) => { db.findOne({ name: name.toLowerCase() }, (err: Error | null, doc: Repo) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -43,30 +55,41 @@ export const getRepo = async (name: string) => { if (err) { reject(err); } else { - resolve(doc); + resolve(doc ? toClass(doc, Repo.prototype) : null); } }); }); }; -export const createRepo = async (repo: Repo) => { - if (isBlank(repo.project)) { - throw new Error('Project name cannot be empty'); - } - if (isBlank(repo.name)) { - throw new Error('Repository name cannot be empty'); - } else { - repo.name = repo.name.toLowerCase(); - } - if (isBlank(repo.url)) { - throw new Error('URL cannot be empty'); - } +export const getRepoByUrl = async (repoURL: string): Promise => { + return new Promise((resolve, reject) => { + db.findOne({ url: repoURL }, (err: Error | null, doc: Repo) => { + // ignore for code coverage as neDB rarely returns errors even for an invalid query + /* istanbul ignore if */ + if (err) { + reject(err); + } else { + resolve(doc ? toClass(doc, Repo.prototype) : null); + } + }); + }); +}; - repo.users = { - canPush: [], - canAuthorise: [], - }; +export const getRepoById = async (_id: string): Promise => { + return new Promise((resolve, reject) => { + db.findOne({ _id: _id }, (err: Error | null, doc: Repo) => { + // ignore for code coverage as neDB rarely returns errors even for an invalid query + /* istanbul ignore if */ + if (err) { + reject(err); + } else { + resolve(doc ? toClass(doc, Repo.prototype) : null); + } + }); + }); +}; +export const createRepo = async (repo: Repo): Promise => { return new Promise((resolve, reject) => { db.insert(repo, (err, doc) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -74,76 +97,73 @@ export const createRepo = async (repo: Repo) => { if (err) { reject(err); } else { - resolve(doc); + resolve(doc ? toClass(doc, Repo.prototype) : null); } }); }); }; -export const addUserCanPush = async (name: string, user: string) => { - name = name.toLowerCase(); +export const addUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); return new Promise(async (resolve, reject) => { - const repo = await getRepo(name); + const repo = await getRepoById(_id); if (!repo) { reject(new Error('Repo not found')); return; } - if (repo.users.canPush.includes(user)) { - resolve(null); + if (repo.users?.canPush.includes(user)) { + resolve(); return; } - repo.users.canPush.push(user); + repo.users?.canPush.push(user); const options = { multi: false, upsert: false }; - db.update({ name: name }, repo, options, (err) => { + db.update({ _id: _id }, repo, options, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { reject(err); } else { - resolve(null); + resolve(); } }); }); }; -export const addUserCanAuthorise = async (name: string, user: string) => { - name = name.toLowerCase(); +export const addUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); return new Promise(async (resolve, reject) => { - const repo = await getRepo(name); + const repo = await getRepoById(_id); if (!repo) { reject(new Error('Repo not found')); return; } if (repo.users.canAuthorise.includes(user)) { - resolve(null); + resolve(); return; } repo.users.canAuthorise.push(user); const options = { multi: false, upsert: false }; - db.update({ name: name }, repo, options, (err) => { + db.update({ _id: _id }, repo, options, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { reject(err); } else { - resolve(null); + resolve(); } }); }); }; -export const removeUserCanAuthorise = async (name: string, user: string) => { - name = name.toLowerCase(); +export const removeUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); return new Promise(async (resolve, reject) => { - const repo = await getRepo(name); + const repo = await getRepoById(_id); if (!repo) { reject(new Error('Repo not found')); return; @@ -152,23 +172,22 @@ export const removeUserCanAuthorise = async (name: string, user: string) => { repo.users.canAuthorise = repo.users.canAuthorise.filter((x: string) => x != user); const options = { multi: false, upsert: false }; - db.update({ name: name }, repo, options, (err) => { + db.update({ _id: _id }, repo, options, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { reject(err); } else { - resolve(null); + resolve(); } }); }); }; -export const removeUserCanPush = async (name: string, user: string) => { - name = name.toLowerCase(); +export const removeUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); return new Promise(async (resolve, reject) => { - const repo = await getRepo(name); + const repo = await getRepoById(_id); if (!repo) { reject(new Error('Repo not found')); return; @@ -177,22 +196,21 @@ export const removeUserCanPush = async (name: string, user: string) => { repo.users.canPush = repo.users.canPush.filter((x) => x != user); const options = { multi: false, upsert: false }; - db.update({ name: name }, repo, options, (err) => { + db.update({ _id: _id }, repo, options, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { reject(err); } else { - resolve(null); + resolve(); } }); }); }; -export const deleteRepo = async (name: string) => { - name = name.toLowerCase(); +export const deleteRepo = async (_id: string): Promise => { return new Promise((resolve, reject) => { - db.remove({ name: name }, (err) => { + db.remove({ _id: _id }, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { @@ -203,45 +221,3 @@ export const deleteRepo = async (name: string) => { }); }); }; - -export const isUserPushAllowed = async (name: string, user: string) => { - name = name.toLowerCase(); - user = user.toLowerCase(); - return new Promise(async (resolve) => { - const repo = await getRepo(name); - if (!repo) { - resolve(false); - return; - } - - console.log(repo.users.canPush); - console.log(repo.users.canAuthorise); - - if (repo.users.canPush.includes(user) || repo.users.canAuthorise.includes(user)) { - resolve(true); - } else { - resolve(false); - } - }); -}; - -export const canUserApproveRejectPushRepo = async (name: string, user: string) => { - name = name.toLowerCase(); - user = user.toLowerCase(); - console.log(`checking if user ${user} can approve/reject for ${name}`); - return new Promise(async (resolve) => { - const repo = await getRepo(name); - if (!repo) { - resolve(false); - return; - } - - if (repo.users.canAuthorise.includes(user)) { - console.log(`user ${user} can approve/reject to repo ${name}`); - resolve(true); - } else { - console.log(`user ${user} cannot approve/reject to repo ${name}`); - resolve(false); - } - }); -}; diff --git a/src/db/file/users.ts b/src/db/file/users.ts index 263c612f4..e449f7ff2 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -13,11 +13,25 @@ if (!fs.existsSync('./.data/db')) fs.mkdirSync('./.data/db'); const db = new Datastore({ filename: './.data/db/users.db', autoload: true }); // Using a unique constraint with the index -db.ensureIndex({ fieldName: 'username', unique: true }); -db.ensureIndex({ fieldName: 'email', unique: true }); +try { + db.ensureIndex({ fieldName: 'username', unique: true }); +} catch (e) { + console.error( + 'Failed to build a unique index of usernames. Please check your database file for duplicate entries or delete the duplicate through the UI and restart. ', + e, + ); +} +try { + db.ensureIndex({ fieldName: 'email', unique: true }); +} catch (e) { + console.error( + 'Failed to build a unique index of user email addresses. Please check your database file for duplicate entries or delete the duplicate through the UI and restart. ', + e, + ); +} db.setAutocompactionInterval(COMPACTION_INTERVAL); -export const findUser = (username: string) => { +export const findUser = (username: string): Promise => { return new Promise((resolve, reject) => { db.findOne({ username: username.toLowerCase() }, (err: Error | null, doc: User) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -35,9 +49,9 @@ export const findUser = (username: string) => { }); }; -export const findUserByOIDC = function (oidcId: string) { - return new Promise((resolve, reject) => { - db.findOne({ oidcId: oidcId }, (err, doc) => { +export const findUserByEmail = (email: string): Promise => { + return new Promise((resolve, reject) => { + db.findOne({ email: email.toLowerCase() }, (err: Error | null, doc: User) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query /* istanbul ignore if */ if (err) { @@ -53,7 +67,25 @@ export const findUserByOIDC = function (oidcId: string) { }); }; -export const createUser = function (user: User) { +export const findUserByOIDC = function (oidcId: string): Promise { + return new Promise((resolve, reject) => { + db.findOne({ oidcId: oidcId }, (err: Error | null, doc: User) => { + // ignore for code coverage as neDB rarely returns errors even for an invalid query + /* istanbul ignore if */ + if (err) { + reject(err); + } else { + if (!doc) { + resolve(null); + } else { + resolve(doc); + } + } + }); + }); +}; + +export const createUser = function (user: User): Promise { user.username = user.username.toLowerCase(); user.email = user.email.toLowerCase(); return new Promise((resolve, reject) => { @@ -63,13 +95,13 @@ export const createUser = function (user: User) { if (err) { reject(err); } else { - resolve(user); + resolve(); } }); }); }; -export const deleteUser = (username: string) => { +export const deleteUser = (username: string): Promise => { return new Promise((resolve, reject) => { db.remove({ username: username.toLowerCase() }, (err) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -83,7 +115,7 @@ export const deleteUser = (username: string) => { }); }; -export const updateUser = (user: User) => { +export const updateUser = (user: User): Promise => { user.username = user.username.toLowerCase(); if (user.email) { user.email = user.email.toLowerCase(); @@ -113,7 +145,7 @@ export const updateUser = (user: User) => { if (err) { reject(err); } else { - resolve(null); + resolve(); } }); } @@ -121,7 +153,7 @@ export const updateUser = (user: User) => { }); }; -export const getUsers = (query: any = {}) => { +export const getUsers = (query: any = {}): Promise => { if (query.username) { query.username = query.username.toLowerCase(); } diff --git a/src/db/helper.ts b/src/db/helper.ts index f706e763b..63532d11c 100644 --- a/src/db/helper.ts +++ b/src/db/helper.ts @@ -6,7 +6,7 @@ export const toClass = function (obj: any, proto: any) { export const trimTrailingDotGit = (str: string): string => { const target = '.git'; - if (str.endsWith(target)) { + if (str && str.endsWith(target)) { // extract string from 0 to the end minus the length of target return str.slice(0, -target.length); } diff --git a/src/db/index.ts b/src/db/index.ts index ff1189f1b..062094492 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,12 +1,23 @@ -const bcrypt = require('bcryptjs'); -const config = require('../config'); -let sink: any; +import { AuthorisedRepo } from '../config/types'; +import { PushQuery, Repo, Sink, User } from './types'; +import * as bcrypt from 'bcryptjs'; +import * as config from '../config'; +import * as mongo from './mongo'; +import * as neDb from './file'; +import { Action } from '../proxy/actions/Action'; +import MongoDBStore from 'connect-mongo'; + +let sink: Sink; if (config.getDatabase().type === 'mongo') { - sink = require('./mongo'); + sink = mongo; } else if (config.getDatabase().type === 'fs') { - sink = require('./file'); + sink = neDb; } +const isBlank = (str: string) => { + return !str || /^\s*$/.test(str); +}; + export const createUser = async ( username: string, password: string, @@ -32,54 +43,143 @@ export const createUser = async ( admin: admin, }; - if (username === undefined || username === null || username === '') { - const errorMessage = `username ${username} cannot be empty`; + if (isBlank(username)) { + const errorMessage = `username cannot be empty`; throw new Error(errorMessage); } - if (gitAccount === undefined || gitAccount === null || gitAccount === '') { - const errorMessage = `GitAccount ${gitAccount} cannot be empty`; + if (isBlank(gitAccount)) { + const errorMessage = `gitAccount cannot be empty`; throw new Error(errorMessage); } - if (email === undefined || email === null || email === '') { - const errorMessage = `Email ${email} cannot be empty`; + if (isBlank(email)) { + const errorMessage = `email cannot be empty`; throw new Error(errorMessage); } const existingUser = await sink.findUser(username); - if (existingUser) { const errorMessage = `user ${username} already exists`; throw new Error(errorMessage); } + const existingUserWithEmail = await sink.findUserByEmail(email); + if (existingUserWithEmail) { + const errorMessage = `A user with email ${email} already exists`; + throw new Error(errorMessage); + } + await sink.createUser(data); }; -export const { - authorise, - reject, - cancel, - getPushes, - writeAudit, - getPush, - deletePush, - findUser, - findUserByOIDC, - getUsers, - deleteUser, - updateUser, - getRepos, - getRepo, - createRepo, - addUserCanPush, - addUserCanAuthorise, - removeUserCanAuthorise, - removeUserCanPush, - deleteRepo, - isUserPushAllowed, - canUserApproveRejectPushRepo, - canUserApproveRejectPush, - canUserCancelPush, - getSessionStore, -} = sink; +export const createRepo = async (repo: AuthorisedRepo) => { + const toCreate = { + ...repo, + users: { + canPush: [], + canAuthorise: [], + }, + }; + toCreate.name = repo.name.toLowerCase(); + + console.log(`creating new repo ${JSON.stringify(toCreate)}`); + + // n.b. project name may be blank but not null for non-github and non-gitlab repos + if (!toCreate.project) { + toCreate.project = ''; + } + if (isBlank(toCreate.name)) { + throw new Error('Repository name cannot be empty'); + } + if (isBlank(toCreate.url)) { + throw new Error('URL cannot be empty'); + } + + return sink.createRepo(toCreate) as Promise>; +}; + +export const isUserPushAllowed = async (url: string, user: string) => { + user = user.toLowerCase(); + return new Promise(async (resolve) => { + const repo = await getRepoByUrl(url); + if (!repo) { + resolve(false); + return; + } + + if (repo.users?.canPush.includes(user) || repo.users?.canAuthorise.includes(user)) { + resolve(true); + } else { + resolve(false); + } + }); +}; + +export const canUserApproveRejectPush = async (id: string, user: string) => { + return new Promise(async (resolve) => { + const action = await getPush(id); + if (!action) { + resolve(false); + return; + } + + const theRepo = await sink.getRepoByUrl(action.url); + + if (theRepo?.users?.canAuthorise?.includes(user)) { + console.log(`user ${user} can approve/reject for repo ${action.url}`); + resolve(true); + } else { + console.log(`user ${user} cannot approve/reject for repo ${action.url}`); + resolve(false); + } + }); +}; + +export const canUserCancelPush = async (id: string, user: string) => { + return new Promise(async (resolve) => { + const action = await getPush(id); + if (!action) { + resolve(false); + return; + } + + const isAllowed = await isUserPushAllowed(action.url, user); + + if (isAllowed) { + resolve(true); + } else { + resolve(false); + } + }); +}; + +export const getSessionStore = (): MongoDBStore | null => + sink.getSessionStore ? sink.getSessionStore() : null; +export const getPushes = (query: PushQuery): Promise => sink.getPushes(query); +export const writeAudit = (action: Action): Promise => sink.writeAudit(action); +export const getPush = (id: string): Promise => sink.getPush(id); +export const deletePush = (id: string): Promise => sink.deletePush(id); +export const authorise = (id: string, attestation: any): Promise<{ message: string }> => + sink.authorise(id, attestation); +export const cancel = (id: string): Promise<{ message: string }> => sink.cancel(id); +export const reject = (id: string, attestation: any): Promise<{ message: string }> => + sink.reject(id, attestation); +export const getRepos = (query?: object): Promise => sink.getRepos(query); +export const getRepo = (name: string): Promise => sink.getRepo(name); +export const getRepoByUrl = (url: string): Promise => sink.getRepoByUrl(url); +export const getRepoById = (_id: string): Promise => sink.getRepoById(_id); +export const addUserCanPush = (_id: string, user: string): Promise => + sink.addUserCanPush(_id, user); +export const addUserCanAuthorise = (_id: string, user: string): Promise => + sink.addUserCanAuthorise(_id, user); +export const removeUserCanPush = (_id: string, user: string): Promise => + sink.removeUserCanPush(_id, user); +export const removeUserCanAuthorise = (_id: string, user: string): Promise => + sink.removeUserCanAuthorise(_id, user); +export const deleteRepo = (_id: string): Promise => sink.deleteRepo(_id); +export const findUser = (username: string): Promise => sink.findUser(username); +export const findUserByEmail = (email: string): Promise => sink.findUserByEmail(email); +export const findUserByOIDC = (oidcId: string): Promise => sink.findUserByOIDC(oidcId); +export const getUsers = (query?: object): Promise => sink.getUsers(query); +export const deleteUser = (username: string): Promise => sink.deleteUser(username); +export const updateUser = (user: User): Promise => sink.updateUser(user); diff --git a/src/db/mongo/index.ts b/src/db/mongo/index.ts index a6d7ce6b2..0c62e8fea 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -5,29 +5,27 @@ import * as users from './users'; export const { getSessionStore } = helper; -export const { - getPushes, - writeAudit, - getPush, - deletePush, - authorise, - cancel, - reject, - canUserCancelPush, - canUserApproveRejectPush, -} = pushes; +export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes; export const { getRepos, getRepo, + getRepoByUrl, + getRepoById, createRepo, addUserCanPush, addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, deleteRepo, - isUserPushAllowed, - canUserApproveRejectPushRepo, } = repo; -export const { findUser, getUsers, createUser, deleteUser, updateUser } = users; +export const { + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + createUser, + deleteUser, + updateUser, +} = users; diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 67d6eb609..e1b3a4bbe 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -1,8 +1,7 @@ import { connect, findDocuments, findOneDocument } from './helper'; import { Action } from '../../proxy/actions'; -import { toClass, trimTrailingDotGit } from '../helper'; -import * as repo from './repo'; -import { Push, PushQuery } from '../types'; +import { toClass } from '../helper'; +import { PushQuery } from '../types'; const collectionName = 'pushes'; @@ -13,8 +12,8 @@ const defaultPushQuery: PushQuery = { authorised: false, }; -export const getPushes = async (query: PushQuery = defaultPushQuery): Promise => { - return findDocuments(collectionName, query, { +export const getPushes = async (query: PushQuery = defaultPushQuery): Promise => { + return findDocuments(collectionName, query, { projection: { _id: 0, id: 1, @@ -45,12 +44,12 @@ export const getPush = async (id: string): Promise => { return doc ? (toClass(doc, Action.prototype) as Action) : null; }; -export const deletePush = async function (id: string) { +export const deletePush = async function (id: string): Promise { const collection = await connect(collectionName); - return collection.deleteOne({ id }); + await collection.deleteOne({ id }); }; -export const writeAudit = async (action: Action): Promise => { +export const writeAudit = async (action: Action): Promise => { const data = JSON.parse(JSON.stringify(action)); const options = { upsert: true }; const collection = await connect(collectionName); @@ -59,10 +58,9 @@ export const writeAudit = async (action: Action): Promise => { throw new Error('Invalid id'); } await collection.updateOne({ id: data.id }, { $set: data }, options); - return action; }; -export const authorise = async (id: string, attestation: any) => { +export const authorise = async (id: string, attestation: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -76,7 +74,7 @@ export const authorise = async (id: string, attestation: any) => { return { message: `authorised ${id}` }; }; -export const reject = async (id: string) => { +export const reject = async (id: string, attestation: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -84,11 +82,12 @@ export const reject = async (id: string) => { action.authorised = false; action.canceled = false; action.rejected = true; + action.attestation = attestation; await writeAudit(action); return { message: `reject ${id}` }; }; -export const cancel = async (id: string) => { +export const cancel = async (id: string): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -99,37 +98,3 @@ export const cancel = async (id: string) => { await writeAudit(action); return { message: `canceled ${id}` }; }; - -export const canUserApproveRejectPush = async (id: string, user: string) => { - return new Promise(async (resolve) => { - const action = await getPush(id); - if (!action) { - resolve(false); - return; - } - - const repoName = trimTrailingDotGit(action.repoName); - const isAllowed = await repo.canUserApproveRejectPushRepo(repoName, user); - - resolve(isAllowed); - }); -}; - -export const canUserCancelPush = async (id: string, user: string) => { - return new Promise(async (resolve) => { - const pushDetail = await getPush(id); - if (!pushDetail) { - resolve(false); - return; - } - - const repoName = trimTrailingDotGit(pushDetail.repoName); - const isAllowed = await repo.isUserPushAllowed(repoName, user); - - if (isAllowed) { - resolve(true); - } else { - resolve(false); - } - }); -}; diff --git a/src/db/mongo/repo.ts b/src/db/mongo/repo.ts index f299f907a..8a0311a63 100644 --- a/src/db/mongo/repo.ts +++ b/src/db/mongo/repo.ts @@ -1,109 +1,71 @@ +import _ from 'lodash'; import { Repo } from '../types'; - -const connect = require('./helper').connect; +import { connect } from './helper'; +import { toClass } from '../helper'; +import { ObjectId, OptionalId, Document } from 'mongodb'; const collectionName = 'repos'; -const isBlank = (str: string) => { - return !str || /^\s*$/.test(str); -}; - -export const getRepos = async (query: any = {}) => { +export const getRepos = async (query: any = {}): Promise => { const collection = await connect(collectionName); - return collection.find(query).toArray(); + const docs = collection.find(query).toArray(); + return _.chain(docs) + .map((x) => toClass(x, Repo.prototype)) + .value(); }; -export const getRepo = async (name: string) => { +export const getRepo = async (name: string): Promise => { name = name.toLowerCase(); const collection = await connect(collectionName); - return collection.findOne({ name: { $eq: name } }); + const doc = collection.findOne({ name: { $eq: name } }); + return doc ? toClass(doc, Repo.prototype) : null; }; -export const createRepo = async (repo: Repo) => { - repo.name = repo.name.toLowerCase(); - console.log(`creating new repo ${JSON.stringify(repo)}`); - - if (isBlank(repo.project)) { - throw new Error('Project name cannot be empty'); - } - if (isBlank(repo.name)) { - throw new Error('Repository name cannot be empty'); - } - if (isBlank(repo.url)) { - throw new Error('URL cannot be empty'); - } - - repo.users = { - canPush: [], - canAuthorise: [], - }; - +export const getRepoByUrl = async (repoUrl: string): Promise => { const collection = await connect(collectionName); - await collection.insertOne(repo); - console.log(`created new repo ${JSON.stringify(repo)}`); + const doc = collection.findOne({ name: { $eq: repoUrl.toLowerCase() } }); + return doc ? toClass(doc, Repo.prototype) : null; }; -export const addUserCanPush = async (name: string, user: string) => { - name = name.toLowerCase(); - user = user.toLowerCase(); +export const getRepoById = async (_id: string): Promise => { const collection = await connect(collectionName); - await collection.updateOne({ name: name }, { $push: { 'users.canPush': user } }); + const doc = collection.findOne({ _id: new ObjectId(_id) }); + return doc ? toClass(doc, Repo.prototype) : null; }; -export const addUserCanAuthorise = async (name: string, user: string) => { - name = name.toLowerCase(); - user = user.toLowerCase(); +export const createRepo = async (repo: Repo): Promise => { const collection = await connect(collectionName); - await collection.updateOne({ name: name }, { $push: { 'users.canAuthorise': user } }); + const response = await collection.insertOne(repo as OptionalId); + console.log(`created new repo ${JSON.stringify(repo)}`); + // add in the _id generated for the record + repo._id = response.insertedId.toString(); + return repo; }; -export const removeUserCanPush = async (name: string, user: string) => { - name = name.toLowerCase(); +export const addUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ name: name }, { $pull: { 'users.canPush': user } }); + await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canPush': user } }); }; -export const removeUserCanAuthorise = async (name: string, user: string) => { - name = name.toLowerCase(); +export const addUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ name: name }, { $pull: { 'users.canAuthorise': user } }); + await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canAuthorise': user } }); }; -export const deleteRepo = async (name: string) => { - name = name.toLowerCase(); +export const removeUserCanPush = async (_id: string, user: string): Promise => { + user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.deleteMany({ name: name }); + await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canPush': user } }); }; -export const isUserPushAllowed = async (name: string, user: string) => { - name = name.toLowerCase(); +export const removeUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); - return new Promise(async (resolve) => { - const repo = await exports.getRepo(name); - console.log(repo.users.canPush); - console.log(repo.users.canAuthorise); - - if (repo.users.canPush.includes(user) || repo.users.canAuthorise.includes(user)) { - resolve(true); - } else { - resolve(false); - } - }); + const collection = await connect(collectionName); + await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canAuthorise': user } }); }; -export const canUserApproveRejectPushRepo = async (name: string, user: string) => { - name = name.toLowerCase(); - user = user.toLowerCase(); - console.log(`checking if user ${user} can approve/reject for ${name}`); - return new Promise(async (resolve) => { - const repo = await exports.getRepo(name); - if (repo.users.canAuthorise.includes(user)) { - console.log(`user ${user} can approve/reject to repo ${name}`); - resolve(true); - } else { - console.log(`user ${user} cannot approve/reject to repo ${name}`); - resolve(false); - } - }); +export const deleteRepo = async (_id: string): Promise => { + const collection = await connect(collectionName); + await collection.deleteMany({ _id: new ObjectId(_id) }); }; diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index ae318e693..82ef2aa34 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -1,38 +1,56 @@ +import { OptionalId, Document } from 'mongodb'; +import { toClass } from '../helper'; import { User } from '../types'; - -const connect = require('./helper').connect; +import { connect } from './helper'; +import _ from 'lodash'; const collectionName = 'users'; -export const findUser = async function (username: string) { +export const findUser = async function (username: string): Promise { + const collection = await connect(collectionName); + const doc = collection.findOne({ username: { $eq: username.toLowerCase() } }); + return doc ? toClass(doc, User.prototype) : null; +}; + +export const findUserByEmail = async function (email: string): Promise { + const collection = await connect(collectionName); + const doc = collection.findOne({ email: { $eq: email.toLowerCase() } }); + return doc ? toClass(doc, User.prototype) : null; +}; + +export const findUserByOIDC = async function (oidcId: string): Promise { const collection = await connect(collectionName); - return collection.findOne({ username: { $eq: username.toLowerCase() } }); + const doc = collection.findOne({ oidcId: { $eq: oidcId } }); + return doc ? toClass(doc, User.prototype) : null; }; -export const getUsers = async function (query: any = {}) { +export const getUsers = async function (query: any = {}): Promise { if (query.username) { query.username = query.username.toLowerCase(); } if (query.email) { query.email = query.email.toLowerCase(); } - console.log(`Getting users for query= ${JSON.stringify(query)}`); + console.log(`Getting users for query = ${JSON.stringify(query)}`); const collection = await connect(collectionName); - return collection.find(query).project({ password: 0 }).toArray(); + const docs = collection.find(query).project({ password: 0 }).toArray(); + return _.chain(docs) + .map((x) => toClass(x, User.prototype)) + .value(); }; -export const deleteUser = async function (username: string) { +export const deleteUser = async function (username: string): Promise { const collection = await connect(collectionName); - return collection.deleteOne({ username: username.toLowerCase() }); + await collection.deleteOne({ username: username.toLowerCase() }); }; -export const createUser = async function (user: User) { +export const createUser = async function (user: User): Promise { user.username = user.username.toLowerCase(); user.email = user.email.toLowerCase(); const collection = await connect(collectionName); - return collection.insertOne(user); + await collection.insertOne(user as OptionalId); }; -export const updateUser = async (user: User) => { +export const updateUser = async (user: User): Promise => { user.username = user.username.toLowerCase(); if (user.email) { user.email = user.email.toLowerCase(); diff --git a/src/db/types.ts b/src/db/types.ts index 04951a699..d95c352e0 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -1,3 +1,6 @@ +import { Action } from '../proxy/actions/Action'; +import MongoDBStore from 'connect-mongo'; + export type PushQuery = { error: boolean; blocked: boolean; @@ -7,42 +10,80 @@ export type PushQuery = { export type UserRole = 'canPush' | 'canAuthorise'; -export type Repo = { +export class Repo { project: string; name: string; url: string; - users: Record; - _id: string; -}; + users: { canPush: string[]; canAuthorise: string[] }; + _id?: string; + + constructor( + project: string, + name: string, + url: string, + users?: Record, + _id?: string, + ) { + this.project = project; + this.name = name; + this.url = url; + this.users = users ?? { canPush: [], canAuthorise: [] }; + this._id = _id; + } +} -export type User = { - _id: string; +export class User { username: string; password: string | null; // null if oidcId is set gitAccount: string; email: string; admin: boolean; - oidcId: string | null; -}; + oidcId?: string | null; + _id?: string; -export type Push = { - id: string; - allowPush: boolean; - authorised: boolean; - blocked: boolean; - blockedMessage: string; - branch: string; - canceled: boolean; - commitData: object; - commitFrom: string; - commitTo: string; - error: boolean; - method: string; - project: string; - rejected: boolean; - repo: string; - repoName: string; - timepstamp: string; - type: string; - url: string; -}; + constructor( + username: string, + password: string, + gitAccount: string, + email: string, + admin: boolean, + oidcId: string | null = null, + _id?: string, + ) { + this.username = username; + this.password = password; + this.gitAccount = gitAccount; + this.email = email; + this.admin = admin; + this.oidcId = oidcId ?? null; + this._id = _id; + } +} + +export interface Sink { + getSessionStore?: () => MongoDBStore; + getPushes: (query: PushQuery) => Promise; + writeAudit: (action: Action) => Promise; + getPush: (id: string) => Promise; + deletePush: (id: string) => Promise; + authorise: (id: string, attestation: any) => Promise<{ message: string }>; + cancel: (id: string) => Promise<{ message: string }>; + reject: (id: string, attestation: any) => Promise<{ message: string }>; + getRepos: (query?: object) => Promise; + getRepo: (name: string) => Promise; + getRepoByUrl: (url: string) => Promise; + getRepoById: (_id: string) => Promise; + createRepo: (repo: Repo) => Promise; + addUserCanPush: (_id: string, user: string) => Promise; + addUserCanAuthorise: (_id: string, user: string) => Promise; + removeUserCanPush: (_id: string, user: string) => Promise; + removeUserCanAuthorise: (_id: string, user: string) => Promise; + deleteRepo: (_id: string) => Promise; + findUser: (username: string) => Promise; + findUserByEmail: (email: string) => Promise; + findUserByOIDC: (oidcId: string) => Promise; + getUsers: (query?: object) => Promise; + createUser: (user: User) => Promise; + deleteUser: (username: string) => Promise; + updateUser: (user: User) => Promise; +} diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index 1be3030eb..c576bb0e1 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -1,4 +1,4 @@ -import { getProxyUrl } from '../../config'; +import { processGitURLForNameAndOrg, processUrlPath } from '../routes/helper'; import { Step } from './Step'; /** @@ -58,17 +58,26 @@ class Action { * @param {string} type The type of the action * @param {string} method The method of the action * @param {number} timestamp The timestamp of the action - * @param {string} repo The repo of the action + * @param {string} url The URL to the repo that should be proxied (with protocol, origin, repo path, but not the path for the git operation). */ - constructor(id: string, type: string, method: string, timestamp: number, repo: string) { + constructor(id: string, type: string, method: string, timestamp: number, url: string) { this.id = id; this.type = type; this.method = method; this.timestamp = timestamp; - this.project = repo.split('/')[0]; - this.repoName = repo.split('/')[1]; - this.url = `${getProxyUrl()}/${repo}`; - this.repo = repo; + this.url = url; + + const urlBreakdown = processUrlPath(url); + if (urlBreakdown) { + this.repo = urlBreakdown.repoPath; + const repoBreakdown = processGitURLForNameAndOrg(urlBreakdown.repoPath); + this.project = repoBreakdown?.project ?? ''; + this.repoName = repoBreakdown?.repoName ?? ''; + } else { + this.repo = 'NOT-FOUND'; + this.project = 'UNKNOWN'; + this.repoName = 'UNKNOWN'; + } } /** diff --git a/src/proxy/actions/Step.ts b/src/proxy/actions/Step.ts index 6eb114e9c..504b5390c 100644 --- a/src/proxy/actions/Step.ts +++ b/src/proxy/actions/Step.ts @@ -35,12 +35,10 @@ class Step { } setContent(content: any): void { - this.log('setting content'); this.content = content; } setAsyncBlock(message: string): void { - this.log('setting blocked'); this.blocked = true; this.blockedMessage = message; } diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 79c91791a..65182a7c0 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -1,8 +1,8 @@ -import express, { Application } from 'express'; +import express, { Express } from 'express'; import http from 'http'; import https from 'https'; import fs from 'fs'; -import { router } from './routes'; +import { getRouter } from './routes'; import { getAuthorisedList, getPlugins, @@ -34,79 +34,82 @@ const options: ServerOptions = { cert: getTLSEnabled() ? fs.readFileSync(getTLSCertPemPath()) : undefined, }; -export const proxyPreparations = async () => { - const plugins = getPlugins(); - 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 = getAuthorisedList(); - const allowedList: Repo[] = await getRepos(); +export default class Proxy { + private httpServer: http.Server | null = null; + private httpsServer: https.Server | null = null; + private expressApp: Express | null = null; - defaultAuthorisedRepoList.forEach(async (x) => { - const found = allowedList.find((y) => y.project === x.project && x.name === y.name); - if (!found) { - await createRepo(x); - await addUserCanPush(x.name, 'admin'); - await addUserCanAuthorise(x.name, 'admin'); - } - }); -}; - -// just keep this async incase it needs async stuff in the future -const createApp = async (): Promise => { - const app = express(); - app.use('/', router); - return app; -}; + constructor() {} -let httpServer: http.Server | null = null; -let httpsServer: https.Server | null = null; + private async proxyPreparations() { + const plugins = getPlugins(); + 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 = getAuthorisedList(); + const allowedList: Repo[] = await getRepos(); -const start = async (): Promise => { - const app = await createApp(); - await proxyPreparations(); - httpServer = http.createServer(options as any, app).listen(proxyHttpPort, () => { - console.log(`HTTP Proxy Listening on ${proxyHttpPort}`); - }); - // Start HTTPS server only if TLS is enabled - if (getTLSEnabled()) { - httpsServer = https.createServer(options, app).listen(proxyHttpsPort, () => { - console.log(`HTTPS Proxy Listening on ${proxyHttpsPort}`); + defaultAuthorisedRepoList.forEach(async (x) => { + const found = allowedList.find((y) => y.project === x.project && x.name === y.name); + if (!found) { + const repo = await createRepo(x); + await addUserCanPush(repo._id!, 'admin'); + await addUserCanAuthorise(repo._id!, 'admin'); + } }); } - return app; -}; -const stop = (): Promise => { - return new Promise((resolve, reject) => { - try { - // Close HTTP server if it exists - if (httpServer) { - httpServer.close(() => { - console.log('HTTP server closed'); - httpServer = null; - }); - } - - // Close HTTPS server if it exists - if (httpsServer) { - httpsServer.close(() => { - console.log('HTTPS server closed'); - httpsServer = null; - }); - } + private async createApp() { + const app = express(); + const router = await getRouter(); + app.use('/', router); + return app; + } - resolve(); - } catch (error) { - reject(error); + public async start() { + await this.proxyPreparations(); + this.expressApp = await this.createApp(); + this.httpServer = http + .createServer(options as any, this.expressApp) + .listen(proxyHttpPort, () => { + console.log(`HTTP Proxy Listening on ${proxyHttpPort}`); + }); + // Start HTTPS server only if TLS is enabled + if (getTLSEnabled()) { + this.httpsServer = https.createServer(options, this.expressApp).listen(proxyHttpsPort, () => { + console.log(`HTTPS Proxy Listening on ${proxyHttpsPort}`); + }); } - }); -}; + } -export default { - proxyPreparations, - createApp, - start, - stop, -}; + public getExpressApp() { + return this.expressApp; + } + + public stop(): Promise { + return new Promise((resolve, reject) => { + try { + // Close HTTP server if it exists + if (this.httpServer) { + this.httpServer.close(() => { + console.log('HTTP server closed'); + this.httpServer = null; + }); + } + + // Close HTTPS server if it exists + if (this.httpsServer) { + this.httpsServer.close(() => { + console.log('HTTPS server closed'); + this.httpsServer = null; + }); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/src/proxy/processors/pre-processor/parseAction.ts b/src/proxy/processors/pre-processor/parseAction.ts index a9c332fdc..ddf854caf 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -1,4 +1,6 @@ import { Action } from '../../actions'; +import { processUrlPath } from '../../routes/helper'; +import * as db from '../../../db'; const exec = async (req: { originalUrl: string; @@ -7,34 +9,40 @@ const exec = async (req: { }) => { const id = Date.now(); const timestamp = id; - const repoName = getRepoNameFromUrl(req.originalUrl); - const paths = req.originalUrl.split('/'); - + const pathBreakdown = processUrlPath(req.originalUrl); let type = 'default'; + if (pathBreakdown) { + if (pathBreakdown.gitPath.endsWith('git-upload-pack') && req.method === 'GET') { + type = 'pull'; + } + if ( + pathBreakdown.gitPath.includes('git-receive-pack') && + req.method === 'POST' && + req.headers['content-type'] === 'application/x-git-receive-pack-request' + ) { + type = 'push'; + } + } // else failed to parse proxy URL path - which is logged in the parsing util - if (paths[paths.length - 1].endsWith('git-upload-pack') && req.method === 'GET') { - type = 'pull'; - } - if ( - paths[paths.length - 1] === 'git-receive-pack' && - req.method === 'POST' && - req.headers['content-type'] === 'application/x-git-receive-pack-request' - ) { - type = 'push'; - } + // Proxy URLs take the form https://:// + // e.g. https://git-proxy-instance.com:8443/github.com/finos/git-proxy.git + // We'll receive /github.com/finos/git-proxy.git as the req.url / req.originalUrl + // Add protocol (assume SSL) to reconstruct full URL - noting path will start with a / + let url = 'https:/' + (pathBreakdown?.repoPath ?? 'NOT-FOUND'); - return new Action(id.toString(), type, req.method, timestamp, repoName); -}; + console.log(`Parse action calculated repo URL: ${url} for inbound URL path: ${req.originalUrl}`); -const getRepoNameFromUrl = (url: string): string => { - const parts = url.split('/'); - for (let i = 0, len = parts.length; i < len; i++) { - const part = parts[i]; - if (part.endsWith('.git')) { - return `${parts[i - 1]}/${part}`.trim(); - } + if (!(await db.getRepoByUrl(url))) { + // fallback for legacy proxy URLs + // legacy git proxy paths took the form: https://:/ + // by assuming the host was github.com + url = 'https://github.com' + (pathBreakdown?.repoPath ?? 'NOT-FOUND'); + console.log( + `Parse action fallback calculated repo URL: ${url} for inbound URL path: ${req.originalUrl}`, + ); } - return 'NOT-FOUND'; + + return new Action(id.toString(), type, req.method, timestamp, url); }; exec.displayName = 'parseAction.exec'; diff --git a/src/proxy/processors/push-action/checkRepoInAuthorisedList.ts b/src/proxy/processors/push-action/checkRepoInAuthorisedList.ts index c3768888b..5030a7ce3 100644 --- a/src/proxy/processors/push-action/checkRepoInAuthorisedList.ts +++ b/src/proxy/processors/push-action/checkRepoInAuthorisedList.ts @@ -1,40 +1,25 @@ import { Action, Step } from '../../actions'; -import { getRepos } from '../../../db'; -import { Repo } from '../../../db/types'; -import { trimTrailingDotGit } from '../../../db/helper'; +import { getRepoByUrl } from '../../../db'; // Execute if the repo is approved -const exec = async ( - req: any, - action: Action, - authorisedList: () => Promise = getRepos, -): Promise => { +const exec = async (req: any, action: Action): Promise => { const step = new Step('checkRepoInAuthorisedList'); - const list = await authorisedList(); - console.log(list); - - const found = list.find((x: Repo) => { - const targetName = trimTrailingDotGit(action.repo.toLowerCase()); - const allowedName = trimTrailingDotGit(`${x.project}/${x.name}`.toLowerCase()); - console.log(`${targetName} = ${allowedName}`); - return targetName === allowedName; - }); - - console.log(found); + // console.log(found); + const found = (await getRepoByUrl(action.url)) !== null; if (!found) { - console.log('not found'); + console.log(`Repository url '${action.url}' not found`); step.error = true; - step.log(`repo ${action.repo} is not in the authorisedList, ending`); + step.log(`repo ${action.url} is not in the authorisedList, ending`); console.log('setting error'); - step.setError(`Rejecting repo ${action.repo} not in the authorisedList`); + step.setError(`Rejecting repo ${action.url} not in the authorisedList`); action.addStep(step); return action; } console.log('found'); - step.log(`repo ${action.repo} is in the authorisedList`); + step.log(`repo ${action.url} is in the authorisedList`); action.addStep(step); return action; }; diff --git a/src/proxy/processors/push-action/checkUserPushPermission.ts b/src/proxy/processors/push-action/checkUserPushPermission.ts index e6ebe1ca0..83f16c968 100644 --- a/src/proxy/processors/push-action/checkUserPushPermission.ts +++ b/src/proxy/processors/push-action/checkUserPushPermission.ts @@ -1,6 +1,5 @@ import { Action, Step } from '../../actions'; import { getUsers, isUserPushAllowed } from '../../../db'; -import { trimTrailingDotGit } from '../../../db/helper'; // Execute if the repo is approved const exec = async (req: any, action: Action): Promise => { @@ -26,16 +25,6 @@ const exec = async (req: any, action: Action): Promise => { * @return {Promise} The action object */ const validateUser = async (userEmail: string, action: Action, step: Step): Promise => { - const repoSplit = trimTrailingDotGit(action.repo.toLowerCase()).split('/'); - - // we expect there to be exactly one / separating org/repoName - if (repoSplit.length != 2) { - step.setError('Server-side issue extracting repoName'); - action.addStep(step); - return action; - } - // pull the 2nd value of the split for repoName - const repoName = repoSplit[1]; let isUserAllowed = false; // Find the user associated with this email address @@ -56,26 +45,25 @@ const validateUser = async (userEmail: string, action: Action, step: Step): Prom } else if (list.length == 0) { console.error(`No user with email address ${userEmail} found`); } else { - isUserAllowed = await isUserPushAllowed(repoName, list[0].username); + isUserAllowed = await isUserPushAllowed(action.url, list[0].username); } - console.log(`User ${userEmail} permission on Repo ${repoName} : ${isUserAllowed}`); + console.log(`User ${userEmail} permission on Repo ${action.url} : ${isUserAllowed}`); if (!isUserAllowed) { console.log('User not allowed to Push'); step.error = true; - step.log(`User ${userEmail} is not allowed to push on repo ${action.repo}, ending`); - + step.log(`User ${userEmail} is not allowed to push on repo ${action.url}, ending`); step.setError( `Your push has been blocked (${action.userEmail} ` + `is not allowed to push on repo ` + - `${action.repo})`, + `${action.url})`, ); action.addStep(step); return action; } - step.log(`User ${userEmail} is allowed to push on repo ${action.repo}`); + step.log(`User ${userEmail} is allowed to push on repo ${action.url}`); action.addStep(step); return action; }; diff --git a/src/proxy/processors/push-action/scanDiff.ts b/src/proxy/processors/push-action/scanDiff.ts index 40b39627b..899fa6442 100644 --- a/src/proxy/processors/push-action/scanDiff.ts +++ b/src/proxy/processors/push-action/scanDiff.ts @@ -72,7 +72,7 @@ const combineMatches = (organization: string) => { ? [] : Object.entries(commitConfig.diff.block.providers); - // Combine all matches (literals, paterns) + // Combine all matches (literals, patterns) const combinedMatches = [ ...blockedLiterals.map((literal) => ({ type: BLOCK_TYPE.LITERAL, @@ -104,7 +104,7 @@ const collectMatches = (parsedDiff: File[], combinedMatches: CombinedMatch[]): M const lineNumber = change.ln; // Iterate through each match types - literal, patterns, providers combinedMatches.forEach(({ type, match }) => { - // using Match all to find all occurences of the pattern in the line + // using Match all to find all occurrences of the pattern in the line const matches = [...change.content.matchAll(match)]; matches.forEach((matchInstance) => { @@ -122,7 +122,7 @@ const collectMatches = (parsedDiff: File[], combinedMatches: CombinedMatch[]): M }; } - // apend line numbers to the list of lines + // append line numbers to the list of lines allMatches[matchKey].lines.push(lineNumber); }); }); @@ -131,7 +131,7 @@ const collectMatches = (parsedDiff: File[], combinedMatches: CombinedMatch[]): M }); }); - // convert matches into a final result array, joining line numbers + // convert matches into a final result array, joining line numbers const result = Object.values(allMatches).map((match) => ({ ...match, lines: match.lines.join(','), // join the line numbers into a comma-separated string diff --git a/src/proxy/routes/helper.ts b/src/proxy/routes/helper.ts new file mode 100644 index 000000000..46f73a2c7 --- /dev/null +++ b/src/proxy/routes/helper.ts @@ -0,0 +1,194 @@ +import * as db from '../../db'; + +/** Regex used to analyze un-proxied Git URLs */ +const GIT_URL_REGEX = /(.+:\/\/)([^/]+)(\/.+\.git)(\/.+)*/; + +/** Used to reject URLs that are too long and may be part of a DoS involving regex. */ +const MAX_URL_LENGTH = 512; + +/** Type representing a breakdown of Git URL (un-proxied)*/ +export type GitUrlBreakdown = { protocol: string; host: string; repoPath: string }; + +/** Function that processes Git URLs to extract the protocol, host, path to the + * git endpoint and discarding any git path (specific operation) that comes after + * the .git element. + * + * E.g. Processing https://github.com/finos/git-proxy.git/info/refs?service=git-upload-pack + * would produce: + * - protocol: https:// + * - host: github.com + * - repoPath: /finos/git-proxy.git + * + * and processing https://someOtherHost.com:8080/repo.git + * would produce: + * - protocol: https:// + * - host: someOtherHost.com:8080 + * - repoPath: /repo.git + * + * @param {string} url The URL to process + * @return {GitUrlBreakdown | null} A breakdown of the components of the URL. + */ +export const processGitUrl = (url: string): GitUrlBreakdown | null => { + // limit URL length to avoid DoS via Regex issue detection in SAST scans + if (url.length > MAX_URL_LENGTH) { + console.error(`The git URL is too long: ${url}`); + return null; + } + const components = url.match(GIT_URL_REGEX); + if (components && components.length >= 5) { + return { + protocol: components[1], + host: components[2], + repoPath: components[3], + // component [4] would be any git path, but isn't needed for repo URLs + }; + } else { + console.error(`Failed to parse git URL: ${url}`); + return null; + } +}; + +/** Regex used to analyze url paths for requests to the proxy and split them + * into the embedded git end point and path for the git operation. */ +const PROXIED_URL_PATH_REGEX = /(.+\.git)(\/.*)?/; + +/** Type representing a breakdown of paths requested from the proxy server */ +export type UrlPathBreakdown = { repoPath: string; gitPath: string }; + +/** Function that processes URL paths (URL with origin removed) of requests to the proxy + * to extract the embedded repository path and path for the specific git operation to be + * proxied. + * + * E.g. Processing /finos/git-proxy.git/info/refs?service=git-upload-pack + * would produce: + * - repoPath: /finos/git-proxy.git + * - gitPath: /info/refs?service=git-upload-pack + * + * and processing /github.com/finos/git-proxy.git/info/refs?service=git-upload-pack + * would produce: + * - repoPath: /github.com/finos/git-proxy.git + * - gitPath: /info/refs?service=git-upload-pack + * + * @param {string} requestPath The URL path to process. + * @return {GitUrlBreakdown | null} A breakdown of the components of the URL path. + */ +export const processUrlPath = (requestPath: string): UrlPathBreakdown | null => { + // limit URL length to avoid DoS via Regex issue detection in SAST scans + if (requestPath.length > MAX_URL_LENGTH) { + console.error(`The requestPath is too long: ${requestPath}`); + return null; + } + const components = requestPath.match(PROXIED_URL_PATH_REGEX); + if (components && components.length >= 3) { + return { + repoPath: components[1], + gitPath: components[2] ?? '/', + }; + } else { + console.error(`Failed to parse proxy url path: ${requestPath}`); + return null; + } +}; + +/** Regex used to analyze repo URLs (with protocol and origin) to extract the repository name and + * any path or organisation that proceeds and drop the origin and protocol if present. */ +const GIT_URL_NAME_ORG_REGEX = /(.+:\/\/)?([^/]+)\/(?:(.*)\/)?([^/]+\.git)/; + +/** Type representing a breakdown Git URL into repository name and organisation (project). */ +export type GitNameBreakdown = { project: string | null; repoName: string }; + +/** Function that processes git URLs embedded in proxy request URLs to extract + * the repository name and any path or organisation. + * + * E.g. Processing https://github.com/finos/git-proxy.git + * would produce: + * - project: finos + * - repoName: git-proxy.git + * + * Processing https://someGitHost.com/repo.git + * would produce: + * - project: null + * - repoName: repo.git + * + * Processing someGitHost.com/repo.git + * would produce: + * - project: null + * - repoName: repo.git + * + * Processing https://anotherGitHost.com/project/subProject/subSubProject/repo.git + * would produce: + * - project: project/subProject/subSubProject + * - repoName: repo.git + * + * @param {string} gitUrl The URL to process. + * @return {GitNameBreakdown | null} A breakdown of the components of the URL. + */ +export const processGitURLForNameAndOrg = (gitUrl: string): GitNameBreakdown | null => { + // limit URL length to avoid DoS via Regex issue detection in SAST scans + if (gitUrl.length > MAX_URL_LENGTH) { + console.error(`The git URL is too long: ${gitUrl}`); + return null; + } + const components = gitUrl.match(GIT_URL_NAME_ORG_REGEX); + if (components && components.length >= 5) { + return { + project: components[3] ?? null, // there may be no project or path for standalone git repo + repoName: components[4], + }; + } else { + console.error(`Failed to parse git URL: ${gitUrl}`); + return null; + } +}; + +/** + * Check whether an HTTP request has the expected properties of a + * Git HTTP request. The URL is expected to be "sanitized", stripped of + * specific paths such as the GitHub {owner}/{repo}.git parts. + * @param {string} gitPath Sanitized URL path which only includes the path + * specific to git (everything after .git/) + * @param {*} headers Request headers (TODO: Fix JSDoc linting and refer to + * node:http.IncomingHttpHeaders) + * @return {boolean} If true, this is a valid and expected git request. + * Otherwise, false. + */ +export const validGitRequest = (gitPath: string, headers: any): boolean => { + const { 'user-agent': agent, accept } = headers; + if (!agent) { + return false; + } + if ( + ['/info/refs?service=git-upload-pack', '/info/refs?service=git-receive-pack'].includes(gitPath) + ) { + // https://www.git-scm.com/docs/http-protocol#_discovering_references + // We can only filter based on User-Agent since the Accept header is not + // sent in this request + return agent.startsWith('git/'); + } + if (['/git-upload-pack', '/git-receive-pack'].includes(gitPath)) { + if (!accept) { + return false; + } + // https://www.git-scm.com/docs/http-protocol#_uploading_data + return agent.startsWith('git/') && accept.startsWith('application/x-git-'); + } + return false; +}; + +/** + * Collect the Set of all host (host and port if specified) that we + * will be proxying requests for, to be used to initialize the proxy. + * + * @return {string[]} an array of origins + */ +export const getAllProxiedHosts = async (): Promise => { + const repos = await db.getRepos(); + const origins = new Set(); + repos.forEach((repo) => { + const parsedUrl = processGitUrl(repo.url); + if (parsedUrl) { + origins.add(parsedUrl.host); + } // failures are logged by parsing util fn + }); + return Array.from(origins); +}; diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index b4794a8ae..e700374f3 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -1,58 +1,138 @@ -import { Router, Request, Response, NextFunction } from 'express'; +import { Router, Request, Response, NextFunction, RequestHandler } from 'express'; import proxy from 'express-http-proxy'; import { PassThrough } from 'stream'; import getRawBody from 'raw-body'; import { executeChain } from '../chain'; -import { getProxyUrl } from '../../config'; - -// eslint-disable-next-line new-cap -const router = Router(); - -/** - * For a given Git HTTP request destined for a GitHub repo, - * remove the GitHub specific components of the URL. - * @param {string} url URL path of the request - * @return {string} Modified path which removes the {owner}/{repo} parts - */ -const stripGitHubFromGitPath = (url: string): string | undefined => { - const parts = url.split('/'); - // url = '/{owner}/{repo}.git/{git-path}' - // url.split('/') = ['', '{owner}', '{repo}.git', '{git-path}'] - if (parts.length !== 4 && parts.length !== 5) { - console.error('unexpected url received: ', url); - return undefined; +import { processUrlPath, validGitRequest, getAllProxiedHosts } from './helper'; +import { ProxyOptions } from 'express-http-proxy'; + +const logAction = ( + url: string, + host: string | null | undefined, + userAgent: string | null | undefined, + errMsg: string | null | undefined, + blockMsg?: string | null | undefined, +) => { + let msg = `Action processed: ${!(errMsg || blockMsg) ? 'Allowed' : 'Blocked'} + Request URL: ${url} + Host: ${host} + User-Agent: ${userAgent}`; + if (errMsg) { + msg += `\n Error: ${errMsg}`; } - parts.splice(1, 2); // remove the {owner} and {repo} from the array - return parts.join('/'); + if (blockMsg) { + msg += `\n Blocked: ${blockMsg}`; + } + console.log(msg); }; -/** - * Check whether an HTTP request has the expected properties of a - * Git HTTP request. The URL is expected to be "sanitized", stripped of - * specific paths such as the GitHub {owner}/{repo}.git parts. - * @param {string} url Sanitized URL which only includes the path specific to git - * @param {*} headers Request headers (TODO: Fix JSDoc linting and refer to node:http.IncomingHttpHeaders) - * @return {boolean} If true, this is a valid and expected git request. Otherwise, false. - */ -const validGitRequest = (url: string, headers: any): boolean => { - const { 'user-agent': agent, accept } = headers; - if (!agent) { +const proxyFilter: ProxyOptions['filter'] = async (req, res) => { + try { + const urlComponents = processUrlPath(req.url); + + if ( + !urlComponents || + urlComponents.gitPath === undefined || + !validGitRequest(urlComponents.gitPath, req.headers) + ) { + res.status(400).send('Invalid request received'); + console.log('action blocked'); + return false; + } + + const action = await executeChain(req, res); + + if (action.error || action.blocked) { + res.set('content-type', 'application/x-git-receive-pack-result'); + res.set('expires', 'Fri, 01 Jan 1980 00:00:00 GMT'); + res.set('pragma', 'no-cache'); + res.set('cache-control', 'no-cache, max-age=0, must-revalidate'); + res.set('vary', 'Accept-Encoding'); + res.set('x-frame-options', 'DENY'); + res.set('connection', 'close'); + + let message = ''; + + if (action.error) { + message = action.errorMessage!; + console.error(message); + } + if (action.blocked) { + message = action.blockedMessage!; + } + + const packetMessage = handleMessage(message); + + logAction( + req.url, + req.headers.host, + req.headers['user-agent'], + action.errorMessage, + action.blockedMessage, + ); + + res.status(200).send(packetMessage); + + return false; + } + + logAction( + req.url, + req.headers.host, + req.headers['user-agent'], + action.errorMessage, + action.blockedMessage, + ); + + return true; + } catch (e) { + console.error('Error occurred in proxy filter function ', e); + logAction( + req.url, + req.headers.host, + req.headers['user-agent'], + 'Error occurred in proxy filter function: ' + ((e as Error).message ?? e), + null, + ); return false; } - if (['/info/refs?service=git-upload-pack', '/info/refs?service=git-receive-pack'].includes(url)) { - // https://www.git-scm.com/docs/http-protocol#_discovering_references - // We can only filter based on User-Agent since the Accept header is not - // sent in this request - return agent.startsWith('git/'); - } - if (['/git-upload-pack', '/git-receive-pack'].includes(url)) { - if (!accept) { - return false; +}; + +const handleMessage = (message: string): string => { + const body = `\t${message}`; + const len = (6 + Buffer.byteLength(body)).toString(16).padStart(4, '0'); + return `${len}\x02${body}\n0000`; +}; + +const getRequestPathResolver: (prefix: string) => ProxyOptions['proxyReqPathResolver'] = ( + prefix, +) => { + return (req) => { + let url; + // try to prevent too many slashes in the URL + if (prefix.endsWith('/') && req.originalUrl.startsWith('/')) { + url = prefix.substring(0, prefix.length - 1) + req.originalUrl; + } else { + url = prefix + req.originalUrl; } - // https://www.git-scm.com/docs/http-protocol#_uploading_data - return agent.startsWith('git/') && accept.startsWith('application/x-git-'); + + console.log(`Request resolved to ${url}`); + return url; + }; +}; + +const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts) => proxyReqOpts; + +const proxyReqBodyDecorator: ProxyOptions['proxyReqBodyDecorator'] = (bodyContent, srcReq) => { + if (srcReq.method === 'GET') { + return ''; } - return false; + return bodyContent; +}; + +const proxyErrorHandler: ProxyOptions['proxyErrorHandler'] = (err, res, next) => { + console.log(`ERROR=${err}`); + next(err); }; const isPackPost = (req: Request) => @@ -109,50 +189,65 @@ const teeAndValidate = async (req: Request, res: Response, next: NextFunction) = } }; -router.use(teeAndValidate); - -router.use( - '/', - proxy(getProxyUrl(), { +const getRouter = async () => { + // eslint-disable-next-line new-cap + const router = Router(); + router.use(teeAndValidate); + + const originsToProxy = await getAllProxiedHosts(); + const proxyKeys: string[] = []; + const proxies: RequestHandler[] = []; + + console.log(`Initializing proxy router for origins: '${JSON.stringify(originsToProxy)}'`); + + // we need to wrap multiple proxy middlewares in a custom middleware as middlewares + // with path are processed in descending path order (/ then /github.com etc.) and + // we want the fallback proxy to go last. + originsToProxy.forEach((origin) => { + console.log(`\tsetting up origin: '${origin}'`); + + proxyKeys.push(`/${origin}/`); + proxies.push( + proxy('https://' + origin, { + parseReqBody: false, + preserveHostHdr: false, + filter: proxyFilter, + proxyReqPathResolver: getRequestPathResolver('https://'), // no need to add host as it's in the URL + proxyReqOptDecorator: proxyReqOptDecorator, + proxyReqBodyDecorator: proxyReqBodyDecorator, + proxyErrorHandler: proxyErrorHandler, + }), + ); + }); + + console.log('\tsetting up catch-all route (github.com) for backwards compatibility'); + const fallbackProxy: RequestHandler = proxy('https://github.com', { parseReqBody: false, preserveHostHdr: false, - - filter: async (req, res) => { - console.log('request url: ', req.url); - console.log('host: ', req.headers.host); - console.log('user-agent: ', req.headers['user-agent']); - const gitPath = stripGitHubFromGitPath(req.url); - if (gitPath === undefined || !validGitRequest(gitPath, req.headers)) { - res.status(400).send('Invalid request received'); - return false; + filter: proxyFilter, + proxyReqPathResolver: getRequestPathResolver('https://github.com'), + proxyReqOptDecorator: proxyReqOptDecorator, + proxyReqBodyDecorator: proxyReqBodyDecorator, + proxyErrorHandler: proxyErrorHandler, + }); + + console.log('proxy keys registered: ', JSON.stringify(proxyKeys)); + + router.use('/', (req, res, next) => { + console.log(`processing request URL: '${req.url}'`); + console.log('proxy keys registered: ', JSON.stringify(proxyKeys)); + + for (let i = 0; i < proxyKeys.length; i++) { + if (req.url.startsWith(proxyKeys[i])) { + console.log(`\tusing proxy ${proxyKeys[i]}`); + return proxies[i](req, res, next); } - return true; - }, - - proxyReqPathResolver: (req) => { - const url = getProxyUrl() + req.originalUrl; - console.log('Sending request to ' + url); - return url; - }, - - proxyErrorHandler: (err, res, next) => { - console.log(`ERROR=${err}`); - next(err); - }, - }), -); - -const handleMessage = (message: string): string => { - const body = `\t${message}`; - const len = (6 + Buffer.byteLength(body)).toString(16).padStart(4, '0'); - return `${len}\x02${body}\n0000`; + } + // fallback + console.log(`\tusing fallback`); + return fallbackProxy(req, res, next); + }); + return router; }; -export { - router, - handleMessage, - validGitRequest, - teeAndValidate, - isPackPost, - stripGitHubFromGitPath, -}; +export { proxyFilter, getRouter, handleMessage, isPackPost, teeAndValidate, validGitRequest }; diff --git a/src/service/index.js b/src/service/index.js index 02e416aa0..f03d75b68 100644 --- a/src/service/index.js +++ b/src/service/index.js @@ -9,7 +9,6 @@ const db = require('../db'); const rateLimit = require('express-rate-limit'); const lusca = require('lusca'); const configLoader = require('../config/ConfigLoader'); -const proxy = require('../proxy'); const limiter = rateLimit(config.getRateLimit()); @@ -22,7 +21,12 @@ const corsOptions = { origin: true, }; -const createApp = async () => { +/** + * Internal function used to bootstrap the Git Proxy API's express application. + * @param {proxy} proxy A reference to the proxy express application, used to restart it when necessary. + * @return {Promise} + */ +async function createApp(proxy) { // configuration of passport is async // Before we can bind the routes - we need the passport strategy const passport = await require('./passport').configure(); @@ -98,17 +102,26 @@ const createApp = async () => { app.use(passport.session()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); - app.use('/', routes); + app.use('/', routes(proxy)); app.use('/', express.static(absBuildPath)); app.get('/*', (req, res) => { res.sendFile(path.join(`${absBuildPath}/index.html`)); }); return app; -}; +} + +/** + * Starts the proxy service. + * @param {proxy?} proxy A reference to the proxy express application, used to restart it when necessary. + * @return {Promise} the express application (used for testing). + */ +async function start(proxy) { + if (!proxy) { + console.warn("WARNING: proxy is null and can't be controlled by the API service"); + } -const start = async () => { - const app = await createApp(); + const app = await createApp(proxy); _httpServer.listen(uiPort); @@ -116,8 +129,14 @@ const start = async () => { app.emit('ready'); return app; -}; +} + +/** + * Stops the proxy service. + */ +async function stop() { + console.log(`Stopping Service Listening on ${uiPort}`); + _httpServer.close(); +} -module.exports.createApp = createApp; -module.exports.start = start; -module.exports.httpServer = _httpServer; +module.exports = { start, stop, httpServer: _httpServer }; diff --git a/src/service/routes/index.js b/src/service/routes/index.js index 4fd7f86ca..e2e0cf1a8 100644 --- a/src/service/routes/index.js +++ b/src/service/routes/index.js @@ -7,14 +7,17 @@ const users = require('./users'); const healthcheck = require('./healthcheck'); const config = require('./config'); const jwtAuthHandler = require('../passport/jwtAuthHandler'); -const router = new express.Router(); -router.use('/api', home); -router.use('/api/auth', auth.router); -router.use('/api/v1/healthcheck', healthcheck); -router.use('/api/v1/push', jwtAuthHandler(), push); -router.use('/api/v1/repo', jwtAuthHandler(), repo); -router.use('/api/v1/user', jwtAuthHandler(), users); -router.use('/api/v1/config', config); +const routes = (proxy) => { + const router = new express.Router(); + router.use('/api', home); + router.use('/api/auth', auth.router); + router.use('/api/v1/healthcheck', healthcheck); + router.use('/api/v1/push', jwtAuthHandler(), push); + router.use('/api/v1/repo', jwtAuthHandler(), repo(proxy)); + router.use('/api/v1/user', jwtAuthHandler(), users); + router.use('/api/v1/config', config); + return router; +}; -module.exports = router; +module.exports = routes; diff --git a/src/service/routes/repo.js b/src/service/routes/repo.js index cc70cec16..7ebbb62e3 100644 --- a/src/service/routes/repo.js +++ b/src/service/routes/repo.js @@ -1,154 +1,203 @@ const express = require('express'); -const router = new express.Router(); const db = require('../../db'); const { getProxyURL } = require('../urls'); - -router.get('/', async (req, res) => { - const proxyURL = getProxyURL(req); - const query = {}; - - for (const k in req.query) { - if (!k) continue; - - if (k === 'limit') continue; - if (k === 'skip') continue; - let v = req.query[k]; - if (v === 'false') v = false; - if (v === 'true') v = true; - query[k] = v; - } - - const qd = await db.getRepos(query); - res.send(qd.map((d) => ({ ...d, proxyURL }))); -}); - -router.get('/:name', async (req, res) => { - const proxyURL = getProxyURL(req); - const name = req.params.name; - const qd = await db.getRepo(name); - res.send({ ...qd, proxyURL }); -}); - -router.patch('/:name/user/push', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.body.username.toLowerCase(); - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; +const { getAllProxiedHosts } = require('../../proxy/routes/helper'); + +// create a reference to the proxy service as arrow functions will lose track of the `proxy` parameter +// used to restart the proxy when a new host is added +let theProxy = null; +const repo = (proxy) => { + theProxy = proxy; + const router = new express.Router(); + + router.get('/', async (req, res) => { + const proxyURL = getProxyURL(req); + const query = {}; + + for (const k in req.query) { + if (!k) continue; + + if (k === 'limit') continue; + if (k === 'skip') continue; + let v = req.query[k]; + if (v === 'false') v = false; + if (v === 'true') v = true; + query[k] = v; } - await db.addUserCanPush(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.patch('/:name/user/authorise', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.body.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } + const qd = await db.getRepos(query); + res.send(qd.map((d) => ({ ...d, proxyURL }))); + }); + + router.get('/:id', async (req, res) => { + const proxyURL = getProxyURL(req); + const _id = req.params.id; + const qd = await db.getRepoById(_id); + res.send({ ...qd, proxyURL }); + }); + + router.patch('/:id/user/push', async (req, res) => { + if (req.user && req.user.admin) { + const _id = req.params.id; + const username = req.body.username.toLowerCase(); + const user = await db.findUser(username); + + if (!user) { + res.status(400).send({ error: 'User does not exist' }); + return; + } - await db.addUserCanAuthorise(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/user/authorise/:username', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; + await db.addUserCanPush(_id, username); + res.send({ message: 'created' }); + } else { + res.status(401).send({ + message: 'You are not authorised to perform this action...', + }); } + }); + + router.patch('/:id/user/authorise', async (req, res) => { + if (req.user && req.user.admin) { + const _id = req.params.id; + const username = req.body.username; + const user = await db.findUser(username); + + if (!user) { + res.status(400).send({ error: 'User does not exist' }); + return; + } - await db.removeUserCanAuthorise(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/user/push/:username', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; + await db.addUserCanAuthorise(_id, username); + res.send({ message: 'created' }); + } else { + res.status(401).send({ + message: 'You are not authorised to perform this action...', + }); } + }); - await db.removeUserCanPush(repoName, username); - res.send({ message: 'created' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.delete('/:name/delete', async (req, res) => { - if (req.user && req.user.admin) { - const repoName = req.params.name; - - await db.deleteRepo(repoName); - res.send({ message: 'deleted' }); - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -router.post('/', async (req, res) => { - if (req.user && req.user.admin) { - if (!req.body.name) { - res.status(400).send({ - message: 'Repository name is required', + router.delete('/:id/user/authorise/:username', async (req, res) => { + if (req.user && req.user.admin) { + const _id = req.params.id; + const username = req.params.username; + const user = await db.findUser(username); + + if (!user) { + res.status(400).send({ error: 'User does not exist' }); + return; + } + + await db.removeUserCanAuthorise(_id, username); + res.send({ message: 'created' }); + } else { + res.status(401).send({ + message: 'You are not authorised to perform this action...', }); - return; } + }); + + router.delete('/:id/user/push/:username', async (req, res) => { + if (req.user && req.user.admin) { + const _id = req.params.id; + const username = req.params.username; + const user = await db.findUser(username); - const repo = await db.getRepo(req.body.name); - if (repo) { - res.status(409).send({ - message: 'Repository already exists!', + if (!user) { + res.status(400).send({ error: 'User does not exist' }); + return; + } + + await db.removeUserCanPush(_id, username); + res.send({ message: 'created' }); + } else { + res.status(401).send({ + message: 'You are not authorised to perform this action...', }); + } + }); + + router.delete('/:id/delete', async (req, res) => { + if (req.user && req.user.admin) { + const _id = req.params.id; + + // determine if we need to restart the proxy + const previousHosts = await getAllProxiedHosts(); + await db.deleteRepo(_id); + const currentHosts = await getAllProxiedHosts(); + + if (currentHosts.length < previousHosts.length) { + // restart the proxy + console.log('Restarting the proxy to remove a host'); + await theProxy.stop(); + await theProxy.start(); + } + + res.send({ message: 'deleted' }); } else { - try { - await db.createRepo(req.body); - res.send({ message: 'created' }); - } catch { - res.send('Failed to create repository'); + res.status(401).send({ + message: 'You are not authorised to perform this action...', + }); + } + }); + + router.post('/', async (req, res) => { + if (req.user && req.user.admin) { + if (!req.body.url) { + res.status(400).send({ + message: 'Repository url is required', + }); + return; + } + + const repo = await db.getRepoByUrl(req.body.url); + if (repo) { + res.status(409).send({ + message: `Repository ${req.body.url} already exists!`, + }); + } else { + try { + // figure out if this represent a new domain to proxy + let newOrigin = true; + + const existingHosts = await getAllProxiedHosts(); + existingHosts.forEach((h) => { + // assume SSL is in use and that our origins are missing the protocol + if (req.body.url.startsWith(`https://${h}`)) { + newOrigin = false; + } + }); + + console.log( + `API request to proxy repository ${req.body.url} is for a new origin: ${newOrigin},\n\texisting origin list was: ${JSON.stringify(existingHosts)}`, + ); + + // create the repository + const repoDetails = await db.createRepo(req.body); + const proxyURL = getProxyURL(req); + + // return data on the new repoistory (including it's _id and the proxyUrl) + res.send({ ...repoDetails, proxyURL, message: 'created' }); + + // restart the proxy if we're proxying a new domain + if (newOrigin) { + console.log('Restarting the proxy to handle an additional host'); + await theProxy.stop(); + await theProxy.start(); + } + } catch (e) { + console.error('Repository creation failed due to error: ', e.message ? e.message : e); + console.error(e.stack); + res.status(500).send({ message: 'Failed to create repository due to error' }); + } } + } else { + res.status(401).send({ + message: 'You are not authorised to perform this action...', + }); } - } else { - res.status(401).send({ - message: 'You are not authorised to perform this action...', - }); - } -}); - -module.exports = router; + }); + + return router; +}; + +module.exports = repo; diff --git a/src/types/models.ts b/src/types/models.ts index a114683e8..3a751f8dc 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -24,6 +24,7 @@ export interface CommitData { export interface PushData { id: string; + url: string; repo: string; branch: string; commitFrom: string; @@ -49,3 +50,61 @@ export interface Route { icon?: string | React.ComponentType; visible?: boolean; } + +export interface GitHubRepositoryMetadata { + description?: string; + language?: string; + license?: { + spdx_id: string; + }; + html_url: string; + parent?: { + full_name: string; + html_url: string; + }; + created_at?: string; + updated_at?: string; + pushed_at?: string; + owner?: { + avatar_url: string; + html_url: string; + }; +} + +export interface GitLabRepositoryMetadata { + description?: string; + primary_language?: string; + license?: { + nickname: string; + }; + web_url: string; + forked_from_project?: { + full_name: string; + web_url: string; + }; + last_activity_at?: string; + avatar_url?: string; + namespace?: { + name: string; + path: string; + full_path: string; + avatar_url?: string; + web_url: string; + }; +} + +export interface SCMRepositoryMetadata { + description?: string; + language?: string; + license?: string; + htmlUrl?: string; + parentName?: string; + parentUrl?: string; + lastUpdated?: string; + created_at?: string; + updated_at?: string; + pushed_at?: string; + + profileUrl?: string; + avatarUrl?: string; +} diff --git a/src/ui/components/CustomButtons/CodeActionButton.tsx b/src/ui/components/CustomButtons/CodeActionButton.tsx index d5ce26eeb..47b3e3e20 100644 --- a/src/ui/components/CustomButtons/CodeActionButton.tsx +++ b/src/ui/components/CustomButtons/CodeActionButton.tsx @@ -20,32 +20,34 @@ const CodeActionButton: React.FC = ({ cloneURL }) => { const [placement, setPlacement] = useState(); const [isCopied, setIsCopied] = useState(false); - const handleClick = (newPlacement: PopperPlacementType) => (event: React.MouseEvent) => { - setIsCopied(false); - setAnchorEl(event.currentTarget); - setOpen((prev) => placement !== newPlacement || !prev); - setPlacement(newPlacement); - }; + const handleClick = + (newPlacement: PopperPlacementType) => (event: React.MouseEvent) => { + setIsCopied(false); + setAnchorEl(event.currentTarget); + setOpen((prev) => placement !== newPlacement || !prev); + setPlacement(newPlacement); + }; return ( <> - {' '} - Code + Code - + = ({ cloneURL }) => { ); }; -export default CodeActionButton; \ No newline at end of file +export default CodeActionButton; diff --git a/src/ui/services/repo.js b/src/ui/services/repo.js index 6bf9e9357..7f787660f 100644 --- a/src/ui/services/repo.js +++ b/src/ui/services/repo.js @@ -9,8 +9,8 @@ const config = { withCredentials: true, }; -const canAddUser = (repoName, user, action) => { - const url = new URL(`${baseUrl}/repo/${repoName}`); +const canAddUser = (repoId, user, action) => { + const url = new URL(`${baseUrl}/repo/${repoId}`); return axios .get(url.toString(), config) .then((response) => { @@ -53,11 +53,14 @@ const getRepos = async ( setIsError(true); if (error.response && error.response.status === 401) { setAuth(false); - setErrorMessage('Failed to authorize user. If JWT auth is enabled, please check your configuration or disable it.'); + setErrorMessage( + 'Failed to authorize user. If JWT auth is enabled, please check your configuration or disable it.', + ); } else { setErrorMessage(`Error fetching repositories: ${error.response.data.message}`); } - }).finally(() => { + }) + .finally(() => { setIsLoading(false); }); }; @@ -76,17 +79,19 @@ const getRepo = async (setIsLoading, setData, setAuth, setIsError, id) => { } else { setIsError(true); } - }).finally(() => { + }) + .finally(() => { setIsLoading(false); }); }; const addRepo = async (onClose, setError, data) => { const url = new URL(`${baseUrl}/repo`); - axios + return axios .post(url, data, { withCredentials: true, headers: { 'X-CSRF-TOKEN': getCookie('csrf') } }) - .then(() => { + .then((response) => { onClose(); + return response.data; }) .catch((error) => { console.log(error.response.data.message); @@ -94,10 +99,10 @@ const addRepo = async (onClose, setError, data) => { }); }; -const addUser = async (repoName, user, action) => { - const canAdd = await canAddUser(repoName, user, action); +const addUser = async (repoId, user, action) => { + const canAdd = await canAddUser(repoId, user, action); if (canAdd) { - const url = new URL(`${baseUrl}/repo/${repoName}/user/${action}`); + const url = new URL(`${baseUrl}/repo/${repoId}/user/${action}`); const data = { username: user }; await axios .patch(url, data, { withCredentials: true, headers: { 'X-CSRF-TOKEN': getCookie('csrf') } }) @@ -111,8 +116,8 @@ const addUser = async (repoName, user, action) => { } }; -const deleteUser = async (user, repoName, action) => { - const url = new URL(`${baseUrl}/repo/${repoName}/user/${action}/${user}`); +const deleteUser = async (user, repoId, action) => { + const url = new URL(`${baseUrl}/repo/${repoId}/user/${action}/${user}`); await axios .delete(url, { withCredentials: true, headers: { 'X-CSRF-TOKEN': getCookie('csrf') } }) @@ -122,8 +127,8 @@ const deleteUser = async (user, repoName, action) => { }); }; -const deleteRepo = async (repoName) => { - const url = new URL(`${baseUrl}/repo/${repoName}/delete`); +const deleteRepo = async (repoId) => { + const url = new URL(`${baseUrl}/repo/${repoId}/delete`); await axios .delete(url, { withCredentials: true, headers: { 'X-CSRF-TOKEN': getCookie('csrf') } }) diff --git a/src/ui/utils.tsx b/src/ui/utils.tsx index 16d08f083..ab6413c41 100644 --- a/src/ui/utils.tsx +++ b/src/ui/utils.tsx @@ -1,3 +1,11 @@ +import axios from 'axios'; +import { + SCMRepositoryMetadata, + GitHubRepositoryMetadata, + GitLabRepositoryMetadata, +} from '../types/models'; +import moment from 'moment'; + /** * Retrieve a decoded cookie value from `document.cookie` with given `name`. * @param {string} name - The name of the cookie to retrieve @@ -15,3 +23,166 @@ export const getCookie = (name: string): string | null => { return decodeURIComponent(cookies[0].split('=')[1]); }; + +/** + * Retrieve a string indicating whether a repository URL is hosted + * by a known SCM provider (github or gitlab). + * @param {string} url The repository URL. + * @return {string} A string representing the SCM provider or 'unknown'. + */ +export const getGitProvider = (url: string) => { + const hostname = new URL(url).hostname.toLowerCase(); + if (hostname === 'github.com') return 'github'; + if (hostname.includes('gitlab')) return 'gitlab'; + return 'unknown'; +}; + +/** + * Predicts a user's profile URL based on their username and the SCM provider's details. + * @param {string} username The username. + * @param {string} provider The name of the SCM provider. + * @param {string} hostname The hostname of the SCM provider. + * @return {string | null} The predicted profile URL or null + */ +export const getUserProfileUrl = (username: string, provider: string, hostname: string) => { + if (provider == 'github') { + return `https://github.com/${username}`; + } else if (provider == 'gitlab') { + return `https://${hostname}/${username}`; + } else { + return null; + } +}; + +/** + * Attempts to construct a link to the user's profile at an SCM provider. + * @param {string} username The username. + * @param {string} provider The name of the SCM provider. + * @param {string} hostname The hostname of the SCM provider. + * @return {string} A string containing an HTML A tag pointing to the user's profile, if possible, degrading to just the username or 'N/A' when not (e.g. because the SCM provider is unknown). + */ +export const getUserProfileLink = (username: string, provider: string, hostname: string) => { + if (username) { + let profileData = ''; + const profileUrl = getUserProfileUrl(username, provider, hostname); + if (profileUrl) { + profileData = `${username}`; + } else { + profileData = `${username}`; + } + return profileData; + } else { + return 'N/A'; + } +}; + +/** + * Predicts an organisation's profile URL at an SCM provider. + * @param {string} project The organisation name. + * @param {string} provider The name of the SCM provider. + * @param {string} hostname The hostname of the SCM provider. + * @return {string} The predicted profile URL or null. + */ +export const getOrganisationProfileUrl = (project: string, provider: string, hostname: string) => { + if (provider == 'github') { + return `https://github.com/${project}`; + } else if (provider == 'gitlab') { + return `https://${hostname}/${project}`; + } else { + return null; + } +}; + +/** + * Predicts an organisation's profile image URL at an SCM provider. + * @param {string} project The organisation name. + * @param {string} provider The name of the SCM provider. + * @param {string} hostname The hostname of the SCM provider. + * @return {string} The predicted profile URL or null. + */ +export const getOrganisationProfileImageUrl = ( + project: string, + provider: string, + hostname: string, +) => { + if (provider == 'github') { + return `https://github.com/${project}.png`; + } else if (provider == 'gitlab') { + return `https://${hostname}/${project}.png`; + } else { + return null; + } +}; + +/** + * Retrieves data about repositories hosted at known SCM providers. + * @param {string} project The organisations's name. + * @param {string} name The repository name. + * @param {string} url The URL of the repository (used to detect the SCM provider) + * @return {Promise} Data retrieved from teh SCM provider or null + */ +export const fetchRemoteRepositoryData = async ( + project: string, + name: string, + url: string, +): Promise => { + const provider = getGitProvider(url); + const hostname = new URL(url).hostname; + + if (provider === 'github') { + const response = await axios.get( + `https://api.github.com/repos/${project}/${name}`, + ); + + return { + description: response.data.description, + language: response.data.language, + license: response.data.license?.spdx_id, + lastUpdated: moment + .max([ + moment(response.data.created_at), + moment(response.data.updated_at), + moment(response.data.pushed_at), + ]) + .fromNow(), + htmlUrl: response.data.html_url, + parentName: response.data.parent?.full_name, + parentUrl: response.data.parent?.html_url, + + avatarUrl: response.data.owner?.avatar_url, + profileUrl: response.data.owner?.html_url, + }; + } else if (provider == 'gitlab') { + const projectPath = encodeURIComponent(`${project}/${name}`); + const apiUrl = `https://${hostname}/api/v4/projects/${projectPath}`; + const response = await axios.get(apiUrl); + + // Make follow-up call to get languages + let primaryLanguage; + try { + const languagesResponse = await axios.get( + `https://${hostname}/api/v4/projects/${projectPath}/languages`, + ); + const languages = languagesResponse.data; + // Get the first key (primary language) from the ordered hash + primaryLanguage = Object.keys(languages)[0]; + } catch (languageError) { + console.warn('Could not fetch language data:', languageError); + } + + return { + description: response.data.description, + language: primaryLanguage, + license: response.data.license?.nickname, + lastUpdated: moment(response.data.last_activity_at).fromNow(), + htmlUrl: response.data.web_url, + parentName: response.data.forked_from_project?.full_name, + parentUrl: response.data.forked_from_project?.web_url, + avatarUrl: response.data.avatar_url, + profileUrl: response.data.namespace?.web_url, + }; + } else { + // For other/unknown providers, don't make API calls + return null; + } +}; diff --git a/src/ui/views/Login/Login.tsx b/src/ui/views/Login/Login.tsx index 9582274c1..dfc23d036 100644 --- a/src/ui/views/Login/Login.tsx +++ b/src/ui/views/Login/Login.tsx @@ -25,7 +25,7 @@ const loginUrl = `${process.env.VITE_API_URI}/api/auth/login`; const Login: React.FC = () => { const navigate = useNavigate(); - const { refreshUser } = useAuth(); + const authContext = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -64,7 +64,7 @@ const Login: React.FC = () => { window.sessionStorage.setItem('git.proxy.login', 'success'); setMessage('Success!'); setSuccess(true); - refreshUser().then(() => navigate('/dashboard/repo')); + authContext.refreshUser().then(() => navigate(0)); }) .catch((error: AxiosError) => { if (error.response?.status === 307) { diff --git a/src/ui/views/OpenPushRequests/components/PushesTable.tsx b/src/ui/views/OpenPushRequests/components/PushesTable.tsx index 4fe5fcad9..4df866d2d 100644 --- a/src/ui/views/OpenPushRequests/components/PushesTable.tsx +++ b/src/ui/views/OpenPushRequests/components/PushesTable.tsx @@ -17,6 +17,7 @@ import Search from '../../../components/Search/Search'; import Pagination from '../../../components/Pagination/Pagination'; import { PushData } from '../../../../types/models'; import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../../db/helper'; +import { getGitProvider, getUserProfileLink } from '../../../utils'; interface PushesTableProps { [key: string]: any; @@ -103,6 +104,10 @@ const PushesTable: React.FC = (props) => { {[...currentItems].reverse().map((row) => { const repoFullName = trimTrailingDotGit(row.repo); const repoBranch = trimPrefixRefsHeads(row.branch); + const repoUrl = row.url; + const repoWebUrl = trimTrailingDotGit(repoUrl); + const gitProvider = getGitProvider(repoUrl); + const hostname = new URL(repoUrl).hostname; const commitTimestamp = row.commitData[0]?.commitTs || row.commitData[0]?.commitTimestamp; @@ -112,22 +117,18 @@ const PushesTable: React.FC = (props) => { {commitTimestamp ? moment.unix(commitTimestamp).toString() : 'N/A'} - + {repoFullName} - + {repoBranch} @@ -135,30 +136,10 @@ const PushesTable: React.FC = (props) => { - {row.commitData[0]?.committer ? ( - - {row.commitData[0].committer} - - ) : ( - 'N/A' - )} + {getUserProfileLink(row.commitData[0].committer, gitProvider, hostname)} - {row.commitData[0]?.author ? ( - - {row.commitData[0].author} - - ) : ( - 'N/A' - )} + {getUserProfileLink(row.commitData[0].author, gitProvider, hostname)} {row.commitData[0]?.authorEmail ? ( diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 8da20e63d..2c5a2e6d3 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -24,6 +24,7 @@ import Snackbar from '@material-ui/core/Snackbar'; import Tooltip from '@material-ui/core/Tooltip'; import { PushData } from '../../../types/models'; import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../db/helper'; +import { getGitProvider } from '../../utils'; const Dashboard: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -108,6 +109,10 @@ const Dashboard: React.FC = () => { const repoFullName = trimTrailingDotGit(data.repo); const repoBranch = trimPrefixRefsHeads(data.branch); + const repoUrl = data.url; + const repoWebUrl = trimTrailingDotGit(repoUrl); + const gitProvider = getGitProvider(repoUrl); + const isGitHub = gitProvider == 'github'; const generateIcon = (title: string) => { switch (title) { @@ -192,18 +197,26 @@ const Dashboard: React.FC = () => { ) : ( <> - - Reviewer - + {isGitHub && ( + + + + )}

- - {data.attestation.reviewer.gitAccount} - {' '} + {isGitHub && ( + + {data.attestation.reviewer.gitAccount} + + )} + {!isGitHub && ( + + {data.attestation.reviewer.username} + + )}{' '} approved this contribution

@@ -241,7 +254,7 @@ const Dashboard: React.FC = () => {

Remote Head

@@ -253,7 +266,7 @@ const Dashboard: React.FC = () => {

Commit SHA

@@ -264,7 +277,7 @@ const Dashboard: React.FC = () => {

Repository

- + {repoFullName}

@@ -272,11 +285,7 @@ const Dashboard: React.FC = () => {

Branch

- + {repoBranch}

@@ -306,18 +315,28 @@ const Dashboard: React.FC = () => { {moment.unix(c.commitTs || c.commitTimestamp || 0).toString()}
- - {c.committer} - + {isGitHub && ( + + {c.committer} + + )} + {!isGitHub && {c.committer}} - - {c.author} - + {isGitHub && ( + + {c.author} + + )} + {!isGitHub && {c.author}} {c.authorEmail ? ( diff --git a/src/ui/views/RepoDetails/Components/AddUser.tsx b/src/ui/views/RepoDetails/Components/AddUser.tsx index bf59676e6..4ed9df42c 100644 --- a/src/ui/views/RepoDetails/Components/AddUser.tsx +++ b/src/ui/views/RepoDetails/Components/AddUser.tsx @@ -19,7 +19,7 @@ import { PersonAdd } from '@material-ui/icons'; import { UserData } from '../../../../types/models'; interface AddUserDialogProps { - repoName: string; + repoId: string; type: string; refreshFn: () => void; open: boolean; @@ -27,7 +27,7 @@ interface AddUserDialogProps { } const AddUserDialog: React.FC = ({ - repoName, + repoId, type, refreshFn, open, @@ -58,7 +58,7 @@ const AddUserDialog: React.FC = ({ const add = async () => { try { setIsLoading(true); - await addUser(repoName, username, type); + await addUser(repoId, username, type); handleSuccess(); handleClose(); } catch (e) { @@ -144,12 +144,12 @@ const AddUserDialog: React.FC = ({ }; interface AddUserProps { - repoName: string; + repoId: string; type: string; refreshFn: () => void; } -const AddUser: React.FC = ({ repoName, type, refreshFn }) => { +const AddUser: React.FC = ({ repoId, type, refreshFn }) => { const [open, setOpen] = useState(false); const handleClickOpen = () => { @@ -166,7 +166,7 @@ const AddUser: React.FC = ({ repoName, type, refreshFn }) => { ({ }, }, table: { - minWidth: 650, + minWidth: 200, }, })); @@ -58,29 +61,36 @@ const RepoDetails: React.FC = () => { const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); + const [remoteRepoData, setRemoteRepoData] = React.useState(null); const { user } = useContext(UserContext); - const { id: repoName } = useParams<{ id: string }>(); + const { id: repoId } = useParams<{ id: string }>(); useEffect(() => { - if (repoName) { - getRepo(setIsLoading, setData, setAuth, setIsError, repoName); + if (repoId) { + getRepo(setIsLoading, setData, setAuth, setIsError, repoId); } - }, [repoName]); + }, [repoId]); + + useEffect(() => { + if (data) { + fetchRemoteRepositoryData(data.project, data.name, data.url).then(setRemoteRepoData); + } + }, [data]); const removeUser = async (userToRemove: string, action: 'authorise' | 'push') => { - if (!repoName) return; - await deleteUser(userToRemove, repoName, action); - getRepo(setIsLoading, setData, setAuth, setIsError, repoName); + if (!repoId) return; + await deleteUser(userToRemove, repoId, action); + getRepo(setIsLoading, setData, setAuth, setIsError, repoId); }; - const removeRepository = async (name: string) => { - await deleteRepo(name); + const removeRepository = async (id: string) => { + await deleteRepo(id); navigate('/dashboard/repo', { replace: true }); }; const refresh = () => { - if (repoName) { - getRepo(setIsLoading, setData, setAuth, setIsError, repoName); + if (repoId) { + getRepo(setIsLoading, setData, setAuth, setIsError, repoId); } }; @@ -88,56 +98,66 @@ const RepoDetails: React.FC = () => { if (isError) return
Something went wrong ...
; if (!data) return
No repository data found
; - const { project: org, name, proxyURL } = data; - const cloneURL = `${proxyURL}/${org}/${name}.git`; + const { url: remoteUrl, proxyURL } = data || {}; + const parsedUrl = new URL(remoteUrl); + const cloneURL = `${proxyURL}/${parsedUrl.host}${parsedUrl.port ? `:${parsedUrl.port}` : ''}${parsedUrl.pathname}`; return ( - {user.admin && ( -
- -
- )} - - - - + + {user.admin && ( + + + + )} + + + +
- - {`${data.project} - - + {remoteRepoData?.avatarUrl && ( + + {`${data.project} + + )} + + Organization

- - {data.project} - + {remoteRepoData?.profileUrl && ( + + {data.project} + + )} + {!remoteRepoData?.profileUrl && {data.project}}

- + Name

@@ -145,7 +165,7 @@ const RepoDetails: React.FC = () => {

- + URL

@@ -164,7 +184,7 @@ const RepoDetails: React.FC = () => {

{user.admin && (
- +
)} @@ -207,7 +227,7 @@ const RepoDetails: React.FC = () => { {user.admin && (
- +
)} diff --git a/src/ui/views/RepoList/Components/NewRepo.tsx b/src/ui/views/RepoList/Components/NewRepo.tsx index 8189566a9..61d7cc715 100644 --- a/src/ui/views/RepoList/Components/NewRepo.tsx +++ b/src/ui/views/RepoList/Components/NewRepo.tsx @@ -19,7 +19,7 @@ import { RepoIcon } from '@primer/octicons-react'; interface AddRepositoryDialogProps { open: boolean; onClose: () => void; - onSuccess: (data: RepositoryData) => void; + onSuccess: (data: RepositoryDataWithId) => void; } export interface RepositoryData { @@ -33,8 +33,10 @@ export interface RepositoryData { proxyURL?: string; } +export type RepositoryDataWithId = Required> & RepositoryData; + interface NewRepoProps { - onSuccess: (data: RepositoryData) => void; + onSuccess: (data: RepositoryDataWithId) => Promise; } const useStyles = makeStyles(styles as any); @@ -53,7 +55,7 @@ const AddRepositoryDialog: React.FC = ({ open, onClose onClose(); }; - const handleSuccess = (data: RepositoryData) => { + const handleSuccess = (data: RepositoryDataWithId) => { onSuccess(data); setTip(true); }; @@ -83,15 +85,19 @@ const AddRepositoryDialog: React.FC = ({ open, onClose } try { - new URL(data.url); + const parsedUrl = new URL(data.url); + if (!parsedUrl.pathname.endsWith('.git')) { + setError('Invalid git URL - Git URLs should end with .git'); + return; + } } catch { setError('Invalid URL format'); return; } try { - await addRepo(onClose, setError, data); - handleSuccess(data); + const repoData = await addRepo(onClose, setError, data); + handleSuccess(repoData); handleClose(); } catch (e) { if (e instanceof Error) { @@ -146,7 +152,9 @@ const AddRepositoryDialog: React.FC = ({ open, onClose onChange={(e) => setProject(e.target.value)} value={project} /> - GitHub Organization + + Organization or path, e.g. finos +
@@ -159,7 +167,9 @@ const AddRepositoryDialog: React.FC = ({ open, onClose onChange={(e) => setName(e.target.value)} value={name} /> - GitHub Repository Name + + Git Repository Name, e.g. git-proxy + @@ -173,7 +183,9 @@ const AddRepositoryDialog: React.FC = ({ open, onClose onChange={(e) => setUrl(e.target.value)} value={url} /> - GitHub Repository URL + + Git Repository URL, e.g. https://github.com/finos/git-proxy.git + diff --git a/src/ui/views/RepoList/Components/RepoOverview.tsx b/src/ui/views/RepoList/Components/RepoOverview.tsx index 0c9a840d0..2191c05db 100644 --- a/src/ui/views/RepoList/Components/RepoOverview.tsx +++ b/src/ui/views/RepoList/Components/RepoOverview.tsx @@ -1,66 +1,52 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Snackbar, TableCell, TableRow } from '@material-ui/core'; import GridContainer from '../../../components/Grid/GridContainer'; import GridItem from '../../../components/Grid/GridItem'; import { CodeReviewIcon, LawIcon, PeopleIcon } from '@primer/octicons-react'; -import axios from 'axios'; -import moment from 'moment'; import CodeActionButton from '../../../components/CustomButtons/CodeActionButton'; import { languageColors } from '../../../../constants/languageColors'; import { RepositoriesProps } from '../repositories.types'; - -interface GitHubRepository { - description?: string; - language?: string; - license?: { - spdx_id: string; - }; - parent?: { - full_name: string; - html_url: string; - }; - created_at?: string; - updated_at?: string; - pushed_at?: string; -} +import { fetchRemoteRepositoryData } from '../../../utils'; +import { SCMRepositoryMetadata } from '../../../../types/models'; const Repositories: React.FC = (props) => { - const [github, setGitHub] = useState({}); - - const [errorMessage, setErrorMessage] = React.useState(''); + const [remoteRepoData, setRemoteRepoData] = React.useState(null); + const [errorMessage] = React.useState(''); const [snackbarOpen, setSnackbarOpen] = React.useState(false); useEffect(() => { - getGitHubRepository(); - }, [props.data?.project, props.data?.name]); + prepareRemoteRepositoryData(); + }, [props.data.project, props.data.name, props.data.url]); - const getGitHubRepository = async () => { - await axios - .get(`https://api.github.com/repos/${props.data?.project}/${props.data?.name}`) - .then((res) => { - setGitHub(res.data); - }) - .catch((error) => { - setErrorMessage( - `Error fetching GitHub repository ${props.data?.project}/${props.data?.name}: ${error}`, - ); - setSnackbarOpen(true); - }); + const prepareRemoteRepositoryData = async () => { + try { + const { url: remoteUrl } = props.data; + if (!remoteUrl) return; + + setRemoteRepoData( + await fetchRemoteRepositoryData(props.data.project, props.data.name, remoteUrl), + ); + } catch (error: any) { + console.warn( + `Unable to fetch repository data for ${props.data.project}/${props.data.name} from '${remoteUrl}' - this may occur if the project is private or from an SCM vendor that is not supported.`, + ); + } }; - const { project: org, name, proxyURL } = props?.data || {}; - const cloneURL = `${proxyURL}/${org}/${name}.git`; + const { url: remoteUrl, proxyURL } = props?.data || {}; + const parsedUrl = new URL(remoteUrl); + const cloneURL = `${proxyURL}/${parsedUrl.host}${parsedUrl.port ? `:${parsedUrl.port}` : ''}${parsedUrl.pathname}`; return (
- + - {props.data?.project}/{props.data?.name} + {props.data.project}/{props.data.name} - {github.parent && ( + {remoteRepoData?.parentName && ( = (props) => { fontWeight: 'normal', color: 'inherit', }} - href={github.parent.html_url} + href={remoteRepoData.parentUrl} > - {github.parent.full_name} + {remoteRepoData.parentName} )} - {github.description &&

{github.description}

} + {remoteRepoData?.description && ( +

{remoteRepoData.description}

+ )} - {github.language && ( + {remoteRepoData?.language && ( - {github.language} + {remoteRepoData.language} )} - {github.license && ( + {remoteRepoData?.license && ( {' '} - {github.license.spdx_id} + {remoteRepoData.license} )} @@ -113,17 +101,8 @@ const Repositories: React.FC = (props) => { {props.data?.users?.canAuthorise?.length || 0} - {(github.created_at || github.updated_at || github.pushed_at) && ( - - Last updated{' '} - {moment - .max([ - moment(github.created_at || 0), - moment(github.updated_at || 0), - moment(github.pushed_at || 0), - ]) - .fromNow()} - + {remoteRepoData?.lastUpdated && ( + Last updated {remoteRepoData.lastUpdated} )}
diff --git a/src/ui/views/RepoList/Components/Repositories.tsx b/src/ui/views/RepoList/Components/Repositories.tsx index be110f532..5c5d75ca3 100644 --- a/src/ui/views/RepoList/Components/Repositories.tsx +++ b/src/ui/views/RepoList/Components/Repositories.tsx @@ -8,7 +8,7 @@ import styles from '../../../assets/jss/material-dashboard-react/views/dashboard import { getRepos } from '../../../services/repo'; import GridContainer from '../../../components/Grid/GridContainer'; import GridItem from '../../../components/Grid/GridItem'; -import NewRepo, { RepositoryData } from './NewRepo'; +import NewRepo, { RepositoryDataWithId } from './NewRepo'; import RepoOverview from './RepoOverview'; import { UserContext } from '../../../../context'; import Search from '../../../components/Search/Search'; @@ -19,7 +19,7 @@ import { RepositoriesProps } from '../repositories.types'; interface GridContainerLayoutProps { classes: any; openRepo: (repo: string) => void; - data: RepositoryData[]; + data: RepositoryDataWithId[]; repoButton: React.ReactNode; onSearch: (query: string) => void; currentPage: number; @@ -27,6 +27,8 @@ interface GridContainerLayoutProps { itemsPerPage: number; onPageChange: (page: number) => void; onFilterChange: (filterOption: FilterOption, sortOrder: SortOrder) => void; + tableId: string; + key: string; } interface UserContextType { @@ -39,8 +41,8 @@ interface UserContextType { export default function Repositories(props: RepositoriesProps): React.ReactElement { const useStyles = makeStyles(styles as any); const classes = useStyles(); - const [data, setData] = useState([]); - const [filteredData, setFilteredData] = useState([]); + const [data, setData] = useState([]); + const [filteredData, setFilteredData] = useState([]); const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -49,8 +51,8 @@ export default function Repositories(props: RepositoriesProps): React.ReactEleme const itemsPerPage: number = 5; const navigate = useNavigate(); const { user } = useContext(UserContext); - - const openRepo = (repo: string): void => navigate(`/dashboard/repo/${repo}`, { replace: true }); + const openRepo = (repoId: string): void => + navigate(`/dashboard/repo/${repoId}`, { replace: true }); useEffect(() => { const query: Record = {}; @@ -60,7 +62,7 @@ export default function Repositories(props: RepositoriesProps): React.ReactEleme } getRepos( setIsLoading, - (data: RepositoryData[]) => { + (data: RepositoryDataWithId[]) => { setData(data); setFilteredData(data); }, @@ -71,7 +73,7 @@ export default function Repositories(props: RepositoriesProps): React.ReactEleme ); }, [props]); - const refresh = async (repo: RepositoryData): Promise => { + const refresh = async (repo: RepositoryDataWithId): Promise => { const updatedData = [...data, repo]; setData(updatedData); setFilteredData(updatedData); @@ -135,24 +137,23 @@ export default function Repositories(props: RepositoriesProps): React.ReactEleme ); - return ( - - ); + return getGridContainerLayOut({ + key: 'x', + classes: classes, + openRepo: openRepo, + data: paginatedData, + repoButton: addrepoButton, + onSearch: handleSearch, + currentPage: currentPage, + totalItems: filteredData.length, + itemsPerPage: itemsPerPage, + onPageChange: handlePageChange, + onFilterChange: handleFilterChange, + tableId: 'RepoListTable', + }); } -function GetGridContainerLayOut(props: GridContainerLayoutProps): React.ReactElement { +function getGridContainerLayOut(props: GridContainerLayoutProps): React.ReactElement { return ( {props.repoButton} @@ -162,10 +163,10 @@ function GetGridContainerLayOut(props: GridContainerLayoutProps): React.ReactEle - +
{props.data.map((row) => { - if (row.project && row.name) { + if (row.url) { return ( ); diff --git a/src/ui/views/RepoList/repositories.types.ts b/src/ui/views/RepoList/repositories.types.ts index 577269671..2e7660147 100644 --- a/src/ui/views/RepoList/repositories.types.ts +++ b/src/ui/views/RepoList/repositories.types.ts @@ -1,7 +1,9 @@ export interface RepositoriesProps { - data?: { + data: { + _id: string; project: string; name: string; + url: string; proxyURL: string; users?: { canPush?: string[]; diff --git a/src/ui/views/User/User.tsx b/src/ui/views/User/User.tsx index a3635a2e8..b5b51e9b4 100644 --- a/src/ui/views/User/User.tsx +++ b/src/ui/views/User/User.tsx @@ -83,6 +83,7 @@ export default function UserProfile(): React.ReactElement { gitAccount: escapeHTML(gitAccount), }; await updateUser(updatedData); + setData(updatedData); navigate(`/dashboard/profile`); } catch { setIsError(true); diff --git a/test/1.test.js b/test/1.test.js index ad67cff6a..227dc0104 100644 --- a/test/1.test.js +++ b/test/1.test.js @@ -10,7 +10,7 @@ chai.should(); describe('init', async () => { let app; before(async function () { - app = await service.start(); + app = await service.start(); // pass in proxy if testing config loading or administration of proxy routes }); it('should not be logged in', async function () { diff --git a/test/addRepoTest.test.js b/test/addRepoTest.test.js deleted file mode 100644 index 172074101..000000000 --- a/test/addRepoTest.test.js +++ /dev/null @@ -1,208 +0,0 @@ -// Import the dependencies for testing -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const db = require('../src/db'); -const service = require('../src/service'); - -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; - -describe('add new repo', async () => { - let app; - let cookie; - - const setCookie = function (res) { - res.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - const value = x.split(';')[0]; - cookie = value; - } - }); - }; - - before(async function () { - app = await service.start(); - // Prepare the data. - await db.deleteRepo('test-repo'); - await db.deleteUser('u1'); - await db.deleteUser('u2'); - await db.createUser('u1', 'abc', 'test@test.com', 'test', true); - await db.createUser('u2', 'abc', 'test2@test.com', 'test', true); - }); - - it('login', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - expect(res).to.have.cookie('connect.sid'); - setCookie(res); - }); - - it('create a new repo', async function () { - const res = await chai.request(app).post('/api/v1/repo').set('Cookie', `${cookie}`).send({ - project: 'finos', - name: 'test-repo', - url: 'https://github.com/finos/test-repo.git', - }); - res.should.have.status(200); - - const repo = await db.getRepo('test-repo'); - repo.project.should.equal('finos'); - repo.name.should.equal('test-repo'); - repo.url.should.equal('https://github.com/finos/test-repo.git'); - repo.users.canPush.length.should.equal(0); - repo.users.canAuthorise.length.should.equal(0); - }); - - it('filter repos', async function () { - const res = await chai - .request(app) - .get('/api/v1/repo') - .set('Cookie', `${cookie}`) - .query({ name: 'test-repo' }); - res.should.have.status(200); - res.body[0].project.should.equal('finos'); - res.body[0].name.should.equal('test-repo'); - res.body[0].url.should.equal('https://github.com/finos/test-repo.git'); - }); - - it('add 1st can push user', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/push') - .set('Cookie', `${cookie}`) - .send({ - username: 'u1', - }); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canPush.length.should.equal(1); - repo.users.canPush[0].should.equal('u1'); - }); - - it('add 2nd can push user', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/push') - .set('Cookie', `${cookie}`) - .send({ - username: 'u2', - }); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canPush.length.should.equal(2); - repo.users.canPush[1].should.equal('u2'); - }); - - it('add push user that does not exist', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/push') - .set('Cookie', `${cookie}`) - .send({ - username: 'u3', - }); - - res.should.have.status(400); - const repo = await db.getRepo('test-repo'); - repo.users.canPush.length.should.equal(2); - }); - - it('delete user u2 from push', async function () { - const res = await chai - .request(app) - .delete('/api/v1/repo/test-repo/user/push/u2') - .set('Cookie', `${cookie}`) - .send({}); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canPush.length.should.equal(1); - }); - - it('add 1st can authorise user', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/authorise') - .set('Cookie', `${cookie}`) - .send({ - username: 'u1', - }); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canAuthorise.length.should.equal(1); - repo.users.canAuthorise[0].should.equal('u1'); - }); - - it('add 2nd can authorise user', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/authorise') - .set('Cookie', `${cookie}`) - .send({ - username: 'u2', - }); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canAuthorise.length.should.equal(2); - repo.users.canAuthorise[1].should.equal('u2'); - }); - - it('add authorise user that does not exist', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/authorise') - .set('Cookie', `${cookie}`) - .send({ - username: 'u3', - }); - - res.should.have.status(400); - const repo = await db.getRepo('test-repo'); - repo.users.canAuthorise.length.should.equal(2); - }); - - it('Can delete u2 user', async function () { - const res = await chai - .request(app) - .delete('/api/v1/repo/test-repo/user/authorise/u2') - .set('Cookie', `${cookie}`) - .send({}); - - res.should.have.status(200); - const repo = await db.getRepo('test-repo'); - repo.users.canAuthorise.length.should.equal(1); - }); - - it('Valid user push permission on repo', async function () { - const res = await chai - .request(app) - .patch('/api/v1/repo/test-repo/user/authorise') - .set('Cookie', `${cookie}`) - .send({ username: 'u2' }); - - res.should.have.status(200); - const isAllowed = await db.isUserPushAllowed('test-repo', 'u2'); - expect(isAllowed).to.be.true; - }); - - it('Invalid user push permission on repo', async function () { - const isAllowed = await db.isUserPushAllowed('test-repo', 'test'); - expect(isAllowed).to.be.false; - }); - - after(async function () { - await service.httpServer.close(); - - // don't clean up data as cypress tests rely on it being present - // await db.deleteRepo('test-repo'); - // await db.deleteUser('u1'); - // await db.deleteUser('u2'); - }); -}); diff --git a/test/processors/checkCommitMessages.test.js b/test/processors/checkCommitMessages.test.js index abea984bf..c864566a3 100644 --- a/test/processors/checkCommitMessages.test.js +++ b/test/processors/checkCommitMessages.test.js @@ -47,7 +47,7 @@ describe('checkCommitMessages', () => { beforeEach(() => { req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.commitData = [ { message: 'Fix bug', author: 'test@example.com' }, { message: 'Update docs', author: 'test@example.com' }, diff --git a/test/processors/checkIfWaitingAuth.test.js b/test/processors/checkIfWaitingAuth.test.js index 5306f8da4..0ee9988bb 100644 --- a/test/processors/checkIfWaitingAuth.test.js +++ b/test/processors/checkIfWaitingAuth.test.js @@ -33,11 +33,17 @@ describe('checkIfWaitingAuth', () => { beforeEach(() => { req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); }); it('should set allowPush when action exists and is authorized', async () => { - const authorizedAction = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const authorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); authorizedAction.authorised = true; getPushStub.resolves(authorizedAction); @@ -50,7 +56,13 @@ describe('checkIfWaitingAuth', () => { }); it('should not set allowPush when action exists but not authorized', async () => { - const unauthorizedAction = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const unauthorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); unauthorizedAction.authorised = false; getPushStub.resolves(unauthorizedAction); @@ -73,7 +85,13 @@ describe('checkIfWaitingAuth', () => { it('should not modify action when it has an error', async () => { action.error = true; - const authorizedAction = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const authorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); authorizedAction.authorised = true; getPushStub.resolves(authorizedAction); diff --git a/test/processors/checkUserPushPermission.test.js b/test/processors/checkUserPushPermission.test.js index fee0c7a64..8b2b6be7e 100644 --- a/test/processors/checkUserPushPermission.test.js +++ b/test/processors/checkUserPushPermission.test.js @@ -11,9 +11,11 @@ describe('checkUserPushPermission', () => { let getUsersStub; let isUserPushAllowedStub; let logStub; + let errorStub; beforeEach(() => { logStub = sinon.stub(console, 'log'); + errorStub = sinon.stub(console, 'error'); getUsersStub = sinon.stub(); isUserPushAllowedStub = sinon.stub(); @@ -41,7 +43,13 @@ describe('checkUserPushPermission', () => { beforeEach(() => { req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); + action = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'https://github.com/finos/git-proxy.git', + ); action.user = 'git-user'; action.userEmail = 'db-user@test.com'; stepSpy = sinon.spy(Step.prototype, 'log'); @@ -58,10 +66,10 @@ describe('checkUserPushPermission', () => { expect(result.steps).to.have.lengthOf(1); expect(result.steps[0].error).to.be.false; expect(stepSpy.lastCall.args[0]).to.equal( - 'User db-user@test.com is allowed to push on repo test/repo.git', + 'User db-user@test.com is allowed to push on repo https://github.com/finos/git-proxy.git', ); expect(logStub.lastCall.args[0]).to.equal( - 'User db-user@test.com permission on Repo repo : true', + 'User db-user@test.com permission on Repo https://github.com/finos/git-proxy.git : true', ); }); @@ -75,9 +83,11 @@ describe('checkUserPushPermission', () => { expect(result.steps).to.have.lengthOf(1); expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.equal( - 'Your push has been blocked (db-user@test.com is not allowed to push on repo test/repo.git)', + expect(stepSpy.lastCall.args[0]).to.equal( + 'Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)', ); + expect(result.steps[0].errorMessage).to.include('Your push has been blocked'); + expect(logStub.lastCall.args[0]).to.equal('User not allowed to Push'); }); it('should reject push when no user found for git account', async () => { @@ -87,6 +97,9 @@ describe('checkUserPushPermission', () => { expect(result.steps).to.have.lengthOf(1); expect(result.steps[0].error).to.be.true; + expect(stepSpy.lastCall.args[0]).to.equal( + 'Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)', + ); expect(result.steps[0].errorMessage).to.include('Your push has been blocked'); }); @@ -100,9 +113,12 @@ describe('checkUserPushPermission', () => { expect(result.steps).to.have.lengthOf(1); expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.equal( + expect(stepSpy.lastCall.args[0]).to.equal( 'Your push has been blocked (there are multiple users with email db-user@test.com)', ); + expect(errorStub.lastCall.args[0]).to.equal( + 'Multiple users found with email address db-user@test.com, ending', + ); }); it('should return error when no user is set in the action', async () => { diff --git a/test/processors/clearBareClone.test.js b/test/processors/clearBareClone.test.js index 1ebcf85c4..3f869ff98 100644 --- a/test/processors/clearBareClone.test.js +++ b/test/processors/clearBareClone.test.js @@ -10,8 +10,8 @@ const timestamp = Date.now(); describe('clear bare and local clones', async () => { it('pull remote generates a local .remote folder', async () => { - const action = new Action('123', 'type', 'get', timestamp, 'finos/git-proxy'); - action.url = 'https://github.com/finos/git-proxy'; + const action = new Action('123', 'type', 'get', timestamp, 'finos/git-proxy.git'); + action.url = 'https://github.com/finos/git-proxy.git'; const authorization = `Basic ${Buffer.from('JamieSlome:test').toString('base64')}`; @@ -28,7 +28,7 @@ describe('clear bare and local clones', async () => { }).timeout(20000); it('clear bare clone function purges .remote folder and specific clone folder', async () => { - const action = new Action('123', 'type', 'get', timestamp, 'finos/git-proxy'); + const action = new Action('123', 'type', 'get', timestamp, 'finos/git-proxy.git'); await clearBareClone(null, action); expect(fs.existsSync(`./.remote`)).to.throw; expect(fs.existsSync(`./.remote/${timestamp}`)).to.throw; diff --git a/test/processors/getDiff.test.js b/test/processors/getDiff.test.js index 7f5bb4cf3..d096f4926 100644 --- a/test/processors/getDiff.test.js +++ b/test/processors/getDiff.test.js @@ -35,7 +35,7 @@ describe('getDiff', () => { await git.add('.'); await git.commit('second commit'); - const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = __dirname; // Temp dir parent path action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; @@ -50,7 +50,7 @@ describe('getDiff', () => { }); it('should get diff between commits with no changes', async () => { - const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = __dirname; // Temp dir parent path action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; @@ -64,7 +64,7 @@ describe('getDiff', () => { }); it('should throw an error if no commit data is provided', async () => { - const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = __dirname; // Temp dir parent path action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; @@ -79,7 +79,7 @@ describe('getDiff', () => { }); it('should throw an error if no commit data is provided', async () => { - const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = __dirname; // Temp dir parent path action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; @@ -102,7 +102,7 @@ describe('getDiff', () => { const parentCommit = log.all[1].hash; const headCommit = log.all[0].hash; - const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = path.dirname(tempDir); action.repoName = path.basename(tempDir); diff --git a/test/processors/gitLeaks.test.js b/test/processors/gitLeaks.test.js index 9157cf301..eca181c61 100644 --- a/test/processors/gitLeaks.test.js +++ b/test/processors/gitLeaks.test.js @@ -39,7 +39,7 @@ describe('gitleaks', () => { exec = gitleaksModule.exec; req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = '/tmp'; action.repoName = 'test-repo'; action.commitFrom = 'abc123'; diff --git a/test/processors/writePack.test.js b/test/processors/writePack.test.js index 2cff64204..746b700ac 100644 --- a/test/processors/writePack.test.js +++ b/test/processors/writePack.test.js @@ -45,7 +45,13 @@ describe('writePack', () => { req = { body: 'pack data', }; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + action = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'https://github.com/finos/git-proxy.git', + ); action.proxyGitPath = '/path/to'; action.repoName = 'repo'; }); diff --git a/test/scanDiff.test.js b/test/scanDiff.test.js index 09fdb9d38..d06baeba9 100644 --- a/test/scanDiff.test.js +++ b/test/scanDiff.test.js @@ -4,6 +4,7 @@ const processor = require('../src/proxy/processors/push-action/scanDiff'); const { Action } = require('../src/proxy/actions/Action'); const { expect } = chai; const config = require('../src/config'); +const db = require('../src/db'); chai.should(); // Load blocked literals and patterns from configuration... @@ -72,14 +73,28 @@ describe('Scan commit diff...', async () => { }, }, }; + + before(async () => { + // needed for private org tests + const repo = await db.createRepo(TEST_REPO); + TEST_REPO._id = repo._id; + }); + + after(async () => { + await db.deleteRepo(TEST_REPO._id); + }); + it('A diff including an AWS (Amazon Web Services) Access Key ID blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff('AKIAIOSFODNN7EXAMPLE'), }, ]; + action.setCommit('38cdc3e', '8a9c321'); + action.setBranch('b'); + action.setMessage('Message'); const { error, errorMessage } = await processor.exec(null, action); expect(error).to.be.true; @@ -88,13 +103,14 @@ describe('Scan commit diff...', async () => { // Formatting test it('A diff including multiple AWS (Amazon Web Services) Access Keys ID blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateMultiLineDiff(), }, ]; + action.setCommit('8b97e49', 'de18d43'); const { error, errorMessage } = await processor.exec(null, action); @@ -107,13 +123,14 @@ describe('Scan commit diff...', async () => { // Formatting test it('A diff including multiple AWS Access Keys ID and Literal blocks the proxy with appropriate message...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateMultiLineDiffWithLiteral(), }, ]; + action.setCommit('8b97e49', 'de18d43'); const { error, errorMessage } = await processor.exec(null, action); @@ -128,13 +145,15 @@ describe('Scan commit diff...', async () => { }); it('A diff including a Google Cloud Platform API Key blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff('AIza0aB7Z4Rfs23MnPqars81yzu19KbH72zaFda'), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); @@ -143,7 +162,7 @@ describe('Scan commit diff...', async () => { }); it('A diff including a GitHub Personal Access Token blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', @@ -158,7 +177,7 @@ describe('Scan commit diff...', async () => { }); it('A diff including a GitHub Fine Grained Personal Access Token blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', @@ -167,6 +186,8 @@ describe('Scan commit diff...', async () => { ), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); @@ -175,13 +196,15 @@ describe('Scan commit diff...', async () => { }); it('A diff including a GitHub Actions Token blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(`ghs_${crypto.randomBytes(20).toString('hex')}`), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); @@ -190,7 +213,7 @@ describe('Scan commit diff...', async () => { }); it('A diff including a JSON Web Token (JWT) blocks the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', @@ -199,6 +222,8 @@ describe('Scan commit diff...', async () => { ), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); @@ -208,13 +233,15 @@ describe('Scan commit diff...', async () => { it('A diff including a blocked literal blocks the proxy...', async () => { for (const [literal] of blockedLiterals.entries()) { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(literal), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); @@ -223,7 +250,7 @@ describe('Scan commit diff...', async () => { } }); it('When no diff is present, the proxy is blocked...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', @@ -238,7 +265,7 @@ describe('Scan commit diff...', async () => { }); it('When diff is not a string, the proxy is blocked...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', @@ -253,20 +280,34 @@ describe('Scan commit diff...', async () => { }); it('A diff with no secrets or sensitive information does not block the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'project/name'); + const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(''), }, ]; + action.commitFrom = '38cdc3e'; + action.commitTo = '8a9c321'; const { error } = await processor.exec(null, action); expect(error).to.be.false; }); + const TEST_REPO = { + project: 'private-org-test', + name: 'repo.git', + url: 'https://github.com/private-org-test/repo.git', + }; + it('A diff including a provider token in a private organization does not block the proxy...', async () => { - const action = new Action('1', 'type', 'method', 1, 'private-org-test'); + const action = new Action( + '1', + 'type', + 'method', + 1, + 'https://github.com/private-org-test/repo.git', // URL needs to be parseable AND exist in DB + ); action.steps = [ { stepName: 'diff', diff --git a/test/testCheckRepoInAuthList.test.js b/test/testCheckRepoInAuthList.test.js index 19d161c12..7ef78cf73 100644 --- a/test/testCheckRepoInAuthList.test.js +++ b/test/testCheckRepoInAuthList.test.js @@ -2,26 +2,37 @@ const chai = require('chai'); const actions = require('../src/proxy/actions/Action'); const processor = require('../src/proxy/processors/push-action/checkRepoInAuthorisedList'); const expect = chai.expect; +const db = require('../src/db'); -const authList = () => { - return [ - { - name: 'repo-is-ok', - project: 'thisproject', - }, - ]; +const TEST_REPO = { + project: 'thisproject', + name: 'repo-is-ok', + url: 'https://github.com/thisproject/repo-is-ok.git', +}; + +const TEST_NON_EXISTENT_REPO = { + url: 'https://github.com/thisproject/repo-is-not-ok.git', }; describe('Check a Repo is in the authorised list', async () => { + before(async function () { + const repo = await db.createRepo(TEST_REPO); + TEST_REPO._id = repo._id; + }); + + after(async function () { + await db.deleteRepo(TEST_REPO._id); + }); + it('Should set ok=true if repo in whitelist', async () => { - const action = new actions.Action('123', 'type', 'get', 1234, 'thisproject/repo-is-ok'); - const result = await processor.exec(null, action, authList); + const action = new actions.Action('123', 'type', 'get', 1234, TEST_REPO.url); + const result = await processor.exec(null, action); expect(result.error).to.be.false; }); it('Should set ok=false if not in authorised', async () => { - const action = new actions.Action('123', 'type', 'get', 1234, 'thisproject/repo-is-not-ok'); - const result = await processor.exec(null, action, authList); + const action = new actions.Action('123', 'type', 'get', 1234, TEST_NON_EXISTENT_REPO.url); + const result = await processor.exec(null, action); expect(result.error).to.be.true; }); }); diff --git a/test/testCheckUserPushPermission.test.js b/test/testCheckUserPushPermission.test.js index 7eb281d5f..dd7e9d187 100644 --- a/test/testCheckUserPushPermission.test.js +++ b/test/testCheckUserPushPermission.test.js @@ -6,7 +6,7 @@ const db = require('../src/db'); chai.should(); const TEST_ORG = 'finos'; -const TEST_REPO = 'test'; +const TEST_REPO = 'user-push-perms-test.git'; const TEST_URL = 'https://github.com/finos/user-push-perms-test.git'; const TEST_USERNAME_1 = 'push-perms-test'; const TEST_EMAIL_1 = 'push-perms-test@test.com'; @@ -15,35 +15,37 @@ const TEST_EMAIL_2 = 'push-perms-test-2@test.com'; const TEST_EMAIL_3 = 'push-perms-test-3@test.com'; describe('CheckUserPushPermissions...', async () => { + let testRepo = null; + before(async function () { - await db.deleteRepo(TEST_REPO); - await db.deleteUser(TEST_USERNAME_1); - await db.deleteUser(TEST_USERNAME_2); - await db.createRepo({ + // await db.deleteRepo(TEST_REPO); + // await db.deleteUser(TEST_USERNAME_1); + // await db.deleteUser(TEST_USERNAME_2); + testRepo = await db.createRepo({ project: TEST_ORG, name: TEST_REPO, url: TEST_URL, }); await db.createUser(TEST_USERNAME_1, 'abc', TEST_EMAIL_1, TEST_USERNAME_1, false); - await db.addUserCanPush(TEST_REPO, TEST_USERNAME_1); + await db.addUserCanPush(testRepo._id, TEST_USERNAME_1); await db.createUser(TEST_USERNAME_2, 'abc', TEST_EMAIL_2, TEST_USERNAME_2, false); }); after(async function () { - await db.deleteRepo(TEST_REPO); + await db.deleteRepo(testRepo._id); await db.deleteUser(TEST_USERNAME_1); await db.deleteUser(TEST_USERNAME_2); }); it('A committer that is approved should be allowed to push...', async () => { - const action = new Action('1', 'type', 'method', 1, TEST_ORG + '/' + TEST_REPO); + const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_1; const { error } = await processor.exec(null, action); expect(error).to.be.false; }); it('A committer that is NOT approved should NOT be allowed to push...', async () => { - const action = new Action('1', 'type', 'method', 1, TEST_ORG + '/' + TEST_REPO); + const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_2; const { error, errorMessage } = await processor.exec(null, action); expect(error).to.be.true; @@ -51,7 +53,7 @@ describe('CheckUserPushPermissions...', async () => { }); it('An unknown committer should NOT be allowed to push...', async () => { - const action = new Action('1', 'type', 'method', 1, TEST_ORG + '/' + TEST_REPO); + const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_3; const { error, errorMessage } = await processor.exec(null, action); expect(error).to.be.true; diff --git a/test/testDb.test.js b/test/testDb.test.js index 0af10c976..cd982f217 100644 --- a/test/testDb.test.js +++ b/test/testDb.test.js @@ -1,7 +1,9 @@ // This test needs to run first const chai = require('chai'); const db = require('../src/db'); -const { trimTrailingDotGit } = require('../src/db/helper'); +const { Repo, User } = require('../src/db/types'); +const { Action } = require('../src/proxy/actions/Action'); +const { Step } = require('../src/proxy/actions/Step'); const { expect } = chai; @@ -11,6 +13,13 @@ const TEST_REPO = { url: 'https://github.com/finos/db-test-repo.git', }; +const TEST_NONEXISTENT_REPO = { + project: 'MegaCorp', + name: 'repo', + url: 'https://example.com/MegaCorp/MegaGroup/repo.git', + _id: 'ABCDEFGHIJKLMNOP', +}; + const TEST_USER = { username: 'db-u1', password: 'abc', @@ -36,7 +45,7 @@ const TEST_PUSH = { timestamp: 1744380903338, project: 'finos', repoName: 'db-test-repo.git', - url: 'https://github.com/finos/db-test-repo.git', + url: TEST_REPO.url, repo: 'finos/db-test-repo.git', user: 'db-test-user', userEmail: 'db-test@test.com', @@ -95,6 +104,100 @@ const cleanResponseData = (example, responses) => { describe('Database clients', async () => { before(async function () {}); + it('should be able to construct a repo instance', async function () { + const repo = new Repo('project', 'name', 'https://github.com/finos.git-proxy.git', null, 'id'); + expect(repo._id).to.equal('id'); + expect(repo.project).to.equal('project'); + expect(repo.name).to.equal('name'); + expect(repo.url).to.equal('https://github.com/finos.git-proxy.git'); + expect(repo.users).to.deep.equals({ canPush: [], canAuthorise: [] }); + + const repo2 = new Repo( + 'project', + 'name', + 'https://github.com/finos.git-proxy.git', + { canPush: ['bill'], canAuthorise: ['ben'] }, + 'id', + ); + expect(repo2.users).to.deep.equals({ canPush: ['bill'], canAuthorise: ['ben'] }); + }); + + it('should be able to construct a user instance', async function () { + const user = new User( + 'username', + 'password', + 'gitAccount', + 'email@domain.com', + true, + null, + 'id', + ); + expect(user.username).to.equal('username'); + expect(user.username).to.equal('username'); + expect(user.gitAccount).to.equal('gitAccount'); + expect(user.email).to.equal('email@domain.com'); + expect(user.admin).to.equal(true); + expect(user.oidcId).to.be.null; + expect(user._id).to.equal('id'); + + const user2 = new User( + 'username', + 'password', + 'gitAccount', + 'email@domain.com', + false, + 'oidcId', + 'id', + ); + expect(user2.admin).to.equal(false); + expect(user2.oidcId).to.equal('oidcId'); + }); + + it('should be able to construct a valid action instance', async function () { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos/git-proxy.git', + ); + expect(action.project).to.equal('finos'); + expect(action.repoName).to.equal('git-proxy.git'); + }); + + it('should be able to block an action by adding a blocked step', async function () { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos.git-proxy.git', + ); + const step = new Step('stepName', false, null, false, null); + step.setAsyncBlock('blockedMessage'); + action.addStep(step); + expect(action.blocked).to.be.true; + expect(action.blockedMessage).to.equal('blockedMessage'); + expect(action.getLastStep()).to.deep.equals(step); + expect(action.continue()).to.be.false; + }); + + it('should be able to error an action by adding a step with an error', async function () { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos.git-proxy.git', + ); + const step = new Step('stepName', true, 'errorMessage', false, null); + action.addStep(step); + expect(action.error).to.be.true; + expect(action.errorMessage).to.equal('errorMessage'); + expect(action.getLastStep()).to.deep.equals(step); + expect(action.continue()).to.be.false; + }); + it('should be able to create a repo', async function () { await db.createRepo(TEST_REPO); const repos = await db.getRepos(); @@ -118,21 +221,30 @@ describe('Database clients', async () => { expect(repos3).to.have.same.deep.members(repos4); }); - it('should be able to retrieve a repo by name', async function () { - // uppercase the filter value to confirm db client is lowercasing inputs - const repo = await db.getRepo(TEST_REPO.name); + it('should be able to retrieve a repo by url', async function () { + const repo = await db.getRepoByUrl(TEST_REPO.url); const cleanRepo = cleanResponseData(TEST_REPO, repo); expect(cleanRepo).to.eql(TEST_REPO); }); + it('should be able to retrieve a repo by id', async function () { + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + const repoById = await db.getRepoById(repo._id); + const cleanRepo = cleanResponseData(TEST_REPO, repoById); + expect(cleanRepo).to.eql(TEST_REPO); + }); + it('should be able to delete a repo', async function () { - await db.deleteRepo(TEST_REPO.name); + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + await db.deleteRepo(repo._id); const repos = await db.getRepos(); const cleanRepos = cleanResponseData(TEST_REPO, repos); expect(cleanRepos).to.not.deep.include(TEST_REPO); }); - it('should NOT be able to create a repo with blank project, name or url', async function () { + it('should be able to create a repo with a blank project', async function () { // test with a null value let threwError = false; let testRepo = { @@ -141,11 +253,12 @@ describe('Database clients', async () => { url: TEST_REPO.url, }; try { - await db.createRepo(testRepo); + const repo = await db.createRepo(testRepo); + await db.deleteRepo(repo._id, true); } catch (e) { threwError = true; } - expect(threwError).to.be.true; + expect(threwError).to.be.false; // test with an empty string threwError = false; @@ -155,11 +268,12 @@ describe('Database clients', async () => { url: TEST_REPO.url, }; try { - await db.createRepo(testRepo); + const repo = await db.createRepo(testRepo); + await db.deleteRepo(repo._id, true); } catch (e) { threwError = true; } - expect(threwError).to.be.true; + expect(threwError).to.be.false; // test with an undefined property threwError = false; @@ -167,6 +281,23 @@ describe('Database clients', async () => { name: TEST_REPO.name, url: TEST_REPO.url, }; + try { + const repo = await db.createRepo(testRepo); + await db.deleteRepo(repo._id, true); + } catch (e) { + threwError = true; + } + expect(threwError).to.be.false; + }); + + it('should NOT be able to create a repo with blank name or url', async function () { + // null name + let threwError = false; + let testRepo = { + project: TEST_REPO.project, + name: null, + url: TEST_REPO.url, + }; try { await db.createRepo(testRepo); } catch (e) { @@ -174,11 +305,11 @@ describe('Database clients', async () => { } expect(threwError).to.be.true; - // repeat tests for other fields, but don't both with all variations as they go through same fn + // blank name threwError = false; testRepo = { project: TEST_REPO.project, - name: null, + name: '', url: TEST_REPO.url, }; try { @@ -188,6 +319,20 @@ describe('Database clients', async () => { } expect(threwError).to.be.true; + // undefined name + threwError = false; + testRepo = { + project: TEST_REPO.project, + url: TEST_REPO.url, + }; + try { + await db.createRepo(testRepo); + } catch (e) { + threwError = true; + } + expect(threwError).to.be.true; + + // null url testRepo = { project: TEST_REPO.project, name: TEST_REPO.name, @@ -199,6 +344,102 @@ describe('Database clients', async () => { threwError = true; } expect(threwError).to.be.true; + + // blank url + testRepo = { + project: TEST_REPO.project, + name: TEST_REPO.name, + url: '', + }; + try { + await db.createRepo(testRepo); + } catch (e) { + threwError = true; + } + expect(threwError).to.be.true; + + // undefined url + testRepo = { + project: TEST_REPO.project, + name: TEST_REPO.name, + }; + try { + await db.createRepo(testRepo); + } catch (e) { + threwError = true; + } + expect(threwError).to.be.true; + }); + + it('should throw an error when creating a user and username or email is not set', async function () { + // null username + let threwError = false; + let message = null; + try { + await db.createUser( + null, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal('username cannot be empty'); + + // blank username + threwError = false; + try { + await db.createUser( + '', + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal('username cannot be empty'); + + // null email + threwError = false; + try { + await db.createUser( + TEST_USER.username, + TEST_USER.password, + null, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal('email cannot be empty'); + + // blank username + threwError = false; + try { + await db.createUser( + TEST_USER.username, + TEST_USER.password, + '', + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal('email cannot be empty'); }); it('should be able to create a user', async function () { @@ -219,6 +460,44 @@ describe('Database clients', async () => { expect(cleanUsers).to.deep.include(TEST_USER_CLEAN); }); + it('should throw an error when creating a duplicate username', async function () { + let threwError = false; + let message = null; + try { + await db.createUser( + TEST_USER.username, + TEST_USER.password, + 'prefix_' + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal(`user ${TEST_USER.username} already exists`); + }); + + it('should throw an error when creating a user with a duplicate email', async function () { + let threwError = false; + let message = null; + try { + await db.createUser( + 'prefix_' + TEST_USER.username, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + } catch (e) { + threwError = true; + message = e.message; + } + expect(threwError).to.be.true; + expect(message).to.equal(`A user with email ${TEST_USER.email} already exists`); + }); + it('should be able to find a user', async function () { const user = await db.findUser(TEST_USER.username); // eslint-disable-next-line no-unused-vars @@ -296,7 +575,7 @@ describe('Database clients', async () => { let threwError = false; try { // uppercase the filter value to confirm db client is lowercasing inputs - await db.addUserCanPush('non-existent-repo', TEST_USER.username); + await db.addUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username); } catch (e) { threwError = true; } @@ -304,40 +583,33 @@ describe('Database clients', async () => { }); it('should be able to authorise a user to push and confirm that they can', async function () { - let threwError = false; - try { - // first create the repo and check that user is not allowed to push - await db.createRepo(TEST_REPO); - let allowed = await db.isUserPushAllowed(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.false; + // first create the repo and check that user is not allowed to push + await db.createRepo(TEST_REPO); - // uppercase the filter value to confirm db client is lowercasing inputs - await db.addUserCanPush(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); + let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).to.be.false; - // repeat, should not throw an error if already set - await db.addUserCanPush(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); + const repo = await db.getRepoByUrl(TEST_REPO.url); - // confirm the setting exists - allowed = await db.isUserPushAllowed(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.true; + // uppercase the filter value to confirm db client is lowercasing inputs + await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); - // confirm that casing doesn't matter - allowed = await db.isUserPushAllowed( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); - expect(allowed).to.be.true; - } catch (e) { - console.error('Error thrown ', e); - threwError = true; - } - expect(threwError).to.be.false; + // repeat, should not throw an error if already set + await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); + + // confirm the setting exists + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).to.be.true; + + // confirm that casing doesn't matter + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); + expect(allowed).to.be.true; }); it('should throw an error when de-authorising a user to push on non-existent repo', async function () { let threwError = false; try { - await db.removeUserCanPush('non-existent-repo', TEST_USER.username); + await db.removeUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username); } catch (e) { threwError = true; } @@ -348,27 +620,26 @@ describe('Database clients', async () => { let threwError = false; try { // repo should already exist with user able to push after previous test - let allowed = await db.isUserPushAllowed(TEST_REPO.name, TEST_USER.username); + let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); expect(allowed).to.be.true; + const repo = await db.getRepoByUrl(TEST_REPO.url); + // uppercase the filter value to confirm db client is lowercasing inputs - await db.removeUserCanPush(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); + await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); // repeat, should not throw an error if already unset - await db.removeUserCanPush(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); + await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); // confirm the setting exists - allowed = await db.isUserPushAllowed(TEST_REPO.name, TEST_USER.username); + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); expect(allowed).to.be.false; // confirm that casing doesn't matter - allowed = await db.isUserPushAllowed( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); expect(allowed).to.be.false; } catch (e) { - console.error('Error thrown ', e); + console.error('Error thrown at: ' + e.stack, e); threwError = true; } expect(threwError).to.be.false; @@ -377,115 +648,27 @@ describe('Database clients', async () => { it('should throw an error when authorising a user to authorise on non-existent repo', async function () { let threwError = false; try { - await db.addUserCanAuthorise('non-existent-repo', TEST_USER.username); + await db.addUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username); } catch (e) { threwError = true; } expect(threwError).to.be.true; }); - it('should be able to authorise a user to authorise and confirm that they can', async function () { - let threwError = false; - try { - // repo should already exist after a previous test - let allowed = await db.canUserApproveRejectPushRepo(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.false; - - // uppercase the filter value to confirm db client is lowercasing inputs - await db.addUserCanAuthorise(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); - - // repeat, should not throw an error if already set - await db.addUserCanAuthorise(TEST_REPO.name.toUpperCase(), TEST_USER.username.toUpperCase()); - - // confirm the setting exists - allowed = await db.canUserApproveRejectPushRepo(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.true; - - // confirm that casing doesn't matter - allowed = await db.canUserApproveRejectPushRepo( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); - expect(allowed).to.be.true; - } catch (e) { - console.error('Error thrown ', e); - threwError = true; - } - expect(threwError).to.be.false; - }); - it('should throw an error when de-authorising a user to push on non-existent repo', async function () { let threwError = false; try { // uppercase the filter value to confirm db client is lowercasing inputs - await db.removeUserCanAuthorise('non-existent-repo', TEST_USER.username); + await db.removeUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username); } catch (e) { threwError = true; } expect(threwError).to.be.true; }); - it("should be able to de-authorise a user to authorise and confirm that they can't", async function () { - let threwError = false; - try { - // repo should already exist after a previous test and user should be an authoriser - let allowed = await db.canUserApproveRejectPushRepo(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.true; - - // uppercase the filter value to confirm db client is lowercasing inputs - await db.removeUserCanAuthorise( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); - - // repeat, should not throw an error if already set - await db.removeUserCanAuthorise( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); - - // confirm the setting was removed - allowed = await db.canUserApproveRejectPushRepo(TEST_REPO.name, TEST_USER.username); - expect(allowed).to.be.false; - - // confirm that casing doesn't matter - allowed = await db.canUserApproveRejectPushRepo( - TEST_REPO.name.toUpperCase(), - TEST_USER.username.toUpperCase(), - ); - expect(allowed).to.be.false; - } catch (e) { - console.error('Error thrown ', e); - threwError = true; - } - expect(threwError).to.be.false; - }); - it('should NOT throw an error when checking whether a user can push on non-existent repo', async function () { - let threwError = false; - try { - // uppercase the filter value to confirm db client is lowercasing inputs - const allowed = await db.isUserPushAllowed('non-existent-repo', TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - }); - - it('should NOT throw an error when checking whether a user can authorise on non-existent repo', async function () { - let threwError = false; - try { - // uppercase the filter value to confirm db client is lowercasing inputs - const allowed = await db.canUserApproveRejectPushRepo( - 'non-existent-repo', - TEST_USER.username, - ); - expect(allowed).to.be.false; - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; + const allowed = await db.isUserPushAllowed(TEST_NONEXISTENT_REPO.url, TEST_USER.username); + expect(allowed).to.be.false; }); it('should be able to create a push', async function () { @@ -580,8 +763,9 @@ describe('Database clients', async () => { it('should be able to check if a user can cancel push', async function () { let threwError = false; - const repoName = trimTrailingDotGit(TEST_PUSH.repoName); try { + const repo = await db.getRepoByUrl(TEST_REPO.url); + // push does not exist yet, should return false let allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); expect(allowed).to.be.false; @@ -592,21 +776,25 @@ describe('Database clients', async () => { expect(allowed).to.be.false; // authorise user and recheck - await db.addUserCanPush(repoName, TEST_USER.username); + await db.addUserCanPush(repo._id, TEST_USER.username); allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); expect(allowed).to.be.true; + + // deauthorise user and recheck + await db.removeUserCanPush(repo._id, TEST_USER.username); + allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).to.be.false; } catch (e) { + console.error(e); threwError = true; } expect(threwError).to.be.false; // clean up await db.deletePush(TEST_PUSH.id); - await db.removeUserCanPush(repoName, TEST_USER.username); }); it('should be able to check if a user can approve/reject push', async function () { let allowed = undefined; - const repoName = trimTrailingDotGit(TEST_PUSH.repoName); try { // push does not exist yet, should return false @@ -626,24 +814,28 @@ describe('Database clients', async () => { } try { + const repo = await db.getRepoByUrl(TEST_REPO.url); + // authorise user and recheck - await db.addUserCanAuthorise(repoName, TEST_USER.username); + await db.addUserCanAuthorise(repo._id, TEST_USER.username); allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); expect(allowed).to.be.true; + + // deauthorise user and recheck + await db.removeUserCanAuthorise(repo._id, TEST_USER.username); + allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).to.be.false; } catch (e) { expect.fail(e); } // clean up await db.deletePush(TEST_PUSH.id); - await db.removeUserCanAuthorise(repoName, TEST_USER.username); }); it('should be able to check if a user can approve/reject push including .git within the repo name', async function () { let allowed = undefined; - const repoName = trimTrailingDotGit(TEST_PUSH_DOT_GIT.repoName); - - await db.createRepo(TEST_REPO_DOT_GIT); + const repo = await db.createRepo(TEST_REPO_DOT_GIT); try { // push does not exist yet, should return false allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); @@ -663,7 +855,7 @@ describe('Database clients', async () => { try { // authorise user and recheck - await db.addUserCanAuthorise(repoName, TEST_USER.username); + await db.addUserCanAuthorise(repo._id, TEST_USER.username); allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); expect(allowed).to.be.true; } catch (e) { @@ -672,12 +864,15 @@ describe('Database clients', async () => { // clean up await db.deletePush(TEST_PUSH_DOT_GIT.id); - await db.removeUserCanAuthorise(repoName, TEST_USER.username); + await db.removeUserCanAuthorise(repo._id, TEST_USER.username); }); after(async function () { - await db.deleteRepo(TEST_REPO.name); - await db.deleteRepo(TEST_REPO_DOT_GIT.name); + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + await db.deleteRepo(repo._id, true); + const repoDotGit = await db.getRepoByUrl(TEST_REPO_DOT_GIT.url); + await db.deleteRepo(repoDotGit._id); await db.deleteUser(TEST_USER.username); await db.deletePush(TEST_PUSH.id); await db.deletePush(TEST_PUSH_DOT_GIT.id); diff --git a/test/testParseAction.test.js b/test/testParseAction.test.js new file mode 100644 index 000000000..02686fc1d --- /dev/null +++ b/test/testParseAction.test.js @@ -0,0 +1,83 @@ +// Import the dependencies for testing +const chai = require('chai'); +chai.should(); +const expect = chai.expect; +const preprocessor = require('../src/proxy/processors/pre-processor/parseAction'); +const db = require('../src/db'); +let testRepo = null; + +const TEST_REPO = { + url: 'https://github.com/finos/git-proxy.git', + name: 'git-proxy', + project: 'finos', +}; + +describe('Pre-processor: parseAction', async () => { + before(async function () { + // make sure the test repo exists as the presence of the repo makes a difference to handling of urls + testRepo = await db.getRepoByUrl(TEST_REPO.url); + if (!testRepo) { + testRepo = await db.createRepo(TEST_REPO); + } + }); + after(async function () { + // clean up test DB + await db.deleteRepo(testRepo._id); + }); + + it('should be able to parse a pull request into an action', async function () { + const req = { + originalUrl: '/github.com/finos/git-proxy.git/git-upload-pack', + method: 'GET', + headers: {}, + }; + + const action = await preprocessor.exec(req); + expect(action.timestamp).is.greaterThan(0); + expect(action.id).to.not.be.false; + expect(action.type).to.equal('pull'); + expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + }); + + it('should be able to parse a pull request with a legacy path into an action', async function () { + const req = { + originalUrl: '/finos/git-proxy.git/git-upload-pack', + method: 'GET', + headers: {}, + }; + + const action = await preprocessor.exec(req); + expect(action.timestamp).is.greaterThan(0); + expect(action.id).to.not.be.false; + expect(action.type).to.equal('pull'); + expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + }); + + it('should be able to parse a push request into an action', async function () { + const req = { + originalUrl: '/github.com/finos/git-proxy.git/git-receive-pack', + method: 'POST', + headers: { 'content-type': 'application/x-git-receive-pack-request' }, + }; + + const action = await preprocessor.exec(req); + expect(action.timestamp).is.greaterThan(0); + expect(action.id).to.not.be.false; + expect(action.type).to.equal('push'); + expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + }); + + it('should be able to parse a push request with a legacy path into an action', async function () { + const req = { + originalUrl: '/finos/git-proxy.git/git-receive-pack', + method: 'POST', + headers: { 'content-type': 'application/x-git-receive-pack-request' }, + }; + + const action = await preprocessor.exec(req); + expect(action.timestamp).is.greaterThan(0); + expect(action.id).to.not.be.false; + expect(action.type).to.equal('push'); + expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + }); +}); diff --git a/test/testProxyRoute.test.js b/test/testProxyRoute.test.js index 1a9a97d2b..57e3ddba8 100644 --- a/test/testProxyRoute.test.js +++ b/test/testProxyRoute.test.js @@ -1,21 +1,41 @@ -const { handleMessage, validGitRequest, stripGitHubFromGitPath } = require('../src/proxy/routes'); +const { handleMessage, validGitRequest } = require('../src/proxy/routes'); const chai = require('chai'); const chaiHttp = require('chai-http'); -const sinon = require('sinon'); -const express = require('express'); -const proxyRouter = require('../src/proxy/routes').router; -const chain = require('../src/proxy/chain'); - chai.use(chaiHttp); chai.should(); const expect = chai.expect; +const sinon = require('sinon'); +const express = require('express'); +const getRouter = require('../src/proxy/routes').getRouter; +const chain = require('../src/proxy/chain'); +const proxyquire = require('proxyquire'); +const { Action, Step } = require('../src/proxy/actions'); +const service = require('../src/service'); +const db = require('../src/db'); + +import Proxy from '../src/proxy'; + +const TEST_DEFAULT_REPO = { + url: 'https://github.com/finos/git-proxy.git', + name: 'git-proxy', + project: 'finos/gitproxy', + host: 'github.com', +}; + +const TEST_GITLAB_REPO = { + url: 'https://gitlab.com/gitlab-community/meta.git', + name: 'gitlab', + project: 'gitlab-community/meta', + host: 'gitlab.com', + proxyUrlPrefix: 'gitlab.com/gitlab-community/meta.git', +}; describe('proxy route filter middleware', () => { let app; - beforeEach(() => { + beforeEach(async () => { app = express(); - app.use('/', proxyRouter); + app.use('/', await getRouter()); }); afterEach(() => { @@ -164,41 +184,271 @@ describe('proxy route helpers', () => { expect(res).to.be.false; }); }); +}); - describe('stripGitHubFromGitPath', () => { - it('should strip owner and repo from a valid GitHub-style path with 4 parts', () => { - const res = stripGitHubFromGitPath('/foo/bar.git/info/refs'); - expect(res).to.equal('/info/refs'); - }); +describe('proxyFilter function', async () => { + let proxyRoutes; + let req; + let res; + let actionToReturn; + let executeChainStub; - it('should strip owner and repo from a valid GitHub-style path with 5 parts', () => { - const res = stripGitHubFromGitPath('/foo/bar.git/git-upload-pack'); - expect(res).to.equal('/git-upload-pack'); - }); + beforeEach(async () => { + executeChainStub = sinon.stub(); - it('should return undefined for malformed path with too few segments', () => { - const res = stripGitHubFromGitPath('/foo/bar.git'); - expect(res).to.be.undefined; + // Re-import the proxy routes module and stub executeChain + proxyRoutes = proxyquire('../src/proxy/routes', { + '../chain': { executeChain: executeChainStub }, }); - it('should return undefined for malformed path with too many segments', () => { - const res = stripGitHubFromGitPath('/foo/bar.git/extra/path/stuff'); - expect(res).to.be.undefined; - }); + req = { + url: '/github.com/finos/git-proxy.git/info/refs?service=git-receive-pack', + headers: { + host: 'dummyHost', + 'user-agent': 'git/dummy-git-client', + accept: 'application/x-git-receive-pack-request', + }, + }; + res = { + set: () => {}, + status: () => { + return { + send: () => {}, + }; + }, + }; + }); + + afterEach(() => { + sinon.restore(); + }); - it('should handle repo names that include dots correctly', () => { - const res = stripGitHubFromGitPath('/foo/some.repo.git/info/refs'); - expect(res).to.equal('/info/refs'); + it('should return false for push requests that should be blocked', async function () { + // mock the executeChain function + actionToReturn = new Action( + 1234, + 'dummy', + 'dummy', + Date.now(), + '/github.com/finos/git-proxy.git', + ); + const step = new Step('dummy', false, null, true, 'test block', null); + actionToReturn.addStep(step); + executeChainStub.returns(actionToReturn); + const result = await proxyRoutes.proxyFilter(req, res); + expect(result).to.be.false; + }); + + it('should return false for push requests that produced errors', async function () { + // mock the executeChain function + actionToReturn = new Action( + 1234, + 'dummy', + 'dummy', + Date.now(), + '/github.com/finos/git-proxy.git', + ); + const step = new Step('dummy', true, 'test error', false, null, null); + actionToReturn.addStep(step); + executeChainStub.returns(actionToReturn); + const result = await proxyRoutes.proxyFilter(req, res); + expect(result).to.be.false; + }); + + it('should return false for invalid push requests', async function () { + // mock the executeChain function + actionToReturn = new Action( + 1234, + 'dummy', + 'dummy', + Date.now(), + '/github.com/finos/git-proxy.git', + ); + const step = new Step('dummy', true, 'test error', false, null, null); + actionToReturn.addStep(step); + executeChainStub.returns(actionToReturn); + + // create an invalid request + req = { + url: '/github.com/finos/git-proxy.git/invalidPath', + headers: { + host: 'dummyHost', + 'user-agent': 'git/dummy-git-client', + accept: 'application/x-git-receive-pack-request', + }, + }; + + const result = await proxyRoutes.proxyFilter(req, res); + expect(result).to.be.false; + }); + + it('should return true for push requests that are valid and pass the chain', async function () { + // mock the executeChain function + actionToReturn = new Action( + 1234, + 'dummy', + 'dummy', + Date.now(), + '/github.com/finos/git-proxy.git', + ); + const step = new Step('dummy', false, null, false, null, null); + actionToReturn.addStep(step); + executeChainStub.returns(actionToReturn); + const result = await proxyRoutes.proxyFilter(req, res); + expect(result).to.be.true; + }); +}); + +describe('proxy express application', async () => { + let apiApp; + let cookie; + let proxy; + + const setCookie = function (res) { + res.headers['set-cookie'].forEach((x) => { + if (x.startsWith('connect')) { + const value = x.split(';')[0]; + cookie = value; + } }); + }; + + const cleanupRepo = async (url) => { + const repo = await db.getRepoByUrl(url); + if (repo) { + await db.deleteRepo(repo._id); + } + }; - it('should not break if the path is just a slash', () => { - const res = stripGitHubFromGitPath('/'); - expect(res).to.be.undefined; + before(async () => { + // pass through requests + sinon.stub(chain, 'executeChain').resolves({ + blocked: false, + blockedMessage: '', + error: false, }); - it('should not break if the path is empty', () => { - const res = stripGitHubFromGitPath(''); - expect(res).to.be.undefined; + // start the API and proxy + proxy = new Proxy(); + apiApp = await service.start(proxy); + await proxy.start(); + + const res = await chai.request(apiApp).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', }); + expect(res).to.have.cookie('connect.sid'); + setCookie(res); + + // if our default repo is not set-up, create it + const repo = await db.getRepoByUrl(TEST_DEFAULT_REPO.url); + if (!repo) { + const res2 = await chai + .request(apiApp) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_DEFAULT_REPO); + res2.should.have.status(200); + } }); + + after(async () => { + sinon.restore(); + await service.stop(); + await proxy.stop(); + await cleanupRepo(TEST_DEFAULT_REPO.url); + await cleanupRepo(TEST_GITLAB_REPO.url); + }); + + it('should proxy requests for the default GitHub repository', async function () { + // proxy a fetch request + const res = await chai + .request(proxy.getExpressApp()) + .get('/github.com/finos/git-proxy.git/info/refs?service=git-upload-pack') + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request') + .buffer(); + + expect(res.status).to.equal(200); + expect(res.text).to.contain('git-upload-pack'); + }); + + it('should proxy requests for the default GitHub repository using the backwards compatibility URL', async function () { + // proxy a fetch request using a fallback URL + const res = await chai + .request(proxy.getExpressApp()) + .get('/finos/git-proxy.git/info/refs?service=git-upload-pack') + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request') + .buffer(); + + expect(res.status).to.equal(200); + expect(res.text).to.contain('git-upload-pack'); + }); + + it('should be restarted by the api and proxy requests for a new host (e.g. gitlab.com) when a project at that host is ADDED via the API', async function () { + // Tests that the proxy restarts properly after a project with a URL at a new host is added + + // check that we do not have the Gitlab test repo set up yet + let repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).to.be.null; + + // create the repo through the API, which should force the proxy to restart to handle the new domain + const res = await chai + .request(apiApp) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_GITLAB_REPO); + res.should.have.status(200); + + // confirm that the repo was created in teh DB + repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).to.not.be.null; + + // proxy a fetch request to the new repo + const res2 = await chai + .request(proxy.getExpressApp()) + .get(`/${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request') + .buffer(); + + res2.should.have.status(200); + expect(res2.text).to.contain('git-upload-pack'); + }).timeout(5000); + + it('should be restarted by the api and stop proxying requests for a host (e.g. gitlab.com) when the last project at that host is DELETED via the API', async function () { + // We are testing that the proxy stops proxying requests for a particular origin + // The chain is stubbed and will always passthrough requests, hence, we are only checking what hosts are proxied. + + // the gitlab test repo should already exist + let repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).to.not.be.null; + + // delete the gitlab test repo, which should force the proxy to restart and stop proxying gitlab.com + // We assume that there are no other gitlab.com repos present + const res = await chai + .request(apiApp) + .delete('/api/v1/repo/' + repo._id + '/delete') + .set('Cookie', `${cookie}`) + .send(); + res.should.have.status(200); + + // confirm that its gone from the DB + repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).to.be.null; + + // give the proxy half a second to restart + await new Promise((resolve) => setTimeout(resolve, 500)); + + // try (and fail) to proxy a request to gitlab.com + const res2 = await chai + .request(proxy.getExpressApp()) + .get(`/${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request') + .buffer(); + + res2.should.have.status(404); + }).timeout(5000); }); diff --git a/test/testPush.test.js b/test/testPush.test.js index 4681f3de5..9e3ad21ff 100644 --- a/test/testPush.test.js +++ b/test/testPush.test.js @@ -41,7 +41,7 @@ const TEST_PUSH = { timestamp: 1744380903338, project: TEST_ORG, repoName: TEST_REPO + '.git', - url: TEST_REPO, + url: TEST_URL, repo: TEST_ORG + '/' + TEST_REPO + '.git', user: TEST_USERNAME_2, userEmail: TEST_EMAIL_2, @@ -55,6 +55,7 @@ const TEST_PUSH = { describe('auth', async () => { let app; let cookie; + let testRepo; const setCookie = function (res) { res.headers['set-cookie'].forEach((x) => { @@ -87,13 +88,19 @@ describe('auth', async () => { }; before(async function () { + // remove existing repo and users if any + const oldRepo = await db.getRepoByUrl(TEST_URL); + if (oldRepo) { + await db.deleteRepo(oldRepo._id); + } + await db.deleteUser(TEST_USERNAME_1); + await db.deleteUser(TEST_USERNAME_2); + app = await service.start(); await loginAsAdmin(); // set up a repo, user and push to test against - await db.deleteRepo(TEST_REPO); - await db.deleteUser(TEST_USERNAME_1); - await db.createRepo({ + testRepo = await db.createRepo({ project: TEST_ORG, name: TEST_REPO, url: TEST_URL, @@ -102,17 +109,23 @@ describe('auth', async () => { // Create a new user for the approver console.log('creating approver'); await db.createUser(TEST_USERNAME_1, TEST_PASSWORD_1, TEST_EMAIL_1, TEST_USERNAME_1, false); - await db.addUserCanAuthorise(TEST_REPO, TEST_USERNAME_1); + await db.addUserCanAuthorise(testRepo._id, TEST_USERNAME_1); // create a new user for the committer console.log('creating committer'); await db.createUser(TEST_USERNAME_2, TEST_PASSWORD_2, TEST_EMAIL_2, TEST_USERNAME_2, false); - await db.addUserCanPush(TEST_REPO, TEST_USERNAME_2); + await db.addUserCanPush(testRepo._id, TEST_USERNAME_2); // logout of admin account await logout(); }); + after(async function () { + await db.deleteRepo(testRepo._id); + await db.deleteUser(TEST_USERNAME_1); + await db.deleteUser(TEST_USERNAME_2); + }); + describe('test push API', async function () { afterEach(async function () { await db.deletePush(TEST_PUSH.id); diff --git a/test/testRepoApi.test.js b/test/testRepoApi.test.js new file mode 100644 index 000000000..23dc40bac --- /dev/null +++ b/test/testRepoApi.test.js @@ -0,0 +1,340 @@ +// Import the dependencies for testing +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const db = require('../src/db'); +const service = require('../src/service'); +const { getAllProxiedHosts } = require('../src/proxy/routes/helper'); + +import Proxy from '../src/proxy'; + +chai.use(chaiHttp); +chai.should(); +const expect = chai.expect; + +const TEST_REPO = { + url: 'https://github.com/finos/test-repo.git', + name: 'test-repo', + project: 'finos', + host: 'github.com', +}; + +const TEST_REPO_NON_GITHUB = { + url: 'https://gitlab.com/org/sub-org/test-repo2.git', + name: 'test-repo2', + project: 'org/sub-org', + host: 'gitlab.com', +}; + +const TEST_REPO_NAKED = { + url: 'https://123.456.789:80/test-repo3.git', + name: 'test-repo3', + project: '', + host: '123.456.789:80', +}; + +const cleanupRepo = async (url) => { + const repo = await db.getRepoByUrl(url); + if (repo) { + await db.deleteRepo(repo._id); + } +}; + +describe('add new repo', async () => { + let app; + let proxy; + let cookie; + const repoIds = []; + + const setCookie = function (res) { + res.headers['set-cookie'].forEach((x) => { + if (x.startsWith('connect')) { + const value = x.split(';')[0]; + cookie = value; + } + }); + }; + + before(async function () { + proxy = new Proxy(); + app = await service.start(proxy); + // Prepare the data. + // _id is autogenerated by the DB so we need to retrieve it before we can use it + cleanupRepo(TEST_REPO.url); + cleanupRepo(TEST_REPO_NON_GITHUB.url); + cleanupRepo(TEST_REPO_NAKED.url); + + await db.deleteUser('u1'); + await db.deleteUser('u2'); + await db.createUser('u1', 'abc', 'test@test.com', 'test', true); + await db.createUser('u2', 'abc', 'test2@test.com', 'test', true); + }); + + it('login', async function () { + const res = await chai.request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + expect(res).to.have.cookie('connect.sid'); + setCookie(res); + }); + + it('create a new repo', async function () { + const res = await chai + .request(app) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_REPO); + res.should.have.status(200); + + const repo = await db.getRepoByUrl(TEST_REPO.url); + // save repo id for use in subsequent tests + repoIds[0] = repo._id; + + repo.project.should.equal(TEST_REPO.project); + repo.name.should.equal(TEST_REPO.name); + repo.url.should.equal(TEST_REPO.url); + repo.users.canPush.length.should.equal(0); + repo.users.canAuthorise.length.should.equal(0); + }); + + it('get a repo', async function () { + const res = await chai + .request(app) + .get('/api/v1/repo/' + repoIds[0]) + .set('Cookie', `${cookie}`) + .send(); + res.should.have.status(200); + + expect(res.body.url).to.equal(TEST_REPO.url); + expect(res.body.name).to.equal(TEST_REPO.name); + expect(res.body.project).to.equal(TEST_REPO.project); + }); + + it('return a 409 error if the repo already exists', async function () { + const res = await chai + .request(app) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_REPO); + res.should.have.status(409); + res.body.message.should.equal('Repository ' + TEST_REPO.url + ' already exists!'); + }); + + it('filter repos', async function () { + const res = await chai + .request(app) + .get('/api/v1/repo') + .set('Cookie', `${cookie}`) + .query({ url: TEST_REPO.url }); + res.should.have.status(200); + res.body[0].project.should.equal(TEST_REPO.project); + res.body[0].name.should.equal(TEST_REPO.name); + res.body[0].url.should.equal(TEST_REPO.url); + }); + + it('add 1st can push user', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u1', + }); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canPush.length.should.equal(1); + repo.users.canPush[0].should.equal('u1'); + }); + + it('add 2nd can push user', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u2', + }); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canPush.length.should.equal(2); + repo.users.canPush[1].should.equal('u2'); + }); + + it('add push user that does not exist', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u3', + }); + + res.should.have.status(400); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canPush.length.should.equal(2); + }); + + it('delete user u2 from push', async function () { + const res = await chai + .request(app) + .delete(`/api/v1/repo/${repoIds[0]}/user/push/u2`) + .set('Cookie', `${cookie}`) + .send({}); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canPush.length.should.equal(1); + }); + + it('add 1st can authorise user', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u1', + }); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canAuthorise.length.should.equal(1); + repo.users.canAuthorise[0].should.equal('u1'); + }); + + it('add 2nd can authorise user', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u2', + }); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canAuthorise.length.should.equal(2); + repo.users.canAuthorise[1].should.equal('u2'); + }); + + it('add authorise user that does not exist', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', `${cookie}`) + .send({ + username: 'u3', + }); + + res.should.have.status(400); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canAuthorise.length.should.equal(2); + }); + + it('Can delete u2 user', async function () { + const res = await chai + .request(app) + .delete(`/api/v1/repo/${repoIds[0]}/user/authorise/u2`) + .set('Cookie', `${cookie}`) + .send({}); + + res.should.have.status(200); + const repo = await db.getRepoById(repoIds[0]); + repo.users.canAuthorise.length.should.equal(1); + }); + + it('Valid user push permission on repo', async function () { + const res = await chai + .request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', `${cookie}`) + .send({ username: 'u2' }); + + res.should.have.status(200); + const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'u2'); + expect(isAllowed).to.be.true; + }); + + it('Invalid user push permission on repo', async function () { + const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'test1234'); + expect(isAllowed).to.be.false; + }); + + it('Proxy route helpers should return the proxied origin', async function () { + const origins = await getAllProxiedHosts(); + expect(origins).to.eql([TEST_REPO.host]); + }); + + it('Proxy route helpers should return the new proxied origins when new repos are added', async function () { + const res = await chai + .request(app) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_REPO_NON_GITHUB); + res.should.have.status(200); + + const repo = await db.getRepoByUrl(TEST_REPO_NON_GITHUB.url); + // save repo id for use in subsequent tests + repoIds[1] = repo._id; + + repo.project.should.equal(TEST_REPO_NON_GITHUB.project); + repo.name.should.equal(TEST_REPO_NON_GITHUB.name); + repo.url.should.equal(TEST_REPO_NON_GITHUB.url); + repo.users.canPush.length.should.equal(0); + repo.users.canAuthorise.length.should.equal(0); + + const origins = await getAllProxiedHosts(); + expect(origins).to.have.members([TEST_REPO.host, TEST_REPO_NON_GITHUB.host]); + + const res2 = await chai + .request(app) + .post('/api/v1/repo') + .set('Cookie', `${cookie}`) + .send(TEST_REPO_NAKED); + res2.should.have.status(200); + const repo2 = await db.getRepoByUrl(TEST_REPO_NAKED.url); + repoIds[2] = repo2._id; + + const origins2 = await getAllProxiedHosts(); + expect(origins2).to.have.members([ + TEST_REPO.host, + TEST_REPO_NON_GITHUB.host, + TEST_REPO_NAKED.host, + ]); + }); + + it('delete a repo', async function () { + const res = await chai + .request(app) + .delete('/api/v1/repo/' + repoIds[1] + '/delete') + .set('Cookie', `${cookie}`) + .send(); + res.should.have.status(200); + + const repo = await db.getRepoByUrl(TEST_REPO_NON_GITHUB.url); + expect(repo).to.be.null; + + const res2 = await chai + .request(app) + .delete('/api/v1/repo/' + repoIds[2] + '/delete') + .set('Cookie', `${cookie}`) + .send(); + res2.should.have.status(200); + + const repo2 = await db.getRepoByUrl(TEST_REPO_NAKED.url); + expect(repo2).to.be.null; + }); + + after(async function () { + await service.httpServer.close(); + + // don't clean up data as cypress tests rely on it being present + // await cleanupRepo(TEST_REPO.url); + // await db.deleteUser('u1'); + // await db.deleteUser('u2'); + + await cleanupRepo(TEST_REPO_NON_GITHUB.url); + await cleanupRepo(TEST_REPO_NAKED.url); + }); +}); diff --git a/test/testRouteFilter.test.js b/test/testRouteFilter.test.js index e9ec026b8..c457adf18 100644 --- a/test/testRouteFilter.test.js +++ b/test/testRouteFilter.test.js @@ -1,21 +1,126 @@ /* eslint-disable max-len */ -const chai = require('chai'); -const validGitRequest = require('../src/proxy/routes').validGitRequest; -const stripGitHubFromGitPath = require('../src/proxy/routes').stripGitHubFromGitPath; +import * as chai from 'chai'; +import { + validGitRequest, + processUrlPath, + processGitUrl, + processGitURLForNameAndOrg, +} from '../src/proxy/routes/helper'; chai.should(); const expect = chai.expect; -describe('url filters for proxying ', function () { - it('stripGitHubFromGitPath should return the sanitized URL with owner & repo removed', function () { - expect(stripGitHubFromGitPath('/octocat/hello-world.git/info/refs?service=git-upload-pack')).eq( - '/info/refs?service=git-upload-pack', +const VERY_LONG_PATH = + '/a/very/very/very/very/very//very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long/path'; + +describe('url helpers and filter functions used in the proxy', function () { + it('processUrlPath should return breakdown of a proxied path, separating the path to repository from the git operation path', function () { + expect( + processUrlPath('/github.com/octocat/hello-world.git/info/refs?service=git-upload-pack'), + ).to.deep.eq({ + repoPath: '/github.com/octocat/hello-world.git', + gitPath: '/info/refs?service=git-upload-pack', + }); + + expect( + processUrlPath('/gitlab.com/org/sub-org/hello-world.git/info/refs?service=git-upload-pack'), + ).to.deep.eq({ + repoPath: '/gitlab.com/org/sub-org/hello-world.git', + gitPath: '/info/refs?service=git-upload-pack', + }); + + expect( + processUrlPath('/123.456.789/hello-world.git/info/refs?service=git-upload-pack'), + ).to.deep.eq({ + repoPath: '/123.456.789/hello-world.git', + gitPath: '/info/refs?service=git-upload-pack', + }); + }); + + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository from the git operation path', function () { + expect(processUrlPath('/octocat/hello-world.git/info/refs?service=git-upload-pack')).to.deep.eq( + { repoPath: '/octocat/hello-world.git', gitPath: '/info/refs?service=git-upload-pack' }, ); }); - it('stripGitHubFromGitPath should return undefined if the url', function () { - expect(stripGitHubFromGitPath('/octocat/hello-world')).undefined; + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when git path is just /', function () { + expect(processUrlPath('/octocat/hello-world.git/')).to.deep.eq({ + repoPath: '/octocat/hello-world.git', + gitPath: '/', + }); + }); + + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when no path is present', function () { + expect(processUrlPath('/octocat/hello-world.git')).to.deep.eq({ + repoPath: '/octocat/hello-world.git', + gitPath: '/', + }); + }); + + it("processUrlPath should return null if the url couldn't be parsed", function () { + expect(processUrlPath('/octocat/hello-world')).to.be.null; + expect(processUrlPath(VERY_LONG_PATH)).to.be.null; + }); + + it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path', function () { + expect(processGitUrl('https://somegithost.com/octocat/hello-world.git')).to.deep.eq({ + protocol: 'https://', + host: 'somegithost.com', + repoPath: '/octocat/hello-world.git', + }); + + expect(processGitUrl('https://123.456.789:1234/hello-world.git')).to.deep.eq({ + protocol: 'https://', + host: '123.456.789:1234', + repoPath: '/hello-world.git', + }); + }); + + it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path and discard any git operation path', function () { + expect( + processGitUrl( + 'https://somegithost.com:1234/octocat/hello-world.git/info/refs?service=git-upload-pack', + ), + ).to.deep.eq({ + protocol: 'https://', + host: 'somegithost.com:1234', + repoPath: '/octocat/hello-world.git', + }); + + expect( + processGitUrl('https://123.456.789/hello-world.git/info/refs?service=git-upload-pack'), + ).to.deep.eq({ + protocol: 'https://', + host: '123.456.789', + repoPath: '/hello-world.git', + }); + }); + + it('processGitUrl should return null for a url it cannot parse', function () { + expect(processGitUrl('somegithost.com:1234/octocat/hello-world.git')).to.be.null; + expect(processUrlPath('somegithost.com:1234' + VERY_LONG_PATH + '.git')).to.be.null; + }); + + it('processGitURLForNameAndOrg should return breakdown of a git URL path separating out the protocol, origin and repository path', function () { + expect(processGitURLForNameAndOrg('github.com/octocat/hello-world.git')).to.deep.eq({ + project: 'octocat', + repoName: 'hello-world.git', + }); + }); + + it('processGitURLForNameAndOrg should return breakdown of a git repository URL separating out the project (organisation) and repository name', function () { + expect(processGitURLForNameAndOrg('https://github.com:80/octocat/hello-world.git')).to.deep.eq({ + project: 'octocat', + repoName: 'hello-world.git', + }); + }); + + it("processGitURLForNameAndOrg should return null for a git repository URL it can't parse", function () { + expect(processGitURLForNameAndOrg('someGitHost.com/repo')).to.be.null; + expect(processGitURLForNameAndOrg('https://someGitHost.com/repo')).to.be.null; + expect(processGitURLForNameAndOrg('https://somegithost.com:1234' + VERY_LONG_PATH + '.git')).to + .be.null; }); it('validGitRequest should return true for safe requests on expected URLs', function () {