diff --git a/INSTALL.rst b/INSTALL.rst index abf93982..6b99d42c 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -274,3 +274,16 @@ There are no specific commands for the project. See ``python src/manage.py --help``. .. _Django framework commands: https://docs.djangoproject.com/en/dev/ref/django-admin/#available-commands + +Configuration (CLI) +=================== + +After deploying Objecttypes API, they need to be configured to be fully functional. +The command line tool ``setup_configuration`` assist with this configuration. + +You can get the full command documentation with: + +See `Django Setup Configuration`_ for all documentation, or type +``python src/manage.py setup_configuration --help``. + +.. _Django Setup Configuration: https://github.com/maykinmedia/django-setup-configuration \ No newline at end of file diff --git a/bin/setup_configuration.sh b/bin/setup_configuration.sh index 216f8697..844ba43d 100755 --- a/bin/setup_configuration.sh +++ b/bin/setup_configuration.sh @@ -3,9 +3,12 @@ # setup initial configuration using environment variables # Run this script from the root of the repository -#set -e -${SCRIPTPATH}/wait_for_db.sh +set -e -src/manage.py migrate +if [[ "${RUN_SETUP_CONFIG,,}" =~ ^(true|1|yes)$ ]]; then + # wait for required services + ${SCRIPTPATH}/wait_for_db.sh -src/manage.py setup_configuration --no-selftest + src/manage.py migrate + src/manage.py setup_configuration --yaml-file setup_configuration/data.yaml +fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ad41c7e5..1e9d68ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ services: db: image: postgres:12-alpine environment: - - POSTGRES_USER=objecttypes - - POSTGRES_PASSWORD=objecttypes + POSTGRES_USER: objecttypes + POSTGRES_PASSWORD: objecttypes command: postgres -c max_connections=300 -c log_min_messages=LOG redis: @@ -13,23 +13,17 @@ services: image: maykinmedia/objecttypes-api:latest build: . environment: &app-env - - DB_USER=objecttypes - - DB_PASSWORD=objecttypes - - DJANGO_SETTINGS_MODULE=objecttypes.conf.docker - - SECRET_KEY=${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cyf15d} - - ALLOWED_HOSTS=* - - CACHE_DEFAULT=redis:6379/0 - - CACHE_AXES=redis:6379/0 - - DISABLE_2FA=yes - - SUBPATH=${SUBPATH:-/} - # setup_configuration env vars - - SITES_CONFIG_ENABLE=yes - - OBJECTTYPES_DOMAIN=web:8000 - - OBJECTTYPES_ORGANIZATION=ObjectTypes - - OBJECTS_OBJECTTYPES_CONFIG_ENABLE=yes - - OBJECTS_OBJECTTYPES_TOKEN=some-random-string - - OBJECTS_OBJECTTYPES_PERSON=Some Person - - OBJECTS_OBJECTTYPES_EMAIL=objects@objects.local + DB_USER: objecttypes + DB_PASSWORD: objecttypes + DJANGO_SETTINGS_MODULE: objecttypes.conf.docker + SECRET_KEY: ${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cyf15d} + ALLOWED_HOSTS: '*' + CACHE_DEFAULT: redis:6379/0 + CACHE_AXES: redis:6379/0 + DISABLE_2FA: yes + SUBPATH: ${SUBPATH:-/} + volumes: + - log:/app/log ports: - 8000:8000 depends_on: @@ -37,9 +31,19 @@ services: condition: service_completed_successfully web-init: - image: maykinmedia/objecttypes-api:latest build: . - environment: *app-env + environment: + <<: *app-env + # + # Django-setup-configuration + RUN_SETUP_CONFIG: ${RUN_SETUP_CONFIG:-true} command: /setup_configuration.sh + volumes: + - log:/app/log + - ./docker/setup_configuration:/app/setup_configuration depends_on: - - db \ No newline at end of file + - db + +volumes: + db: + log: \ No newline at end of file diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml new file mode 100644 index 00000000..2b89efd4 --- /dev/null +++ b/docker/setup_configuration/data.yaml @@ -0,0 +1,16 @@ +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + organization: Organization 1 + application: Application 1 + administration: Administration 1 + +sites_config_enable: true +sites_config: + items: + - domain: example.com + name: example \ No newline at end of file diff --git a/requirements/base.in b/requirements/base.in index 7bd81885..8d6f74fa 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -7,7 +7,7 @@ furl # Framework libraries django-jsonsuit sharing-configs -django-setup-configuration +django-setup-configuration>=0.4.0 # API libraries drf-nested-routers diff --git a/requirements/base.txt b/requirements/base.txt index 004a7254..8c4d6a28 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,6 +6,8 @@ # amqp==5.1.1 # via kombu +annotated-types==0.7.0 + # via pydantic ape-pie==0.1.0 # via # commonground-api-common @@ -154,7 +156,7 @@ django-sendfile2==0.7.0 # via django-privates django-sessionprofile==3.0.0 # via open-api-framework -django-setup-configuration==0.1.0 +django-setup-configuration==0.4.0 # via # -r requirements/base.in # open-api-framework @@ -193,9 +195,7 @@ drf-nested-routers==0.94.1 # -r requirements/base.in # commonground-api-common drf-spectacular[sidecar]==0.27.2 - # via - # drf-spectacular - # open-api-framework + # via open-api-framework drf-spectacular-sidecar==2024.7.1 # via drf-spectacular drf-yasg==1.21.7 @@ -266,6 +266,14 @@ psycopg2==2.9.9 # via open-api-framework pycparser==2.21 # via cffi +pydantic==2.9.2 + # via + # django-setup-configuration + # pydantic-settings +pydantic-core==2.23.4 + # via pydantic +pydantic-settings[yaml]==2.6.1 + # via django-setup-configuration pyjwt==2.7.0 # via # commonground-api-common @@ -283,16 +291,19 @@ python-dateutil==2.8.2 python-decouple==3.8 # via open-api-framework python-dotenv==1.0.0 - # via open-api-framework + # via + # open-api-framework + # pydantic-settings pytz==2023.3 # via # drf-yasg # flower -pyyaml==6.0 +pyyaml==6.0.2 # via # drf-spectacular # drf-yasg # oyaml + # pydantic-settings qrcode==6.1 # via django-two-factor-auth redis==4.5.5 @@ -330,6 +341,8 @@ tornado==6.4.1 typing-extensions==4.11.0 # via # mozilla-django-oidc-db + # pydantic + # pydantic-core # zgw-consumers tzdata==2024.1 # via celery diff --git a/requirements/ci.txt b/requirements/ci.txt index fdf3ab03..53e47dfc 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -8,6 +8,10 @@ amqp==5.1.1 # via # -r requirements/base.txt # kombu +annotated-types==0.7.0 + # via + # -r requirements/base.txt + # pydantic ape-pie==0.1.0 # via # -r requirements/base.txt @@ -238,7 +242,7 @@ django-sessionprofile==3.0.0 # via # -r requirements/base.txt # open-api-framework -django-setup-configuration==0.1.0 +django-setup-configuration==0.4.0 # via # -r requirements/base.txt # open-api-framework @@ -258,7 +262,6 @@ django-solo==2.0.0 django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 # via # -r requirements/base.txt - # django-two-factor-auth # maykin-2fa django-webtest==1.9.10 # via -r requirements/test-tools.in @@ -293,7 +296,6 @@ drf-nested-routers==0.94.1 drf-spectacular[sidecar]==0.27.2 # via # -r requirements/base.txt - # drf-spectacular # open-api-framework drf-spectacular-sidecar==2024.7.1 # via @@ -448,6 +450,19 @@ pycparser==2.21 # via # -r requirements/base.txt # cffi +pydantic==2.9.2 + # via + # -r requirements/base.txt + # django-setup-configuration + # pydantic-settings +pydantic-core==2.23.4 + # via + # -r requirements/base.txt + # pydantic +pydantic-settings[yaml]==2.6.1 + # via + # -r requirements/base.txt + # django-setup-configuration pyflakes==3.2.0 # via flake8 pyjwt==2.7.0 @@ -481,17 +496,19 @@ python-dotenv==1.0.0 # via # -r requirements/base.txt # open-api-framework + # pydantic-settings pytz==2023.3 # via # -r requirements/base.txt # drf-yasg # flower -pyyaml==6.0 +pyyaml==6.0.2 # via # -r requirements/base.txt # drf-spectacular # drf-yasg # oyaml + # pydantic-settings # vcrpy qrcode==6.1 # via @@ -551,6 +568,8 @@ typing-extensions==4.11.0 # via # -r requirements/base.txt # mozilla-django-oidc-db + # pydantic + # pydantic-core # zgw-consumers tzdata==2024.1 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index a31c3bd8..d98a3024 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -10,6 +10,10 @@ amqp==5.1.1 # via # -r requirements/base.txt # kombu +annotated-types==0.7.0 + # via + # -r requirements/base.txt + # pydantic ape-pie==0.1.0 # via # -r requirements/base.txt @@ -249,7 +253,7 @@ django-sessionprofile==3.0.0 # via # -r requirements/base.txt # open-api-framework -django-setup-configuration==0.1.0 +django-setup-configuration==0.4.0 # via # -r requirements/base.txt # open-api-framework @@ -269,7 +273,6 @@ django-solo==2.0.0 django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 # via # -r requirements/base.txt - # django-two-factor-auth # maykin-2fa django-webtest==1.9.10 # via -r requirements/test-tools.in @@ -308,7 +311,6 @@ drf-nested-routers==0.94.1 drf-spectacular[sidecar]==0.27.2 # via # -r requirements/base.txt - # drf-spectacular # open-api-framework drf-spectacular-sidecar==2024.7.1 # via @@ -470,6 +472,19 @@ pycparser==2.21 # via # -r requirements/base.txt # cffi +pydantic==2.9.2 + # via + # -r requirements/base.txt + # django-setup-configuration + # pydantic-settings +pydantic-core==2.23.4 + # via + # -r requirements/base.txt + # pydantic +pydantic-settings[yaml]==2.6.1 + # via + # -r requirements/base.txt + # django-setup-configuration pyflakes==3.0.1 # via flake8 pygments==2.15.1 @@ -507,17 +522,19 @@ python-dotenv==1.0.0 # via # -r requirements/base.txt # open-api-framework + # pydantic-settings pytz==2023.3 # via # -r requirements/base.txt # drf-yasg # flower -pyyaml==6.0 +pyyaml==6.0.2 # via # -r requirements/base.txt # drf-spectacular # drf-yasg # oyaml + # pydantic-settings # vcrpy qrcode==6.1 # via @@ -601,6 +618,8 @@ typing-extensions==4.11.0 # via # -r requirements/base.txt # mozilla-django-oidc-db + # pydantic + # pydantic-core # zgw-consumers tzdata==2024.1 # via diff --git a/src/objecttypes/conf/base.py b/src/objecttypes/conf/base.py index c398951a..60bbf727 100644 --- a/src/objecttypes/conf/base.py +++ b/src/objecttypes/conf/base.py @@ -1,5 +1,4 @@ from open_api_framework.conf.base import * # noqa -from open_api_framework.conf.utils import config from .api import * # noqa @@ -18,7 +17,7 @@ # Project applications. "objecttypes.accounts", "objecttypes.api", - "objecttypes.config", + "objecttypes.setup_configuration", "objecttypes.core", "objecttypes.token", "objecttypes.utils", @@ -53,30 +52,6 @@ # Django setup configuration # SETUP_CONFIGURATION_STEPS = [ - "objecttypes.config.site.SiteConfigurationStep", - "objecttypes.config.objects.ObjectsAuthStep", - "objecttypes.config.demo.DemoUserStep", + "objecttypes.setup_configuration.steps.sites.SitesConfigurationStep", + "objecttypes.setup_configuration.steps.token_auth.TokenAuthConfigurationStep", ] - - -# -# Objecttypes settings -# - -# setup_configuration command -# sites config -SITES_CONFIG_ENABLE = config("SITES_CONFIG_ENABLE", default=False) -OBJECTTYPES_DOMAIN = config("OBJECTTYPES_DOMAIN", "") -OBJECTTYPES_ORGANIZATION = config("OBJECTTYPES_ORGANIZATION", "") -# objects auth config -OBJECTS_OBJECTTYPES_CONFIG_ENABLE = config( - "OBJECTS_OBJECTTYPES_CONFIG_ENABLE", default=False -) -OBJECTS_OBJECTTYPES_TOKEN = config("OBJECTS_OBJECTTYPES_TOKEN", "") -OBJECTS_OBJECTTYPES_PERSON = config("OBJECTS_OBJECTTYPES_PERSON", "") -OBJECTS_OBJECTTYPES_EMAIL = config("OBJECTS_OBJECTTYPES_EMAIL", "") -# Demo User Configuration -DEMO_CONFIG_ENABLE = config("DEMO_CONFIG_ENABLE", default=False) -DEMO_TOKEN = config("DEMO_TOKEN", "") -DEMO_PERSON = config("DEMO_PERSON", "") -DEMO_EMAIL = config("DEMO_EMAIL", "") diff --git a/src/objecttypes/config/demo.py b/src/objecttypes/config/demo.py deleted file mode 100644 index 215d4c7e..00000000 --- a/src/objecttypes/config/demo.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.conf import settings -from django.urls import reverse - -import requests -from django_setup_configuration.configuration import BaseConfigurationStep -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.token.models import TokenAuth -from objecttypes.utils import build_absolute_url - - -class DemoUserStep(BaseConfigurationStep): - """ - Create demo user to request Objectypes API - """ - - verbose_name = "Demo User Configuration" - required_settings = [ - "DEMO_TOKEN", - "DEMO_PERSON", - "DEMO_EMAIL", - ] - enable_setting = "DEMO_CONFIG_ENABLE" - - def is_configured(self) -> bool: - return TokenAuth.objects.filter(token=settings.DEMO_TOKEN).exists() - - def configure(self): - TokenAuth.objects.update_or_create( - token=settings.DEMO_TOKEN, - defaults={ - "contact_person": settings.DEMO_PERSON, - "email": settings.DEMO_EMAIL, - }, - ) - - def test_configuration(self): - endpoint = reverse("v2:objecttype-list") - full_url = build_absolute_url(endpoint, request=None) - - try: - response = requests.get( - full_url, - headers={ - "Authorization": f"Token {settings.DEMO_TOKEN}", - "Accept": "application/json", - }, - ) - response.raise_for_status() - except requests.RequestException as exc: - raise SelfTestFailed( - "Could not list objecttypes for the configured token" - ) from exc diff --git a/src/objecttypes/config/objects.py b/src/objecttypes/config/objects.py deleted file mode 100644 index a24d5cf5..00000000 --- a/src/objecttypes/config/objects.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.conf import settings -from django.urls import reverse - -import requests -from django_setup_configuration.configuration import BaseConfigurationStep -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.token.models import TokenAuth -from objecttypes.utils import build_absolute_url - - -class ObjectsAuthStep(BaseConfigurationStep): - """ - Configure credentials for Objects API to request Objecttypes API - """ - - verbose_name = "Objects API Authentication Configuration" - required_settings = [ - "OBJECTS_OBJECTTYPES_TOKEN", - "OBJECTS_OBJECTTYPES_PERSON", - "OBJECTS_OBJECTTYPES_EMAIL", - ] - enable_setting = "OBJECTS_OBJECTTYPES_CONFIG_ENABLE" - - def is_configured(self) -> bool: - return TokenAuth.objects.filter( - token=settings.OBJECTS_OBJECTTYPES_TOKEN - ).exists() - - def configure(self): - TokenAuth.objects.update_or_create( - token=settings.OBJECTS_OBJECTTYPES_TOKEN, - defaults={ - "contact_person": settings.OBJECTS_OBJECTTYPES_PERSON, - "email": settings.OBJECTS_OBJECTTYPES_EMAIL, - }, - ) - - def test_configuration(self): - endpoint = reverse("v2:objecttype-list") - full_url = build_absolute_url(endpoint, request=None) - - try: - response = requests.get( - full_url, - headers={ - "Authorization": f"Token {settings.OBJECTS_OBJECTTYPES_TOKEN}", - "Accept": "application/json", - }, - ) - response.raise_for_status() - except requests.RequestException as exc: - raise SelfTestFailed( - "Could not list objecttypes for the configured token" - ) from exc diff --git a/src/objecttypes/config/site.py b/src/objecttypes/config/site.py deleted file mode 100644 index 3d00b878..00000000 --- a/src/objecttypes/config/site.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.conf import settings -from django.contrib.sites.models import Site -from django.urls import reverse - -import requests -from django_setup_configuration.configuration import BaseConfigurationStep -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.utils import build_absolute_url - - -class SiteConfigurationStep(BaseConfigurationStep): - """ - Configure the application site/domain. - """ - - verbose_name = "Site Configuration" - required_settings = ["OBJECTTYPES_DOMAIN", "OBJECTTYPES_ORGANIZATION"] - enable_setting = "SITES_CONFIG_ENABLE" - - def is_configured(self) -> bool: - site = Site.objects.get_current() - return site.domain == settings.OBJECTTYPES_DOMAIN - - def configure(self): - site = Site.objects.get_current() - site.domain = settings.OBJECTTYPES_DOMAIN - site.name = f"Objecttypes {settings.OBJECTTYPES_ORGANIZATION}".strip() - site.save() - - def test_configuration(self): - full_url = build_absolute_url(reverse("home")) - try: - response = requests.get(full_url) - response.raise_for_status() - except requests.RequestException as exc: - raise SelfTestFailed(f"Could not access home page at '{full_url}'") from exc diff --git a/src/objecttypes/config/__init__.py b/src/objecttypes/setup_configuration/__init__.py similarity index 100% rename from src/objecttypes/config/__init__.py rename to src/objecttypes/setup_configuration/__init__.py diff --git a/src/objecttypes/tests/commands/__init__.py b/src/objecttypes/setup_configuration/models/__init__.py similarity index 100% rename from src/objecttypes/tests/commands/__init__.py rename to src/objecttypes/setup_configuration/models/__init__.py diff --git a/src/objecttypes/setup_configuration/models/sites.py b/src/objecttypes/setup_configuration/models/sites.py new file mode 100644 index 00000000..360bdbbc --- /dev/null +++ b/src/objecttypes/setup_configuration/models/sites.py @@ -0,0 +1,17 @@ +from django.contrib.sites.models import Site + +from django_setup_configuration.models import ConfigurationModel + + +class SiteConfigurationModel(ConfigurationModel): + class Meta: + django_model_refs = { + Site: ( + "domain", + "name", + ) + } + + +class SitesConfigurationModel(ConfigurationModel): + items: list[SiteConfigurationModel] diff --git a/src/objecttypes/setup_configuration/models/token_auth.py b/src/objecttypes/setup_configuration/models/token_auth.py new file mode 100644 index 00000000..20beab13 --- /dev/null +++ b/src/objecttypes/setup_configuration/models/token_auth.py @@ -0,0 +1,22 @@ +from django_setup_configuration.models import ConfigurationModel + +from objecttypes.token.models import TokenAuth + + +class TokenAuthConfigurationModel(ConfigurationModel): + class Meta: + django_model_refs = { + TokenAuth: ( + "identifier", + "token", + "contact_person", + "email", + "organization", + "application", + "administration", + ) + } + + +class TokenAuthGroupConfigurationModel(ConfigurationModel): + items: list[TokenAuthConfigurationModel] diff --git a/src/objecttypes/tests/config/__init__.py b/src/objecttypes/setup_configuration/steps/__init__.py similarity index 100% rename from src/objecttypes/tests/config/__init__.py rename to src/objecttypes/setup_configuration/steps/__init__.py diff --git a/src/objecttypes/setup_configuration/steps/sites.py b/src/objecttypes/setup_configuration/steps/sites.py new file mode 100644 index 00000000..b5993d1e --- /dev/null +++ b/src/objecttypes/setup_configuration/steps/sites.py @@ -0,0 +1,58 @@ +import logging + +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from objecttypes.setup_configuration.models.sites import SitesConfigurationModel + +logger = logging.getLogger(__name__) + + +class SitesConfigurationStep(BaseConfigurationStep[SitesConfigurationModel]): + """ + Configure the application site/domain. + """ + + namespace = "sites_config" + enable_setting = "sites_config_enable" + + verbose_name = "Configuration to set up Sites for ObjectTypes" + config_model = SitesConfigurationModel + + def execute(self, model: SitesConfigurationModel) -> None: + for item in model.items: + logger.info(f"Configuring {item.domain}") + + model_kwargs = { + "domain": item.domain, + "name": item.name, + } + + instance = Site(**model_kwargs) + + try: + instance.full_clean(exclude=("id",), validate_unique=False) + except ValidationError as exception: + exception_message = f"Validation error(s) occured for {item.domain}." + raise ConfigurationRunFailed(exception_message) from exception + + logger.debug(f"No validation errors found for {item.domain}") + + try: + logger.debug(f"Saving {item.domain}") + Site.objects.update_or_create( + domain=item.domain, + defaults={ + "name": item.name, + }, + ) + + except IntegrityError as exception: + exception_message = f"Failed configuring token {item.domain}." + raise ConfigurationRunFailed(exception_message) from exception + + logger.info(f"Configured {item.domain}") diff --git a/src/objecttypes/setup_configuration/steps/token_auth.py b/src/objecttypes/setup_configuration/steps/token_auth.py new file mode 100644 index 00000000..e9f08069 --- /dev/null +++ b/src/objecttypes/setup_configuration/steps/token_auth.py @@ -0,0 +1,74 @@ +import logging + +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from objecttypes.setup_configuration.models.token_auth import ( + TokenAuthGroupConfigurationModel, +) +from objecttypes.token.models import TokenAuth + +logger = logging.getLogger(__name__) + + +class TokenAuthConfigurationStep( + BaseConfigurationStep[TokenAuthGroupConfigurationModel] +): + """ + Configure tokens for other applications to access Objecttypes API + """ + + namespace = "tokenauth" + enable_setting = "tokenauth_config_enable" + + verbose_name = "Configuration to set up authentication tokens for ObjectTypes" + config_model = TokenAuthGroupConfigurationModel + + def execute(self, model: TokenAuthGroupConfigurationModel) -> None: + if len(model.items) == 0: + logger.warning("No tokens provided for configuration") + + for item in model.items: + logger.info(f"Configuring {item.identifier}") + + model_kwargs = { + "identifier": item.identifier, + "token": item.token, + "contact_person": item.contact_person, + "email": item.email, + "organization": item.organization, + "application": item.application, + "administration": item.administration, + } + + token_instance = TokenAuth(**model_kwargs) + + try: + token_instance.full_clean(exclude=("id",), validate_unique=False) + except ValidationError as exception: + exception_message = ( + f"Validation error(s) occured for {item.identifier}." + ) + raise ConfigurationRunFailed(exception_message) from exception + + logger.debug(f"No validation errors found for {item.identifier}") + + try: + logger.debug(f"Saving {item.identifier}") + + TokenAuth.objects.update_or_create( + identifier=item.identifier, + defaults={ + key: value + for key, value in model_kwargs.items() + if key != "identifier" + }, + ) + except IntegrityError as exception: + exception_message = f"Failed configuring token {item.identifier}." + raise ConfigurationRunFailed(exception_message) from exception + + logger.info(f"Configured {item.identifier}") diff --git a/src/objecttypes/setup_configuration/tests/__init__.py b/src/objecttypes/setup_configuration/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objecttypes/setup_configuration/tests/files/sites/invalid_setup.yaml b/src/objecttypes/setup_configuration/tests/files/sites/invalid_setup.yaml new file mode 100644 index 00000000..c6bda534 --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/files/sites/invalid_setup.yaml @@ -0,0 +1,3 @@ +sites_config_enable: true +sites_config: + items: \ No newline at end of file diff --git a/src/objecttypes/setup_configuration/tests/files/sites/valid_setup.yaml b/src/objecttypes/setup_configuration/tests/files/sites/valid_setup.yaml new file mode 100644 index 00000000..08f60856 --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/files/sites/valid_setup.yaml @@ -0,0 +1,8 @@ +sites_config_enable: true +sites_config: + items: + - domain: example-1.com + name: example-1 + + - domain: example-2.com + name: example-2 diff --git a/src/objecttypes/setup_configuration/tests/files/token_auth/invalid_setup.yaml b/src/objecttypes/setup_configuration/tests/files/token_auth/invalid_setup.yaml new file mode 100644 index 00000000..c4481cdc --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/files/token_auth/invalid_setup.yaml @@ -0,0 +1,3 @@ +tokenauth_config_enable: true +tokenauth: + items: \ No newline at end of file diff --git a/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml b/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml new file mode 100644 index 00000000..fc7d6ead --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml @@ -0,0 +1,18 @@ +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + organization: Organization 1 + application: Application 1 + administration: Administration 1 + + - identifier: token-2 + token: e882642bd0ec2482adcdc97258c2e6f98cb06d85 + contact_person: Person 2 + email: person-2@example.com + organization: Organization 2 + application: Application 2 + administration: Administration 2 diff --git a/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_default.yaml b/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_default.yaml new file mode 100644 index 00000000..40d4c9ad --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/files/token_auth/valid_setup_default.yaml @@ -0,0 +1,12 @@ +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + + - identifier: token-2 + token: e882642bd0ec2482adcdc97258c2e6f98cb06d85 + contact_person: Person 2 + email: person-2@example.com diff --git a/src/objecttypes/setup_configuration/tests/test_sites_config.py b/src/objecttypes/setup_configuration/tests/test_sites_config.py new file mode 100644 index 00000000..a2ee69c1 --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/test_sites_config.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from django.contrib.sites.models import Site +from django.test import TestCase + +from django_setup_configuration.exceptions import PrerequisiteFailed +from django_setup_configuration.test_utils import execute_single_step + +from objecttypes.setup_configuration.steps.sites import SitesConfigurationStep + +DIR_FILES = (Path(__file__).parent / "files/sites").resolve() + + +class SitesConfigurationStepTests(TestCase): + def test_valid_setup_default(self): + self.assertTrue( + Site.objects.filter(domain="example.com", name="example.com").exists() + ) + + execute_single_step( + SitesConfigurationStep, yaml_source=str(DIR_FILES / "valid_setup.yaml") + ) + + sites = Site.objects.all() + self.assertEqual(sites.count(), 3) + self.assertTrue(sites.filter(domain="example-1.com", name="example-1").exists()) + self.assertTrue(sites.filter(domain="example-2.com", name="example-2").exists()) + + def test_valid_update_existing_sites(self): + self.assertTrue( + Site.objects.filter(domain="example.com", name="example.com").exists() + ) + + Site.objects.create(domain="example-2.com", name="example-3") + self.assertEqual(Site.objects.count(), 2) + + execute_single_step( + SitesConfigurationStep, yaml_source=str(DIR_FILES / "valid_setup.yaml") + ) + + sites = Site.objects.all() + self.assertEqual(sites.count(), 3) + self.assertTrue(sites.filter(domain="example-2.com", name="example-2").exists()) + self.assertTrue(sites.filter(domain="example-1.com", name="example-1").exists()) + + def test_invalid_setup(self): + self.assertTrue( + Site.objects.filter(domain="example.com", name="example.com").exists() + ) + + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step( + SitesConfigurationStep, + yaml_source=str(DIR_FILES / "invalid_setup.yaml"), + ) + + self.assertTrue("Input should be a valid list" in str(command_error.exception)) + self.assertEqual(Site.objects.count(), 1) diff --git a/src/objecttypes/setup_configuration/tests/test_token_auth_config.py b/src/objecttypes/setup_configuration/tests/test_token_auth_config.py new file mode 100644 index 00000000..2b494f9d --- /dev/null +++ b/src/objecttypes/setup_configuration/tests/test_token_auth_config.py @@ -0,0 +1,361 @@ +from pathlib import Path + +from django.test import TestCase + +from django_setup_configuration.exceptions import ( + ConfigurationRunFailed, + PrerequisiteFailed, +) +from django_setup_configuration.test_utils import execute_single_step + +from objecttypes.setup_configuration.steps.token_auth import TokenAuthConfigurationStep +from objecttypes.token.models import TokenAuth +from objecttypes.token.tests.factories.token import TokenAuthFactory + +DIR_FILES = (Path(__file__).parent / "files/token_auth").resolve() + + +class TokenAuthConfigurationStepTests(TestCase): + def test_valid_setup_default(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_default.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "") + self.assertEqual(token.application, "") + self.assertEqual(token.administration, "") + + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "") + self.assertEqual(token.application, "") + self.assertEqual(token.administration, "") + + def test_valid_setup_complete(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + # Same as configuration + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "Organization 1") + self.assertEqual(token.application, "Application 1") + self.assertEqual(token.administration, "Administration 1") + + # Token data updated + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "Organization 2") + self.assertEqual(token.application, "Application 2") + self.assertEqual(token.administration, "Administration 2") + + def test_valid_update_existing_tokens(self): + TokenAuthFactory( + identifier="token-1", + token="18b2b74ef994314b84021d47b9422e82b685d82f", + contact_person="Person 1", + email="person-1@example.com", + organization="Organization XYZ", + application="Application XYZ", + administration="Administration XYZ", + ) + + TokenAuthFactory( + identifier="token-2", + token="1cad42916dfa439af8c69000bf7b6af6a66782af", + contact_person="Person 3", + email="person-3@example.com", + ) + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + # Same as configuration + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "Organization 1") + self.assertEqual(token.application, "Application 1") + self.assertEqual(token.administration, "Administration 1") + + # Token data updated + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "Organization 2") + self.assertEqual(token.application, "Application 2") + self.assertEqual(token.administration, "Administration 2") + + self.assertNotEqual(token.token, "1cad42916dfa439af8c69000bf7b6af6a66782af") + self.assertNotEqual(token.contact_person, "Person 3") + self.assertNotEqual(token.email, "person-3@example.com") + + def test_valid_idempotent_step(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + old_token_a = tokens.get(identifier="token-1") + self.assertEqual(old_token_a.identifier, "token-1") + self.assertEqual(old_token_a.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(old_token_a.contact_person, "Person 1") + self.assertEqual(old_token_a.email, "person-1@example.com") + self.assertEqual(old_token_a.organization, "Organization 1") + self.assertEqual(old_token_a.application, "Application 1") + self.assertEqual(old_token_a.administration, "Administration 1") + + old_token_b = tokens.get(identifier="token-2") + self.assertEqual(old_token_b.identifier, "token-2") + self.assertEqual(old_token_b.contact_person, "Person 2") + self.assertEqual(old_token_b.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(old_token_b.email, "person-2@example.com") + self.assertEqual(old_token_b.organization, "Organization 2") + self.assertEqual(old_token_b.application, "Application 2") + self.assertEqual(old_token_b.administration, "Administration 2") + + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + new_token_a = tokens.get(identifier="token-1") + self.assertEqual(new_token_a.identifier, old_token_a.identifier) + self.assertEqual(new_token_a.token, old_token_a.token) + self.assertEqual(new_token_a.contact_person, old_token_a.contact_person) + self.assertEqual(new_token_a.email, old_token_a.email) + self.assertEqual(new_token_a.organization, old_token_a.organization) + self.assertEqual(new_token_a.application, old_token_a.application) + self.assertEqual(new_token_a.administration, old_token_a.administration) + + new_token_b = tokens.get(identifier="token-2") + self.assertEqual(new_token_b.identifier, old_token_b.identifier) + self.assertEqual(new_token_b.contact_person, old_token_b.contact_person) + self.assertEqual(new_token_b.token, old_token_b.token) + self.assertEqual(new_token_b.email, old_token_b.email) + self.assertEqual(new_token_b.organization, old_token_b.organization) + self.assertEqual(new_token_b.application, old_token_b.application) + self.assertEqual(new_token_b.administration, old_token_b.administration) + + def test_invalid_setup(self): + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "invalid_setup.yaml"), + ) + + self.assertTrue("Input should be a valid list" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_email(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "Person 1", + "email": "invalid", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "invalid token", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_empty_token(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token_missing(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue("Field required" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token_unique(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "contact_person": "Person 1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + { + "identifier": "token-2", + "contact_person": "Person 2", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "email": "person-2@example.com", + "organization": "Organization 2", + "application": "Application 2", + "administration": "Administration 2", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Failed configuring token token-2" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 1) + + def test_invalid_setup_contact_person(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_identifier(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "invalid identifier", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for invalid identifier" + in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) diff --git a/src/objecttypes/tests/commands/test_setup_configuration.py b/src/objecttypes/tests/commands/test_setup_configuration.py deleted file mode 100644 index ff05edeb..00000000 --- a/src/objecttypes/tests/commands/test_setup_configuration.py +++ /dev/null @@ -1,102 +0,0 @@ -from io import StringIO - -from django.contrib.sites.models import Site -from django.core.management import CommandError, call_command -from django.test import TestCase, override_settings -from django.urls import reverse - -import requests_mock -from rest_framework import status - -from objecttypes.config.demo import DemoUserStep -from objecttypes.config.objects import ObjectsAuthStep -from objecttypes.config.site import SiteConfigurationStep - - -@override_settings( - SITES_CONFIG_ENABLE=True, - OBJECTTYPES_DOMAIN="objecttypes.example.com", - OBJECTTYPES_ORGANIZATION="ACME", - OBJECTS_OBJECTTYPES_CONFIG_ENABLE=True, - OBJECTS_OBJECTTYPES_TOKEN="some-random-string", - OBJECTS_OBJECTTYPES_PERSON="Some Person", - OBJECTS_OBJECTTYPES_EMAIL="objects@objects.local", - DEMO_CONFIG_ENABLE=True, - DEMO_TOKEN="demo-random-string", - DEMO_PERSON="Demo", - DEMO_EMAIL="demo@demo.local", -) -class SetupConfigurationTests(TestCase): - def setUp(self): - super().setUp() - - self.addCleanup(Site.objects.clear_cache) - - @requests_mock.Mocker() - def test_setup_configuration(self, m): - stdout = StringIO() - # mocks - m.get("http://objecttypes.example.com/", status_code=200) - m.get("http://objecttypes.example.com/api/v2/objecttypes", json=[]) - - call_command("setup_configuration", stdout=stdout) - - with self.subTest("Command output"): - command_output = stdout.getvalue().splitlines() - expected_output = [ - f"Configuration will be set up with following steps: [{SiteConfigurationStep()}, " - f"{ObjectsAuthStep()}, {DemoUserStep()}]", - f"Configuring {SiteConfigurationStep()}...", - f"{SiteConfigurationStep()} is successfully configured", - f"Configuring {ObjectsAuthStep()}...", - f"{ObjectsAuthStep()} is successfully configured", - f"Configuring {DemoUserStep()}...", - f"{DemoUserStep()} is successfully configured", - "Instance configuration completed.", - ] - - self.assertEqual(command_output, expected_output) - - with self.subTest("Site configured correctly"): - site = Site.objects.get_current() - self.assertEqual(site.domain, "objecttypes.example.com") - self.assertEqual(site.name, "Objecttypes ACME") - - with self.subTest("Objects can query Objecttypes API"): - response = self.client.get( - reverse("v2:objecttype-list"), - HTTP_AUTHORIZATION="Token some-random-string", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - with self.subTest("Demo user configured correctly"): - response = self.client.get( - reverse("v2:objecttype-list"), - HTTP_AUTHORIZATION="Token demo-random-string", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @requests_mock.Mocker() - def test_setup_configuration_selftest_fails(self, m): - m.get("http://objecttypes.example.com/", status_code=200) - m.get("http://objecttypes.example.com/api/v2/objecttypes", status_code=500) - - with self.assertRaisesMessage( - CommandError, - "Configuration test failed with errors: " - "Objects API Authentication Configuration: " - "Could not list objecttypes for the configured token", - ): - call_command("setup_configuration") - - @requests_mock.Mocker() - def test_setup_configuration_without_selftest(self, m): - stdout = StringIO() - - call_command("setup_configuration", no_selftest=True, stdout=stdout) - command_output = stdout.getvalue() - - self.assertEqual(len(m.request_history), 0) - self.assertTrue("Selftest is skipped" in command_output) diff --git a/src/objecttypes/tests/config/test_demo_configuration.py b/src/objecttypes/tests/config/test_demo_configuration.py deleted file mode 100644 index 952cfdb5..00000000 --- a/src/objecttypes/tests/config/test_demo_configuration.py +++ /dev/null @@ -1,72 +0,0 @@ -from unittest.mock import patch - -from django.test import TestCase, override_settings - -import requests -import requests_mock -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.config.demo import DemoUserStep -from objecttypes.token.models import TokenAuth - - -@override_settings( - DEMO_TOKEN="demo-random-string", DEMO_PERSON="Demo", DEMO_EMAIL="demo@demo.local" -) -class DemoConfigurationTests(TestCase): - def test_configure(self): - configuration = DemoUserStep() - - configuration.configure() - - token_auth = TokenAuth.objects.get() - self.assertEqual(token_auth.token, "demo-random-string") - self.assertEqual(token_auth.contact_person, "Demo") - self.assertEqual(token_auth.email, "demo@demo.local") - - @requests_mock.Mocker() - @patch( - "objecttypes.config.demo.build_absolute_url", - return_value="http://testserver/objecttypes", - ) - def test_configuration_check_ok(self, m, *mocks): - configuration = DemoUserStep() - configuration.configure() - m.get("http://testserver/objecttypes", json=[]) - - configuration.test_configuration() - - self.assertEqual(m.last_request.url, "http://testserver/objecttypes") - self.assertEqual(m.last_request.method, "GET") - - @requests_mock.Mocker() - @patch( - "objecttypes.config.demo.build_absolute_url", - return_value="http://testserver/objecttypes", - ) - def test_configuration_check_failures(self, m, *mocks): - configuration = DemoUserStep() - configuration.configure() - - mock_kwargs = ( - {"exc": requests.ConnectTimeout}, - {"exc": requests.ConnectionError}, - {"status_code": 404}, - {"status_code": 403}, - {"status_code": 500}, - ) - for mock_config in mock_kwargs: - with self.subTest(mock=mock_config): - m.get("http://testserver/objecttypes", **mock_config) - - with self.assertRaises(SelfTestFailed): - configuration.test_configuration() - - def test_is_configured(self): - configuration = DemoUserStep() - - self.assertFalse(configuration.is_configured()) - - configuration.configure() - - self.assertTrue(configuration.is_configured()) diff --git a/src/objecttypes/tests/config/test_objects_configuration.py b/src/objecttypes/tests/config/test_objects_configuration.py deleted file mode 100644 index 044680c7..00000000 --- a/src/objecttypes/tests/config/test_objects_configuration.py +++ /dev/null @@ -1,72 +0,0 @@ -from unittest.mock import patch - -from django.test import TestCase, override_settings - -import requests -import requests_mock -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.config.objects import ObjectsAuthStep -from objecttypes.token.models import TokenAuth - - -@override_settings( - OBJECTS_OBJECTTYPES_TOKEN="some-random-string", - OBJECTS_OBJECTTYPES_PERSON="Some Person", - OBJECTS_OBJECTTYPES_EMAIL="objects@objects.local", -) -class ObjectsConfigurationTests(TestCase): - def test_configure(self): - configuration = ObjectsAuthStep() - - configuration.configure() - - token_auth = TokenAuth.objects.get(token="some-random-string") - self.assertEqual(token_auth.contact_person, "Some Person") - self.assertEqual(token_auth.email, "objects@objects.local") - - @requests_mock.Mocker() - @patch( - "objecttypes.config.objects.build_absolute_url", - return_value="http://testserver/objecttypes", - ) - def test_selftest_ok(self, m, *mocks): - configuration = ObjectsAuthStep() - configuration.configure() - m.get("http://testserver/objecttypes", json=[]) - - configuration.test_configuration() - - self.assertEqual(m.last_request.url, "http://testserver/objecttypes") - self.assertEqual(m.last_request.method, "GET") - - @requests_mock.Mocker() - @patch( - "objecttypes.config.objects.build_absolute_url", - return_value="http://testserver/objecttypes", - ) - def test_selftest_fail(self, m, *mocks): - configuration = ObjectsAuthStep() - configuration.configure() - - mock_kwargs = ( - {"exc": requests.ConnectTimeout}, - {"exc": requests.ConnectionError}, - {"status_code": 404}, - {"status_code": 403}, - {"status_code": 500}, - ) - for mock_config in mock_kwargs: - with self.subTest(mock=mock_config): - m.get("http://testserver/objecttypes", **mock_config) - - with self.assertRaises(SelfTestFailed): - configuration.test_configuration() - - def test_is_configured(self): - configuration = ObjectsAuthStep() - self.assertFalse(configuration.is_configured()) - - configuration.configure() - - self.assertTrue(configuration.is_configured()) diff --git a/src/objecttypes/tests/config/test_site_configuration.py b/src/objecttypes/tests/config/test_site_configuration.py deleted file mode 100644 index 4dccee19..00000000 --- a/src/objecttypes/tests/config/test_site_configuration.py +++ /dev/null @@ -1,66 +0,0 @@ -from django.contrib.sites.models import Site -from django.test import TestCase, override_settings - -import requests -import requests_mock -from django_setup_configuration.exceptions import SelfTestFailed - -from objecttypes.config.site import SiteConfigurationStep - - -@override_settings( - OBJECTTYPES_DOMAIN="localhost:8000", - OBJECTTYPES_ORGANIZATION="ACME", -) -class SiteConfigurationTests(TestCase): - def setUp(self): - super().setUp() - - self.addCleanup(Site.objects.clear_cache) - - def test_set_domain(self): - configuration = SiteConfigurationStep() - configuration.configure() - - site = Site.objects.get_current() - self.assertEqual(site.domain, "localhost:8000") - self.assertEqual(site.name, "Objecttypes ACME") - - @requests_mock.Mocker() - def test_configuration_check_ok(self, m): - m.get("http://localhost:8000/", status_code=200) - configuration = SiteConfigurationStep() - configuration.configure() - - configuration.test_configuration() - - self.assertEqual(m.last_request.url, "http://localhost:8000/") - self.assertEqual(m.last_request.method, "GET") - - @requests_mock.Mocker() - def test_configuration_check_failures(self, m): - configuration = SiteConfigurationStep() - configuration.configure() - - mock_kwargs = ( - {"exc": requests.ConnectTimeout}, - {"exc": requests.ConnectionError}, - {"status_code": 404}, - {"status_code": 403}, - {"status_code": 500}, - ) - for mock_config in mock_kwargs: - with self.subTest(mock=mock_config): - m.get("http://localhost:8000/", **mock_config) - - with self.assertRaises(SelfTestFailed): - configuration.test_configuration() - - def test_is_configured(self): - configuration = SiteConfigurationStep() - - self.assertFalse(configuration.is_configured()) - - configuration.configure() - - self.assertTrue(configuration.is_configured()) diff --git a/src/objecttypes/token/admin.py b/src/objecttypes/token/admin.py index 968c9477..7ca9e990 100644 --- a/src/objecttypes/token/admin.py +++ b/src/objecttypes/token/admin.py @@ -5,11 +5,11 @@ @admin.register(TokenAuth) class TokenAuthAdmin(admin.ModelAdmin): - readonly_fields = ("token",) list_display = ( - "token", + "identifier", "contact_person", "organization", "administration", "application", ) + readonly_fields = ("token",) diff --git a/src/objecttypes/token/migrations/0009_tokenauth_identifier_alter_tokenauth_token.py b/src/objecttypes/token/migrations/0009_tokenauth_identifier_alter_tokenauth_token.py new file mode 100644 index 00000000..6a67bd8d --- /dev/null +++ b/src/objecttypes/token/migrations/0009_tokenauth_identifier_alter_tokenauth_token.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.15 on 2024-12-05 15:26 + +import logging + +from django.db import migrations, models +from django.db.migrations.state import StateApps + +import objecttypes.token.validators + +logger = logging.getLogger(__name__) + + +def _generate_unique_identifiers(apps: StateApps, schema_editor) -> None: + TokenAuth = apps.get_model("token", "TokenAuth") + + count = 1 + + for token in TokenAuth.objects.filter(identifier__isnull=True): + while TokenAuth.objects.filter(identifier=f"token-{count}").exists(): + count += 1 + + identifier = f"token-{count}" + logger.debug(f"Generated {identifier} for token {token.pk}") + + token.identifier = identifier + token.save(update_fields=("identifier",)) + + +class Migration(migrations.Migration): + dependencies = [ + ("token", "0008_alter_tokenauth_token"), + ] + + operations = [ + migrations.AddField( + model_name="tokenauth", + name="identifier", + field=models.CharField(blank=True, null=True), + ), + migrations.RunPython( + code=_generate_unique_identifiers, + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="tokenauth", + name="identifier", + field=models.SlugField(unique=True), + ), + migrations.AlterField( + model_name="tokenauth", + name="identifier", + field=models.SlugField( + help_text="A human-friendly label to refer to this token", unique=True + ), + ), + migrations.AlterField( + model_name="tokenauth", + name="token", + field=models.CharField( + max_length=40, + unique=True, + validators=[ + objecttypes.token.validators.validate_no_empty, + objecttypes.token.validators.validate_no_whitespace, + ], + verbose_name="token", + ), + ), + ] diff --git a/src/objecttypes/token/models.py b/src/objecttypes/token/models.py index 6b2bcea0..c6220f34 100644 --- a/src/objecttypes/token/models.py +++ b/src/objecttypes/token/models.py @@ -1,19 +1,32 @@ -import binascii -import os +import secrets from django.db import models from django.utils.translation import gettext_lazy as _ +from objecttypes.token.validators import validate_no_empty, validate_no_whitespace + class TokenAuth(models.Model): - token = models.CharField(_("token"), max_length=40, unique=True) + identifier = models.SlugField( + unique=True, + help_text=_("A human-friendly label to refer to this token"), + ) + + token = models.CharField( + _("token"), + max_length=40, + unique=True, + validators=[validate_no_empty, validate_no_whitespace], + ) + contact_person = models.CharField( _("contact person"), max_length=200, help_text=_("Name of the person in the organization who can access the API"), ) email = models.EmailField( - _("email"), help_text=_("Email of the person, who can access the API") + _("email"), + help_text=_("Email of the person, who can access the API"), ) organization = models.CharField( _("organization"), @@ -27,7 +40,9 @@ class TokenAuth(models.Model): help_text=_("Last date when the token was modified"), ) created = models.DateTimeField( - _("created"), auto_now_add=True, help_text=_("Date when the token was created") + _("created"), + auto_now_add=True, + help_text=_("Date when the token was created"), ) application = models.CharField( _("application"), @@ -52,4 +67,4 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def generate_token(self): - return binascii.hexlify(os.urandom(20)).decode() + return secrets.token_hex(20) diff --git a/src/objecttypes/token/tests/__init__.py b/src/objecttypes/token/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objecttypes/token/tests/factories/__init__.py b/src/objecttypes/token/tests/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objecttypes/token/tests/factories/token.py b/src/objecttypes/token/tests/factories/token.py new file mode 100644 index 00000000..7e150ab7 --- /dev/null +++ b/src/objecttypes/token/tests/factories/token.py @@ -0,0 +1,12 @@ +import factory + +from objecttypes.token.models import TokenAuth + + +class TokenAuthFactory(factory.django.DjangoModelFactory): + identifier = factory.Sequence(lambda sequence: f"token-{sequence}") + contact_person = factory.Faker("name") + email = factory.Faker("email") + + class Meta: + model = TokenAuth diff --git a/src/objecttypes/token/tests/test_authenticaton.py b/src/objecttypes/token/tests/test_authenticaton.py new file mode 100644 index 00000000..b6c39305 --- /dev/null +++ b/src/objecttypes/token/tests/test_authenticaton.py @@ -0,0 +1,63 @@ +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APITestCase + +from objecttypes.token.models import TokenAuth + + +class TestTokenAuthAuthorization(APITestCase): + def test_valid_token(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_valid_token_with_no_spaces(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + token="1234-Token-5678", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_invalid_token_with_spaces(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + token="1234 Token 5678", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_invalid_token_existing(self): + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION="Token 1234-Token-5678", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_empty_token(self): + response = self.client.get( + reverse("v2:objecttype-list"), HTTP_AUTHORIZATION="Token " + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_non_token(self): + response = self.client.get(reverse("v2:objecttype-list")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/src/objecttypes/token/tests/test_migrations.py b/src/objecttypes/token/tests/test_migrations.py new file mode 100644 index 00000000..0597b317 --- /dev/null +++ b/src/objecttypes/token/tests/test_migrations.py @@ -0,0 +1,88 @@ +from django.core.management import call_command +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.db.migrations.state import StateApps +from django.test import TransactionTestCase + + +class BaseMigrationTest(TransactionTestCase): + app: str + migrate_from: str # The migration before the one we want to test + migrate_to: str # The migration we want to test + + setting_overrides: dict = {} + + old_app_state: StateApps + app_state: StateApps + + def setUp(self) -> None: + """ + Setup the migration test by reversing to `migrate_from` state, + then applying the `migrate_to` state. + """ + assert self.app is not None, "You must define the `app` attribute" + assert self.migrate_from is not None, "You must define `migrate_from`" + assert self.migrate_to is not None, "You must define `migrate_to`" + + # Step 1: Set up the MigrationExecutor + executor = MigrationExecutor(connection) + + # Step 2: Reverse to the starting migration state + migrate_from = [(self.app, self.migrate_from)] + old_migrate_state = executor.migrate(migrate_from) + + self.old_app_state = old_migrate_state.apps + + def _perform_migration(self) -> None: + migrate_to = [(self.app, self.migrate_to)] + + executor = MigrationExecutor(connection) + executor.loader.build_graph() # reload the graph in case of dependency changes + executor.migrate(migrate_to) + + self.apps = executor.loader.project_state(migrate_to).apps + + @classmethod + def tearDownClass(cls) -> None: + super().tearDownClass() + + # reset to latest migration + call_command("migrate", verbosity=0, database=connection._alias) + + +class TestTokenAuthUniqueness(BaseMigrationTest): + app = "token" + migrate_from = "0008_alter_tokenauth_token" + migrate_to = "0009_tokenauth_identifier_alter_tokenauth_token" + + def test_migrate_tokens_check_attr(self): + TokenAuth = self.old_app_state.get_model("token", "TokenAuth") + self.assertFalse(hasattr(TokenAuth, "identifier")) + + self._perform_migration() + + TokenAuth = self.apps.get_model("token", "TokenAuth") + self.assertTrue(hasattr(TokenAuth, "identifier")) + + def test_migrate_tokens_to_unique_identifiers(self): + TokenAuth = self.old_app_state.get_model("token", "TokenAuth") + TokenAuth.objects.create( + token="aa018d1c576c9dae33be1e549f739f2834ebc811", + contact_person="Person 1", + email="test@example.com", + ) + TokenAuth.objects.create( + token="ab700d6bf906c2b4b42a961c529657314c6a8246", + contact_person="Other person", + email="somebody@else.com", + ) + + self._perform_migration() + + TokenAuth = self.apps.get_model("token", "TokenAuth") + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + first_token = tokens.get(token="aa018d1c576c9dae33be1e549f739f2834ebc811") + second_token = tokens.get(token="ab700d6bf906c2b4b42a961c529657314c6a8246") + self.assertNotEqual(first_token.identifier, second_token.identifier) diff --git a/src/objecttypes/token/tests/test_validators.py b/src/objecttypes/token/tests/test_validators.py new file mode 100644 index 00000000..c1125835 --- /dev/null +++ b/src/objecttypes/token/tests/test_validators.py @@ -0,0 +1,54 @@ +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase + +from objecttypes.token.validators import validate_no_empty, validate_no_whitespace + + +class NoEmptyValidatorTestCase(SimpleTestCase): + def test_valid(self): + self.assertIsNone(validate_no_empty("test123")) + + def test_invalid_string(self): + with self.assertRaises(ValidationError): + validate_no_empty("") + + def test_invalid_none(self): + with self.assertRaises(ValidationError): + validate_no_empty(None) + + +class WhiteSpaceValidatorTestCase(SimpleTestCase): + def test_characters_only(self): + self.assertIsNone(validate_no_whitespace("test123")) + + def test_trailing_whitespace(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test123 ") + + def test_leading_whitespace(self): + with self.assertRaises(ValidationError): + validate_no_whitespace(" test123") + + def test_whitespace_in_between(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test 123") + + def test_whitespace_only(self): + with self.assertRaises(ValidationError): + validate_no_whitespace(" ") + + def test_trailing_tab_character(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test123\t") + + def test_leading_tab_character(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("\ttest123") + + def test_tab_character_in_between(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test\t123") + + def test_tab_characters_only(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("\t\t") diff --git a/src/objecttypes/token/validators.py b/src/objecttypes/token/validators.py new file mode 100644 index 00000000..21d23277 --- /dev/null +++ b/src/objecttypes/token/validators.py @@ -0,0 +1,20 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +# includes tabs, carriage returns, newlines, form-feeds and vertical whitespace characters +WHITESPACE_PATTERN = re.compile(r".*\s.*") + + +def validate_no_whitespace(value: str) -> None: + if WHITESPACE_PATTERN.match(value): + raise ValidationError( + code="all-whitespace", + message=_("Tokens cannot contain whitespace-like characters"), + ) + + +def validate_no_empty(value: str) -> None: + if not value: + raise ValidationError(code="invalid", message=_("Blank values are not allowed"))