diff --git a/controllers/handlePR.js b/controllers/handlePR.js deleted file mode 100644 index 87aeacde..00000000 --- a/controllers/handlePR.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict' - -const config = require('../config') -const GitHub = require('../lib/GitHub') -const Staticman = require('../lib/Staticman') - -module.exports = async (repo, data) => { - const ua = config.get('analytics.uaTrackingId') - ? require('universal-analytics')(config.get('analytics.uaTrackingId')) - : null - - if (!data.number) { - return - } - - const github = await new GitHub({ - username: data.repository.owner.login, - repository: data.repository.name, - version: '1' - }) - - try { - let review = await github.getReview(data.number) - if (review.sourceBranch.indexOf('staticman_')) { - return null - } - - if (review.state !== 'merged' && review.state !== 'closed') { - return null - } - - if (review.state === 'merged') { - const bodyMatch = review.body.match(/(?:.*?)(?:.*?)/i) - - if (bodyMatch && (bodyMatch.length === 2)) { - try { - const parsedBody = JSON.parse(bodyMatch[1]) - const staticman = await new Staticman(parsedBody.parameters) - - staticman.setConfigPath(parsedBody.configPath) - staticman.processMerge(parsedBody.fields, parsedBody.options) - } catch (err) { - return Promise.reject(err) - } - } - } - - if (ua) { - ua.event('Hooks', 'Delete branch').send() - } - return github.deleteBranch(review.sourceBranch) - } catch (e) { - console.log(e.stack || e) - - if (ua) { - ua.event('Hooks', 'Delete branch error').send() - } - - return Promise.reject(e) - } -} diff --git a/controllers/webhook.js b/controllers/webhook.js new file mode 100644 index 00000000..d5b56227 --- /dev/null +++ b/controllers/webhook.js @@ -0,0 +1,302 @@ +'use strict' + +const bufferEq = require('buffer-equal-constant-time') +const path = require('path') +const config = require(path.join(__dirname, '/../config')) +const crypto = require('crypto') + +const gitFactory = require('../lib/GitServiceFactory') +const Staticman = require('../lib/Staticman') + +/** + * Express handler for webhook requests/notifications generated by the backing git service. + */ +module.exports = async (req, res, next) => { + let errorsRaised = [] + + let service = null + let staticman = null + if (_calcIsVersion1WhenGitHubAssumed(req)) { + service = 'github' + } else { + /* + * In versions of the webhook endpoint beyond v1, we have the parameters necessary to + * instantiate a Staticman instance right away. + */ + service = req.params.service + staticman = await new Staticman(req.params) + staticman.setConfigPath() + } + + switch (service) { + case 'github': + await _handleWebhookGitHub(req, service, staticman).catch((errors) => { + errorsRaised = errorsRaised.concat(errors) + }) + break + case 'gitlab': + await _handleWebhookGitLab(req, service, staticman).catch((errors) => { + errorsRaised = errorsRaised.concat(errors) + }) + break + default: + errorsRaised.push('Unexpected service specified.') + } + + if (errorsRaised.length > 0) { + res.status(400).send({ + errors: JSON.stringify(errorsRaised) + }) + } else { + res.status(200).send({ + success: true + }) + } +} + +const _handleWebhookGitHub = async function (req, service, staticman) { + let errorsRaised = [] + + const event = req.headers['x-github-event'] + if (!event) { + errorsRaised.push('No event found in the request') + } else { + if (event === 'pull_request') { + let webhookSecretExpected = null + if (staticman) { + // Webhook request authentication is NOT supported in v1 of the endpoint. + await staticman.getSiteConfig().then((siteConfig) => { + webhookSecretExpected = siteConfig.get('githubWebhookSecret') + }) + } + + let reqAuthenticated = true + if (webhookSecretExpected) { + reqAuthenticated = false + const webhookSecretSent = req.headers['x-hub-signature'] + if (!webhookSecretSent) { + // This could be worth logging... unless the endpoint gets hammered with spam. + errorsRaised.push('No secret found in the webhook request') + } else if (_verifyGitHubSignature(webhookSecretExpected, JSON.stringify(req.body), webhookSecretSent)) { + reqAuthenticated = true + } else { + // This could be worth logging... unless the endpoint gets hammered with spam. + errorsRaised.push('Unable to verify authenticity of request') + } + } + + if (reqAuthenticated) { + await _handleMergeRequest(req.params, service, req.body, staticman).catch((errors) => { + errorsRaised = errors + }) + } + } + } + + if (errorsRaised.length > 0) { + return Promise.reject(errorsRaised) + } +} + +const _handleWebhookGitLab = async function (req, service, staticman) { + let errorsRaised = [] + + const event = req.headers['x-gitlab-event'] + if (!event) { + errorsRaised.push('No event found in the request') + } else { + if (event === 'Merge Request Hook') { + let webhookSecretExpected = null + if (staticman) { + // Webhook request authentication is NOT supported in v1 of the endpoint. + await staticman.getSiteConfig().then((siteConfig) => { + webhookSecretExpected = siteConfig.get('gitlabWebhookSecret') + }) + } + + let reqAuthenticated = true + if (webhookSecretExpected) { + reqAuthenticated = false + const webhookSecretSent = req.headers['x-gitlab-token'] + if (!webhookSecretSent) { + // This could be worth logging... unless the endpoint gets hammered with spam. + errorsRaised.push('No secret found in the webhook request') + } else if (webhookSecretExpected === webhookSecretSent) { + /* + * Whereas GitHub uses the webhook secret to sign the request body, GitLab does not. + * As such, just check that the received secret equals the expected value. + */ + reqAuthenticated = true + } else { + // This could be worth logging... unless the endpoint gets hammered with spam. + errorsRaised.push('Unable to verify authenticity of request') + } + } + + if (reqAuthenticated) { + await _handleMergeRequest(req.params, service, req.body, staticman).catch((errors) => { + errorsRaised = errors + }) + } + } + } + + if (errorsRaised.length > 0) { + return Promise.reject(errorsRaised) + } +} + +const _calcIsVersion1WhenGitHubAssumed = function (req) { + const service = req.params.service + const version = req.params.version + return (!service && version === '1') +} + +const _verifyGitHubSignature = function (secret, data, signature) { + const signedData = 'sha1=' + crypto.createHmac('sha1', secret).update(data).digest('hex') + return bufferEq(Buffer.from(signature), Buffer.from(signedData)) +} + +const _handleMergeRequest = async function (params, service, data, staticman) { + const errors = [] + + const ua = config.get('analytics.uaTrackingId') + ? require('universal-analytics')(config.get('analytics.uaTrackingId')) + : null + + const version = params.version + const username = params.username + const repository = params.repository + const branch = params.branch + + let gitService = null + let mergeReqNbr = null + if (service === 'github') { + gitService = await _buildGitHubService(version, service, username, repository, branch, data) + mergeReqNbr = data.number + } else if (service === 'gitlab') { + gitService = await gitFactory.create('gitlab', { + version: version, + username: username, + repository: repository, + branch: branch + }) + mergeReqNbr = data.object_attributes.iid + } else { + errors.push('Unable to determine service.') + return Promise.reject(errors) + } + + if (mergeReqNbr === null || typeof mergeReqNbr === 'undefined') { + errors.push('No pull/merge request number found.') + return Promise.reject(errors) + } + + let review = await gitService.getReview(mergeReqNbr).catch((error) => { + const msg = `Failed to retrieve merge request ${mergeReqNbr} - ${error}` + console.error(msg) + errors.push(msg) + return Promise.reject(errors) + }) + + if (_calcIsMergeRequestStaticmanGenerated(review)) { + if (_calcIsMergeRequestAccepted(review)) { + await _createNotifyMailingList(review, staticman, ua).catch((error) => { + errors.push(error.message) + }) + + if (_calcIsMergeRequestBranchRequiresManualDelete(service)) { + await _deleteMergeRequestBranch(gitService, review, ua).catch((error) => { + errors.push(error) + }) + } + } + } + + if (errors.length > 0) { + return Promise.reject(errors) + } +} + +const _buildGitHubService = function (version, service, username, repository, branch, data) { + /* + * In v1 of the endpoint, the service, username, repository, and branch parameters were + * ommitted. As such, if not provided in the webhook request URL, pull them from the webhook + * payload. + */ + if (username === null || typeof username === 'undefined') { + username = data.repository.owner.login + } + if (repository === null || typeof repository === 'undefined') { + repository = data.repository.name + } + if (branch === null || typeof branch === 'undefined') { + branch = data.pull_request.base.ref + } + + return gitFactory.create(service, { + version: version, + username: username, + repository: repository, + branch: branch + }) +} + +const _calcIsMergeRequestAccepted = function (review) { + return (review.state === 'merged' || review.state === 'closed') +} + +const _calcIsMergeRequestStaticmanGenerated = function (review) { + return (review.sourceBranch.indexOf('staticman_') > -1) +} + +const _calcIsMergeRequestBranchRequiresManualDelete = function (service) { + return (service === 'github') +} + +const _createNotifyMailingList = async function (review, staticman, ua) { + const bodyMatch = review.body.match(/(?:.*?)(?:.*?)/i) + if (_calcIsNotificationsEnabledWhenMergeRequestCreated(bodyMatch)) { + try { + const parsedBody = JSON.parse(bodyMatch[1]) + if (staticman === null) { + staticman = await new Staticman(parsedBody.parameters) + staticman.setConfigPath(parsedBody.configPath) + } + + await staticman.processMerge(parsedBody.fields, parsedBody.extendedFields, parsedBody.options).then(msg => { + if (ua) { + ua.event('Hooks', 'Create/notify mailing list').send() + } + }) + } catch (err) { + if (ua) { + ua.event('Hooks', 'Create/notify mailing list error').send() + } + + return Promise.reject(err) + } + } +} + +const _calcIsNotificationsEnabledWhenMergeRequestCreated = function (bodyMatch) { + return (bodyMatch && (bodyMatch.length === 2)) +} + +const _deleteMergeRequestBranch = async function (gitService, review, ua) { + try { + // This will throw the error 'Reference does not exist' if the branch has already been deleted. + await gitService.deleteBranch(review.sourceBranch) + if (ua) { + ua.event('Hooks', 'Delete branch').send() + } + } catch (err) { + if (ua) { + ua.event('Hooks', 'Delete branch error').send() + } + + const msg = `Failed to delete merge branch ${review.sourceBranch} - ${err}` + console.error(msg) + return Promise.reject(msg) + } +} diff --git a/lib/GitHub.js b/lib/GitHub.js index 67558241..f159acad 100644 --- a/lib/GitHub.js +++ b/lib/GitHub.js @@ -19,7 +19,8 @@ class GitHub extends GitService { const isAppAuth = config.get('githubAppID') && config.get('githubPrivateKey') const isLegacyAuth = config.get('githubToken') && - ['1', '2'].includes(options.version) + // Staticman version may not always be available to the caller. As such, allow for blank. + ['1', '2', ''].includes(options.version) let authToken diff --git a/lib/Staticman.js b/lib/Staticman.js index 85dafa12..7a2f2b6f 100644 --- a/lib/Staticman.js +++ b/lib/Staticman.js @@ -573,18 +573,12 @@ class Staticman { } processMerge (fields, options) { - this.fields = Object.assign({}, fields) - this.options = Object.assign({}, options) - return this.getSiteConfig().then(config => { const subscriptions = this._initialiseSubscriptions() return subscriptions.send(options.parent, fields, options, this.siteConfig) }).catch(err => { - return Promise.reject(errorHandler('ERROR_PROCESSING_MERGE', { - err, - instance: this - })) + return Promise.reject(err) }) } diff --git a/lib/SubscriptionsManager.js b/lib/SubscriptionsManager.js index fdfc66a0..3a65db59 100644 --- a/lib/SubscriptionsManager.js +++ b/lib/SubscriptionsManager.js @@ -35,12 +35,16 @@ SubscriptionsManager.prototype._get = function (entryId) { SubscriptionsManager.prototype.send = function (entryId, fields, options, siteConfig) { return this._get(entryId).then(list => { - if (list) { + if (list !== null) { const notifications = new Notification(this.mailAgent) return notifications.send(list, fields, options, { siteName: siteConfig.get('name') }) + } else { + const msg = `Unable to find mailing list for ${entryId}` + console.log(msg) + return Promise.reject(new Error(msg)) } }) } diff --git a/package.json b/package.json index b873936a..558189e3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "convict": "^4.3.0", "express": "^4.14.0", "express-brute": "^0.6.0", - "express-github-webhook": "^1.0.5", "express-recaptcha": "^2.1.0", "gitlab": "^3.5.1", "js-yaml": "^3.10.0", diff --git a/server.js b/server.js index 003af118..cdaacdd0 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,6 @@ const bodyParser = require('body-parser') const config = require('./config') const express = require('express') const ExpressBrute = require('express-brute') -const GithubWebHook = require('express-github-webhook') const objectPath = require('object-path') class StaticmanAPI { @@ -11,9 +10,9 @@ class StaticmanAPI { connect: require('./controllers/connect'), encrypt: require('./controllers/encrypt'), auth: require('./controllers/auth'), - handlePR: require('./controllers/handlePR'), home: require('./controllers/home'), - process: require('./controllers/process') + process: require('./controllers/process'), + webhook: require('./controllers/webhook') } this.server = express() @@ -23,7 +22,6 @@ class StaticmanAPI { // type: '*' })) - this.initialiseWebhookHandler() this.initialiseCORS() this.initialiseBruteforceProtection() this.initialiseRoutes() @@ -96,6 +94,18 @@ class StaticmanAPI { this.controllers.auth ) + this.server.post( + /* + * Make the service, username, repository, etc. parameters optional in order to + * maintain backwards-compatibility with v1 of the endpoint, which assumed GitHub. + */ + '/v:version/webhook/:service?/:username?/:repository?/:branch?/:property?', + this.bruteforce.prevent, + this.requireApiVersion([1, 3]), + this.requireService(['github', 'gitlab'], true), + this.controllers.webhook + ) + // Route: root this.server.get( '/', @@ -103,16 +113,6 @@ class StaticmanAPI { ) } - initialiseWebhookHandler () { - const webhookHandler = GithubWebHook({ - path: '/v1/webhook' - }) - - webhookHandler.on('pull_request', this.controllers.handlePR) - - this.server.use(webhookHandler) - } - requireApiVersion (versions) { return (req, res, next) => { const versionMatch = versions.some(version => { @@ -130,9 +130,17 @@ class StaticmanAPI { } } - requireService (services) { + requireService (services, optional = false) { return (req, res, next) => { - const serviceMatch = services.some(service => service === req.params.service) + let serviceMatch = false + if (optional && !req.params.service) { + serviceMatch = true + } else { + serviceMatch = services.some(service => { + let requestedService = req.params.service ? req.params.service : '' + return service === requestedService + }) + } if (!serviceMatch) { return res.status(400).send({ diff --git a/siteConfig.js b/siteConfig.js index ec83d562..e459ea11 100644 --- a/siteConfig.js +++ b/siteConfig.js @@ -107,6 +107,11 @@ const schema = { default: false } }, + githubWebhookSecret: { + doc: 'Token to verify that webhook requests are from GitHub', + format: 'EncryptedString', + default: null + }, gitlabAuth: { clientId: { doc: 'The client ID to the GitLab Application used for GitLab OAuth.', @@ -124,6 +129,11 @@ const schema = { default: '' } }, + gitlabWebhookSecret: { + doc: 'Token to verify that webhook requests are from GitLab', + format: 'EncryptedString', + default: null + }, moderation: { doc: 'When set to `true`, a pull request with the data files will be created to allow site administrators to approve or reject an entry. Otherwise, entries will be pushed to `branch` immediately.', format: Boolean, diff --git a/test/unit/controllers/handlePR.test.js b/test/unit/controllers/handlePR.test.js deleted file mode 100644 index 786525bc..00000000 --- a/test/unit/controllers/handlePR.test.js +++ /dev/null @@ -1,210 +0,0 @@ -const helpers = require('./../../helpers') -const sampleData = require('./../../helpers/sampleData') -const Review = require('../../../lib/models/Review') - -let mockSetConfigPathFn -let mockProcessMergeFn -let req - -// Mock Staticman module -jest.mock('../../../lib/Staticman', () => { - return jest.fn().mockImplementation(() => { - return { - setConfigPath: mockSetConfigPathFn, - processMerge: mockProcessMergeFn - } - }) -}) - -beforeEach(() => { - mockSetConfigPathFn = jest.fn() - mockProcessMergeFn = jest.fn() - req = helpers.getMockRequest() - res = helpers.getMockResponse() - - jest.resetAllMocks() - jest.resetModules() -}) - -describe('HandlePR controller', () => { - test('ignores pull requests from branches not prefixed with `staticman_`', async () => { - const pr = { - number: 123, - title: 'Some random PR', - body: 'Unrelated review body', - head: { - ref: 'some-other-branch' - }, - base: { - ref: 'master' - }, - merged: false, - repository: { - name: req.params.repository, - owner: { - login: req.params.username - } - }, - state: 'open' - } - - const mockReview = new Review(pr.title, pr.body, 'false', pr.head.ref, pr.base.ref) - const mockGetReview = jest.fn().mockResolvedValue(mockReview) - - jest.mock('../../../lib/GitHub', () => { - return jest.fn().mockImplementation(() => { - return { - getReview: mockGetReview - } - }) - }) - - const handlePR = require('./../../../controllers/handlePR') - - let response = await handlePR(req.params.repository, pr) - expect(mockGetReview).toHaveBeenCalledTimes(1) - expect(response).toBe(null) - }) - - describe('processes notifications if the pull request has been merged', () => { - test('do nothing if PR body doesn\'t match template', async () => { - const pr = { - number: 123, - title: 'Add Staticman data', - body: sampleData.prBody2, - head: { - ref: 'staticman_1234567' - }, - base: { - ref: 'master' - }, - merged: true, - repository: { - name: req.params.repository, - owner: { - login: req.params.username - } - }, - state: 'open' - } - - const mockReview = new Review(pr.title, pr.body, 'false', pr.head.ref, pr.base.ref) - const mockGetReview = jest.fn().mockResolvedValue(mockReview) - const mockDeleteBranch = jest.fn() - - jest.mock('../../../lib/GitHub', () => { - return jest.fn().mockImplementation(() => { - return { - getReview: mockGetReview, - deleteBranch: mockDeleteBranch - } - }) - }) - - const handlePR = require('./../../../controllers/handlePR') - - await handlePR(req.params.repository, pr) - expect(mockGetReview).toHaveBeenCalledTimes(1) - expect(mockDeleteBranch).not.toHaveBeenCalled() - }) - - test('abort and return an error if `processMerge` fails', async () => { - const pr = { - number: 123, - title: 'Add Staticman data', - body: sampleData.prBody1, - head: { - ref: 'staticman_1234567' - }, - base: { - ref: 'master' - }, - merged: true, - repository: { - name: req.params.repository, - owner: { - login: req.params.username - } - }, - state: 'closed' - } - - const mockReview = new Review(pr.title, pr.body, 'merged', pr.head.ref, pr.base.ref) - const mockGetReview = jest.fn().mockResolvedValue(mockReview) - const mockDeleteBranch = jest.fn() - - jest.mock('../../../lib/GitHub', () => { - return jest.fn().mockImplementation(() => { - return { - getReview: mockGetReview, - deleteBranch: mockDeleteBranch - } - }) - }) - - const errorMessage = 'some error' - - mockProcessMergeFn = jest.fn(() => { - throw errorMessage - }) - - const handlePR = require('./../../../controllers/handlePR') - - expect.assertions(4) - try { - await handlePR(req.params.repository, pr) - } catch (e) { - expect(e).toBe(errorMessage) - expect(mockGetReview).toHaveBeenCalledTimes(1) - // expect(mockSetConfigPathFn.mock.calls.length).toBe(1) - // expect(mockProcessMergeFn.mock.calls.length).toBe(1) - expect(mockSetConfigPathFn).toHaveBeenCalledTimes(1) - expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) - } - }) - - test('delete the branch if the pull request is closed', async () => { - const pr = { - number: 123, - title: 'Add Staticman data', - body: sampleData.prBody1, - head: { - ref: 'staticman_1234567' - }, - base: { - ref: 'master' - }, - merged: true, - repository: { - name: req.params.repository, - owner: { - login: req.params.username - } - }, - state: 'closed' - } - - const mockReview = new Review(pr.title, pr.body, 'merged', pr.head.ref, pr.base.ref) - const mockDeleteBranch = jest.fn() - const mockGetReview = jest.fn().mockResolvedValue(mockReview) - - jest.mock('../../../lib/GitHub', () => { - return jest.fn().mockImplementation(() => { - return { - deleteBranch: mockDeleteBranch, - getReview: mockGetReview - } - }) - }) - - const handlePR = require('./../../../controllers/handlePR') - - await handlePR(req.params.repository, pr) - expect(mockGetReview).toHaveBeenCalledTimes(1) - expect(mockGetReview.mock.calls[0][0]).toEqual(123) - expect(mockDeleteBranch).toHaveBeenCalledTimes(1) - expect(mockSetConfigPathFn.mock.calls.length).toBe(1) - expect(mockProcessMergeFn.mock.calls.length).toBe(1) - }) - }) -}) diff --git a/test/unit/controllers/webhook.test.js b/test/unit/controllers/webhook.test.js new file mode 100644 index 00000000..ea127a21 --- /dev/null +++ b/test/unit/controllers/webhook.test.js @@ -0,0 +1,751 @@ +let mockCreateHmacFn = jest.fn() + +const helpers = require('./../../helpers') +const Review = require('../../../lib/models/Review') +const sampleData = require('./../../helpers/sampleData') + +let req +let res +let mockReview + +let mockGetReviewFn = jest.fn() +let mockDeleteBranchFn = jest.fn() +let mockCreateFn = jest.fn() +let mockSetConfigPathFn = jest.fn() +let mockGetSiteConfigFn = jest.fn() +let mockProcessMergeFn = jest.fn() + +jest.mock('../../../lib/GitServiceFactory', () => { + return { + create: mockCreateFn + } +}) + +const mockHmacDigest = 'mock Hmac digest' +/* + * Mock the createHmac function within the native crypto module, but leave every other function. + * This allows us to test logic that invokes GitHub's webhook request authentication, which + * involves signing the webhook payload using the agreed-upon secret token. + */ +jest.mock('crypto', () => { + const cryptoOrig = require.requireActual('crypto') + return { + ...cryptoOrig, + createHmac: mockCreateHmacFn + } +}) + +jest.mock('../../../lib/Staticman', () => { + return jest.fn().mockImplementation(() => { + return { + setConfigPath: mockSetConfigPathFn, + getSiteConfig: mockGetSiteConfigFn, + processMerge: mockProcessMergeFn + } + }) +}) + +// Instantiate the module being tested AFTER mocking dependendent modules above. +const webhook = require('./../../../controllers/webhook') + +beforeEach(() => { + req = helpers.getMockRequest() + res = helpers.getMockResponse() + + mockCreateFn.mockImplementation((service, options) => { + return { + getReview: mockGetReviewFn, + deleteBranch: mockDeleteBranchFn + } + }) + + mockCreateHmacFn.mockImplementation((algo, data) => { + return { + update: data => { + return { + digest: encoding => { + return mockHmacDigest + } + } + } + } + }) +}) + +afterEach(() => { + mockGetReviewFn.mockClear() + mockDeleteBranchFn.mockClear() + mockCreateFn.mockClear() + mockSetConfigPathFn.mockClear() + mockGetSiteConfigFn.mockClear() + mockProcessMergeFn.mockClear() + mockCreateHmacFn.mockClear() +}) + +describe('Webhook controller', () => { + test.each([ + ['gitfoo'] + ])('abort and return an error if unknown service specified - %s', async (service) => { + req.params.service = service + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateFn).toHaveBeenCalledTimes(0) + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"Unexpected service specified.\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if no event header found - %s', async (service) => { + req.params.service = service + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateFn).toHaveBeenCalledTimes(0) + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"No event found in the request\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return success if not "Merge Request Hook" event - %s', async (service) => { + req.params.service = service + if (service === 'github') { + req.headers['x-github-event'] = 'Some Other Hook' + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Some Other Hook' + } + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateFn).toHaveBeenCalledTimes(0) + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if webhook secret expected, but not sent - %s', async (service) => { + req.params.service = service + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + } + + // Inject a value for the expected webhook secret into the site config. + if (service === 'github') { + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', '2a-foobar-db72'] + ]))) + ) + } else if (service === 'gitlab') { + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + } + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateFn).toHaveBeenCalledTimes(0) + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"No secret found in the webhook request\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if unexpected webhook secret sent - %s', async (service) => { + req.params.service = service + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + + // Inject a value for the expected webhook secret into the site config. + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + + // Mock a signature from GitHub that does NOT match the expected signature. + req.headers['x-hub-signature'] = 'sha1=' + 'foobar' + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + + // Inject a value for the expected webhook secret into the site config. + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + + // Mock a token from GitLab that does NOT match the expected token. + req.headers['x-gitlab-token'] = '2a-different-db72' + } + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(0) + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"Unable to verify authenticity of request\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if no merge request number found in webhook payload - %s', async (service) => { + req.params.service = service + + req.body = { + object_attributes: {} + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + + // Inject a value for the expected webhook secret into the site config. + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + + // Mock a signature from GitHub that matches the expected signature. + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + + // Inject a value for the expected webhook secret into the site config. + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + + // Mock a token from GitLab that matches the expected token. + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn.mock.calls[0][0]).toBe(service) + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"No pull/merge request number found.\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + [null] + ])('default to github if version equals 1 and no service specified in parameters - %s', async (service) => { + req.params.version = '1' + req.params.service = null + req.params.username = null + req.params.repository = null + req.params.branch = null + + req.body = { + pull_request: { + base: { + ref: 'master' + } + }, + repository: { + name: req.params.repository, + owner: { + login: req.params.username + } + } + } + + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + + expect.hasAssertions() + return webhook(req, res).then(response => { + /* + * The necessary parameters to retrieve the site config and verify the webhook signature are + * not passed in v1 of the webhook endpoint. + */ + expect(mockGetSiteConfigFn).toHaveBeenCalledTimes(0) + expect(mockCreateHmacFn).toHaveBeenCalledTimes(0) + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn.mock.calls[0][0]).toBe('github') + }) + }) + + test.each([ + ['github'] + ])('use values from webhook payload if no parameters specified - %s', async (service) => { + req.params.version = '1' + req.params.service = null + req.params.username = null + req.params.repository = null + req.params.branch = null + + req.body = { + pull_request: { + base: { + ref: 'master' + } + }, + repository: { + name: 'foorepo', + owner: { + login: 'foouser' + } + } + } + + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + + expect.hasAssertions() + return webhook(req, res).then(response => { + /* + * The necessary parameters to retrieve the site config and verify the webhook signature are + * not passed in v1 of the webhook endpoint. + */ + expect(mockGetSiteConfigFn).toHaveBeenCalledTimes(0) + expect(mockCreateHmacFn).toHaveBeenCalledTimes(0) + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn.mock.calls[0][0]).toBe('github') + expect(mockCreateFn.mock.calls[0][1].username).toBe(req.body.repository.owner.login) + expect(mockCreateFn.mock.calls[0][1].repository).toBe(req.body.repository.name) + expect(mockCreateFn.mock.calls[0][1].branch).toBe(req.body.pull_request.base.ref) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if error retrieving merge request - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + const rejectErrorMsg = 'get review error msg' + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => reject(rejectErrorMsg))) + + // Suppress any calls to console.error - to keep test output clean. + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + if (service === 'github') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.number) + expect(res.send.mock.calls[0][0]).toEqual( + { errors: '[\"Failed to retrieve merge request ' + req.body.number + ' - ' + rejectErrorMsg + '\"]' }) + } else if (service === 'gitlab') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.object_attributes.iid) + expect(res.send.mock.calls[0][0]).toEqual( + { errors: '[\"Failed to retrieve merge request ' + req.body.object_attributes.iid + ' - ' + rejectErrorMsg + '\"]' }) + } + expect(res.status.mock.calls[0][0]).toBe(400) + + // Restore console.error + consoleSpy.mockRestore(); + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return success if merge request source branch not created by Staticman - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + mockReview = new Review('Some random PR', 'Review body', 'merged', 'some-other-branch', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + if (service === 'github') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.number) + } else if (service === 'gitlab') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.object_attributes.iid) + } + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return success if merge request state not merged or closed - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + mockReview = new Review('Some random PR', 'Review body', 'not merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + if (service === 'github') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.number) + } else if (service === 'gitlab') { + expect(mockGetReviewFn.mock.calls[0][0]).toBe(req.body.object_attributes.iid) + } + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('return success if merge request body does not match template - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + mockReview = new Review('Some random PR', 'Review body', 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + // No attempt should be made to send notification emails. + expect(mockProcessMergeFn).toHaveBeenCalledTimes(0) + if (service === 'github') { + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(1) + } else if (service === 'gitlab') { + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(0) + } + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['github'], ['gitlab'] + ])('abort and return an error if error raised sending notification emails - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + if (service === 'github') { + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + } else if (service === 'gitlab') { + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + } + + /* + * Mock a review body that contains the "staticman_notification" comment section that triggers + * notifications to be sent. + */ + mockReview = new Review('Some random PR', sampleData.prBody1, 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + + const errorMsg = 'process merge error msg' + mockProcessMergeFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => reject(new Error(errorMsg)))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + if (service === 'github') { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + } + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) + // Shold still attempt to delete branch despite error sending notification emails. + if (service === 'github') { + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(1) + } else if (service === 'gitlab') { + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(0) + } + expect(res.send.mock.calls[0][0]).toEqual({ errors: '[\"' + errorMsg + '\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + }) + }) + + test.each([ + ['github'] + ])('abort and return an error if delete branch fails - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + + mockReview = new Review('Some random PR', sampleData.prBody1, 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + mockProcessMergeFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(true))) + + const errorMsg = 'delete branch error msg' + mockDeleteBranchFn.mockImplementation((sourceBranch) => new Promise((resolve, reject) => reject(errorMsg))) + + // Suppress any calls to console.error - to keep test output clean. + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(1) + expect(res.send.mock.calls[0][0]).toEqual( + { errors: '[\"Failed to delete merge branch ' + mockReview.sourceBranch + ' - ' + errorMsg + '\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + + // Restore console.error + consoleSpy.mockRestore(); + }) + }) + + test.each([ + ['github'] + ])('return success if delete branch succeeds - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + + mockReview = new Review('Some random PR', sampleData.prBody1, 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + mockProcessMergeFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(true))) + mockDeleteBranchFn.mockImplementation((sourceBranch) => new Promise((resolve, reject) => resolve(true))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(1) + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['gitlab'] + ])('no attempt to delete branch if gitlab - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + req.headers['x-gitlab-event'] = 'Merge Request Hook' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['gitlabWebhookSecret', '2a-foobar-db72'] + ]))) + ) + req.headers['x-gitlab-token'] = '2a-foobar-db72' + + mockReview = new Review('Some random PR', sampleData.prBody1, 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + mockProcessMergeFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(true))) + mockDeleteBranchFn.mockImplementation((sourceBranch) => new Promise((resolve, reject) => resolve(true))) + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(0) + expect(res.status.mock.calls[0][0]).toBe(200) + }) + }) + + test.each([ + ['github'] + ])('abort and return multiple errors if BOTH sending notification emails and deleting the branch fails - %s', async (service) => { + req.params.service = service + + req.body = { + number: 123, + object_attributes: { + iid: 234 + } + } + + req.headers['x-github-event'] = 'pull_request' + mockGetSiteConfigFn.mockImplementation(() => new Promise((resolve, reject) => resolve(new Map([ + ['githubWebhookSecret', 'sha1=' + mockHmacDigest] + ]))) + ) + req.headers['x-hub-signature'] = 'sha1=' + mockHmacDigest + + mockReview = new Review('Some random PR', sampleData.prBody1, 'merged', 'staticman_1234567', 'master') + mockGetReviewFn.mockImplementation((mergeReqNbr) => new Promise((resolve, reject) => resolve(mockReview))) + + const processMergeErrorMsg = 'process merge error msg' + mockProcessMergeFn.mockImplementation( + (mergeReqNbr) => new Promise((resolve, reject) => reject(new Error(processMergeErrorMsg)))) + const deleteBranchErrorMsg = 'delete branch error msg' + mockDeleteBranchFn.mockImplementation( + (sourceBranch) => new Promise((resolve, reject) => reject(deleteBranchErrorMsg))) + + // Suppress any calls to console.error - to keep test output clean. + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect.hasAssertions() + return webhook(req, res).then(response => { + expect(mockCreateHmacFn).toHaveBeenCalledTimes(1) + expect(mockCreateFn).toHaveBeenCalledTimes(1) + expect(mockGetReviewFn).toHaveBeenCalledTimes(1) + expect(mockProcessMergeFn).toHaveBeenCalledTimes(1) + expect(mockDeleteBranchFn).toHaveBeenCalledTimes(1) + expect(res.send.mock.calls[0][0]).toEqual( + { errors: '[\"' + processMergeErrorMsg + '\",\"Failed to delete merge branch ' + mockReview.sourceBranch + ' - ' + deleteBranchErrorMsg + '\"]' }) + expect(res.status.mock.calls[0][0]).toBe(400) + + // Restore console.error + consoleSpy.mockRestore(); + }) + }) +}) diff --git a/test/unit/lib/GitHub.test.js b/test/unit/lib/GitHub.test.js index 24bc5098..f00f03ef 100644 --- a/test/unit/lib/GitHub.test.js +++ b/test/unit/lib/GitHub.test.js @@ -37,6 +37,22 @@ describe('GitHub interface', () => { expect(scope.isDone()).toBe(true) }) + test('authenticates with the GitHub API using a personal access token when version blank', async () => { + const scope = nock((/api\.github\.com/), { + reqheaders: { + authorization: 'token '.concat('1q2w3e4r') + } + }) + .get('/user/repository_invitations') + .reply(200) + + let paramsWithoutVersion = Object.assign({}, req.params) + paramsWithoutVersion.version = '' + const githubInstance = await new GitHub(paramsWithoutVersion) + await githubInstance.api.repos.listInvitationsForAuthenticatedUser(); + expect(scope.isDone()).toBe(true) + }) + test('authenticates with the GitHub API using an OAuth token', async () => { const scope = nock((/api\.github\.com/), { reqheaders: { diff --git a/test/unit/lib/Staticman.test.js b/test/unit/lib/Staticman.test.js index a5915a4c..3737f320 100644 --- a/test/unit/lib/Staticman.test.js +++ b/test/unit/lib/Staticman.test.js @@ -1499,10 +1499,11 @@ describe('Staticman interface', () => { test('subscribes the user to notifications', async () => { const mockSubscriptionSet = jest.fn(() => Promise.resolve(true)) + const mockSubscriptionSend = jest.fn(() => Promise.resolve(true)) jest.mock('./../../../lib/SubscriptionsManager', () => { return jest.fn(() => ({ - send: jest.fn(), + send: mockSubscriptionSend, set: mockSubscriptionSet })) }) @@ -1630,8 +1631,43 @@ describe('Staticman interface', () => { }) describe('`processMerge()`', () => { + test('returns error if subscription send fails', async () => { + const sendErrorMsg = 'send error msg' + const mockSubscriptionSend = jest.fn(() => Promise.reject(sendErrorMsg)) + + jest.mock('./../../../lib/SubscriptionsManager', () => { + return jest.fn(() => ({ + send: mockSubscriptionSend + })) + }) + + const Staticman = require('./../../../lib/Staticman') + const staticman = await new Staticman(mockParameters) + const fields = mockHelpers.getFields() + const extendedFields = { + _id: '70c33c00-17b3-11eb-b910-2f4fc1bf5873' + } + const options = { + parent: '1a2b3c4d5e6f', + subscribe: 'email' + } + + mockConfig.set('notifications.enabled', true) + + staticman.siteConfig = mockConfig + + expect.hasAssertions() + return staticman.processMerge( + fields, + extendedFields, + options + ).catch(error => { + expect(error).toEqual(sendErrorMsg) + }) + }) + test('subscribes the user to notifications', async () => { - const mockSubscriptionSend = jest.fn() + const mockSubscriptionSend = jest.fn(() => Promise.resolve(true)) jest.mock('./../../../lib/SubscriptionsManager', () => { return jest.fn(() => ({