Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"server": "node index.js",
"start": "concurrently \"npm run server\" \"npm run client\"",
"build": "vite build",
"test": "NODE_ENV=test mocha --exit",
"test": "NODE_ENV=test mocha './test/**/*.js' --exit",
"test-coverage": "nyc npm run test",
"test-coverage-ci": "nyc --reporter=lcovonly --reporter=text npm run test",
"prepare": "node ./scripts/prepare.js",
Expand Down
1 change: 1 addition & 0 deletions src/proxy/chain.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const pushActionChain = [
proc.push.checkIfWaitingAuth,
proc.push.pullRemote,
proc.push.writePack,
proc.push.preReceive,
proc.push.getDiff,
proc.push.clearBareClone,
proc.push.scanDiff,
Expand Down
1 change: 1 addition & 0 deletions src/proxy/processors/push-action/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
exports.parsePush = require('./parsePush').exec;
exports.preReceive = require('./preReceive').exec;
exports.checkRepoInAuthorisedList = require('./checkRepoInAuthorisedList').exec;
exports.audit = require('./audit').exec;
exports.pullRemote = require('./pullRemote').exec;
Expand Down
60 changes: 60 additions & 0 deletions src/proxy/processors/push-action/preReceive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const fs = require('fs');
const path = require('path');
const Step = require('../../actions').Step;
const { spawnSync } = require('child_process');

const sanitizeInput = (_req, action) => {
return `${action.commitFrom} ${action.commitTo} ${action.branch} \n`;
};

const exec = async (req, action, hookFilePath = './hooks/pre-receive.sh') => {
const step = new Step('executeExternalPreReceiveHook');

try {
const resolvedPath = path.resolve(hookFilePath);
const hookDir = path.dirname(resolvedPath);

if (!fs.existsSync(hookDir) || !fs.existsSync(resolvedPath)) {
step.log('Pre-receive hook not found, skipping execution.');
action.addStep(step);
return action;
}

const repoPath = `${action.proxyGitPath}/${action.repoName}`;

step.log(`Executing pre-receive hook from: ${resolvedPath}`);

const sanitizedInput = sanitizeInput(req, action);

const hookProcess = spawnSync(resolvedPath, [], {
input: sanitizedInput,
encoding: 'utf-8',
cwd: repoPath,
});

const { stdout, stderr, status } = hookProcess;

const stderrTrimmed = stderr ? stderr.trim() : '';
const stdoutTrimmed = stdout ? stdout.trim() : '';

if (status !== 0) {
step.error = true;
step.log(`Hook stderr: ${stderrTrimmed}`);
step.setError(stdoutTrimmed);
action.addStep(step);
return action;
}

step.log('Pre-receive hook executed successfully');
action.addStep(step);
return action;
} catch (error) {
step.error = true;
step.setError(`Hook execution error: ${error.message}`);
action.addStep(step);
return action;
}
};

exec.displayName = 'executeExternalPreReceiveHook.exec';
exports.exec = exec;
20 changes: 16 additions & 4 deletions test/chain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mockPushProcessors = {
checkIfWaitingAuth: sinon.stub(),
pullRemote: sinon.stub(),
writePack: sinon.stub(),
preReceive: sinon.stub(),
getDiff: sinon.stub(),
clearBareClone: sinon.stub(),
scanDiff: sinon.stub(),
Expand All @@ -38,6 +39,7 @@ mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermissio
mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth';
mockPushProcessors.pullRemote.displayName = 'pullRemote';
mockPushProcessors.writePack.displayName = 'writePack';
mockPushProcessors.preReceive.displayName = 'preReceive';
mockPushProcessors.getDiff.displayName = 'getDiff';
mockPushProcessors.clearBareClone.displayName = 'clearBareClone';
mockPushProcessors.scanDiff.displayName = 'scanDiff';
Expand All @@ -63,7 +65,7 @@ describe('proxy chain', function () {
// Re-require the chain module after stubbing processors
chain = require('../src/proxy/chain');

chain.chainPluginLoader = new PluginLoader([])
chain.chainPluginLoader = new PluginLoader([]);
});

afterEach(() => {
Expand Down Expand Up @@ -108,7 +110,11 @@ describe('proxy chain', function () {
mockPushProcessors.checkUserPushPermission.resolves(continuingAction);

// this stops the chain from further execution
mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => false, allowPush: false });
mockPushProcessors.checkIfWaitingAuth.resolves({
type: 'push',
continue: () => false,
allowPush: false,
});
const result = await chain.executeChain(req);

expect(mockPreProcessors.parseAction.called).to.be.true;
Expand Down Expand Up @@ -136,7 +142,11 @@ describe('proxy chain', function () {
mockPushProcessors.checkAuthorEmails.resolves(continuingAction);
mockPushProcessors.checkUserPushPermission.resolves(continuingAction);
// this stops the chain from further execution
mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', continue: () => true, allowPush: true });
mockPushProcessors.checkIfWaitingAuth.resolves({
type: 'push',
continue: () => true,
allowPush: true,
});
const result = await chain.executeChain(req);

expect(mockPreProcessors.parseAction.called).to.be.true;
Expand Down Expand Up @@ -166,6 +176,7 @@ describe('proxy chain', function () {
mockPushProcessors.checkIfWaitingAuth.resolves(continuingAction);
mockPushProcessors.pullRemote.resolves(continuingAction);
mockPushProcessors.writePack.resolves(continuingAction);
mockPushProcessors.preReceive.resolves(continuingAction);
mockPushProcessors.getDiff.resolves(continuingAction);
mockPushProcessors.clearBareClone.resolves(continuingAction);
mockPushProcessors.scanDiff.resolves(continuingAction);
Expand All @@ -182,6 +193,7 @@ describe('proxy chain', function () {
expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true;
expect(mockPushProcessors.pullRemote.called).to.be.true;
expect(mockPushProcessors.writePack.called).to.be.true;
expect(mockPushProcessors.preReceive.called).to.be.true;
expect(mockPushProcessors.getDiff.called).to.be.true;
expect(mockPushProcessors.clearBareClone.called).to.be.true;
expect(mockPushProcessors.scanDiff.called).to.be.true;
Expand Down Expand Up @@ -232,5 +244,5 @@ describe('proxy chain', function () {
expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.false;
expect(mockPushProcessors.parsePush.called).to.be.false;
expect(result).to.deep.equal(action);
})
});
});
1 change: 1 addition & 0 deletions test/preReceive/mock/repo/test-repo/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Mock repository.
5 changes: 5 additions & 0 deletions test/preReceive/pre-receive-hooks/always-allow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
while read oldrev newrev refname; do
echo "Push allowed to $refname"
done
exit 0
5 changes: 5 additions & 0 deletions test/preReceive/pre-receive-hooks/always-reject.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
while read oldrev newrev refname; do
echo "Push rejected to $refname"
done
exit 1
99 changes: 99 additions & 0 deletions test/preReceive/preReceive.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const { expect } = require('chai');
const sinon = require('sinon');
const path = require('path');
const { exec } = require('../../src/proxy/processors/push-action/preReceive');

describe('Pre-Receive Hook Execution', function () {
let action;
let req;

beforeEach(() => {
req = {};
action = {
steps: [],
commitFrom: 'oldCommitHash',
commitTo: 'newCommitHash',
branch: 'feature-branch',
proxyGitPath: 'test/preReceive/mock/repo',
repoName: 'test-repo',
addStep: function (step) {
this.steps.push(step);
},
};
});

afterEach(() => {
sinon.restore();
});

it('should execute hook successfully', async () => {
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-allow.sh');

const result = await exec(req, action, scriptPath);

expect(result.steps).to.have.lengthOf(1);
expect(result.steps[0].error).to.be.false;
expect(
result.steps[0].logs.some((log) => log.includes('Pre-receive hook executed successfully')),
).to.be.true;
});

it('should skip execution when hook file does not exist', async () => {
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/missing-hook.sh');

const result = await exec(req, action, scriptPath);

expect(result.steps).to.have.lengthOf(1);
expect(result.steps[0].error).to.be.false;
expect(
result.steps[0].logs.some((log) =>
log.includes('Pre-receive hook not found, skipping execution.'),
),
).to.be.true;
});

it('should skip execution when hook directory does not exist', async () => {
const scriptPath = path.resolve(__dirname, 'non-existent-directory/pre-receive.sh');

const result = await exec(req, action, scriptPath);

expect(result.steps).to.have.lengthOf(1);
expect(result.steps[0].error).to.be.false;
expect(
result.steps[0].logs.some((log) =>
log.includes('Pre-receive hook not found, skipping execution.'),
),
).to.be.true;
});

it('should fail when hook execution returns an error', async () => {
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-reject.sh');

const result = await exec(req, action, scriptPath);

expect(result.steps).to.have.lengthOf(1);

const step = result.steps[0];

expect(step.error).to.be.true;
expect(step.logs.some((log) => log.includes('Hook stderr:'))).to.be.true;

expect(step.errorMessage).to.exist;

expect(action.steps).to.deep.include(step);
});

it('should catch and handle unexpected errors', async () => {
const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-allow.sh');

sinon.stub(require('fs'), 'existsSync').throws(new Error('Unexpected FS error'));

const result = await exec(req, action, scriptPath);

expect(result.steps).to.have.lengthOf(1);
expect(result.steps[0].error).to.be.true;
expect(
result.steps[0].logs.some((log) => log.includes('Hook execution error: Unexpected FS error')),
).to.be.true;
});
});
Loading