|
| 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