Skip to content

Commit 7dbf8a1

Browse files
committed
Add a webhook processing server
1 parent 2c77eaa commit 7dbf8a1

File tree

11 files changed

+441
-47
lines changed

11 files changed

+441
-47
lines changed

.github/workflows/build-image.yml

Lines changed: 0 additions & 46 deletions
This file was deleted.

.github/workflows/lint.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ name: Linting
22
on: [pull_request]
33

44
jobs:
5+
flake8:
6+
name: flake8
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v2
10+
11+
- name: Set up Python 3
12+
uses: actions/setup-python@v1
13+
14+
- name: Install flake8
15+
run: pip3 install flake8
16+
17+
- name: Set up reviewdog
18+
run: |
19+
mkdir -p "$HOME/bin"
20+
curl -sfL \
21+
https://github.com/reviewdog/reviewdog/raw/master/install.sh | \
22+
sh -s -- -b "$HOME/bin"
23+
echo "$HOME/bin" >> $GITHUB_PATH
24+
25+
- name: Run flake8
26+
env:
27+
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
run: |
29+
set -o pipefail
30+
flake8 | \
31+
reviewdog -f=pep8 -name=flake8 \
32+
-tee -reporter=github-check -filter-mode nofilter
33+
534
caddyfmt:
635
name: caddyfmt
736
runs-on: ubuntu-latest

.github/workflows/tests.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
3+
name: Tests
4+
on: [push, pull_request]
5+
6+
jobs:
7+
webhook:
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
python-version: ["3.9", "3.10"]
12+
steps:
13+
- uses: actions/checkout@v2
14+
15+
- name: Install Python ${{ matrix.python-version }}
16+
uses: actions/setup-python@v2
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
20+
- name: Install dependencies
21+
run: |
22+
pip install -r dev-requirements.txt
23+
git config --global user.name "GitHub CI Bot"
24+
git config --global user.email "[email protected]"
25+
26+
- name: Run tests
27+
run: pytest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
/__pycache__
12
/sites/

Caddyfile

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,34 @@
1414

