diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ee8c50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +.tox/* +*.pyc +.coverage* +!.coveragerc +*egg* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3493c21 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +dist: xenial +language: python +python: + - "2.7" + - "3.6" + - "3.7" + - "pypy" + - "pypy3" +install: + - pip install tox-travis +script: + - tox +matrix: + include: + - python: "3.7" + script: black --line-length 120 --check github_webhook tests setup.py + install: pip install black diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c39c821..59f2987 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ contributions! Below you will find some basic steps required to be able to contribute to python-github-webhook. If you have any questions about this process or any other aspect of contributing to a Bloomberg open -source project, feel free to send an email to open-tech@bloomberg.net and we'll get your questions +source project, feel free to send an email to open-source@bloomberg.net and we'll get your questions answered as quickly as we can. diff --git a/github_webhook/__init__.py b/github_webhook/__init__.py index 89ffe17..fee418e 100644 --- a/github_webhook/__init__.py +++ b/github_webhook/__init__.py @@ -8,7 +8,7 @@ :license: Apache License, Version 2.0 """ -from github_webhook.webhook import Webhook +from github_webhook.webhook import Webhook # noqa # ----------------------------------------------------------------------------- # Copyright 2015 Bloomberg Finance L.P. diff --git a/github_webhook/test_webhook.py b/github_webhook/test_webhook.py deleted file mode 100644 index 4df373d..0000000 --- a/github_webhook/test_webhook.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for github_webhook.webhook""" - -from __future__ import print_function - -import unittest -try: - from unittest.mock import Mock -except ImportError: - from mock import Mock - -from github_webhook.webhook import Webhook - - -class TestWebhook(unittest.TestCase): - - def test_constructor(self): - # GIVEN - app = Mock() - - # WHEN - webhook = Webhook(app) - - # THEN - app.add_url_rule.assert_called_once_with( - '/postreceive', view_func=webhook._postreceive, methods=['POST']) - -# ----------------------------------------------------------------------------- -# Copyright 2015 Bloomberg Finance L.P. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ----------------------------- END-OF-FILE ----------------------------------- diff --git a/github_webhook/webhook.py b/github_webhook/webhook.py index 4005beb..f4ca352 100644 --- a/github_webhook/webhook.py +++ b/github_webhook/webhook.py @@ -2,7 +2,7 @@ import hashlib import hmac import logging - +import json import six from flask import abort, request @@ -16,17 +16,30 @@ class Webhook(object): :param secret: Optional secret, used to authenticate the hook comes from Github """ - def __init__(self, app, endpoint='/postreceive', secret=None): - app.add_url_rule(endpoint, view_func=self._postreceive, - methods=['POST']) + def __init__(self, app=None, endpoint="/postreceive", secret=None): + self.app = app + self.secret = secret + if app is not None: + self.init_app(app, endpoint, secret) + def init_app(self, app, endpoint="/postreceive", secret=None): self._hooks = collections.defaultdict(list) - self._logger = logging.getLogger('webhook') + self._logger = logging.getLogger("webhook") + if secret is not None: + self.secret = secret + app.add_url_rule(rule=endpoint, endpoint=endpoint, view_func=self._postreceive, methods=["POST"]) + + @property + def secret(self): + return self._secret + + @secret.setter + def secret(self, secret): if secret is not None and not isinstance(secret, six.binary_type): - secret = secret.encode('utf-8') + secret = secret.encode("utf-8") self._secret = secret - def hook(self, event_type='push'): + def hook(self, event_type="push"): """ Registers a function as a hook. Multiple hooks can be registered for a given type, but the order in which they are invoke is unspecified. @@ -43,8 +56,7 @@ def decorator(func): def _get_digest(self): """Return message digest if a secret key was provided""" - return hmac.new( - self._secret, request.data, hashlib.sha1).hexdigest() if self._secret else None + return hmac.new(self._secret, request.data, hashlib.sha1).hexdigest() if self._secret else None def _postreceive(self): """Callback from Flask""" @@ -52,27 +64,31 @@ def _postreceive(self): digest = self._get_digest() if digest is not None: - sig_parts = _get_header('X-Hub-Signature').split('=', 1) + sig_parts = _get_header("X-Hub-Signature").split("=", 1) if not isinstance(digest, six.text_type): digest = six.text_type(digest) - if (len(sig_parts) < 2 or sig_parts[0] != 'sha1' - or not hmac.compare_digest(sig_parts[1], digest)): - abort(400, 'Invalid signature') + if len(sig_parts) < 2 or sig_parts[0] != "sha1" or not hmac.compare_digest(sig_parts[1], digest): + abort(400, "Invalid signature") - event_type = _get_header('X-Github-Event') - data = request.get_json() + event_type = _get_header("X-Github-Event") + content_type = _get_header("content-type") + data = ( + json.loads(request.form.to_dict(flat=True)["payload"]) + if content_type == "application/x-www-form-urlencoded" + else request.get_json() + ) if data is None: - abort(400, 'Request body must contain json') + abort(400, "Request body must contain json") - self._logger.info( - '%s (%s)', _format_event(event_type, data), _get_header('X-Github-Delivery')) + self._logger.info("%s (%s)", _format_event(event_type, data), _get_header("X-Github-Delivery")) for hook in self._hooks.get(event_type, []): hook(data) - return '', 204 + return "", 204 + def _get_header(key): """Return message header""" @@ -80,60 +96,51 @@ def _get_header(key): try: return request.headers[key] except KeyError: - abort(400, 'Missing header: ' + key) + abort(400, "Missing header: " + key) + EVENT_DESCRIPTIONS = { - 'commit_comment': '{comment[user][login]} commented on ' - '{comment[commit_id]} in {repository[full_name]}', - 'create': '{sender[login]} created {ref_type} ({ref}) in ' - '{repository[full_name]}', - 'delete': '{sender[login]} deleted {ref_type} ({ref}) in ' - '{repository[full_name]}', - 'deployment': '{sender[login]} deployed {deployment[ref]} to ' - '{deployment[environment]} in {repository[full_name]}', - 'deployment_status': 'deployment of {deployement[ref]} to ' - '{deployment[environment]} ' - '{deployment_status[state]} in ' - '{repository[full_name]}', - 'fork': '{forkee[owner][login]} forked {forkee[name]}', - 'gollum': '{sender[login]} edited wiki pages in {repository[full_name]}', - 'issue_comment': '{sender[login]} commented on issue #{issue[number]} ' - 'in {repository[full_name]}', - 'issues': '{sender[login]} {action} issue #{issue[number]} in ' - '{repository[full_name]}', - 'member': '{sender[login]} {action} member {member[login]} in ' - '{repository[full_name]}', - 'membership': '{sender[login]} {action} member {member[login]} to team ' - '{team[name]} in {repository[full_name]}', - 'page_build': '{sender[login]} built pages in {repository[full_name]}', - 'ping': 'ping from {sender[login]}', - 'public': '{sender[login]} publicized {repository[full_name]}', - 'pull_request': '{sender[login]} {action} pull #{pull_request[number]} in ' - '{repository[full_name]}', - 'pull_request_review': '{sender[login]} {action} {review[state]} review on pull #{pull_request[number]} in ' - '{repository[full_name]}', - 'pull_request_review_comment': '{comment[user][login]} {action} comment ' - 'on pull #{pull_request[number]} in ' - '{repository[full_name]}', - 'push': '{pusher[name]} pushed {ref} in {repository[full_name]}', - 'release': '{release[author][login]} {action} {release[tag_name]} in ' - '{repository[full_name]}', - 'repository': '{sender[login]} {action} repository ' - '{repository[full_name]}', - 'status': '{sender[login]} set {sha} status to {state} in ' - '{repository[full_name]}', - 'team_add': '{sender[login]} added repository {repository[full_name]} to ' - 'team {team[name]}', - 'watch': '{sender[login]} {action} watch in repository ' - '{repository[full_name]}' + "commit_comment": "{comment[user][login]} commented on " "{comment[commit_id]} in {repository[full_name]}", + "create": "{sender[login]} created {ref_type} ({ref}) in " "{repository[full_name]}", + "delete": "{sender[login]} deleted {ref_type} ({ref}) in " "{repository[full_name]}", + "deployment": "{sender[login]} deployed {deployment[ref]} to " + "{deployment[environment]} in {repository[full_name]}", + "deployment_status": "deployment of {deployement[ref]} to " + "{deployment[environment]} " + "{deployment_status[state]} in " + "{repository[full_name]}", + "fork": "{forkee[owner][login]} forked {forkee[name]}", + "gollum": "{sender[login]} edited wiki pages in {repository[full_name]}", + "issue_comment": "{sender[login]} commented on issue #{issue[number]} " "in {repository[full_name]}", + "issues": "{sender[login]} {action} issue #{issue[number]} in " "{repository[full_name]}", + "member": "{sender[login]} {action} member {member[login]} in " "{repository[full_name]}", + "membership": "{sender[login]} {action} member {member[login]} to team " "{team[name]} in {repository[full_name]}", + "page_build": "{sender[login]} built pages in {repository[full_name]}", + "ping": "ping from {sender[login]}", + "public": "{sender[login]} publicized {repository[full_name]}", + "pull_request": "{sender[login]} {action} pull #{pull_request[number]} in " "{repository[full_name]}", + "pull_request_review": "{sender[login]} {action} {review[state]} " + "review on pull #{pull_request[number]} in " + "{repository[full_name]}", + "pull_request_review_comment": "{comment[user][login]} {action} comment " + "on pull #{pull_request[number]} in " + "{repository[full_name]}", + "push": "{pusher[name]} pushed {ref} in {repository[full_name]}", + "release": "{release[author][login]} {action} {release[tag_name]} in " "{repository[full_name]}", + "repository": "{sender[login]} {action} repository " "{repository[full_name]}", + "status": "{sender[login]} set {sha} status to {state} in " "{repository[full_name]}", + "team_add": "{sender[login]} added repository {repository[full_name]} to " "team {team[name]}", + "watch": "{sender[login]} {action} watch in repository " "{repository[full_name]}", } + def _format_event(event_type, data): try: return EVENT_DESCRIPTIONS[event_type].format(**data) except KeyError: return event_type + # ----------------------------------------------------------------------------- # Copyright 2015 Bloomberg Finance L.P. # diff --git a/setup.py b/setup.py index 647a1e3..b1ebbae 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,29 @@ from setuptools import setup -setup(name="github-webhook", - version="1.0", - description="Very simple, but powerful, microframework for writing Github webhooks in Python", - url="https://github.com/bloomberg/python-github-webhook", - author="Alex Chamberlain, Fred Phillips, Daniel Kiss, Daniel Beer", - author_email="achamberlai9@bloomberg.net, fphillips7@bloomberg.net, dkiss1@bloomberg.net, dbeer1@bloomberg.net", - license='Apache 2.0', - packages=["github_webhook"], - install_requires=['flask', 'six'], - tests_require=['mock', 'nose'], - - classifiers=[ - 'Development Status :: 4 - Beta', - 'Framework :: Flask', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: POSIX', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - 'Topic :: Software Development :: Version Control' - ], - test_suite='nose.collector') +setup( + name="github-webhook", + version="1.0.4", + description="Very simple, but powerful, microframework for writing Github webhooks in Python", + url="https://github.com/bloomberg/python-github-webhook", + author="Alex Chamberlain, Fred Phillips, Daniel Kiss, Daniel Beer", + author_email="achamberlai9@bloomberg.net, fphillips7@bloomberg.net, dkiss1@bloomberg.net, dbeer1@bloomberg.net", + license="Apache 2.0", + packages=["github_webhook"], + install_requires=["flask", "six"], + tests_require=["mock", "pytest"], + classifiers=[ + "Development Status :: 4 - Beta", + "Framework :: Flask", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Version Control", + ], + test_suite="nose.collector", +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..29eba7d --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,218 @@ +"""Tests for github_webhook.webhook""" + +from __future__ import print_function + +import pytest +import werkzeug +import json + +try: + from unittest import mock +except ImportError: + import mock + +from github_webhook.webhook import Webhook + + +@pytest.fixture +def mock_request(): + with mock.patch("github_webhook.webhook.request") as req: + req.headers = {"X-Github-Delivery": ""} + yield req + + +@pytest.fixture +def push_request(mock_request): + mock_request.headers["X-Github-Event"] = "push" + mock_request.headers["content-type"] = "application/json" + yield mock_request + + +@pytest.fixture +def push_request_encoded(mock_request): + mock_request.headers["X-Github-Event"] = "push" + mock_request.headers["content-type"] = "application/x-www-form-urlencoded" + yield mock_request + + +@pytest.fixture +def app(): + yield mock.Mock() + + +@pytest.fixture +def webhook(app): + yield Webhook(app) + + +@pytest.fixture +def handler(webhook): + handler = mock.Mock() + webhook.hook()(handler) + yield handler + + +def test_constructor(): + # GIVEN + app = mock.Mock() + + # WHEN + webhook = Webhook(app) + + # THEN + app.add_url_rule.assert_called_once_with( + endpoint="/postreceive", rule="/postreceive", view_func=webhook._postreceive, methods=["POST"] + ) + + +def test_init_app_flow(): + # GIVEN + app = mock.Mock() + + # WHEN + webhook = Webhook() + webhook.init_app(app) + + # THEN + app.add_url_rule.assert_called_once_with( + endpoint="/postreceive", rule="/postreceive", view_func=webhook._postreceive, methods=["POST"] + ) + + +def test_init_app_flow_should_not_accidentally_override_secrets(): + # GIVEN + app = mock.Mock() + + # WHEN + webhook = Webhook(secret="hello-world-of-secrecy") + webhook.init_app(app) + + # THEN + assert webhook.secret is not None + + +def test_init_app_flow_should_override_secrets(): + # GIVEN + app = mock.Mock() + + # WHEN + webhook = Webhook(secret="hello-world-of-secrecy") + webhook.init_app(app, secret="a-new-world-of-secrecy") + + # THEN + assert webhook.secret == "a-new-world-of-secrecy".encode("utf-8") + + +def test_run_push_hook(webhook, handler, push_request): + # WHEN + webhook._postreceive() + + # THEN + handler.assert_called_once_with(push_request.get_json.return_value) + + +def test_run_push_hook_urlencoded(webhook, handler, push_request_encoded): + github_mock_payload = {"payload": '{"key": "value"}'} + push_request_encoded.form.to_dict.return_value = github_mock_payload + payload = json.loads(github_mock_payload["payload"]) + + # WHEN + webhook._postreceive() + + # THEN + handler.assert_called_once_with(payload) + + +def test_do_not_run_push_hook_on_ping(webhook, handler, mock_request): + # GIVEN + mock_request.headers["X-Github-Event"] = "ping" + mock_request.headers["content-type"] = "application/json" + + # WHEN + webhook._postreceive() + + # THEN + handler.assert_not_called() + + +def test_do_not_run_push_hook_on_ping_urlencoded(webhook, handler, mock_request): + # GIVEN + mock_request.headers["X-Github-Event"] = "ping" + mock_request.headers["content-type"] = "application/x-www-form-urlencoded" + mock_request.form.to_dict.return_value = {"payload": '{"key": "value"}'} + + # WHEN + webhook._postreceive() + + # THEN + handler.assert_not_called() + + +def test_can_handle_zero_events(webhook, push_request): + # WHEN, THEN + webhook._postreceive() # noop + + +@pytest.mark.parametrize("secret", [u"secret", b"secret"]) +@mock.patch("github_webhook.webhook.hmac") +def test_calls_if_signature_is_correct(mock_hmac, app, push_request, secret): + # GIVEN + webhook = Webhook(app, secret=secret) + push_request.headers["X-Hub-Signature"] = "sha1=hash_of_something" + push_request.data = b"something" + handler = mock.Mock() + mock_hmac.compare_digest.return_value = True + + # WHEN + webhook.hook()(handler) + webhook._postreceive() + + # THEN + handler.assert_called_once_with(push_request.get_json.return_value) + + +@mock.patch("github_webhook.webhook.hmac") +def test_does_not_call_if_signature_is_incorrect(mock_hmac, app, push_request): + # GIVEN + webhook = Webhook(app, secret="super_secret") + push_request.headers["X-Hub-Signature"] = "sha1=hash_of_something" + push_request.data = b"something" + handler = mock.Mock() + mock_hmac.compare_digest.return_value = False + + # WHEN, THEN + webhook.hook()(handler) + with pytest.raises(werkzeug.exceptions.BadRequest): + webhook._postreceive() + + +def test_request_has_no_data(webhook, handler, push_request): + # GIVEN + push_request.get_json.return_value = None + + # WHEN, THEN + with pytest.raises(werkzeug.exceptions.BadRequest): + webhook._postreceive() + + +def test_request_had_headers(webhook, handler, mock_request): + # WHEN, THEN + with pytest.raises(werkzeug.exceptions.BadRequest): + webhook._postreceive() + + +# ----------------------------------------------------------------------------- +# Copyright 2015 Bloomberg Finance L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ----------------------------- END-OF-FILE ----------------------------------- diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ed2ef8b --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = py27,py36,py37,pypy,pypy3,flake8 + +[testenv] +deps = + pytest + pytest-cov + flask + six + py{27,py}: mock +commands = pytest -vl --cov=github_webhook --cov-report term-missing --cov-fail-under 100 + +[testenv:flake8] +deps = flake8 +commands = flake8 github_webhook + +[flake8] +max-line-length = 100 \ No newline at end of file