diff --git a/package-lock.json b/package-lock.json index 78b74dada..717f6552f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-standard": "^5.0.0", "eslint-plugin-typescript": "^0.14.0", + "fast-check": "^4.2.0", "husky": "^9.1.7", "mocha": "^10.8.2", "nyc": "^17.1.0", @@ -5995,6 +5996,29 @@ "node >=0.6.0" ] }, + "node_modules/fast-check": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.2.0.tgz", + "integrity": "sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10387,6 +10411,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -11837,10 +11878,11 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } diff --git a/package.json b/package.json index 28cb69de0..cb966e403 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-standard": "^5.0.0", "eslint-plugin-typescript": "^0.14.0", + "fast-check": "^4.2.0", "husky": "^9.1.7", "mocha": "^10.8.2", "nyc": "^17.1.0", diff --git a/test/processors/blockForAuth.test.js b/test/processors/blockForAuth.test.js index e242cb247..77bd75871 100644 --- a/test/processors/blockForAuth.test.js +++ b/test/processors/blockForAuth.test.js @@ -1,3 +1,4 @@ +const fc = require('fast-check'); const chai = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); @@ -92,4 +93,41 @@ describe('blockForAuth', () => { expect(message).to.include('/push/push@special#chars!'); }); }); + + describe('fuzzing', () => { + it('should create a step with correct parameters regardless of action ID', () => { + fc.assert( + fc.asyncProperty(fc.string(), async (actionId) => { + action.id = actionId; + + const freshStepInstance = new Step('temp'); + const setAsyncBlockStub = sinon.stub(freshStepInstance, 'setAsyncBlock'); + + const StepSpyLocal = sinon.stub().returns(freshStepInstance); + const getServiceUIURLStubLocal = sinon.stub().returns('http://localhost:8080'); + + const blockForAuth = proxyquire('../../src/proxy/processors/push-action/blockForAuth', { + '../../../service/urls': { getServiceUIURL: getServiceUIURLStubLocal }, + '../../actions': { Step: StepSpyLocal } + }); + + const result = await blockForAuth.exec(req, action); + + expect(StepSpyLocal.calledOnce).to.be.true; + expect(StepSpyLocal.calledWithExactly('authBlock')).to.be.true; + expect(setAsyncBlockStub.calledOnce).to.be.true; + + const message = setAsyncBlockStub.firstCall.args[0]; + expect(message).to.include(`http://localhost:8080/dashboard/push/${actionId}`); + expect(message).to.include('\x1B[32mGitProxy has received your push ✅\x1B[0m'); + expect(message).to.include(`\x1B[34mhttp://localhost:8080/dashboard/push/${actionId}\x1B[0m`); + expect(message).to.include('🔗 Shareable Link'); + expect(result).to.equal(action); + }), + { + numRuns: 100 + } + ); + }); + }); }); diff --git a/test/processors/checkAuthorEmails.test.js b/test/processors/checkAuthorEmails.test.js index 849842704..4ef2e041e 100644 --- a/test/processors/checkAuthorEmails.test.js +++ b/test/processors/checkAuthorEmails.test.js @@ -1,6 +1,7 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); const { expect } = require('chai'); +const fc = require('fast-check'); describe('checkAuthorEmails', () => { let action; @@ -169,4 +170,72 @@ describe('checkAuthorEmails', () => { ).to.be.true; }); }); + + describe('fuzzing', () => { + it('should not crash on random string in commit email', () => { + fc.assert( + fc.property(fc.string(), (commitEmail) => { + action.commitData = [ + { authorEmail: commitEmail } + ]; + exec({}, action); + }), + { + numRuns: 100 + } + ); + + expect(action.step.error).to.be.true; + expect(stepSpy.calledWith( + 'The following commit author e-mails are illegal: ' + )).to.be.true; + }); + + it('should handle valid emails with random characters', () => { + fc.assert( + fc.property(fc.emailAddress(), (commitEmail) => { + action.commitData = [ + { authorEmail: commitEmail } + ]; + exec({}, action); + }), + { + numRuns: 100 + } + ); + expect(action.step.error).to.be.undefined; + }); + + it('should handle invalid types in commit email', () => { + fc.assert( + fc.property(fc.anything(), (commitEmail) => { + action.commitData = [ + { authorEmail: commitEmail } + ]; + exec({}, action); + }), + { + numRuns: 100 + } + ); + + expect(action.step.error).to.be.true; + expect(stepSpy.calledWith( + 'The following commit author e-mails are illegal: ' + )).to.be.true; + }); + + it('should handle arrays of valid emails', () => { + fc.assert( + fc.property(fc.array(fc.emailAddress()), (commitEmails) => { + action.commitData = commitEmails.map(email => ({ authorEmail: email })); + exec({}, action); + }), + { + numRuns: 100 + } + ); + expect(action.step.error).to.be.undefined; + }); + }); }); diff --git a/test/processors/checkCommitMessages.test.js b/test/processors/checkCommitMessages.test.js index abea984bf..9e9e1b2ec 100644 --- a/test/processors/checkCommitMessages.test.js +++ b/test/processors/checkCommitMessages.test.js @@ -2,6 +2,7 @@ const chai = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire'); const { Action, Step } = require('../../src/proxy/actions'); +const fc = require('fast-check'); chai.should(); const expect = chai.expect; @@ -149,5 +150,53 @@ describe('checkCommitMessages', () => { expect(logStub.calledWith('The following commit messages are illegal: secret password here')) .to.be.true; }); + + describe('fuzzing', () => { + it('should not crash on arbitrary commit messages', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + message: fc.oneof( + fc.string(), + fc.constant(null), + fc.constant(undefined), + fc.integer(), + fc.double(), + fc.boolean(), + fc.object(), + ), + author: fc.string() + }), + { maxLength: 20 } + ), + async (fuzzedCommits) => { + const fuzzAction = new Action( + 'fuzz', + 'push', + 'POST', + Date.now(), + 'fuzz/repo' + ); + fuzzAction.commitData = Array.isArray(fuzzedCommits) ? fuzzedCommits : []; + + const result = await exec({}, fuzzAction); + + expect(result).to.have.property('steps'); + expect(result.steps[0]).to.have.property('error').that.is.a('boolean'); + } + ), + { + examples: [ + [{ message: '', author: 'me' }], + [{ message: '1234-5678-9012-3456', author: 'me' }], + [{ message: null, author: 'me' }], + [{ message: {}, author: 'me' }], + [{ message: 'SeCrEt', author: 'me' }] + ] + } + ); + }); + }); }); }); diff --git a/test/processors/checkUserPushPermission.test.js b/test/processors/checkUserPushPermission.test.js index fee0c7a64..58b96a04a 100644 --- a/test/processors/checkUserPushPermission.test.js +++ b/test/processors/checkUserPushPermission.test.js @@ -1,6 +1,7 @@ const chai = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire'); +const fc = require('fast-check'); const { Action, Step } = require('../../src/proxy/actions'); chai.should(); @@ -116,5 +117,26 @@ describe('checkUserPushPermission', () => { 'Push blocked: User not found. Please contact an administrator for support.', ); }); + + describe('fuzzing', () => { + it('should not crash on arbitrary getUsers return values (fuzzing)', async () => { + const userList = fc.sample( + fc.array( + fc.record({ + username: fc.string(), + gitAccount: fc.string() + }), + { maxLength: 5 } + ), + 1 + )[0]; + getUsersStub.resolves(userList); + + const result = await exec(req, action); + + expect(result.steps).to.have.lengthOf(1); + expect(result.steps[0].error).to.be.true; + }); + }); }); }); diff --git a/test/processors/getDiff.test.js b/test/processors/getDiff.test.js index 7f5bb4cf3..f14431bc3 100644 --- a/test/processors/getDiff.test.js +++ b/test/processors/getDiff.test.js @@ -1,6 +1,7 @@ const path = require('path'); const simpleGit = require('simple-git'); const fs = require('fs').promises; +const fc = require('fast-check'); const { Action } = require('../../src/proxy/actions'); const { exec } = require('../../src/proxy/processors/push-action/getDiff'); @@ -116,4 +117,57 @@ describe('getDiff', () => { expect(result.steps[0].content).to.not.be.null; expect(result.steps[0].content.length).to.be.greaterThan(0); }); + + describe('fuzzing', () => { + it('should handle random action inputs without crashing', async function () { + // Not comprehensive but helps prevent crashing on bad input + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 0, maxLength: 40 }), + fc.string({ minLength: 0, maxLength: 40 }), + fc.array(fc.record({ parent: fc.string({ minLength: 0, maxLength: 40 }) }), { maxLength: 3 }), + async (from, to, commitData) => { + const action = new Action('id', 'push', 'POST', Date.now(), 'test/repo'); + action.proxyGitPath = __dirname; + action.repoName = 'temp-test-repo'; + action.commitFrom = from; + action.commitTo = to; + action.commitData = commitData; + + const result = await exec({}, action); + + expect(result).to.have.property('steps'); + expect(result.steps[0]).to.have.property('error'); + expect(result.steps[0]).to.have.property('content'); + } + ), + { numRuns: 10 } + ); + }); + + it('should handle randomized commitFrom and commitTo of proper length', async function () { + await fc.assert( + fc.asyncProperty( + fc.stringMatching(/^[0-9a-fA-F]{40}$/), + fc.stringMatching(/^[0-9a-fA-F]{40}$/), + async (from, to) => { + const action = new Action('id', 'push', 'POST', Date.now(), 'test/repo'); + action.proxyGitPath = __dirname; + action.repoName = 'temp-test-repo'; + action.commitFrom = from; + action.commitTo = to; + action.commitData = [ + { parent: '0000000000000000000000000000000000000000' } + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).to.be.true; + expect(result.steps[0].errorMessage).to.contain('Invalid revision range'); + } + ), + { numRuns: 10 } + ); + }); + }); }); diff --git a/test/testCheckRepoInAuthList.test.js b/test/testCheckRepoInAuthList.test.js index 19d161c12..dea640ba2 100644 --- a/test/testCheckRepoInAuthList.test.js +++ b/test/testCheckRepoInAuthList.test.js @@ -2,6 +2,7 @@ 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 fc = require('fast-check'); const authList = () => { return [ @@ -24,4 +25,20 @@ describe('Check a Repo is in the authorised list', async () => { const result = await processor.exec(null, action, authList); expect(result.error).to.be.true; }); + + describe('fuzzing', () => { + it('should not crash on random repo names', async () => { + await fc.assert( + fc.asyncProperty( + fc.string(), + async (repoName) => { + const action = new actions.Action('123', 'type', 'get', 1234, repoName); + const result = await processor.exec(null, action, authList); + expect(result.error).to.be.true; + } + ), + { numRuns: 100 } + ); + }); + }); });