diff --git a/.gitignore b/.gitignore index a8a0904..b813ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ private.py docs/_build *.backup *.log +*.pyc + +# Making sure not to add init file for test module +tests/__init__.py # Visual Studio Code .vscode diff --git a/CHANGELOG.rst b/CHANGELOG.rst index af3b530..bf1f69e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,7 +11,12 @@ Change Log .. There should always be an "Unreleased" section for changes pending release. -[0.1.2] - 2019-03-07 +[0.1.4] - 2019-03-07 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* Adding a number of utils for roles in JWTs and the database + +[0.1.3] - 2019-03-07 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Adding get_context to the UserRoleAssignment class. diff --git a/Makefile b/Makefile index 4888809..4d87a93 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,9 @@ requirements: ## install development environment requirements pip-sync requirements/dev.txt requirements/private.* test: clean ## run tests in the current virtualenv + touch tests/__init__.py pytest + rm tests/__init__.py diff_cover: test ## find diff lines that need test coverage diff-cover coverage.xml diff --git a/edx_rbac/__init__.py b/edx_rbac/__init__.py index fcba721..8cde062 100644 --- a/edx_rbac/__init__.py +++ b/edx_rbac/__init__.py @@ -4,6 +4,6 @@ from __future__ import absolute_import, unicode_literals -__version__ = '0.1.3' +__version__ = '0.1.4' default_app_config = 'edx_rbac.apps.EdxRbacConfig' # pylint: disable=invalid-name diff --git a/edx_rbac/utils.py b/edx_rbac/utils.py new file mode 100644 index 0000000..47b24da --- /dev/null +++ b/edx_rbac/utils.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +Utils for 'edx-rbac' module. +""" +from __future__ import absolute_import, unicode_literals + +import crum +from django.apps import apps +from django.conf import settings +from django.test.client import RequestFactory +from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name +from edx_rest_framework_extensions.auth.jwt.decoder import jwt_decode_handler +from six.moves.urllib.parse import urlparse # pylint: disable=import-error + + +# Taken from edx-platform +def get_request_or_stub(): + """ + Return the current request or a stub request. + + If called outside the context of a request, construct a fake + request that can be used to build an absolute URI. + This is useful in cases where we need to pass in a request object + but don't have an active request (for example, in tests, celery tasks, and XBlocks). + """ + request = crum.get_current_request() + + if request is None: + + # The settings SITE_NAME may contain a port number, so we need to + # parse the full URL. + full_url = "http://{site_name}".format(site_name=settings.SITE_NAME) + parsed_url = urlparse(full_url) + + # Construct the fake request. This can be used to construct absolute + # URIs to other paths. + return RequestFactory( + SERVER_NAME=parsed_url.hostname, + SERVER_PORT=parsed_url.port or 80, + ).get("/") + + else: + return request + + +def get_decoded_jwt_from_request(request): + """ + Grab jwt from request if possible. + + Returns a decoded jwt dict if it finds it. + Returns a None if it does not. + """ + jwt_cookie = request.COOKIES.get(jwt_cookie_name(), None) + + if not jwt_cookie: + return None + return jwt_decode_handler(jwt_cookie) + + +def request_user_has_implicit_access_via_jwt(decoded_jwt, role_name): + """ + Check the request's user access by mapping user's roles found in jwt to local feature roles. + + decoded_jwt is a dict + role_name is a string + + Returns a boolean. + + Mapping should be in settings and look like: + + SYSTEM_TO_FEATURE_ROLE_MAPPING = { + 'enterprise_admin': ['coupon-management', 'data_api_access'], + 'enterprise_leaner': [], + 'coupon-manager': ['coupon-management'] + } + """ + jwt_roles_claim = decoded_jwt.get('roles', []) + + feature_roles = [] + for role_data in jwt_roles_claim: + role_in_jwt = role_data.split(':')[0] # split should be more robust because of our cousekeys having colons + mapped_roles = settings.SYSTEM_TO_FEATURE_ROLE_MAPPING.get(role_in_jwt, []) + feature_roles.extend(mapped_roles) + + if role_name in feature_roles: + return True + return False + + +def user_has_access_via_database(user, role_name, role_assignment_class): + """ + Check if there is a role assignment for a given user and role. + + The role object itself is found via the role_name + """ + try: + role_assignment_class.objects.get(user=user, role__name=role_name) + except role_assignment_class.DoesNotExist: + return False + return True + + +def create_role_auth_claim_for_user(user): + """ + Create role auth claim for a given user. + + Takes a user, and for each RoleAssignment class specified in config as a + system wide jwt role associated with that user, creates a list of strings + denoting the role and context. + + Returns a list. + + This setting is a list of classes whose roles should be added to the + jwt. The setting should look something like this: + + SYSTEM_WIDE_ROLE_CLASSES = [ + SystemWideConcreteUserRoleAssignment + ] + """ + role_auth_claim = [] + for system_role_class in settings.SYSTEM_WIDE_ROLE_CLASSES: + app_name, model_name = system_role_class.split('.') + model_class = apps.get_model(app_name, model_name) + + role_assignments = model_class.objects.filter( + user=user + ).select_related('role') + + for role_assignment in role_assignments: + role_string = role_assignment.role.name + context = role_assignment.get_context() + if context: + role_string += ':{}'.format(context) + role_auth_claim.append(role_string) + return role_auth_claim diff --git a/requirements/base.in b/requirements/base.in index 4990705..8317b51 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,4 +2,6 @@ Django>=1.8,<2.0 # Web application framework django-model-utils # Provides TimeStampedModel abstract base class -six \ No newline at end of file +six +django-crum +edx-drf-extensions diff --git a/requirements/base.txt b/requirements/base.txt index e3245b1..b34a6f3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,7 +4,31 @@ # # make upgrade # +certifi==2018.11.29 # via requests +chardet==3.0.4 # via requests +django-crum==0.7.3 django-model-utils==3.1.2 +django-waffle==0.15.1 # via edx-django-utils, edx-drf-extensions django==1.11.20 +djangorestframework-jwt==1.11.0 # via edx-drf-extensions +djangorestframework==3.9.2 # via edx-drf-extensions, rest-condition +edx-django-utils==1.0.3 # via edx-drf-extensions +edx-drf-extensions==2.0.1 +edx-opaque-keys==0.4.4 # via edx-drf-extensions +future==0.17.1 # via pyjwkest +idna==2.8 # via requests +newrelic==4.14.0.115 # via edx-django-utils +pbr==5.1.3 # via stevedore +psutil==1.2.1 # via edx-django-utils, edx-drf-extensions +pycryptodomex==3.7.3 # via pyjwkest +pyjwkest==1.3.2 # via edx-drf-extensions +pyjwt==1.7.1 # via djangorestframework-jwt +pymongo==3.7.2 # via edx-opaque-keys +python-dateutil==2.8.0 # via edx-drf-extensions pytz==2018.9 # via django +requests==2.21.0 # via edx-drf-extensions, pyjwkest +rest-condition==1.0.3 # via edx-drf-extensions +semantic-version==2.6.0 # via edx-drf-extensions six==1.12.0 +stevedore==1.30.1 # via edx-opaque-keys +urllib3==1.24.1 # via requests diff --git a/requirements/dev.txt b/requirements/dev.txt index 5bcce01..0c0ca1a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -21,13 +21,21 @@ contextlib2==0.5.5 # via importlib-metadata coverage==4.5.2 diff-cover==1.0.6 distlib==0.2.8 +django-crum==0.7.3 django-model-utils==3.1.2 +django-waffle==0.15.1 django==1.11.20 +djangorestframework-jwt==1.11.0 +djangorestframework==3.9.2 +edx-django-utils==1.0.3 +edx-drf-extensions==2.0.1 edx-i18n-tools==0.4.8 edx-lint==1.1.1 +edx-opaque-keys==0.4.4 enum34==1.1.6 filelock==3.0.10 funcsigs==1.0.2 +future==0.17.1 futures==3.1.1 idna==2.8 importlib-metadata==0.8 # via path.py @@ -38,7 +46,9 @@ jinja2==2.10 lazy-object-proxy==1.3.1 markupsafe==1.1.1 mccabe==0.6.1 +mock==2.0.0 more-itertools==5.0.0 +newrelic==4.14.0.115 packaging==19.0 path.py==11.5.0 # via edx-i18n-tools pathlib2==2.3.3 @@ -46,23 +56,31 @@ pbr==5.1.3 pip-tools==3.4.0 pluggy==0.9.0 polib==1.1.0 # via edx-i18n-tools +psutil==1.2.1 py==1.8.0 pycodestyle==2.5.0 +pycryptodomex==3.7.3 pydocstyle==3.0.0 pygments==2.3.1 # via diff-cover +pyjwkest==1.3.2 +pyjwt==1.7.1 pylint-celery==0.3 pylint-django==0.7.2 pylint-plugin-utils==0.5 pylint==1.7.6 +pymongo==3.7.2 pyparsing==2.3.1 pytest-cov==2.6.1 pytest-django==3.4.8 pytest==4.3.0 +python-dateutil==2.8.0 python-slugify==3.0.0 pytz==2018.9 pyyaml==3.13 requests==2.21.0 +rest-condition==1.0.3 scandir==1.9.0 +semantic-version==2.6.0 singledispatch==3.4.0.3 six==1.12.0 snowballstemmer==1.2.1 diff --git a/requirements/doc.txt b/requirements/doc.txt index 77322ca..2ddd385 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -9,39 +9,57 @@ atomicwrites==1.3.0 attrs==19.1.0 babel==2.6.0 # via sphinx bleach==3.1.0 # via readme-renderer -certifi==2018.11.29 # via requests -chardet==3.0.4 # via doc8, requests +certifi==2018.11.29 +chardet==3.0.4 click==7.0 code-annotations==0.3 coverage==4.5.2 +django-crum==0.7.3 django-model-utils==3.1.2 +django-waffle==0.15.1 django==1.11.20 +djangorestframework-jwt==1.11.0 +djangorestframework==3.9.2 doc8==0.8.0 docutils==0.14 # via doc8, readme-renderer, restructuredtext-lint, sphinx +edx-django-utils==1.0.3 +edx-drf-extensions==2.0.1 +edx-opaque-keys==0.4.4 edx-sphinx-theme==1.4.0 funcsigs==1.0.2 -idna==2.8 # via requests +future==0.17.1 +idna==2.8 imagesize==1.1.0 # via sphinx jinja2==2.10 markupsafe==1.1.1 +mock==2.0.0 more-itertools==5.0.0 +newrelic==4.14.0.115 packaging==19.0 # via sphinx pathlib2==2.3.3 pbr==5.1.3 pluggy==0.9.0 +psutil==1.2.1 py==1.8.0 +pycryptodomex==3.7.3 pygments==2.3.1 # via readme-renderer, sphinx +pyjwkest==1.3.2 +pyjwt==1.7.1 +pymongo==3.7.2 pyparsing==2.3.1 # via packaging pytest-cov==2.6.1 pytest-django==3.4.8 pytest==4.3.0 +python-dateutil==2.8.0 python-slugify==3.0.0 pytz==2018.9 pyyaml==3.13 readme-renderer==24.0 -requests==2.21.0 # via sphinx +requests==2.21.0 +rest-condition==1.0.3 restructuredtext-lint==1.2.2 # via doc8 scandir==1.9.0 +semantic-version==2.6.0 six==1.12.0 snowballstemmer==1.2.1 # via sphinx sphinx==1.8.4 @@ -49,5 +67,5 @@ sphinxcontrib-websupport==1.1.0 # via sphinx stevedore==1.30.1 text-unidecode==1.2 typing==3.6.6 # via sphinx -urllib3==1.24.1 # via requests +urllib3==1.24.1 webencodings==0.5.1 # via bleach diff --git a/requirements/quality.txt b/requirements/quality.txt index e24e472..d984655 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -10,51 +10,69 @@ atomicwrites==1.3.0 attrs==19.1.0 backports.functools-lru-cache==1.5 # via astroid, caniusepython3, isort, pylint caniusepython3==7.0.0 -certifi==2018.11.29 # via requests -chardet==3.0.4 # via requests +certifi==2018.11.29 +chardet==3.0.4 click-log==0.1.8 # via edx-lint click==7.0 code-annotations==0.3 configparser==3.7.3 # via pydocstyle, pylint coverage==4.5.2 distlib==0.2.8 # via caniusepython3 +django-crum==0.7.3 django-model-utils==3.1.2 +django-waffle==0.15.1 django==1.11.20 +djangorestframework-jwt==1.11.0 +djangorestframework==3.9.2 +edx-django-utils==1.0.3 +edx-drf-extensions==2.0.1 edx-lint==1.1.1 +edx-opaque-keys==0.4.4 enum34==1.1.6 # via astroid funcsigs==1.0.2 +future==0.17.1 futures==3.1.1 -idna==2.8 # via requests +idna==2.8 isort==4.3.12 jinja2==2.10 lazy-object-proxy==1.3.1 # via astroid markupsafe==1.1.1 mccabe==0.6.1 # via pylint +mock==2.0.0 more-itertools==5.0.0 +newrelic==4.14.0.115 packaging==19.0 # via caniusepython3 pathlib2==2.3.3 pbr==5.1.3 pluggy==0.9.0 +psutil==1.2.1 py==1.8.0 pycodestyle==2.5.0 +pycryptodomex==3.7.3 pydocstyle==3.0.0 +pyjwkest==1.3.2 +pyjwt==1.7.1 pylint-celery==0.3 # via edx-lint pylint-django==0.7.2 # via edx-lint pylint-plugin-utils==0.5 # via pylint-celery, pylint-django pylint==1.7.6 # via edx-lint, pylint-celery, pylint-django, pylint-plugin-utils +pymongo==3.7.2 pyparsing==2.3.1 # via packaging pytest-cov==2.6.1 pytest-django==3.4.8 pytest==4.3.0 +python-dateutil==2.8.0 python-slugify==3.0.0 pytz==2018.9 pyyaml==3.13 -requests==2.21.0 # via caniusepython3 +requests==2.21.0 +rest-condition==1.0.3 scandir==1.9.0 +semantic-version==2.6.0 singledispatch==3.4.0.3 # via astroid, pylint six==1.12.0 snowballstemmer==1.2.1 # via pydocstyle stevedore==1.30.1 text-unidecode==1.2 -urllib3==1.24.1 # via requests +urllib3==1.24.1 wrapt==1.11.1 # via astroid diff --git a/requirements/test.in b/requirements/test.in index 0730387..09dae2a 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -5,3 +5,4 @@ pytest-cov # pytest extension for code coverage statistics pytest-django # pytest extension for better Django support code-annotations # provides commands used by the pii_check make target. +mock diff --git a/requirements/test.txt b/requirements/test.txt index 14a7924..1938ab5 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -6,25 +6,48 @@ # atomicwrites==1.3.0 # via pytest attrs==19.1.0 # via pytest +certifi==2018.11.29 +chardet==3.0.4 click==7.0 # via code-annotations code-annotations==0.3 coverage==4.5.2 # via pytest-cov +django-crum==0.7.3 django-model-utils==3.1.2 -funcsigs==1.0.2 # via pytest +django-waffle==0.15.1 +djangorestframework-jwt==1.11.0 +djangorestframework==3.9.2 +edx-django-utils==1.0.3 +edx-drf-extensions==2.0.1 +edx-opaque-keys==0.4.4 +funcsigs==1.0.2 # via mock, pytest +future==0.17.1 +idna==2.8 jinja2==2.10 # via code-annotations markupsafe==1.1.1 # via jinja2 +mock==2.0.0 more-itertools==5.0.0 # via pytest +newrelic==4.14.0.115 pathlib2==2.3.3 # via pytest, pytest-django -pbr==5.1.3 # via stevedore +pbr==5.1.3 pluggy==0.9.0 # via pytest +psutil==1.2.1 py==1.8.0 # via pytest +pycryptodomex==3.7.3 +pyjwkest==1.3.2 +pyjwt==1.7.1 +pymongo==3.7.2 pytest-cov==2.6.1 pytest-django==3.4.8 pytest==4.3.0 # via pytest-cov, pytest-django +python-dateutil==2.8.0 python-slugify==3.0.0 # via code-annotations pytz==2018.9 pyyaml==3.13 # via code-annotations +requests==2.21.0 +rest-condition==1.0.3 scandir==1.9.0 # via pathlib2 +semantic-version==2.6.0 six==1.12.0 -stevedore==1.30.1 # via code-annotations +stevedore==1.30.1 text-unidecode==1.2 # via python-slugify +urllib3==1.24.1 diff --git a/test_settings.py b/test_settings.py index c0440e1..1bb56e1 100644 --- a/test_settings.py +++ b/test_settings.py @@ -32,6 +32,7 @@ def root(*args): 'django.contrib.auth', 'django.contrib.contenttypes', 'edx_rbac', + 'tests', ) LOCALE_PATHS = [ @@ -41,3 +42,53 @@ def root(*args): ROOT_URLCONF = 'edx_rbac.urls' SECRET_KEY = 'insecure-secret-key' + +SITE_NAME = 'testserver' + +SYSTEM_TO_FEATURE_ROLE_MAPPING = { + 'enterprise_admin': ['coupon-management', 'data_api_access'], + 'enterprise_leaner': [], + 'coupon-manager': ['coupon-management'], +} + +SYSTEM_WIDE_ROLE_CLASSES = [ + 'tests.ConcreteUserRoleAssignment' +] + +# Required for use with edx-drf-extensions JWT functionality: +# USER_SETTINGS overrides for djangorestframework-jwt APISettings class +# See https://github.com/GetBlimp/django-rest-framework-jwt/blob/master/rest_framework_jwt/settings.py +JWT_AUTH = { + + 'JWT_AUDIENCE': 'test-aud', + + 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', + + 'JWT_ISSUER': 'test-iss', + + 'JWT_LEEWAY': 1, + + 'JWT_SECRET_KEY': 'test-key', + + 'JWT_SUPPORTED_VERSION': '1.0.0', + + 'JWT_VERIFY_AUDIENCE': False, + + 'JWT_VERIFY_EXPIRATION': True, + + # JWT_ISSUERS enables token decoding for multiple issuers (Note: This is not a native DRF-JWT field) + # We use it to allow different values for the 'ISSUER' field, but keep the same SECRET_KEY and + # AUDIENCE values across all issuers. + 'JWT_ISSUERS': [ + { + 'ISSUER': 'test-issuer-1', + 'SECRET_KEY': 'test-secret-key', + 'AUDIENCE': 'test-audience', + }, + { + 'ISSUER': 'test-issuer-2', + 'SECRET_KEY': 'test-secret-key', + 'AUDIENCE': 'test-audience', + } + ], +} diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..f975ae7 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +These models exist solely for testing. + +They are not something that gets created when you install this application. +""" + +from __future__ import absolute_import, unicode_literals + +from edx_rbac.models import UserRole, UserRoleAssignment + + +class ConcreteUserRole(UserRole): # pylint: disable=model-missing-unicode + """ + Used for testing the UserRole model. + """ + + pass + + +class ConcreteUserRoleAssignment(UserRoleAssignment): # pylint: disable=model-missing-unicode + """ + Used for testing the UserRoleAssignment model. + """ + + role_class = ConcreteUserRole + + def get_context(self): + """ + Generate a context string to be used in tests. + """ + return "a-test-context" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..79493a0 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +""" +Tests for the `edx-rbac` utilities module. +""" +from __future__ import absolute_import, unicode_literals + +from django.conf import settings +from django.contrib.auth.models import User +from django.test import RequestFactory, TestCase +from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name +# edx_rest_framework_extensions test utils should change when the package +# does. Given edx_rbac is tightly coupled to edx_rest_framework_extensions, +# using those utils seems reasonable in the way of not repeating ourselves +from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt_token, generate_unversioned_payload +from mock import patch + +from edx_rbac.utils import ( + create_role_auth_claim_for_user, + get_decoded_jwt_from_request, + get_request_or_stub, + request_user_has_implicit_access_via_jwt, + user_has_access_via_database +) +from tests.models import ConcreteUserRole, ConcreteUserRoleAssignment + + +class TestUtils(TestCase): + """ + TestUtils tests. + """ + + def setUp(self): + super(TestUtils, self).setUp() + self.request = RequestFactory().get('/') + self.user = User.objects.create(username='test_user', password='pw') + self.request.user = self.user + + def test_get_request_or_stub(self): + """ + Outside the context of the request, we should still get a request + that allows us to build an absolute URI. + """ + stub = get_request_or_stub() + expected_url = "http://{site_name}/foobar".format(site_name=settings.SITE_NAME) + self.assertEqual(stub.build_absolute_uri("foobar"), expected_url) + + @patch('edx_rbac.utils.jwt_decode_handler') + def test_get_decoded_jwt_from_request(self, mock_decoder): + """ + A decoded jwt should be returned from request if it exists + """ + payload = generate_unversioned_payload(self.request.user) + payload.update({ + "roles": [ + "some_new_role_name:some_context" + ] + }) + jwt_token = generate_jwt_token(payload) + + self.request.COOKIES[jwt_cookie_name()] = jwt_token + get_decoded_jwt_from_request(self.request) + + mock_decoder.assert_called_once() + + @patch('edx_rbac.utils.jwt_decode_handler') + def test_get_decoded_jwt_from_request_no_jwt_in_request(self, mock_decoder): + """ + None should be returned if the request has no jwt + """ + result = get_decoded_jwt_from_request(self.request) + + assert result is None + mock_decoder.assert_not_called() + + # Check out test_settings for the variable declaration + def test_request_user_has_implicit_access_via_jwt(self): + """ + Helper function should discern what roles user has based on role data + in jwt, and then return true if any of those match the role we're + asking about + """ + toy_decoded_jwt = { + "roles": [ + "coupon-manager:some_context" + ] + } + assert request_user_has_implicit_access_via_jwt( + toy_decoded_jwt, + 'coupon-management', + ) + + def test_request_user_has_no_implicit_access_via_jwt(self): + """ + Helper function should discern what roles user has based on role data + in jwt, and then return true if any of those match the role we're + asking about + """ + toy_decoded_jwt = { + "roles": [ + "coupon-manager:some_context" + ] + } + assert not request_user_has_implicit_access_via_jwt( + toy_decoded_jwt, + 'superuser-access', + ) + + +class TestUtilsWithDatabaseRequirements(TestCase): + """ + TestUtilsWithDatabaseRequirements tests. + """ + + def setUp(self): + super(TestUtilsWithDatabaseRequirements, self).setUp() + self.user = User.objects.create(username='test_user', password='pw') + self.role = ConcreteUserRole(name='coupon-manager') + self.role.save() + + def test_user_has_access_via_database(self): + """ + Access check should return true if RoleAssignment exists for user + """ + ConcreteUserRoleAssignment.objects.create( + user=self.user, + role=self.role + ) + assert user_has_access_via_database( + self.user, + 'coupon-manager', + ConcreteUserRoleAssignment, + ) + + def test_user_has_no_access_via_database(self): + """ + Access check should return false if RoleAssignment does not exist for user + """ + assert not user_has_access_via_database( + self.user, + 'coupon-manager', + ConcreteUserRoleAssignment, + ) + + def test_create_role_auth_claim_for_user(self): + """ + Helper function should create a list of strings based on the roles + associated with the user. + """ + ConcreteUserRoleAssignment.objects.create( + user=self.user, + role=self.role + ) + + expected_claim = [ + 'coupon-manager:a-test-context' + ] + actual_claim = create_role_auth_claim_for_user(self.user) + assert expected_claim == actual_claim diff --git a/tox.ini b/tox.ini index 6c0c514..a76f66f 100644 --- a/tox.ini +++ b/tox.ini @@ -39,8 +39,13 @@ deps = django111: Django>=1.11,<2.0 django20: Django>=2.0,<2.1 -r{toxinidir}/requirements/test.txt +whitelist_externals = + touch + rm commands = + touch tests/__init__.py py.test {posargs} + rm tests/__init__.py [testenv:docs] setenv =