diff --git a/.gitignore b/.gitignore index e430a4d..39f681f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ -*.pyc -__pycache__ +/.cache +/.coverage +/htmlcov /*.egg-info +__pycache__ +.*.sw? +*.pyc diff --git a/jade/centralauth.py b/jade/centralauth.py index 9eb1016..a725af0 100644 --- a/jade/centralauth.py +++ b/jade/centralauth.py @@ -9,8 +9,11 @@ ''' # noqa: E501 import mwapi +from . import about from . import errors +USER_AGENT = "{} -- {}".format(about.__name__, about.__author_email__) + class CentralAuth: @@ -31,8 +34,7 @@ def get_globaluser_info(self, gu_id): return gui_doc def get_localuser_info(self, name, context_doc): - session = mwapi.Session( - context_doc['url'], self.ca_session.user_agent) + session = mwapi.Session(context_doc['url'], user_agent=USER_AGENT) doc = session.get( action='query', list='users', ususers={name}, usprop={'groups'}) lui_doc = doc['query']['users'][0] @@ -45,8 +47,7 @@ def check_user_rights(self, gu_id, context, requirements): gui_doc = self.get_globaluser_info(gu_id) if 'locked' in gui_doc: - raise errors.UserLockedError( - "The user account with gu_id={0} is locked".format(gu_id)) + raise errors.UserLockedError(gu_id) # Check for local context if context in gui_doc['merged']: @@ -54,8 +55,10 @@ def check_user_rights(self, gu_id, context, requirements): if 'blocked' in context_doc: raise errors.UserBlockedError( - "the user account with gu_id={0} is blocked on {1}: {2}" - .format(gu_id, context, context_doc['blocked'])) + gu_id, + context, + context_doc['blocked']['expiry'], + context_doc['blocked']['reason']) lui_doc = self.get_localuser_info(gui_doc['name'], context_doc) local_groups = lui_doc.get('groups', []) or [] @@ -66,4 +69,15 @@ def check_user_rights(self, gu_id, context, requirements): @classmethod def from_config(cls, config): - return cls(config['centralauth']) + # Extract config. + session_config = config['centralauth'].copy() + if 'user_agent' not in session_config: + session_config['user_agent'] = USER_AGENT + # Pull host out as it's not a keyword arg. + host = session_config['host'] + del(session_config['host']) + + # Create session based on config. + ca_session = mwapi.Session(host, **session_config) + + return cls(ca_session) diff --git a/jade/errors.py b/jade/errors.py index 369ba24..403cd5d 100644 --- a/jade/errors.py +++ b/jade/errors.py @@ -91,6 +91,34 @@ class UserPermissionError(RequestError): HTTP_CODE = 403 +class GlobalUserExistenceError(UserPermissionError): + "This user is globally missing." + SUBTYPE = "user_missing_global" + HTTP_CODE = 403 + + def __init__(self, gu_id): + super().__init__() + self.gu_id = gu_id + + def format_detail(self): + return {'gu_id': self.gu_id} + + +class LocalUserExistenceError(UserPermissionError): + "This user is locally missing." + SUBTYPE = "user_missing_local" + HTTP_CODE = 403 + + def __init__(self, name, context): + super().__init__() + self.name = name + self.context = context + + def format_detail(self): + return {'name': self.name, + 'context': self.context} + + class UserBlockedError(UserPermissionError): "This action cannot be performed because the user is blocked." SUBTYPE = "user_blocked" @@ -110,6 +138,19 @@ def format_detail(self): 'reason': self.reason} +class UserLockedError(UserPermissionError): + "This action cannot be performed because the user is blocked." + SUBTYPE = "user_locked" + HTTP_CODE = 403 + + def __init__(self, gu_id): + super().__init__() + self.gu_id = gu_id + + def format_detail(self): + return {'gu_id': self.gu_id} + + class UserRightsError(UserPermissionError): "This action cannot be performed because the user lacks necessary rights." SUBTYPE = "user_rights" diff --git a/jade/tests/__init__.py b/jade/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jade/tests/test_centralauth.py b/jade/tests/test_centralauth.py new file mode 100644 index 0000000..09dfbb8 --- /dev/null +++ b/jade/tests/test_centralauth.py @@ -0,0 +1,262 @@ +import pytest + +from jade.centralauth import CentralAuth +from jade import errors +from . import util + +test_config = { + 'centralauth': { + # Global info host can be arbitrary? + 'host': 'https://www.mediawiki.org', + }, +} + +# TODO: Made this up cos I haven't found a missing user yet. +missing_gui_response = { + 'batchcomplete': '', + 'query': { + 'globaluserinfo': { + 'missing': '', + } + } +} + +missing_lui_response = { + "batchcomplete": "", + "query": { + "users": [{ + "name": "Example", + "missing": "" + }] + } +} + +enwiki_blocked_gui_response = { + 'batchcomplete': '', + 'query': { + 'globaluserinfo': { + 'home': 'enwiki', + 'id': 19729909, + 'registration': '2013-04-14T06:48:25Z', + 'name': 'FUCKYOU', + 'groups': [], + 'rights': [], + 'merged': [ + { + 'wiki': 'enwiki', + 'url': 'https://en.wikipedia.org', + 'timestamp': '2013-04-14T06:48:25Z', + 'method': 'primary', + 'editcount': 0, + 'registration': '2006-03-26T17:01:06Z', + 'blocked': { + 'expiry': 'infinity', + 'reason': '{{username}}' + }, + }, + ] + } + } +} + +locked_gui_response = { + 'batchcomplete': '', + 'query': { + 'globaluserinfo': { + 'home': 'metawiki', + 'id': 33085348, + 'registration': '2014-08-17T23:22:31Z', + 'name': 'Jalexander', + 'locked': '', + 'groups': [], + 'rights': [], + 'merged': [ + { + 'wiki': 'commonswiki', + 'url': 'https://commons.wikimedia.org', + 'timestamp': '2017-12-12T19:44:56Z', + 'method': 'login', + 'editcount': 0, + 'registration': '2017-12-12T19:44:56Z', + }, + { + 'wiki': 'loginwiki', + 'url': 'https://login.wikimedia.org', + 'timestamp': '2014-08-17T23:22:33Z', + 'method': 'login', + 'editcount': 0, + 'registration': '2014-08-17T23:22:33Z', + }, + { + 'wiki': 'metawiki', + 'url': 'https://meta.wikimedia.org', + 'timestamp': '2014-08-17T23:22:31Z', + 'method': 'new', + 'editcount': 0, + 'registration': '2014-08-17T23:22:31Z', + }, + { + 'wiki': 'nowikimedia', + 'url': 'https://no.wikimedia.org', + 'timestamp': '2017-12-13T09:53:07Z', + 'method': 'login', + 'editcount': 0, + 'registration': '2017-12-13T09:53:07Z', + } + ] + } + } +} + +autopatrolled_metawiki_gui_response = { + "batchcomplete": "", + "query": { + "globaluserinfo": { + "home": "enwiki", + "id": 15622560, + "registration": "2012-12-21T22:37:22Z", + "name": "Awight (WMF)", + "groups": [ + "oathauth-tester", + "wmf-researcher" + ], + "merged": [ + { + "wiki": "metawiki", + "url": "https://meta.wikimedia.org", + "timestamp": "2012-12-21T23:59:21Z", + "method": "login", + "editcount": 222, + "registration": "2012-12-21T23:59:21Z", + "groups": [ + "autopatrolled" + ] + }, + { + "wiki": "ptwiki", + "url": "https://pt.wikipedia.org", + "timestamp": "2013-04-09T01:26:35Z", + "method": "login", + "editcount": 0, + "registration": "2013-04-09T01:26:35Z" + }, + ], + "unattached": [] + } + } +} + +autopatrolled_metawiki_lui_response = { + 'batchcomplete': '', + 'query': { + 'users': [{ + 'userid': 2043420, + 'name': 'Awight (WMF)', + 'groups': ['autopatrolled', '*', 'user', 'autoconfirmed'] + }] + } +} + +no_autopatrolled_metawiki_lui_response = { + 'batchcomplete': '', + 'query': { + 'users': [{ + 'userid': 2043420, + 'name': 'Awight (WMF)', + 'groups': ['*', 'user', 'autoconfirmed'] + }] + } +} + + +def test_from_config(): + ca = CentralAuth.from_config(test_config) + assert isinstance(ca, CentralAuth) + + +# TODO: We can't test this using the current mocking strategy, because the +# error comes from within .get +# def test_get_globaluser_info_invalid_user_id(): +# ca = CentralAuth.from_config(test_config) +# with pytest.raises(mwapi.errors.APIError): +# ca.get_globaluser_info(0) + + +def test_get_globaluser_info_missing_user(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get(missing_gui_response): + with pytest.raises(errors.GlobalUserExistenceError) as exc_info: + ca.get_globaluser_info(12345) + + assert exc_info.value.gu_id == 12345 + + +def test_get_globaluser_info(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get(enwiki_blocked_gui_response): + gui_doc = ca.get_globaluser_info(19729909) + + assert gui_doc['home'] == 'enwiki' + assert gui_doc['name'] == 'FUCKYOU' + + +def test_check_user_rights_blocked_in_context(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get(enwiki_blocked_gui_response): + with pytest.raises(errors.UserBlockedError) as exc_info: + ca.check_user_rights(19729909, 'enwiki', ['autopatrolled']) + + assert exc_info.value.gu_id == 19729909 + assert exc_info.value.context == 'enwiki' + assert exc_info.value.expiry == 'infinity' + assert exc_info.value.reason == '{{username}}' + + +def test_check_user_rights_blocked_other_context(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get(enwiki_blocked_gui_response): + ca.check_user_rights(19729909, 'frwiki', ['autopatrolled']) + + assert True + + +def test_check_user_rights_locked(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get(locked_gui_response): + with pytest.raises(errors.UserLockedError) as exc_info: + ca.check_user_rights(33085348, 'enwiki', ['autopatrolled']) + + assert exc_info.value.gu_id == 33085348 + + +def test_check_user_rights_has_autopatrolled(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get([autopatrolled_metawiki_gui_response, + autopatrolled_metawiki_lui_response]): + ca.check_user_rights(15622560, 'metawiki', ['autopatrolled']) + + assert True + + +def test_check_user_rights_lacks_autopatrolled(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get([autopatrolled_metawiki_gui_response, + no_autopatrolled_metawiki_lui_response]): + with pytest.raises(errors.UserRightsError) as exc_info: + ca.check_user_rights(15622560, 'metawiki', ['autopatrolled']) + + assert exc_info.value.gu_id == 15622560 + assert exc_info.value.context == 'metawiki' + assert exc_info.value.required == ['autopatrolled'] + assert exc_info.value.user_groups == ['*', 'user', 'autoconfirmed'] + + +def test_check_user_rights_local_missing(): + ca = CentralAuth.from_config(test_config) + with util.mock_mwapi_get([autopatrolled_metawiki_gui_response, + missing_lui_response]): + with pytest.raises(errors.LocalUserExistenceError) as exc_info: + ca.check_user_rights(15622560, 'metawiki', ['autopatrolled']) + + assert exc_info.value.name == 'Awight (WMF)' + assert exc_info.value.context == 'metawiki' diff --git a/jade/tests/util.py b/jade/tests/util.py new file mode 100644 index 0000000..428c4ae --- /dev/null +++ b/jade/tests/util.py @@ -0,0 +1,25 @@ +import contextlib +import copy +from unittest import mock + + +@contextlib.contextmanager +def mock_mwapi_get(response): + """ + Helper to mock mwapi responses. + + TODO: Handle a series of responses. + """ + # Our functions mutate their inputs, so make a copy in order to use + # fixtures in multiple tests. + response = copy.deepcopy(response) + with mock.patch('mwapi.Session.get') as mock_get: + if isinstance(response, dict): + # Mocking a single API call. + mock_get.return_value = response + else: + # Mocking a series of API calls, `response` is a list. + mock_get.side_effect = response + + # Call the code under test. + yield diff --git a/requirements.txt b/requirements.txt index c2418cc..f243460 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ docopt_subcommands flask_swaggerui Flask >= 0.12.2, < 0.12.999 jwt +kafka-python mwapi yamlconf diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..e69de29