diff --git a/addon_service/external_service/__init__.py b/addon_service/credentials_issuer/__init__.py similarity index 100% rename from addon_service/external_service/__init__.py rename to addon_service/credentials_issuer/__init__.py diff --git a/addon_service/external_service/models.py b/addon_service/credentials_issuer/models.py similarity index 86% rename from addon_service/external_service/models.py rename to addon_service/credentials_issuer/models.py index 9c5a3625..2a8a0ac3 100644 --- a/addon_service/external_service/models.py +++ b/addon_service/credentials_issuer/models.py @@ -4,7 +4,7 @@ # TODO: consider another name -class ExternalService(AddonsServiceBaseModel): +class CredentialsIssuer(AddonsServiceBaseModel): name = models.CharField(null=False) class Meta: diff --git a/addon_service/external_account/models.py b/addon_service/external_account/models.py index 68497de1..4bfbf661 100644 --- a/addon_service/external_account/models.py +++ b/addon_service/external_account/models.py @@ -8,8 +8,8 @@ class ExternalAccount(AddonsServiceBaseModel): remote_account_id = models.CharField() remote_account_display_name = models.CharField() - external_service = models.ForeignKey( - "addon_service.ExternalService", + credentials_issuer = models.ForeignKey( + "addon_service.CredentialsIssuer", on_delete=models.CASCADE, related_name="external_accounts", ) diff --git a/addon_service/external_storage_service/models.py b/addon_service/external_storage_service/models.py index 8a2dee1b..3972adef 100644 --- a/addon_service/external_storage_service/models.py +++ b/addon_service/external_storage_service/models.py @@ -9,8 +9,8 @@ class ExternalStorageService(AddonsServiceBaseModel): auth_uri = models.URLField(null=False) - external_service = models.ForeignKey( - "addon_service.ExternalService", + credentials_issuer = models.ForeignKey( + "addon_service.CredentialsIssuer", on_delete=models.CASCADE, related_name="external_storage_services", ) diff --git a/addon_service/internal_resource/serializers.py b/addon_service/internal_resource/serializers.py index 035097d7..7fda82c5 100644 --- a/addon_service/internal_resource/serializers.py +++ b/addon_service/internal_resource/serializers.py @@ -18,7 +18,6 @@ class InternalResourceSerializer(serializers.HyperlinkedModelSerializer): queryset=ConfiguredStorageAddon.objects.all(), related_link_view_name=f"{RESOURCE_NAME}-related", ) - included_serializers = { "configured_storage_addons": ( "addon_service.serializers.ConfiguredStorageAddonSerializer" diff --git a/addon_service/internal_resource/views.py b/addon_service/internal_resource/views.py index 040f946c..0eba9bd8 100644 --- a/addon_service/internal_resource/views.py +++ b/addon_service/internal_resource/views.py @@ -1,10 +1,10 @@ -from rest_framework_json_api.views import ModelViewSet +from rest_framework_json_api.views import ReadOnlyModelViewSet from .models import InternalResource from .serializers import InternalResourceSerializer -class InternalResourceViewSet(ModelViewSet): # TODO: read-only +class InternalResourceViewSet(ReadOnlyModelViewSet): queryset = InternalResource.objects.all() serializer_class = InternalResourceSerializer # TODO: permissions_classes diff --git a/addon_service/internal_user/views.py b/addon_service/internal_user/views.py index 387cdae7..5ff85420 100644 --- a/addon_service/internal_user/views.py +++ b/addon_service/internal_user/views.py @@ -1,10 +1,10 @@ -from rest_framework_json_api.views import ModelViewSet +from rest_framework_json_api.views import ReadOnlyModelViewSet from .models import InternalUser from .serializers import InternalUserSerializer -class InternalUserViewSet(ModelViewSet): # TODO: read-only +class InternalUserViewSet(ReadOnlyModelViewSet): queryset = InternalUser.objects.all() serializer_class = InternalUserSerializer # TODO: permissions_classes diff --git a/addon_service/management/commands/fill_garbage.py b/addon_service/management/commands/fill_garbage.py index 8a58c108..9aa7e8c4 100644 --- a/addon_service/management/commands/fill_garbage.py +++ b/addon_service/management/commands/fill_garbage.py @@ -13,12 +13,12 @@ class Command(LabelCommand): def handle_label(self, label, **options): if not settings.DEBUG: raise Exception("must have DEBUG set to eat garbage") - _es = db.ExternalService.objects.create(name=f"entity-{label}") + _ci = db.CredentialsIssuer.objects.create(name=f"entity-{label}") _ess = db.ExternalStorageService.objects.create( max_concurrent_downloads=2, max_upload_mb=2, auth_uri=f"http://foo.example/{label}", - external_service=_es, + credentials_issuer=_ci, ) for _i in range(3): _iu, _ = db.InternalUser.objects.get_or_create( @@ -28,7 +28,7 @@ def handle_label(self, label, **options): _ea = db.ExternalAccount.objects.create( remote_account_id=label, remote_account_display_name=label, - external_service=_es, + credentials_issuer=_ci, owner=_iu, credentials=_ec, ) diff --git a/addon_service/migrations/0001_initial.py b/addon_service/migrations/0001_initial.py index 5639ad8c..66b8547f 100644 --- a/addon_service/migrations/0001_initial.py +++ b/addon_service/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-11-28 19:41 +# Generated by Django 4.2.7 on 2023-12-11 20:02 import django.db.models.deletion from django.db import ( @@ -35,7 +35,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="ExternalCredentials", + name="CredentialsIssuer", fields=[ ( "id", @@ -48,19 +48,15 @@ class Migration(migrations.Migration): ), ("created", models.DateTimeField(editable=False)), ("modified", models.DateTimeField()), - ("oauth_key", models.CharField(blank=True, null=True)), - ("oauth_secret", models.CharField(blank=True, null=True)), - ("refresh_token", models.CharField(blank=True, null=True)), - ("date_last_refreshed", models.DateTimeField(blank=True, null=True)), - ("expires_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField()), ], options={ - "verbose_name": "External Credentials", - "verbose_name_plural": "External Credentials", + "verbose_name": "External Service", + "verbose_name_plural": "External Services", }, ), migrations.CreateModel( - name="ExternalService", + name="ExternalCredentials", fields=[ ( "id", @@ -73,11 +69,15 @@ class Migration(migrations.Migration): ), ("created", models.DateTimeField(editable=False)), ("modified", models.DateTimeField()), - ("name", models.CharField()), + ("oauth_key", models.CharField(blank=True, null=True)), + ("oauth_secret", models.CharField(blank=True, null=True)), + ("refresh_token", models.CharField(blank=True, null=True)), + ("date_last_refreshed", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), ], options={ - "verbose_name": "External Service", - "verbose_name_plural": "External Services", + "verbose_name": "External Credentials", + "verbose_name_plural": "External Credentials", }, ), migrations.CreateModel( @@ -140,10 +140,11 @@ class Migration(migrations.Migration): ("max_upload_mb", models.IntegerField()), ("auth_uri", models.URLField()), ( - "external_service", + "credentials_issuer", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to="addon_service.externalservice", + related_name="external_storage_services", + to="addon_service.credentialsissuer", ), ), ], @@ -172,20 +173,23 @@ class Migration(migrations.Migration): "credentials", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="external_accounts", to="addon_service.externalcredentials", ), ), ( - "external_service", + "credentials_issuer", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to="addon_service.externalservice", + related_name="external_accounts", + to="addon_service.credentialsissuer", ), ), ( "owner", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="external_accounts", to="addon_service.internaluser", ), ), @@ -214,6 +218,7 @@ class Migration(migrations.Migration): "authorized_storage_account", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="configured_storage_addons", to="addon_service.authorizedstorageaccount", ), ), @@ -236,6 +241,7 @@ class Migration(migrations.Migration): name="external_account", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="authorized_storage_accounts", to="addon_service.externalaccount", ), ), @@ -244,6 +250,7 @@ class Migration(migrations.Migration): name="external_storage_service", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="authorized_storage_accounts", to="addon_service.externalstorageservice", ), ), diff --git a/addon_service/models.py b/addon_service/models.py index c4dd90c3..63f93ff7 100644 --- a/addon_service/models.py +++ b/addon_service/models.py @@ -1,9 +1,9 @@ """ Import models here so they auto-detect for makemigrations """ from addon_service.authorized_storage_account.models import AuthorizedStorageAccount from addon_service.configured_storage_addon.models import ConfiguredStorageAddon +from addon_service.credentials_issuer.models import CredentialsIssuer from addon_service.external_account.models import ExternalAccount from addon_service.external_credentials.models import ExternalCredentials -from addon_service.external_service.models import ExternalService from addon_service.external_storage_service.models import ExternalStorageService from addon_service.internal_resource.models import InternalResource from addon_service.internal_user.models import InternalUser @@ -14,9 +14,9 @@ # 'AuthorizedComputeAccount', "ConfiguredStorageAddon", # 'ConfiguredComputeAddon', + "CredentialsIssuer", "ExternalAccount", "ExternalCredentials", - "ExternalService", "ExternalStorageService", # 'ExternalComputeService', "InternalResource", diff --git a/addon_service/tests/_factories.py b/addon_service/tests/_factories.py new file mode 100644 index 00000000..1be76fa4 --- /dev/null +++ b/addon_service/tests/_factories.py @@ -0,0 +1,74 @@ +import factory +from factory.django import DjangoModelFactory + +from addon_service import models as db + + +class InternalUserFactory(DjangoModelFactory): + class Meta: + model = db.InternalUser + + user_uri = factory.Sequence(lambda n: f"http://osf.example/user{n}") + + +class InternalResourceFactory(DjangoModelFactory): + class Meta: + model = db.InternalResource + + resource_uri = factory.Sequence(lambda n: f"http://osf.example/thing{n}") + + +class CredentialsIssuerFactory(DjangoModelFactory): + class Meta: + model = db.CredentialsIssuer + + +class ExternalCredentialsFactory(DjangoModelFactory): + class Meta: + model = db.ExternalCredentials + + +class ExternalAccountFactory(DjangoModelFactory): + class Meta: + model = db.ExternalAccount + + remote_account_id = factory.Faker("word") + remote_account_display_name = factory.Faker("word") + + credentials_issuer = factory.SubFactory(CredentialsIssuerFactory) + owner = factory.SubFactory(InternalUserFactory) + credentials = factory.SubFactory(ExternalCredentialsFactory) + + +### +# "Storage" models + + +class ExternalStorageServiceFactory(DjangoModelFactory): + class Meta: + model = db.ExternalStorageService + + max_concurrent_downloads = factory.Faker("pyint") + max_upload_mb = factory.Faker("pyint") + auth_uri = factory.Sequence(lambda n: f"http://auth.example/{n}") + credentials_issuer = factory.SubFactory(CredentialsIssuerFactory) + + +class AuthorizedStorageAccountFactory(DjangoModelFactory): + class Meta: + model = db.AuthorizedStorageAccount + + default_root_folder = "/" + external_storage_service = factory.SubFactory(ExternalStorageServiceFactory) + external_account = factory.SubFactory(ExternalAccountFactory) + # TODO: external_account.credentials_issuer same as + # external_storage_service.credentials_issuer + + +class ConfiguredStorageAddonFactory(DjangoModelFactory): + class Meta: + model = db.ConfiguredStorageAddon + + root_folder = "/" + authorized_storage_account = factory.SubFactory(AuthorizedStorageAccountFactory) + internal_resource = factory.SubFactory(InternalResourceFactory) diff --git a/addon_service/tests/_helpers.py b/addon_service/tests/_helpers.py new file mode 100644 index 00000000..c2f75f57 --- /dev/null +++ b/addon_service/tests/_helpers.py @@ -0,0 +1,12 @@ +from rest_framework.test import ( + APIRequestFactory, + force_authenticate, +) + + +def get_test_request(user=None, method="get", path=""): + _factory_method = getattr(APIRequestFactory(), method) + _request = _factory_method(path) # note that path is optional for view tests + if user is not None: + force_authenticate(_request, user=user) + return _request diff --git a/addon_service/tests/factories.py b/addon_service/tests/factories.py deleted file mode 100644 index b3c43ebf..00000000 --- a/addon_service/tests/factories.py +++ /dev/null @@ -1,8 +0,0 @@ -from factory.django import DjangoModelFactory - -from addon_service.internal_user.models import InternalUser - - -class InternalUserFactory(DjangoModelFactory): - class Meta: - model = InternalUser diff --git a/addon_service/tests/test_authorized_storage_account.py b/addon_service/tests/test_authorized_storage_account.py new file mode 100644 index 00000000..2780f46b --- /dev/null +++ b/addon_service/tests/test_authorized_storage_account.py @@ -0,0 +1,172 @@ +import json +import unittest +from http import HTTPStatus + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.authorized_storage_account.views import ( + AuthorizedStorageAccountViewSet, +) +from addon_service.tests import _factories +from addon_service.tests._helpers import get_test_request + + +# smoke-test api +class TestAuthorizedStorageAccountAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._asa = _factories.AuthorizedStorageAccountFactory() + + @property + def _detail_path(self): + return reverse( + "authorized-storage-accounts-detail", + kwargs={"pk": self._asa.pk}, + ) + + @property + def _list_path(self): + return reverse("authorized-storage-accounts-list") + + def _related_path(self, related_field): + return reverse( + "authorized-storage-accounts-related", + kwargs={ + "pk": self._asa.pk, + "related_field": related_field, + }, + ) + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual( + _resp.data["default_root_folder"], + self._asa.default_root_folder, + ) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"post"}, + # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, + self._related_path("account_owner"): {"patch", "put", "post"}, + self._related_path("external_storage_service"): {"patch", "put", "post"}, + self._related_path("configured_storage_addons"): {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +# unit-test data model +class TestAuthorizedStorageAccountModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._asa = _factories.AuthorizedStorageAccountFactory() + + def test_can_load(self): + _resource_from_db = db.AuthorizedStorageAccount.objects.get(id=self._asa.id) + self.assertEqual(self._asa.pk, _resource_from_db.pk) + + def test_configured_storage_addons__empty(self): + self.assertEqual( + list(self._asa.configured_storage_addons.all()), + [], + ) + + def test_configured_storage_addons__several(self): + _accounts = set( + _factories.ConfiguredStorageAddonFactory.create_batch( + size=3, + authorized_storage_account=self._asa, + ) + ) + self.assertEqual( + set(self._asa.configured_storage_addons.all()), + _accounts, + ) + + +# unit-test viewset (call the view with test requests) +class TestAuthorizedStorageAccountViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._asa = _factories.AuthorizedStorageAccountFactory() + cls._view = AuthorizedStorageAccountViewSet.as_view({"get": "retrieve"}) + + def test_get(self): + _resp = self._view( + get_test_request(), + pk=self._asa.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "default_root_folder", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "account_owner", + "external_storage_service", + "configured_storage_addons", + }, + ) + + @unittest.expectedFailure # TODO + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._user.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) + + @unittest.expectedFailure # TODO + def test_wrong_user(self): + _another_user = _factories.InternalUserFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestAuthorizedStorageAccountRelatedView(TestCase): + @classmethod + def setUpTestData(cls): + cls._asa = _factories.AuthorizedStorageAccountFactory() + cls._related_view = AuthorizedStorageAccountViewSet.as_view( + {"get": "retrieve_related"}, + ) + + def test_get_related__empty(self): + _resp = self._related_view( + get_test_request(), + pk=self._asa.pk, + related_field="configured_storage_addons", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data, []) + + def test_get_related__several(self): + _addons = _factories.ConfiguredStorageAddonFactory.create_batch( + size=5, + authorized_storage_account=self._asa, + ) + _resp = self._related_view( + get_test_request(), + pk=self._asa.pk, + related_field="configured_storage_addons", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + {_datum["id"] for _datum in _content["data"]}, + {str(_addon.pk) for _addon in _addons}, + ) diff --git a/addon_service/tests/test_base.py b/addon_service/tests/test_base.py index dc308e39..59b87a29 100644 --- a/addon_service/tests/test_base.py +++ b/addon_service/tests/test_base.py @@ -1,6 +1,6 @@ from django.test import TestCase -from .factories import InternalUserFactory +from ._factories import InternalUserFactory class TestTestCase(TestCase): @@ -12,4 +12,4 @@ def test_model(self): """Simple base test to test test models""" user = InternalUserFactory(user_uri="http://osf.example/hurts") user.save() - assert user.user_uri == "http://osf.example/hurts" + self.assertEqual(user.user_uri, "http://osf.example/hurts") diff --git a/addon_service/tests/test_configured_storage_addon.py b/addon_service/tests/test_configured_storage_addon.py new file mode 100644 index 00000000..d351d163 --- /dev/null +++ b/addon_service/tests/test_configured_storage_addon.py @@ -0,0 +1,138 @@ +import json +import unittest +from http import HTTPStatus + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.configured_storage_addon.views import ConfiguredStorageAddonViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import get_test_request + + +# smoke-test api +class TestConfiguredStorageAddonAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._csa = _factories.ConfiguredStorageAddonFactory() + + @property + def _detail_path(self): + return reverse( + "configured-storage-addons-detail", + kwargs={"pk": self._csa.pk}, + ) + + @property + def _list_path(self): + return reverse("configured-storage-addons-list") + + def _related_path(self, related_field): + return reverse( + "configured-storage-addons-related", + kwargs={ + "pk": self._csa.pk, + "related_field": related_field, + }, + ) + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual( + _resp.data["root_folder"], + self._csa.root_folder, + ) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"post"}, + # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, + self._related_path("account_owner"): {"patch", "put", "post"}, + self._related_path("external_storage_service"): {"patch", "put", "post"}, + self._related_path("configured_storage_addons"): {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +# unit-test data model +class TestConfiguredStorageAddonModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._csa = _factories.ConfiguredStorageAddonFactory() + + def test_can_load(self): + _resource_from_db = db.ConfiguredStorageAddon.objects.get(id=self._csa.id) + self.assertEqual(self._csa.pk, _resource_from_db.pk) + + +# unit-test viewset (call the view with test requests) +class TestConfiguredStorageAddonViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._csa = _factories.ConfiguredStorageAddonFactory() + cls._view = ConfiguredStorageAddonViewSet.as_view({"get": "retrieve"}) + + def test_get(self): + _resp = self._view( + get_test_request(), + pk=self._csa.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "root_folder", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "authorized_storage_account", + "internal_resource", + }, + ) + + @unittest.expectedFailure # TODO + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._user.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) + + @unittest.expectedFailure # TODO + def test_wrong_user(self): + _another_user = _factories.InternalUserFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestConfiguredStorageAddonRelatedView(TestCase): + @classmethod + def setUpTestData(cls): + cls._csa = _factories.ConfiguredStorageAddonFactory() + cls._related_view = ConfiguredStorageAddonViewSet.as_view( + {"get": "retrieve_related"}, + ) + + def test_get_related(self): + _resp = self._related_view( + get_test_request(), + pk=self._csa.pk, + related_field="authorized_storage_account", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + _content["data"]["id"], + str(self._csa.authorized_storage_account_id), + ) diff --git a/addon_service/tests/test_external_storage_service.py b/addon_service/tests/test_external_storage_service.py new file mode 100644 index 00000000..e8f09a09 --- /dev/null +++ b/addon_service/tests/test_external_storage_service.py @@ -0,0 +1,169 @@ +import json +import unittest +from http import HTTPStatus + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.external_storage_service.views import ExternalStorageServiceViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import get_test_request + + +# smoke-test api +class TestExternalStorageServiceAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._ess = _factories.ExternalStorageServiceFactory() + + @property + def _detail_path(self): + return reverse("external-storage-services-detail", kwargs={"pk": self._ess.pk}) + + @property + def _list_path(self): + return reverse("external-storage-services-list") + + @property + def _related_authorized_storage_accounts_path(self): + return reverse( + "external-storage-services-related", + kwargs={ + "pk": self._ess.pk, + "related_field": "authorized_storage_accounts", + }, + ) + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["auth_uri"], self._ess.auth_uri) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"post"}, + # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, + self._related_authorized_storage_accounts_path: {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +# unit-test data model +class TestExternalStorageServiceModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._ess = _factories.ExternalStorageServiceFactory() + + def test_can_load(self): + _resource_from_db = db.ExternalStorageService.objects.get(id=self._ess.id) + self.assertEqual(self._ess.auth_uri, _resource_from_db.auth_uri) + + def test_authorized_storage_accounts__empty(self): + self.assertEqual( + list(self._ess.authorized_storage_accounts.all()), + [], + ) + + def test_authorized_storage_accounts__several(self): + _accounts = set( + _factories.AuthorizedStorageAccountFactory.create_batch( + size=3, + external_storage_service=self._ess, + ) + ) + self.assertEqual( + set(self._ess.authorized_storage_accounts.all()), + _accounts, + ) + + def test_validation(self): + self._ess.auth_uri = "not a uri" + with self.assertRaises(ValidationError): + self._ess.clean_fields(exclude=["modified"]) + + +# unit-test viewset (call the view with test requests) +class TestExternalStorageServiceViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._ess = _factories.ExternalStorageServiceFactory() + cls._view = ExternalStorageServiceViewSet.as_view({"get": "retrieve"}) + + def test_get(self): + _resp = self._view( + get_test_request(), + pk=self._ess.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "auth_uri", + "max_concurrent_downloads", + "max_upload_mb", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "authorized_storage_accounts", + }, + ) + + @unittest.expectedFailure # TODO + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._user.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) + + @unittest.expectedFailure # TODO + def test_wrong_user(self): + _another_user = _factories.InternalUserFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestExternalStorageServiceRelatedView(TestCase): + @classmethod + def setUpTestData(cls): + cls._ess = _factories.ExternalStorageServiceFactory() + cls._related_view = ExternalStorageServiceViewSet.as_view( + {"get": "retrieve_related"}, + ) + + def test_get_related__empty(self): + _resp = self._related_view( + get_test_request(), + pk=self._ess.pk, + related_field="authorized_storage_accounts", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data, []) + + def test_get_related__several(self): + _accounts = _factories.AuthorizedStorageAccountFactory.create_batch( + size=5, + external_storage_service=self._ess, + ) + _resp = self._related_view( + get_test_request(), + pk=self._ess.pk, + related_field="authorized_storage_accounts", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + {_datum["id"] for _datum in _content["data"]}, + {str(_account.pk) for _account in _accounts}, + ) diff --git a/addon_service/tests/test_internal_resource.py b/addon_service/tests/test_internal_resource.py new file mode 100644 index 00000000..a65db681 --- /dev/null +++ b/addon_service/tests/test_internal_resource.py @@ -0,0 +1,167 @@ +import json +import unittest +from http import HTTPStatus + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.internal_resource.views import InternalResourceViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import get_test_request + + +# smoke-test api +class TestInternalResourceAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._resource = _factories.InternalResourceFactory() + + @property + def _detail_path(self): + return reverse("internal-resources-detail", kwargs={"pk": self._resource.pk}) + + @property + def _list_path(self): + return reverse("internal-resources-list") + + @property + def _related_configured_storage_addons_path(self): + return reverse( + "internal-resources-related", + kwargs={ + "pk": self._resource.pk, + "related_field": "configured_storage_addons", + }, + ) + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["resource_uri"], self._resource.resource_uri) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"patch", "put", "post"}, + # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, + self._related_configured_storage_addons_path: {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +# unit-test data model +class TestInternalResourceModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._resource = _factories.InternalResourceFactory() + + def test_can_load(self): + _resource_from_db = db.InternalResource.objects.get(id=self._resource.id) + self.assertEqual(self._resource.resource_uri, _resource_from_db.resource_uri) + + def test_configured_storage_addons__empty(self): + self.assertEqual( + list(self._resource.configured_storage_addons.all()), + [], + ) + + def test_configured_storage_addons__several(self): + _accounts = set( + _factories.ConfiguredStorageAddonFactory.create_batch( + size=3, + internal_resource=self._resource, + ) + ) + self.assertEqual( + set(self._resource.configured_storage_addons.all()), + _accounts, + ) + + def test_validation(self): + self._resource.resource_uri = "not a uri" + with self.assertRaises(ValidationError): + self._resource.clean_fields(exclude=["modified"]) + + +# unit-test viewset (call the view with test requests) +class TestInternalResourceViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._resource = _factories.InternalResourceFactory() + cls._view = InternalResourceViewSet.as_view({"get": "retrieve"}) + + def test_get(self): + _resp = self._view( + get_test_request(), + pk=self._resource.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "resource_uri", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "configured_storage_addons", + }, + ) + + @unittest.expectedFailure # TODO + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._user.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) + + @unittest.expectedFailure # TODO + def test_wrong_user(self): + _another_user = _factories.InternalUserFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestInternalResourceRelatedView(TestCase): + @classmethod + def setUpTestData(cls): + cls._resource = _factories.InternalResourceFactory() + cls._related_view = InternalResourceViewSet.as_view( + {"get": "retrieve_related"}, + ) + + def test_get_related__empty(self): + _resp = self._related_view( + get_test_request(), + pk=self._resource.pk, + related_field="configured_storage_addons", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data, []) + + def test_get_related__several(self): + _addons = _factories.ConfiguredStorageAddonFactory.create_batch( + size=5, + internal_resource=self._resource, + ) + _resp = self._related_view( + get_test_request(), + pk=self._resource.pk, + related_field="configured_storage_addons", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + {_datum["id"] for _datum in _content["data"]}, + {str(_addon.pk) for _addon in _addons}, + ) diff --git a/addon_service/tests/test_internal_user.py b/addon_service/tests/test_internal_user.py index d6d252a0..34eb354e 100644 --- a/addon_service/tests/test_internal_user.py +++ b/addon_service/tests/test_internal_user.py @@ -1,14 +1,176 @@ +import json +import unittest +from http import HTTPStatus + +from django.core.exceptions import ValidationError +from django.db.models.query import QuerySet +from django.test import TestCase from django.urls import reverse from rest_framework.test import APITestCase -from addon_service.tests.factories import InternalUserFactory +from addon_service import models as db +from addon_service.internal_user.views import InternalUserViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import get_test_request + + +# smoke-test api +class TestInternalUserAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.InternalUserFactory() + @property + def _detail_path(self): + return reverse("internal-users-detail", kwargs={"pk": self._user.pk}) + + @property + def _list_path(self): + return reverse("internal-users-list") + + @property + def _related_accounts_path(self): + return reverse( + "internal-users-related", + kwargs={ + "pk": self._user.pk, + "related_field": "authorized_storage_accounts", + }, + ) -class TestInternalUser(APITestCase): def test_get(self): - _user = InternalUserFactory(user_uri="http://osf.example/hurts1") - _resp = self.client.get( - reverse("internal-users-detail", kwargs={"pk": _user.pk}), + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "user_uri", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "authorized_storage_accounts", + }, + ) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"patch", "put", "post"}, + # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, + self._related_accounts_path: {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +# unit-test data model +class TestInternalUserModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.InternalUserFactory() + + def test_can_load(self): + _user_from_db = db.InternalUser.objects.get(id=self._user.id) + self.assertEqual(self._user.user_uri, _user_from_db.user_uri) + + def test_authorized_storage_accounts__empty(self): + _authed_storage_accounts_qs = self._user.authorized_storage_accounts + self.assertIsInstance(_authed_storage_accounts_qs, QuerySet) + self.assertEqual(list(_authed_storage_accounts_qs), []) + + def test_authorized_storage_accounts__several(self): + _accounts = set( + _factories.AuthorizedStorageAccountFactory.create_batch( + size=3, + external_account__owner=self._user, + ) + ) + _authed_storage_accounts_qs = self._user.authorized_storage_accounts + self.assertIsInstance(_authed_storage_accounts_qs, QuerySet) + self.assertEqual(set(_authed_storage_accounts_qs), _accounts) + + def test_validation(self): + self._user.user_uri = "not a uri" + with self.assertRaises(ValidationError): + self._user.clean_fields(exclude=["modified"]) + + +# unit-test viewset (call the view with test requests) +class TestInternalUserViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.InternalUserFactory() + cls._view = InternalUserViewSet.as_view({"get": "retrieve"}) + + def test_get(self): + _resp = self._view( + get_test_request(user=self._user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + set(_content["data"]["attributes"].keys()), + { + "user_uri", + }, + ) + self.assertEqual( + set(_content["data"]["relationships"].keys()), + { + "authorized_storage_accounts", + }, + ) + + @unittest.expectedFailure # TODO + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._user.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) + + @unittest.expectedFailure # TODO + def test_wrong_user(self): + _another_user = _factories.InternalUserFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._user.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestInternalUserRelatedView(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.InternalUserFactory() + cls._related_view = InternalUserViewSet.as_view({"get": "retrieve_related"}) + + def test_get_related__empty(self): + _resp = self._related_view( + get_test_request(user=self._user), + pk=self._user.pk, + related_field="authorized_storage_accounts", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data, []) + + def test_get_related__several(self): + _accounts = _factories.AuthorizedStorageAccountFactory.create_batch( + size=5, + external_account__owner=self._user, + ) + _resp = self._related_view( + get_test_request(user=self._user), + pk=self._user.pk, + related_field="authorized_storage_accounts", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + _content = json.loads(_resp.rendered_content) + self.assertEqual( + {_datum["id"] for _datum in _content["data"]}, + {str(_account.pk) for _account in _accounts}, ) - assert _resp.status_code == 200 - assert _resp.data["user_uri"] == "http://osf.example/hurts1" diff --git a/addon_service/urls.py b/addon_service/urls.py index c80c6240..42cc4eab 100644 --- a/addon_service/urls.py +++ b/addon_service/urls.py @@ -1,44 +1,61 @@ -from django.urls import path -from rest_framework.routers import SimpleRouter +from rest_framework.routers import ( + Route, + SimpleRouter, +) from rest_framework_json_api.utils import get_resource_type_from_serializer from addon_service import views -def _urls_for_viewsets(*viewsets): - """returns urlpatterns for viewsets that each correspond to a resource type - - includes patterns for jsonapi-style relationships - """ - _router = SimpleRouter() - _additional_urlpatterns = [] - for _viewset in viewsets: - # NOTE: assumes each viewset corresponds to a distinct resource_name - _resource_name = get_resource_type_from_serializer(_viewset.serializer_class) - _router.register( - prefix=_resource_name, - viewset=_viewset, - basename=_resource_name, - ) +### +# routing helpers + + +class _AddonServiceRouter(SimpleRouter): + routes = [ + *SimpleRouter.routes, # add route for all relationship "related" links # https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls - _additional_urlpatterns.append( - path( - f"{_resource_name}///", - _viewset.as_view({"get": "retrieve_related"}), - name=f"{_resource_name}-related", - ), - ) - return [ - *_router.urls, - *_additional_urlpatterns, + Route( + url=r"^{prefix}/{lookup}/(?P[^/]+){trailing_slash}", + mapping={"get": "retrieve_related"}, + name="{basename}-related", + detail=False, + initkwargs={"suffix": "Related"}, + ), + # note: omitting relationship "self" links because we don't expect to need them + # (our frontend is fine PATCHing a top-level resource to update a relationship) + # and rest_framework_json_api's RelationshipView exposes all relationships from + # the model instead of going through the serializer (unlike `retrieve_related`) ] -urlpatterns = _urls_for_viewsets( - views.AuthorizedStorageAccountViewSet, - views.ConfiguredStorageAddonViewSet, - views.ExternalStorageServiceViewSet, - views.InternalResourceViewSet, - views.InternalUserViewSet, -) +_router = _AddonServiceRouter() + + +def _register_viewset(viewset): + # NOTE: assumes each viewset corresponds to a distinct resource_name + _resource_name = get_resource_type_from_serializer(viewset.serializer_class) + _router.register( + prefix=_resource_name, + viewset=viewset, + basename=_resource_name, + ) + + +### +# register viewsets with _router + +_register_viewset(views.AuthorizedStorageAccountViewSet) +_register_viewset(views.ConfiguredStorageAddonViewSet) +_register_viewset(views.ExternalStorageServiceViewSet) +_register_viewset(views.InternalResourceViewSet) +_register_viewset(views.InternalUserViewSet) + + +### +# the only public part of this module + +__all__ = ("urlpatterns",) + +urlpatterns = _router.urls