|
| 1 | +const crypto = require('crypto'); |
| 2 | +const express = require('express'); |
| 3 | +const request = require('supertest'); |
| 4 | + |
| 5 | +const mockBinding = jest.fn(); |
| 6 | +const mockCreateReceipt = jest.fn(); |
| 7 | +const mockHandle = jest.fn(); |
| 8 | +jest.mock('../../../services/githubGigService', () => ({ |
| 9 | + githubLocation: jest.requireActual('../../../services/githubGigService').githubLocation, |
| 10 | + boundPodForGithubEvent: (...args) => mockBinding(...args), |
| 11 | +})); |
| 12 | +jest.mock('../../../models/GithubGigDelivery', () => ({ create: (...args) => mockCreateReceipt(...args) })); |
| 13 | +jest.mock('../../../services/installable/eventHandlers', () => ({ |
| 14 | + eventHandlers: { 'github.gig': (...args) => mockHandle(...args) }, |
| 15 | +})); |
| 16 | + |
| 17 | +const route = require('../../../routes/webhooks/github'); |
| 18 | +const app = express(); |
| 19 | +app.use('/api/webhooks/github', express.raw({ type: 'application/json' })); |
| 20 | +app.use(express.json()); |
| 21 | +app.use('/api/webhooks/github', route); |
| 22 | + |
| 23 | +const payload = { |
| 24 | + action: 'opened', installation: { id: 123 }, |
| 25 | + repository: { owner: { login: 'Team-Commonly' }, name: 'commonly' }, |
| 26 | + pull_request: { number: 42, title: 'External text' }, |
| 27 | +}; |
| 28 | +const deliver = (body, overrides = {}) => { |
| 29 | + const signature = `sha256=${crypto.createHmac('sha256', 'secret').update(body).digest('hex')}`; |
| 30 | + return request(app).post('/api/webhooks/github') |
| 31 | + .set('Content-Type', 'application/json') |
| 32 | + .set('X-Hub-Signature-256', signature) |
| 33 | + .set('X-GitHub-Event', 'pull_request') |
| 34 | + .set('X-GitHub-Delivery', '123e4567-e89b-12d3-a456-426614174000') |
| 35 | + .set(overrides) |
| 36 | + .send(body); |
| 37 | +}; |
| 38 | + |
| 39 | +beforeEach(() => { |
| 40 | + process.env.GITHUB_GIG_WEBHOOK_SECRET = 'secret'; |
| 41 | + process.env.GITHUB_GIG_ENABLED = 'true'; |
| 42 | + mockBinding.mockReset().mockResolvedValue(null); |
| 43 | + mockCreateReceipt.mockReset().mockResolvedValue({}); |
| 44 | + mockHandle.mockReset().mockResolvedValue(undefined); |
| 45 | +}); |
| 46 | +afterAll(() => { |
| 47 | + delete process.env.GITHUB_GIG_WEBHOOK_SECRET; |
| 48 | + delete process.env.GITHUB_GIG_ENABLED; |
| 49 | +}); |
| 50 | + |
| 51 | +test('missing and invalid signatures return 401 before parsing or lookup', async () => { |
| 52 | + const missing = await request(app).post('/api/webhooks/github') |
| 53 | + .set('Content-Type', 'application/json').send('{bad json'); |
| 54 | + const invalid = await deliver('{bad json', { 'X-Hub-Signature-256': 'sha256=0000' }); |
| 55 | + expect(missing.status).toBe(401); |
| 56 | + expect(invalid.status).toBe(401); |
| 57 | + expect(missing.text).toBe(''); |
| 58 | + expect(mockBinding).not.toHaveBeenCalled(); |
| 59 | + expect(mockCreateReceipt).not.toHaveBeenCalled(); |
| 60 | +}); |
| 61 | + |
| 62 | +test('signature covers the raw bytes, including whitespace', async () => { |
| 63 | + const body = JSON.stringify(payload, null, 2); |
| 64 | + const response = await deliver(body); |
| 65 | + expect(response.status).toBe(204); |
| 66 | + expect(mockBinding).toHaveBeenCalledWith({ |
| 67 | + installationId: '123', owner: 'Team-Commonly', repo: 'commonly', number: '42', |
| 68 | + }); |
| 69 | + expect(mockCreateReceipt).not.toHaveBeenCalled(); |
| 70 | +}); |
| 71 | + |
| 72 | +test('an unbound App-wide delivery is silent and does not write a receipt', async () => { |
| 73 | + const response = await deliver(JSON.stringify(payload)); |
| 74 | + expect(response.status).toBe(204); |
| 75 | + expect(mockBinding).toHaveBeenCalledTimes(1); |
| 76 | + expect(mockCreateReceipt).not.toHaveBeenCalled(); |
| 77 | + expect(mockHandle).not.toHaveBeenCalled(); |
| 78 | +}); |
| 79 | + |
| 80 | +test('a bound delivery is claimed once and replay does not post twice', async () => { |
| 81 | + mockBinding.mockResolvedValue('aaaaaaaaaaaaaaaaaaaaaaaa'); |
| 82 | + mockCreateReceipt.mockResolvedValueOnce({}).mockRejectedValueOnce(Object.assign(new Error('duplicate'), { code: 11000 })); |
| 83 | + const first = await deliver(JSON.stringify(payload)); |
| 84 | + await new Promise((resolve) => setImmediate(resolve)); |
| 85 | + const replay = await deliver(JSON.stringify(payload)); |
| 86 | + expect(first.status).toBe(204); |
| 87 | + expect(replay.status).toBe(204); |
| 88 | + expect(mockCreateReceipt).toHaveBeenCalledTimes(2); |
| 89 | + expect(mockHandle).toHaveBeenCalledTimes(1); |
| 90 | +}); |
| 91 | + |
| 92 | +test('checks remain off until checks:read is granted', async () => { |
| 93 | + const check = { ...payload, action: 'completed', check_run: { conclusion: 'failure', pull_requests: [{ number: 42 }] } }; |
| 94 | + const response = await deliver(JSON.stringify(check), { 'X-GitHub-Event': 'check_run' }); |
| 95 | + expect(response.status).toBe(204); |
| 96 | + expect(mockBinding).not.toHaveBeenCalled(); |
| 97 | +}); |
| 98 | + |
| 99 | +test.each([ |
| 100 | + ['pull_request_review', 'submitted', { pull_request: { number: 42 }, review: { state: 'approved', body: 'outsider' } }], |
| 101 | + ['issues', 'opened', { issue: { number: 42, title: 'outsider' } }], |
| 102 | + ['issues', 'closed', { issue: { number: 42, body: 'outsider' } }], |
| 103 | +])('%s %s routes only when bound', async (eventType, action, fields) => { |
| 104 | + mockBinding.mockResolvedValue('aaaaaaaaaaaaaaaaaaaaaaaa'); |
| 105 | + const body = JSON.stringify({ |
| 106 | + action, installation: payload.installation, repository: payload.repository, ...fields, |
| 107 | + }); |
| 108 | + const response = await deliver(body, { |
| 109 | + 'X-GitHub-Event': eventType, |
| 110 | + 'X-GitHub-Delivery': `123e4567-e89b-12d3-a456-42661417400${action === 'closed' ? 1 : eventType === 'issues' ? 2 : 3}`, |
| 111 | + }); |
| 112 | + await new Promise((resolve) => setImmediate(resolve)); |
| 113 | + expect(response.status).toBe(204); |
| 114 | + expect(mockBinding).toHaveBeenCalledTimes(1); |
| 115 | + expect(mockCreateReceipt).toHaveBeenCalledTimes(1); |
| 116 | + expect(mockHandle).toHaveBeenCalledWith(expect.objectContaining({ eventType })); |
| 117 | +}); |
0 commit comments