1515
# Set this variable in the environment when running in production.
1616
{$SITE_ADDRESS::2015} {
17-
root * .
17+
root * {$SITE_DIR:.}
18+
19+
# Setup a webhook
20+
handle /gh/* {
21+
# https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#delivery-headers
22+
@valid_webhook {
23+
method POST
24+
header User-Agent GitHub-Hookshot/*
25+
header X-GitHub-Event ping
26+
header X-GitHub-Event push
27+
header X-GitHub-Delivery *
28+
header X-Hub-Signature-256 *
29+
}
30+
31+
handle @valid_webhook {
32+
reverse_proxy * localhost:1234 {
33+
# Don't leak out internal problems.
34+
@error status 4xx 5xx
35+
handle_response @error {
36+
error 400
37+
}
38+
}
39+
}
40+
41+
handle {
42+
error 400
43+
}
44+
}
1845

1946
import subproject basemap
2047
import subproject cheatsheets

dev-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r requirements.txt
2+
pytest
3+
pytest-aiohttp
4+
pytest-asyncio

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pytest]
2+
norecursedirs =
3+
sites
4+
asyncio_mode = auto

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
aiohttp

test_webhook.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""GitHub Documentation WebHook handler tests."""
2+
3+
import functools
4+
from pathlib import Path
5+
import subprocess
6+
from unittest import mock
7+
8+
from aiohttp import web
9+
import pytest
10+
11+
import webhook
12+
from webhook import create_app, update_repo, verify_signature
13+
14+
15+
run_check = functools.partial(subprocess.run, check=True)
16+
17+
18+
async def test_signature(monkeypatch):
19+
"""Test that signature verification fails and works as expected."""
20+
with pytest.raises(web.HTTPBadRequest,
21+
match='Secret for non-existent was not set'):
22+
await verify_signature(b'unused', 'unused', 'non-existent', 'unused')
23+
24+
monkeypatch.setenv('WEBHOOK_REPO_SECRET', 'abcdef')
25+
with pytest.raises(web.HTTPBadRequest, match='signature was invalid'):
26+
await verify_signature(b'unused', 'sha256=incorrect', 'repo', 'unused')
27+
28+
# Signature found by passing data to `openssl dgst -sha256 -hmac $SECRET`.
29+
monkeypatch.setenv('WEBHOOK_REPO_SECRET', 'abcdef')
30+
await verify_signature(
31+
b'{"data": "foo"}',
32+
'sha256='
33+
'ebf35862e15f2ac2aa1339ebefff9a88b8270fc8a33dec8f756a398036d86329',
34+
'repo',
35+
'unused')
36+
37+
38+
async def test_update_repo_error():
39+
"""Test that updating a repository fails as expected."""
40+
with pytest.raises(web.HTTPServerError,
41+
match='non-existent does not exist'):
42+
await update_repo(Path('non-existent'), 'unused',
43+
'matplotlib/non-existent')
44+
45+
46+
async def test_update_repo_empty(tmp_path_factory):
47+
"""Test that updating an empty repository works as expected."""
48+
repo1 = tmp_path_factory.mktemp('repo1')
49+
run_check(['git', 'init', '-b', 'main', repo1])
50+
await update_repo(repo1, 'unused', 'matplotlib/repo1')
51+
52+
53+
async def test_update_repo(tmp_path_factory):
54+
"""Test that updating a repository works as expected."""
55+
# Set up a source repository.
56+
src = tmp_path_factory.mktemp('src')
57+
run_check(['git', 'init', '-b', 'main', src])
58+
(src / 'readme.txt').write_text('Test repo information')
59+
run_check(['git', 'add', 'readme.txt'], cwd=src)
60+
run_check(['git', 'commit', '-m', 'Initial commit'], cwd=src)
61+
62+
# Make second from the first.
63+
dest = tmp_path_factory.mktemp('dest')
64+
run_check(['git', 'clone', src, dest])
65+
66+
# Make a second commit in source repository.
67+
(src / 'install.txt').write_text('There is no installation.')
68+
run_check(['git', 'add', 'install.txt'], cwd=src)
69+
run_check(['git', 'commit', '-m', 'Add install information'], cwd=src)
70+
src_stdout = run_check(['git', 'show-ref', '--head', 'HEAD'], cwd=src,
71+
capture_output=True).stdout.splitlines()
72+
src_commit = next(
73+
(line for line in src_stdout if line.split()[-1] == 'HEAD'), '')
74+
75+
# Now this should correctly update the first repository.
76+
await update_repo(dest, 'unused', 'matplotlib/dest')
77+
dest_stdout = run_check(['git', 'show-ref', '--head', 'HEAD'], cwd=dest,
78+
capture_output=True).stdout.splitlines()
79+
dest_commit = next(
80+
(line for line in dest_stdout if line.split()[-1] == 'HEAD'), '')
81+
assert dest_commit == src_commit
82+
83+
84+
async def test_github_webhook_errors(aiohttp_client, monkeypatch):
85+
"""Test invalid inputs to webhook."""
86+
client = await aiohttp_client(create_app())
87+
88+
# Only /gh/<repo-name> exists.
89+
resp = await client.get('/')
90+
assert resp.status == 404
91+
resp = await client.get('/gh')
92+
assert resp.status == 404
93+
94+
# Not allowed if missing correct headers.
95+
resp = await client.get('/gh/non-existent-repo')
96+
assert resp.status == 405
97+
resp = await client.post('/gh/non-existent-repo')
98+
assert resp.status == 400
99+
assert 'No delivery' in await resp.text()
100+
resp = await client.post('/gh/non-existent-repo',
101+
headers={'X-GitHub-Delivery': 'foo'})
102+
assert resp.status == 400
103+
assert 'No signature' in await resp.text()
104+
105+
monkeypatch.setattr(webhook, 'verify_signature',
106+
mock.Mock(verify_signature, return_value=True))
107+
108+
# Data should be JSON.
109+
resp = await client.post(
110+
'/gh/non-existent-repo',
111+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
112+
data='}{')
113+
assert resp.status == 400
114+
assert 'Invalid data input' in await resp.text()
115+
116+
# Some data fields are required.
117+
resp = await client.post(
118+
'/gh/non-existent-repo',
119+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
120+
data='{}')
121+
assert resp.status == 400
122+
assert 'Missing required fields' in await resp.text()
123+
124+
resp = await client.post(
125+
'/gh/non-existent-repo',
126+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
127+
data='{"action": "ping", "sender": "QuLogic", "organization": "foo",'
128+
' "repository": "foo"}')
129+
assert resp.status == 400
130+
assert 'incorrect organization' in await resp.text()
131+
132+
resp = await client.post(
133+
'/gh/non-existent-repo',
134+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
135+
data='{"action": "ping", "sender": "QuLogic", '
136+
'"organization": "matplotlib", "repository": "foo"}')
137+
assert resp.status == 400
138+
assert 'incorrect repository' in await resp.text()
139+
140+
141+
async def test_github_webhook_valid(aiohttp_client, monkeypatch):
142+
"""Test valid input to webhook."""
143+
client = await aiohttp_client(create_app())
144+
145+
# Do no actual work, since that's tested above.
146+
monkeypatch.setattr(webhook, 'verify_signature',
147+
mock.Mock(verify_signature, return_value=True))
148+
monkeypatch.setenv('SITE_DIR', 'non-existent-site-dir')
149+
ur_mock = mock.Mock(update_repo, return_value=None)
150+
monkeypatch.setattr(webhook, 'update_repo', ur_mock)
151+
152+
# Ping event just returns success.
153+
resp = await client.post(
154+
'/gh/non-existent-repo',
155+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
156+
data='{"action": "ping", "sender": "QuLogic", "hook_id": "foo",'
157+
' "zen": "Beautiful is better than ugly.",'
158+
' "organization": "matplotlib",'
159+
' "repository": "non-existent-repo"}')
160+
assert resp.status == 200
161+
ur_mock.assert_not_called()
162+
163+
# Push event should run an update.
164+
resp = await client.post(
165+
'/gh/non-existent-repo',
166+
headers={'X-GitHub-Delivery': 'foo', 'X-Hub-Signature-256': 'unused'},
167+
data='{"action": "push", "sender": "QuLogic",'
168+
' "organization": "matplotlib",'
169+
' "repository": "non-existent-repo"}')
170+
assert resp.status == 200
171+
ur_mock.assert_called_once_with(
172+
Path('non-existent-site-dir/non-existent-repo'), 'foo',
173+
'matplotlib/non-existent-repo')

0 commit comments

Comments
 (0)