diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f05ad16 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +[*.py] +indent_style = space +indent_size = 4 + +[*.{json,toml,yaml,yml}] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..974c02f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: Continuous integration + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Cache pip packages + uses: actions/cache@v2 + env: + cache-name: cache-pip-modules + with: + path: ~/.pip-cache + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/requirements-dev.txt') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Install dependencies + run: | + pip install -r requirements-dev.txt codecov + pip install -e . + - name: Run tests + run: pytest -ra -vv --doctest-modules --cov=. + + - name: Coverage + run: codecov + + build: + name: Build package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + + - name: Install dependencies + run: pip install -U setuptools wheel build + - name: Build + run: python -m build . + + black: + name: Coding style - black + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run black + uses: psf/black@stable + with: + args: ". --check" + + flake8: + name: Coding style - flake8 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: pip install flake8 pep8-naming flake8-bugbear + - name: Run flake8 + uses: liskin/gh-problem-matcher-wrap@v1 + with: + linters: flake8 + run: flake8 + + isort: + name: Coding style - isort + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: pip install isort + - name: Run isort + uses: liskin/gh-problem-matcher-wrap@v1 + with: + linters: isort + run: isort -c . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f1246f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + push: + tags: + - 'release-*' + +jobs: + build-and-publish: + name: Build and publish + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + + - name: Install dependencies + run: pip install -U setuptools wheel build + - name: Build + run: python -m build . + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 0000000..feba33f --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,29 @@ +name: Publish to TestPyPI + +on: + push: + branches: [main] + +jobs: + build-and-publish: + name: Build and publish + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + + - name: Install dependencies + run: pip install -U setuptools wheel build + - name: Build + run: python -m build . + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + skip_existing: true diff --git a/.gitignore b/.gitignore index b6e4761..b0ed43c 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,15 @@ dmypy.json # Pyre type checker .pyre/ + +# Editors +*.stTheme.cache +*.sublime-workspace +*.tmlanguage.cache +*.tmPreferences.cache +._* +.AppleDouble +.DS_Store +.LSOverride +.idea +.vscode/settings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b5b179e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +default_language_version: + python: python3 +repos: + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + additional_dependencies: [pep8-naming, flake8-bugbear] + - repo: https://github.com/timothycrosley/isort + rev: 5.7.0 + hooks: + - id: isort diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..95589b1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +Contributions are accepted as pull requests. Contributions should be in line with +the best practices described in this repo. Please also observe our coding +practices at https://dev.hel.fi/. +Please make your pull requests short, elegant and only handling one +issue at a time! + +If you make a pull request, you may also want to notify our developers on +[Gitter](https://gitter.im/City-of-Helsinki/heldev) to tell about your contribution. + +Please also observe our [contribution handling guidelines](https://dev.hel.fi/accepting-contributions) +for contributing to City of Helsinki open source projects in general. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c1a7121 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.md diff --git a/README.md b/README.md index 1a42318..226fe30 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ -# helsinki-profile-gdpr-api -Django app for implementing Helsinki profile GDPR API +# Helsinki profile GDPR API + +Django app for implementing Helsinki profile GDPR API. + +This library will allow a service using Helsinki profile to implement the GDPR +functionality required by [open-city-profile](https://github.com/City-of-Helsinki/open-city-profile) +backend. + +## Installation + +1. `pip install helsinki-profile-gdpr-api` + +## Usage + +1. Authentication needs to be configured for the required `django-heluser` + +2. Model which is to be used for GDPR operations should inherit `SerializableMixin` and + include the required `serialize_fields` property. + +3. Define the following settings in your Django configuration. + + | Setting | Example | Description | + |---|---|---| + | GDPR_API_MODEL | "youths.YouthProfile" | GDPR profile model in the form `app_label.model_name`. model_name is case-insensitive. | + | GDPR_API_QUERY_SCOPE | "jassariapi.gdprquery" | API scope required for the query operation. | + | GDPR_API_DELETE_SCOPE | "jassariapi.gdprdelete" | API scope required for the delete operation. | + +4. Add the GDPR API urls into your url config: + + ```python + urlpatterns = [ + ... + path("gdpr-api/", include("helsinki_gdpr.urls")), + ] + ``` + +## Code format + +This project uses +[`black`](https://github.com/ambv/black), +[`flake8`](https://gitlab.com/pycqa/flake8) and +[`isort`](https://github.com/timothycrosley/isort) +for code formatting and quality checking. Project follows the basic black config, without any modifications. + +Basic `black` commands: + +* To let `black` do its magic: `black .` +* To see which files `black` would change: `black --check .` + +[`pre-commit`](https://pre-commit.com/) can be used to install and run all the formatting tools as git hooks +automatically before a commit. diff --git a/helsinki_gdpr/__init__.py b/helsinki_gdpr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helsinki_gdpr/models.py b/helsinki_gdpr/models.py new file mode 100644 index 0000000..710e54b --- /dev/null +++ b/helsinki_gdpr/models.py @@ -0,0 +1,119 @@ +from django.db import models +from django.db.models.fields.reverse_related import OneToOneRel + + +class SerializableMixin(models.Model): + """ + Mixin to add custom serialization for django models in order to get the desired tree of models to + the downloadable JSON form. It detects relationships automatically (many to many not yet fully + supported). Check for the example for more details about the structure. + + Attributes need to be defined in the extending model: + + - serialize_fields (required) + - tuple of dicts: + - name (required), name of the field or relation that's going to be added to the serialized object + - accessor (optional), function that is called when value of the field is resolved and it takes the + actual field value as argument + + Example usage and output: + + class Post(SerializableMixin): + serialize_fields = ( + { "name": "title" }, + { "name": "content" }, + { + "name": "created_at", + "accessor": lambda x: x.strftime("%Y-%m-%d") + } + { "name": "comments" }, + ) + + class Comment(SerializableMixin): + serialize_fields = ( + { "name": "text" }, + { "name": "author" }, + ) + + Calling serialize() on a single post object generates: + + { + "key": "POST", + "children": [ + { "key": "TITLE", "value": "Post about serialization" }, + { "key": "CONTENT", "value": "This is the content of the post" }, + { "key": "CREATED_AT", "value": "2020-02-03" }, + { "key": "COMMENTS", "children": [ + { + "key": "COMMENT" + "children": [ + { "key": "TEXT", "value": "I really like this post" }, + { "key": "AUTHOR", "value": "Mike" } + ] + }, + { + "key": "COMMENT" + "children": [ + { "key": "TEXT", "value": "I don't agree with this 100%" }, + { "key": "AUTHOR", "value": "Maria" } + ] + } + ]} + ] + } + """ + + class SerializableManager(models.Manager): + def serialize(self): + return [ + obj.serialize() if hasattr(obj, "serialize") else [] + for obj in self.get_queryset().all() + ] + + class Meta: + abstract = True + + objects = SerializableManager() + + def _resolve_field(self, model, field): + def _resolve_value(data, field): + if "accessor" in field: + # call the accessor with value as an argument + return field["accessor"](getattr(data, field.get("name"))) + else: + # no accessor, return the value + return getattr(data, field.get("name")) + + related_types = {item.name: type(item) for item in model._meta.related_objects} + if field.get("name") in related_types.keys(): + value = ( + getattr(model, field.get("name")).serialize() + if hasattr(model, field.get("name")) + and hasattr(getattr(model, field.get("name")), "serialize") + else None + ) + # field is a related object, let's serialize more + if related_types[field.get("name")] == OneToOneRel: + # do not wrap one-to-one relations into list + return value + else: + return { + "key": field.get("name").upper(), + "children": value, + } + else: + # concrete field, let's just add the value + return { + "key": field.get("name").upper(), + "value": _resolve_value(model, field), + } + + def serialize(self): + return { + "key": self._meta.model_name.upper(), + "children": [ + self._resolve_field(self, field) + for field in self.serialize_fields + if self._resolve_field(self, field) is not None + ], + } diff --git a/helsinki_gdpr/urls.py b/helsinki_gdpr/urls.py new file mode 100644 index 0000000..b264464 --- /dev/null +++ b/helsinki_gdpr/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from helsinki_gdpr.views import GDPRAPIView + +app_name = "helsinki_gdpr" +urlpatterns = [ + path("v1/profiles/", GDPRAPIView.as_view(), name="gdpr_v1"), +] diff --git a/helsinki_gdpr/views.py b/helsinki_gdpr/views.py new file mode 100644 index 0000000..6b16ead --- /dev/null +++ b/helsinki_gdpr/views.py @@ -0,0 +1,95 @@ +from django.apps import apps +from django.conf import settings +from django.db import DatabaseError, transaction +from django.shortcuts import get_object_or_404 +from helusers.oidc import ApiTokenAuthentication +from rest_framework import serializers, status +from rest_framework.exceptions import APIException +from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework.views import APIView + +from helsinki_gdpr.models import SerializableMixin + + +class DeletionNotAllowed(APIException): + status_code = 403 + default_detail = "Profile cannot be deleted." + default_code = "deletion_not_allowed" + + +class DryRunException(Exception): + """Indicate that request is being done as a dry run.""" + + +class DryRunSerializer(serializers.Serializer): + dry_run = serializers.BooleanField(required=False, default=False) + + +class GDPRScopesPermission(IsAuthenticated): + def has_permission(self, request, view): + authenticated = super().has_permission(request, view) + if authenticated: + if request.method == "GET": + return request.auth.has_api_scopes(settings.GDPR_API_QUERY_SCOPE) + elif request.method == "DELETE": + return request.auth.has_api_scopes(settings.GDPR_API_DELETE_SCOPE) + return False + + def has_object_permission(self, request, view, obj): + if obj.user: + return request.user == obj.user + return False + + +class GDPRAPIView(APIView): + """Fetch or delete all information related to the profile.""" + + renderer_classes = [JSONRenderer] + authentication_classes = [ApiTokenAuthentication] + permission_classes = [GDPRScopesPermission] + + def get_object(self) -> SerializableMixin: + model = apps.get_model(settings.GDPR_API_MODEL) + obj = get_object_or_404(model, pk=self.kwargs["pk"]) + self.check_object_permissions(self.request, obj) + return obj + + def check_dry_run(self): + """Check if parameters provided to the view indicate it's being used for dry_run.""" + data = DryRunSerializer(data=self.request.data) + query = DryRunSerializer(data=self.request.query_params) + data.is_valid() + query.is_valid() + + if data.validated_data["dry_run"] or query.validated_data["dry_run"]: + raise DryRunException() + + def get(self, request, *args, **kwargs): + """Retrieve all profile data related to the given id.""" + return Response(self.get_object().serialize(), status=status.HTTP_200_OK) + + def delete(self, request, *args, **kwargs): + """Delete all data related to the given profile. + + Deletes all data related to the given profile id, or just checks if the data can be deleted, + depending on the `dry_run` parameter. Raises DeletionNotAllowed if the item + + Dry run delete is expected to always give the same end result as the proper delete i.e. if + dry run indicated deleting is OK, the proper delete should be OK too. + """ + try: + with transaction.atomic(): + obj = self.get_object() + user = obj.user + obj.delete() + user.delete() + self.check_dry_run() + except DryRunException: + # Deletion is possible. Due to dry run, transaction is rolled back. + pass + except DatabaseError: + raise DeletionNotAllowed() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ddb34b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[build-system] +# These are the assumed default build requirements from pip: +# https://pip.pypa.io/en/stable/reference/pip/#pep-517-and-518-support +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..c8808b2 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,11 @@ +django +djangorestframework +django-helusers +drf-oidc-auth<1.0.0 +factory_boy +pytest +pytest-cov +pytest-django +python-jose +requests-mock +snapshottest diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c066bbd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,114 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements-dev.in +# +asgiref==3.3.1 + # via django +attrs==20.3.0 + # via pytest +cachetools==4.2.1 + # via django-helusers +certifi==2020.12.5 + # via requests +chardet==4.0.0 + # via requests +coverage==5.5 + # via pytest-cov +deprecation==2.1.0 + # via django-helusers +django-helusers==0.6.0 + # via -r requirements-dev.in +django==3.1.7 + # via + # -r requirements-dev.in + # django-helusers + # djangorestframework + # drf-oidc-auth +djangorestframework==3.12.2 + # via + # -r requirements-dev.in + # drf-oidc-auth +drf-oidc-auth==0.10.0 + # via -r requirements-dev.in +ecdsa==0.14.1 + # via python-jose +factory-boy==3.2.0 + # via -r requirements-dev.in +faker==6.6.0 + # via factory-boy +fastdiff==0.2.0 + # via snapshottest +future==0.18.2 + # via pyjwkest +idna==2.10 + # via requests +iniconfig==1.1.1 + # via pytest +packaging==20.9 + # via + # deprecation + # pytest +pluggy==0.13.1 + # via pytest +py==1.10.0 + # via pytest +pyasn1==0.4.8 + # via + # python-jose + # rsa +pycryptodomex==3.10.1 + # via pyjwkest +pyjwkest==1.4.2 + # via drf-oidc-auth +pyparsing==2.4.7 + # via packaging +pytest-cov==2.11.1 + # via -r requirements-dev.in +pytest-django==4.1.0 + # via -r requirements-dev.in +pytest==6.2.2 + # via + # -r requirements-dev.in + # pytest-cov + # pytest-django +python-dateutil==2.8.1 + # via faker +python-jose==3.2.0 + # via + # -r requirements-dev.in + # django-helusers +pytz==2021.1 + # via django +requests-mock==1.8.0 + # via -r requirements-dev.in +requests==2.25.1 + # via + # django-helusers + # pyjwkest + # requests-mock +rsa==4.7.2 + # via python-jose +six==1.15.0 + # via + # ecdsa + # pyjwkest + # python-dateutil + # python-jose + # requests-mock + # snapshottest +snapshottest==0.6.0 + # via -r requirements-dev.in +sqlparse==0.4.1 + # via django +termcolor==1.1.0 + # via snapshottest +text-unidecode==1.3 + # via faker +toml==0.10.2 + # via pytest +urllib3==1.26.3 + # via requests +wasmer==1.0.0 + # via fastdiff diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c5e1ade --- /dev/null +++ b/setup.cfg @@ -0,0 +1,83 @@ +[metadata] +name = helsinki-profile-gdpr-api +version = 0.1.0 +author = City of Helsinki +author_email = dev@hel.fi +description = Django app for implementing Helsinki profile GDPR API +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT License +url = https://github.com/City-of-Helsinki/helsinki-profile-gdpr-api +project_urls = + Bug Tracker = https://github.com/City-of-Helsinki/helsinki-profile-gdpr-api/issues +classifiers = + Environment :: Web Environment + Framework :: Django + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Internet :: WWW/HTTP + Topic :: Internet :: WWW/HTTP :: Dynamic Content + Topic :: Software Development :: Libraries :: Python Modules + +[options] +packages = find: +python_requires = >=3.6 +include_package_data = True +install_requires = + Django + djangorestframework + django-helusers + drf-oidc-auth<1.0.0 + +[options.packages.find] +exclude = + tests + tests.* + +[pep8] +max-line-length = 120 +exclude = *migrations* +ignore = E309 + +[flake8] +max-line-length = 120 +exclude = *migrations* +max-complexity = 10 + +[tool:pytest] +DJANGO_SETTINGS_MODULE = tests.settings +norecursedirs = .git .idea venv* +doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ALLOW_UNICODE + +[coverage:run] +branch = True +omit = *migrations*,*site-packages*,*venv*,*tests* + +[isort] +atomic = True +combine_as_imports = False +indent = 4 +length_sort = False +multi_line_output = 3 +order_by_type = False +skip = migrations,venv +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 +default_section = THIRDPARTY +extra_standard_library= token,tokenize,enum,importlib +known_first_party= + helsinki_gdpr, +known_third_party = django + +[pydocstyle] +ignore=D100,D104,D105,D200,D203,D400 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b908cbe --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f242fb6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest +from rest_framework.test import APIClient + +from tests.factories import ProfileFactory, UserFactory + + +@pytest.fixture(autouse=True) +def autouse_db(db): + pass + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def user(): + return UserFactory() + + +@pytest.fixture +def profile(): + return ProfileFactory() diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..32624cc --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,32 @@ +import factory +from django.contrib.auth import get_user_model + +from tests.models import ExtraData, Profile + +User = get_user_model() + + +class UserFactory(factory.django.DjangoModelFactory): + uuid = factory.Faker("uuid4", cast_to=None) + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + email = factory.Faker("email") + + class Meta: + model = User + + +class ProfileFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + memo = "Memo" + + class Meta: + model = Profile + + +class ExtraDataFactory(factory.django.DjangoModelFactory): + profile = factory.SubFactory(ProfileFactory) + data = "Extra" + + class Meta: + model = ExtraData diff --git a/tests/keys.py b/tests/keys.py new file mode 100644 index 0000000..1d56f2b --- /dev/null +++ b/tests/keys.py @@ -0,0 +1,62 @@ +from jose import jwk +from jose.constants import ALGORITHMS + + +def _build_key(private_pem, public_pem): + class _Key: + pass + + key = _Key() + key.jose_algorithm = ALGORITHMS.RS256 + key.private_key_pem = private_pem + key.public_key_pem = public_pem + key.public_key_jwk = jwk.construct(public_pem, key.jose_algorithm).to_dict() + + # Ensure values are strings and not bytes + for name in ["n", "e"]: + value = key.public_key_jwk[name] + if isinstance(value, bytes): + key.public_key_jwk[name] = value.decode("utf-8") + + return key + + +rsa_key = _build_key( + """-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDUeT3OFPatye6I +tmArvjR+f0lZu4QEOxtGxHj1UWzLiUbONygTrWCVXh5OFaH+GFPOfqax2iJSWc+7 +6JYy2y2XxdG1Tehcvpsv/gKqRJG2afVKY+qCCmtRwksWan4kRU6F9UKDHJl6emkE +e9xA/+DRVAE7cCM1xblI7YD3zXsf1xIWHjYfLS6/EJeVnq/bCN84LPuCO6148N2D +H7E0PEH2oZyg0QlnU2tTcZMG2D172wFxyM8jGfy7Hm8X+MQH5N/aFeKb7f/6eTA7 +4a5hW5ncRTasaH5djCcBFSyAaS8F4UCZYsRdth1lp4nBi986DbL1uaVbumU2Oo6F +a/K3uGRFAgMBAAECggEAAc5J/S9mbVGzCkxqgtSqA403ZWDXnWWXNMHEuWkIwK4Z +APWtDIXDtWFIZqd+afdw9udSqV5OPl7vCgzPAf2k5I5U2vKfj/I6xWymPyY4CtHZ +uNkijBpkkRxSoQ0kp1BDe5X7C7w5fbX+oIAg/hhuo7jQDd5FHlbg3ULPfsurSTj5 +wnaekK64DgTM6N/kLbFKMbRmyC54EfBqu/H3fnY3uljNZGFnL0ueyGB0NEij/f7P +NXPzvi+r3Bmpe+K2P8mFBPNXhRIIUmfhxe19OcnkneGF3FTv+EqwzKid45/eSnC2 +a8SeH/1b9pMPg0WycNwIh4sz3K7w0/vwJVpMC3thgQKBgQDq5pImHasJCIEHw2bY +A9NTGNP72ry8t96A6yLEa9w2i9lS7SMeUwuA5QdTE80+3FgC/S5YVxTyp0ceiUfr +nELEFgqD+6wXao6CYaelcwligmockXrBDGWfC/QviMRh6zympCZC0MNQcGR61PMx +tv8SqKi5ual+nT7PKqpbVygP5QKBgQDnjviEJa/E22VPoDZwnuwQCBf4fgQcOqSq +j2SYBLdISPzNhkXJqqlQ7LKZsWQH+4YeDDL2gOnmyvvQiRXlhQSNiCE26RSv1E3f +HiCvo8+AiDY49ywbbkL8E7sp5dSjmQhmQuRPolPMh2fJXvENxfEIXbwqpsCxxw0n +F3NGlpn84QKBgAtvCbIdQ5P++/jaxAjDtueWj8r0jLdK4+O2jkytS1zEVeG5dTom +pKqzezXKAvWKWCZdGIJoSra8+bM8z2lig8VzpTNjbq79Gs6x3i0pek13N58IXcdD +yTaCqHIf4B88Cgm6d7pM2xTxQ5LPBr9mvuezmfLgXKWzFbmTxBMKHQMZAoGATb33 +e853U71hJzmf7XG9yagd/CS61otty4G3AT7cFh3DGnGRLqLok63UTLt83R06Kw5n +cdFYNk9B+gJ8YoGlRKtGk3vvoRTDTDx+Ntnlib6xjbCWk2MShDVPqkJqgL6ZTlP4 ++S+DuPBhDP+eKMSjJu7phNxVZ5pvtQcvgayAaKECgYAsUa2jEZpLYwh2x+OW58YT +6Po5IhRvBAiNJg/N7mirkdb7NyB6I4aA1ztoimizDrJPn7edVKUTluNw2Fk3QsLS +va0XlEJu3URaqHLcKi6J74dlSt+3W4pSTJF7eseyFMI64bWSEho1tvChLSCq6lUE +zyIWjEHazLOEdBArFsgWsg== +-----END PRIVATE KEY-----""", + """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1Hk9zhT2rcnuiLZgK740 +fn9JWbuEBDsbRsR49VFsy4lGzjcoE61glV4eThWh/hhTzn6msdoiUlnPu+iWMtst +l8XRtU3oXL6bL/4CqkSRtmn1SmPqggprUcJLFmp+JEVOhfVCgxyZenppBHvcQP/g +0VQBO3AjNcW5SO2A9817H9cSFh42Hy0uvxCXlZ6v2wjfOCz7gjutePDdgx+xNDxB +9qGcoNEJZ1NrU3GTBtg9e9sBccjPIxn8ux5vF/jEB+Tf2hXim+3/+nkwO+GuYVuZ +3EU2rGh+XYwnARUsgGkvBeFAmWLEXbYdZaeJwYvfOg2y9bmlW7plNjqOhWvyt7hk +RQIDAQAB +-----END PUBLIC KEY-----""", +) diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..90e651f --- /dev/null +++ b/tests/models.py @@ -0,0 +1,34 @@ +import uuid + +from django.conf import settings +from django.db import models +from helusers.models import AbstractUser + +from helsinki_gdpr.models import SerializableMixin + + +class User(AbstractUser): + pass + + +class Profile(SerializableMixin): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + memo = models.CharField(max_length=128, blank=True) + + serialize_fields = ( + {"name": "memo"}, + {"name": "user", "accessor": lambda x: x.first_name}, + {"name": "extra_data"}, + ) + + +class ExtraData(SerializableMixin): + profile = models.ForeignKey( + Profile, + on_delete=models.CASCADE, + related_name="extra_data", + ) + data = models.CharField(max_length=255) + + serialize_fields = ({"name": "data"},) diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..df4231c --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,44 @@ +SECRET_KEY = "secret" + +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} + +INSTALLED_APPS = ( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "helusers.apps.HelusersConfig", + "helusers.apps.HelusersAdminConfig", + "helsinki_gdpr", + "tests", +) + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "tests.urls" + +AUTH_USER_MODEL = "tests.User" + +GDPR_API_MODEL = "tests.Profile" +GDPR_API_QUERY_SCOPE = "testprefix.gdprquery" +GDPR_API_DELETE_SCOPE = "testprefix.gdprdelete" + +DEBUG = True +USE_TZ = True + +OIDC_API_TOKEN_AUTH = { + "AUDIENCE": "test_audience", + "ISSUER": "https://test_issuer_1", + "REQUIRE_API_SCOPE_FOR_AUTHENTICATION": False, + "API_AUTHORIZATION_FIELD": "", + "API_SCOPE_PREFIX": "", +} diff --git a/tests/snapshots/__init__.py b/tests/snapshots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/snapshots/snap_test_gdpr_api.py b/tests/snapshots/snap_test_gdpr_api.py new file mode 100644 index 0000000..dac4a29 --- /dev/null +++ b/tests/snapshots/snap_test_gdpr_api.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + +snapshots = Snapshot() + +snapshots["test_get_profile_information_from_gdpr_api 1"] = { + "children": [ + {"key": "MEMO", "value": "Memo"}, + {"key": "USER", "value": "First"}, + {"children": [], "key": "EXTRA_DATA"}, + ], + "key": "PROFILE", +} diff --git a/tests/snapshots/snap_test_models.py b/tests/snapshots/snap_test_models.py new file mode 100644 index 0000000..fada047 --- /dev/null +++ b/tests/snapshots/snap_test_models.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + +snapshots = Snapshot() + +snapshots["test_model_serialization 1"] = { + "children": [ + {"key": "MEMO", "value": "Memo"}, + {"key": "USER", "value": "First"}, + { + "children": [ + {"children": [{"key": "DATA", "value": "Extra"}], "key": "EXTRADATA"} + ], + "key": "EXTRA_DATA", + }, + ], + "key": "PROFILE", +} diff --git a/tests/test_gdpr_api.py b/tests/test_gdpr_api.py new file mode 100644 index 0000000..7bb4614 --- /dev/null +++ b/tests/test_gdpr_api.py @@ -0,0 +1,245 @@ +import datetime + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from helusers.settings import api_token_auth_settings +from jose import jwt + +from .keys import rsa_key +from .models import Profile + +User = get_user_model() + +TRUE_VALUES = ["true", "True", "TRUE", "1", 1, True] +FALSE_VALUES = ["false", "False", "FALSE", "0", 0, False] + + +def get_api_token_for_user_with_scopes(user, scopes: list, requests_mock): + """Build a proper auth token with desired scopes.""" + audience = api_token_auth_settings.AUDIENCE + issuer = api_token_auth_settings.ISSUER + auth_field = api_token_auth_settings.API_AUTHORIZATION_FIELD + config_url = f"{issuer}/.well-known/openid-configuration" + jwks_url = f"{issuer}/jwks" + + configuration = { + "issuer": issuer, + "jwks_uri": jwks_url, + } + + keys = {"keys": [rsa_key.public_key_jwk]} + + now = datetime.datetime.now() + expire = now + datetime.timedelta(days=14) + + jwt_data = { + "iss": issuer, + "aud": audience, + "sub": str(user.uuid), + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + auth_field: scopes, + } + encoded_jwt = jwt.encode( + jwt_data, key=rsa_key.private_key_pem, algorithm=rsa_key.jose_algorithm + ) + + requests_mock.get(config_url, json=configuration) + requests_mock.get(jwks_url, json=keys) + + auth_header = f"{api_token_auth_settings.AUTH_SCHEME} {encoded_jwt}" + + return auth_header + + +def test_get_profile_information_from_gdpr_api( + api_client, profile, snapshot, requests_mock, settings +): + profile.user.first_name = "First" + profile.user.save() + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_QUERY_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.get( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + + assert response.status_code == 200 + snapshot.assert_match(response.json()) + + +@pytest.mark.parametrize("true_value", TRUE_VALUES) +def test_delete_profile_dry_run_data( + true_value, api_client, profile, requests_mock, settings +): + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}), + data={"dry_run": true_value}, + format="json", + ) + + assert response.status_code == 204 + assert Profile.objects.count() == 1 + assert User.objects.count() == 1 + + +@pytest.mark.parametrize("true_value", TRUE_VALUES) +def test_delete_profile_dry_run_query_params( + true_value, api_client, profile, requests_mock, settings +): + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + + f"?dry_run={true_value}", + ) + + assert response.status_code == 204 + assert Profile.objects.count() == 1 + assert User.objects.count() == 1 + + +def test_delete_profile(api_client, profile, requests_mock, settings): + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + + assert response.status_code == 204 + assert Profile.objects.count() == 0 + assert User.objects.count() == 0 + + +@pytest.mark.parametrize("false_value", FALSE_VALUES) +def test_delete_profile_dry_run_query_params_false( + false_value, api_client, profile, requests_mock, settings +): + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + + f"?dry_run={false_value}", + ) + + assert response.status_code == 204 + assert Profile.objects.count() == 0 + assert User.objects.count() == 0 + + +@pytest.mark.parametrize("false_value", FALSE_VALUES) +def test_delete_profile_dry_run_data_false( + false_value, api_client, profile, requests_mock, settings +): + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}), + data={"dry_run": false_value}, + format="json", + ) + + assert response.status_code == 204 + assert Profile.objects.count() == 0 + assert User.objects.count() == 0 + + +def test_gdpr_api_requires_authentication(api_client, profile): + response = api_client.get( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + assert response.status_code == 401 + + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + assert response.status_code == 401 + + +def test_user_can_only_access_his_own_profile( + api_client, user, profile, requests_mock, settings +): + auth_header = get_api_token_for_user_with_scopes( + user, + [settings.GDPR_API_QUERY_SCOPE, settings.GDPR_API_DELETE_SCOPE], + requests_mock, + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + + response = api_client.get( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + assert response.status_code == 403 + + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("use_correct_scope", [True, False]) +def test_gdpr_query_requires_correct_scope( + use_correct_scope, api_client, profile, requests_mock, settings +): + if use_correct_scope: + auth_header = get_api_token_for_user_with_scopes( + profile.user, + [settings.GDPR_API_QUERY_SCOPE, settings.GDPR_API_DELETE_SCOPE], + requests_mock, + ) + else: + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_DELETE_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + + response = api_client.get( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + + if use_correct_scope: + assert response.status_code == 200 + else: + assert response.status_code == 403 + + +@pytest.mark.parametrize("use_correct_scope", [True, False]) +def test_gdpr_delete_requires_correct_scope( + use_correct_scope, api_client, profile, requests_mock, settings +): + if use_correct_scope: + auth_header = get_api_token_for_user_with_scopes( + profile.user, + [settings.GDPR_API_QUERY_SCOPE, settings.GDPR_API_DELETE_SCOPE], + requests_mock, + ) + else: + auth_header = get_api_token_for_user_with_scopes( + profile.user, [settings.GDPR_API_QUERY_SCOPE], requests_mock + ) + api_client.credentials(HTTP_AUTHORIZATION=auth_header) + + response = api_client.delete( + reverse("helsinki_gdpr:gdpr_v1", kwargs={"pk": profile.id}) + ) + + if use_correct_scope: + assert response.status_code == 204 + else: + assert response.status_code == 403 + assert Profile.objects.count() == 1 + assert User.objects.count() == 1 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..0d90017 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,11 @@ +from tests.factories import ExtraDataFactory + + +def test_model_serialization(profile, snapshot): + profile.user.first_name = "First" + profile.user.save() + ExtraDataFactory(profile=profile) + + serialized_profile = profile.serialize() + + snapshot.assert_match(serialized_profile) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..d009524 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,3 @@ +from django.urls import include, path + +urlpatterns = [path("gdpr-api/", include("helsinki_gdpr.urls"))]