From 0f9327a1defbe0c8957fdf2dc55222c148e2282d Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 9 Feb 2024 19:32:12 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20refactor=20post?= =?UTF-8?q?=20hackathon=20to=20a=20first=20working=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This project was copied and hacked to make a POC in a 2-day hackathon. We need to clean and refactor things in order to get a first version of the product we want. --- Makefile | 1 - README.md | 2 +- src/backend/.pylintrc | 2 +- src/backend/core/admin.py | 76 +- src/backend/core/api/permissions.py | 15 +- src/backend/core/api/serializers.py | 71 +- src/backend/core/api/viewsets.py | 233 +++-- src/backend/core/factories.py | 39 +- src/backend/core/forms.py | 8 - src/backend/core/migrations/0001_initial.py | 30 +- src/backend/core/models.py | 173 ++-- .../core/tests/swagger/test_openapi_schema.py | 2 +- .../tests/teams/test_core_api_teams_create.py | 50 -- .../tests/teams/test_core_api_teams_delete.py | 107 --- .../tests/teams/test_core_api_teams_list.py | 118 --- .../teams/test_core_api_teams_retrieve.py | 86 -- .../tests/teams/test_core_api_teams_update.py | 176 ---- .../templates/test_api_templates_create.py | 47 + .../templates/test_api_templates_delete.py | 84 ++ .../test_api_templates_generate_document.py | 107 +++ .../templates/test_api_templates_list.py | 124 +++ .../templates/test_api_templates_retrieve.py | 130 +++ .../templates/test_api_templates_update.py | 154 ++++ .../core/tests/test_api_team_accesses.py | 843 ------------------ .../core/tests/test_api_template_accesses.py | 791 ++++++++++++++++ src/backend/core/tests/test_api_users.py | 51 +- .../core/tests/test_models_identities.py | 183 ---- .../core/tests/test_models_team_accesses.py | 264 ------ src/backend/core/tests/test_models_teams.py | 135 --- .../tests/test_models_template_accesses.py | 256 ++++++ .../core/tests/test_models_templates.py | 161 ++++ src/backend/core/tests/test_models_users.py | 56 +- src/backend/core/tests/utils.py | 16 +- src/backend/core/urls.py | 36 +- src/backend/core/views.py | 79 -- .../management/commands/createsuperuser.py | 25 +- src/backend/demo/utils.py | 23 - src/backend/publish/api_urls.py | 36 - src/backend/publish/settings.py | 4 +- src/backend/publish/urls.py | 30 +- src/backend/pyproject.toml | 2 +- 41 files changed, 2259 insertions(+), 2567 deletions(-) delete mode 100644 src/backend/core/forms.py delete mode 100644 src/backend/core/tests/teams/test_core_api_teams_create.py delete mode 100644 src/backend/core/tests/teams/test_core_api_teams_delete.py delete mode 100644 src/backend/core/tests/teams/test_core_api_teams_list.py delete mode 100644 src/backend/core/tests/teams/test_core_api_teams_retrieve.py delete mode 100644 src/backend/core/tests/teams/test_core_api_teams_update.py create mode 100644 src/backend/core/tests/templates/test_api_templates_create.py create mode 100644 src/backend/core/tests/templates/test_api_templates_delete.py create mode 100644 src/backend/core/tests/templates/test_api_templates_generate_document.py create mode 100644 src/backend/core/tests/templates/test_api_templates_list.py create mode 100644 src/backend/core/tests/templates/test_api_templates_retrieve.py create mode 100644 src/backend/core/tests/templates/test_api_templates_update.py delete mode 100644 src/backend/core/tests/test_api_team_accesses.py create mode 100644 src/backend/core/tests/test_api_template_accesses.py delete mode 100644 src/backend/core/tests/test_models_identities.py delete mode 100644 src/backend/core/tests/test_models_team_accesses.py delete mode 100644 src/backend/core/tests/test_models_teams.py create mode 100644 src/backend/core/tests/test_models_template_accesses.py create mode 100644 src/backend/core/tests/test_models_templates.py delete mode 100644 src/backend/core/views.py delete mode 100644 src/backend/demo/utils.py delete mode 100644 src/backend/publish/api_urls.py diff --git a/Makefile b/Makefile index 70593f7f..e8297f24 100644 --- a/Makefile +++ b/Makefile @@ -152,7 +152,6 @@ test-back-parallel: ## run all back-end tests in parallel bin/pytest -n auto $${args:-${1}} .PHONY: test-back-parallel - makemigrations: ## run django makemigrations for the publish project. @echo "$(BOLD)Running makemigrations$(RESET)" @$(COMPOSE) up -d postgresql diff --git a/README.md b/README.md index 83419852..3b7f174d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Publish -publish is an application to handle users and teams. +publish is an application to handle users and templates. publish is built on top of [Django Rest Framework](https://www.django-rest-framework.org/). diff --git a/src/backend/.pylintrc b/src/backend/.pylintrc index 4416e3e2..d7490918 100644 --- a/src/backend/.pylintrc +++ b/src/backend/.pylintrc @@ -172,7 +172,7 @@ ignore-on-opaque-inference=yes # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local,responses, - Team,Contact + Template,Contact # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 814a1bf6..d286cda3 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,22 +1,11 @@ -"""Admin classes and registrations for Magnify's core app.""" +"""Admin classes and registrations for core app.""" from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.utils.translation import gettext_lazy as _ from . import models -class IdentityInline(admin.TabularInline): - """Inline admin class for user identities.""" - - model = models.Identity - extra = 0 - - -@admin.register(models.User) -class UserAdmin(admin.ModelAdmin): - """User admin interface declaration.""" - - inlines = (IdentityInline,) - class TemplateAccessInline(admin.TabularInline): """Inline admin class for template accesses.""" @@ -25,6 +14,65 @@ class TemplateAccessInline(admin.TabularInline): extra = 0 +@admin.register(models.User) +class UserAdmin(auth_admin.UserAdmin): + """Admin class for the User model""" + + fieldsets = ( + ( + None, + { + "fields": ( + "id", + "admin_email", + "password", + ) + }, + ), + (_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_device", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("created_at", "updated_at")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + inlines = (TemplateAccessInline,) + list_display = ( + "id", + "sub", + "admin_email", + "email", + "is_active", + "is_staff", + "is_superuser", + "is_device", + "created_at", + "updated_at", + ) + list_filter = ("is_staff", "is_superuser", "is_device", "is_active") + ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at") + readonly_fields = ("id", "sub", "email", "created_at", "updated_at") + search_fields = ("id", "sub", "admin_email", "email") + + @admin.register(models.Template) class TemplateAdmin(admin.ModelAdmin): """Template admin interface declaration.""" diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 2e4a68f4..4ab55b32 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -11,7 +11,16 @@ class IsAuthenticated(permissions.BasePermission): """ def has_permission(self, request, view): - return bool(request.auth) if request.auth else request.user.is_authenticated + return bool(request.auth) or request.user.is_authenticated + + +class IsAuthenticatedOrSafe(IsAuthenticated): + """Allows access to authenticated users (or anonymous users but only on safe methods).""" + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + return super().has_permission(request, view) class IsSelf(IsAuthenticated): @@ -45,10 +54,10 @@ class IsOwnedOrPublic(IsAuthenticated): return False -class AccessPermission(IsAuthenticated): +class AccessPermission(permissions.BasePermission): """Permission class for access objects.""" def has_object_permission(self, request, view, obj): """Check permission for a given object.""" abilities = obj.get_abilities(request.user) - return abilities.get(request.method.lower(), False) + return abilities.get(view.action, False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 3d490a22..151eba31 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,31 +1,12 @@ """Client serializers for the publish core app.""" +from django.utils.translation import gettext_lazy as _ + from rest_framework import exceptions, serializers from timezone_field.rest_framework import TimeZoneSerializerField from core import models -class ContactSerializer(serializers.ModelSerializer): - """Serialize contacts.""" - - class Meta: - model = models.Contact - fields = [ - "id", - "base", - "data", - "full_name", - "owner", - "short_name", - ] - read_only_fields = ["id", "owner"] - - def update(self, instance, validated_data): - """Make "base" field readonly but only for update/patch.""" - validated_data.pop("base", None) - return super().update(instance, validated_data) - - class UserSerializer(serializers.ModelSerializer): """Serialize users.""" @@ -42,13 +23,14 @@ class UserSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "is_device", "is_staff"] -class TeamAccessSerializer(serializers.ModelSerializer): - """Serialize team accesses.""" + +class TemplateAccessSerializer(serializers.ModelSerializer): + """Serialize template accesses.""" abilities = serializers.SerializerMethodField(read_only=True) class Meta: - model = models.TeamAccess + model = models.TemplateAccess fields = ["id", "user", "role", "abilities"] read_only_fields = ["id", "abilities"] @@ -80,58 +62,65 @@ class TeamAccessSerializer(serializers.ModelSerializer): message = ( f"You are only allowed to set role to {', '.join(can_set_role_to)}" if can_set_role_to - else "You are not allowed to set this role for this team." + else "You are not allowed to set this role for this template." ) raise exceptions.PermissionDenied(message) # Create else: try: - team_id = self.context["team_id"] + template_id = self.context["template_id"] except KeyError as exc: raise exceptions.ValidationError( - "You must set a team ID in kwargs to create a new team access." + "You must set a template ID in kwargs to create a new template access." ) from exc - if not models.TeamAccess.objects.filter( - team=team_id, + if not models.TemplateAccess.objects.filter( + template=template_id, user=user, role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN], ).exists(): raise exceptions.PermissionDenied( - "You are not allowed to manage accesses for this team." + "You are not allowed to manage accesses for this template." ) if ( role == models.RoleChoices.OWNER - and not models.TeamAccess.objects.filter( - team=team_id, + and not models.TemplateAccess.objects.filter( + template=template_id, user=user, role=models.RoleChoices.OWNER, ).exists() ): raise exceptions.PermissionDenied( - "Only owners of a team can assign other users as owners." + "Only owners of a template can assign other users as owners." ) - attrs["team_id"] = self.context["team_id"] + attrs["template_id"] = self.context["template_id"] return attrs -class TeamSerializer(serializers.ModelSerializer): - """Serialize teams.""" +class TemplateSerializer(serializers.ModelSerializer): + """Serialize templates.""" abilities = serializers.SerializerMethodField(read_only=True) - accesses = TeamAccessSerializer(many=True, read_only=True) + accesses = TemplateAccessSerializer(many=True, read_only=True) class Meta: - model = models.Team - fields = ["id", "name", "accesses", "abilities"] + model = models.Template + fields = ["id", "title", "accesses", "abilities"] read_only_fields = ["id", "accesses", "abilities"] - def get_abilities(self, team) -> dict: + def get_abilities(self, template) -> dict: """Return abilities of the logged-in user on the instance.""" request = self.context.get("request") if request: - return team.get_abilities(request.user) + return template.get_abilities(request.user) return {} + + +# pylint: disable=abstract-method +class DocumentGenerationSerializer(serializers.Serializer): + """Serializer to receive a request to generate a document on a template.""" + + body = serializers.CharField(label=_("Markdown Body")) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 333a7864..be2e902c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,27 +1,31 @@ """API endpoints""" -from django.contrib.postgres.search import TrigramSimilarity -from django.core.cache import cache +from io import BytesIO + from django.db.models import ( - Func, OuterRef, Q, Subquery, - Value, ) +from django.http import FileResponse from rest_framework import ( decorators, exceptions, mixins, pagination, - response, + status, viewsets, ) +from rest_framework import ( + response as drf_response, +) from core import models from . import permissions, serializers +# pylint: disable=too-many-ancestors + class NestedGenericViewSet(viewsets.GenericViewSet): """ @@ -103,83 +107,6 @@ class Pagination(pagination.PageNumberPagination): page_size_query_param = "page_size" -# pylint: disable=too-many-ancestors -class ContactViewSet( - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, -): - """Contact ViewSet""" - - permission_classes = [permissions.IsOwnedOrPublic] - queryset = models.Contact.objects.all() - serializer_class = serializers.ContactSerializer - - def list(self, request, *args, **kwargs): - """Limit listed users by a query with throttle protection.""" - user = self.request.user - queryset = self.filter_queryset(self.get_queryset()) - - if not user.is_authenticated: - return queryset.none() - - # Exclude contacts that: - queryset = queryset.filter( - # - belong to another user (keep public and owned contacts) - Q(owner__isnull=True) | Q(owner=user), - # - are profile contacts for a user - user__isnull=True, - # - are overriden base contacts - overriding_contacts__isnull=True, - ) - - # Search by case-insensitive and accent-insensitive trigram similarity - if query := self.request.GET.get("q", ""): - query = Func(Value(query), function="unaccent") - similarity = TrigramSimilarity( - Func("full_name", function="unaccent"), - query, - ) + TrigramSimilarity(Func("short_name", function="unaccent"), query) - queryset = ( - queryset.annotate(similarity=similarity) - .filter( - similarity__gte=0.05 - ) # Value determined by testing (test_api_contacts.py) - .order_by("-similarity") - ) - - # Throttle protection - key_base = f"throttle-contact-list-{user.id!s}" - key_minute = f"{key_base:s}-minute" - key_hour = f"{key_base:s}-hour" - - try: - count_minute = cache.incr(key_minute) - except ValueError: - cache.set(key_minute, 1, 60) - count_minute = 1 - - try: - count_hour = cache.incr(key_hour) - except ValueError: - cache.set(key_hour, 1, 3600) - count_hour = 1 - - if count_minute > 20 or count_hour > 150: - raise exceptions.Throttled() - - serializer = self.get_serializer(queryset, many=True) - return response.Response(serializer.data) - - def perform_create(self, serializer): - """Set the current user as owner of the newly created contact.""" - user = self.request.user - serializer.validated_data["owner"] = user - return super().perform_create(serializer) - - class UserViewSet( mixins.UpdateModelMixin, viewsets.GenericViewSet, @@ -202,12 +129,12 @@ class UserViewSet( Return information on currently logged user """ context = {"request": request} - return response.Response( + return drf_response.Response( self.serializer_class(request.user, context=context).data ) -class TeamViewSet( +class TemplateViewSet( mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, @@ -215,32 +142,69 @@ class TeamViewSet( mixins.UpdateModelMixin, viewsets.GenericViewSet, ): - """Team ViewSet""" + """Template ViewSet""" - permission_classes = [permissions.AccessPermission] - serializer_class = serializers.TeamSerializer - queryset = models.Team.objects.all() + permission_classes = [ + permissions.IsAuthenticatedOrSafe, + permissions.AccessPermission, + ] + serializer_class = serializers.TemplateSerializer + queryset = models.Template.objects.all() def get_queryset(self): - """Custom queryset to get user related teams.""" - user_role_query = models.TeamAccess.objects.filter( - user=self.request.user, team=OuterRef("pk") + """Custom queryset to get user related templates.""" + if not self.request.user.is_authenticated: + return models.Template.objects.filter(is_public=True) + + user_role_query = models.TemplateAccess.objects.filter( + user=self.request.user, template=OuterRef("pk") ).values("role")[:1] - return models.Team.objects.filter(accesses__user=self.request.user).annotate( - user_role=Subquery(user_role_query) + return ( + models.Template.objects.filter( + Q(accesses__user=self.request.user) | Q(is_public=True) + ) + .annotate(user_role=Subquery(user_role_query)) + .distinct() ) def perform_create(self, serializer): - """Set the current user as owner of the newly created team.""" - team = serializer.save() - models.TeamAccess.objects.create( - team=team, + """Set the current user as owner of the newly created template.""" + template = serializer.save() + models.TemplateAccess.objects.create( + template=template, user=self.request.user, role=models.RoleChoices.OWNER, ) + @decorators.action( + detail=True, + methods=["post"], + url_path="generate-document", + permission_classes=[permissions.AccessPermission], + ) + # pylint: disable=unused-argument + def generate_document(self, request, pk=None): + """ + Generate and return pdf for this template with the content passed. + """ + serializer = serializers.DocumentGenerationSerializer(data=request.data) -class TeamAccessViewSet( + if not serializer.is_valid(): + return drf_response.Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + body = serializer.validated_data["body"] + + template = self.get_object() + pdf_content = template.generate_document(body) + + response = FileResponse(BytesIO(pdf_content), content_type="application/pdf") + response["Content-Disposition"] = f"attachment; filename={template.title}.pdf" + return response + + +class TemplateAccessViewSet( mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, @@ -249,37 +213,37 @@ class TeamAccessViewSet( viewsets.GenericViewSet, ): """ - API ViewSet for all interactions with team accesses. + API ViewSet for all interactions with template accesses. - GET /api/v1.0/teams//accesses/: - Return list of all team accesses related to the logged-in user or one - team access if an id is provided. + GET /api/v1.0/templates//accesses/: + Return list of all template accesses related to the logged-in user or one + template access if an id is provided. - POST /api/v1.0/teams//accesses/ with expected data: + POST /api/v1.0/templates//accesses/ with expected data: - user: str - role: str [owner|admin|member] - Return newly created team access + Return newly created template access - PUT /api/v1.0/teams//accesses// with expected data: + PUT /api/v1.0/templates//accesses// with expected data: - role: str [owner|admin|member] - Return updated team access + Return updated template access - PATCH /api/v1.0/teams//accesses// with expected data: + PATCH /api/v1.0/templates//accesses// with expected data: - role: str [owner|admin|member] - Return partially updated team access + Return partially updated template access - DELETE /api/v1.0/teams//accesses// - Delete targeted team access + DELETE /api/v1.0/templates//accesses// + Delete targeted template access """ lookup_field = "pk" pagination_class = Pagination - permission_classes = [permissions.AccessPermission] - queryset = models.TeamAccess.objects.all().select_related("user") - serializer_class = serializers.TeamAccessSerializer + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.TemplateAccess.objects.all().select_related("user") + serializer_class = serializers.TemplateAccessSerializer def get_permissions(self): - """User only needs to be authenticated to list team accesses""" + """User only needs to be authenticated to list template accesses""" if self.action == "list": permission_classes = [permissions.IsAuthenticated] else: @@ -290,24 +254,26 @@ class TeamAccessViewSet( def get_serializer_context(self): """Extra context provided to the serializer class.""" context = super().get_serializer_context() - context["team_id"] = self.kwargs["team_id"] + context["template_id"] = self.kwargs["template_id"] return context def get_queryset(self): """Return the queryset according to the action.""" queryset = super().get_queryset() - queryset = queryset.filter(team=self.kwargs["team_id"]) + queryset = queryset.filter(template=self.kwargs["template_id"]) if self.action == "list": - # Limit to team access instances related to a team THAT also has a team access - # instance for the logged-in user (we don't want to list only the team access - # instances pointing to the logged-in user) - user_role_query = models.TeamAccess.objects.filter( - team__accesses__user=self.request.user + # Limit to template access instances related to a template THAT also has + # a template access + # instance for the logged-in user (we don't want to list only the template + # access instances pointing to the logged-in user) + user_role_query = models.TemplateAccess.objects.filter( + template=self.kwargs["template_id"], + template__accesses__user=self.request.user, ).values("role")[:1] queryset = ( queryset.filter( - team__accesses__user=self.request.user, + template__accesses__user=self.request.user, ) .annotate(user_role=Subquery(user_role_query)) .distinct() @@ -317,13 +283,16 @@ class TeamAccessViewSet( def destroy(self, request, *args, **kwargs): """Forbid deleting the last owner access""" instance = self.get_object() - team = instance.team + template = instance.template - # Check if the access being deleted is the last owner access for the team - if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1: - return response.Response( - {"detail": "Cannot delete the last owner access for the team."}, - status=400, + # Check if the access being deleted is the last owner access for the template + if ( + instance.role == "owner" + and template.accesses.filter(role="owner").count() == 1 + ): + return drf_response.Response( + {"detail": "Cannot delete the last owner access for the template."}, + status=403, ) return super().destroy(request, *args, **kwargs) @@ -337,13 +306,13 @@ class TeamAccessViewSet( "role" in self.request.data and self.request.data["role"] != models.RoleChoices.OWNER ): - team = instance.team - # Check if the access being updated is the last owner access for the team + template = instance.template + # Check if the access being updated is the last owner access for the template if ( instance.role == models.RoleChoices.OWNER - and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 + and template.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 ): message = "Cannot change the role to a non-owner role for the last owner access." - raise exceptions.ValidationError({"role": message}) + raise exceptions.PermissionDenied({"detail": message}) serializer.save() diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 8a860016..fe65b601 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -19,48 +19,39 @@ class UserFactory(factory.django.DjangoModelFactory): class Meta: model = models.User + sub = factory.Sequence(lambda n: f"user{n!s}") + email = factory.Faker("email") language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) password = make_password("password") -class IdentityFactory(factory.django.DjangoModelFactory): - """A factory to create identities for a user""" +class TemplateFactory(factory.django.DjangoModelFactory): + """A factory to create templates""" class Meta: - model = models.Identity - django_get_or_create = ("sub",) + model = models.Template + django_get_or_create = ("title",) - user = factory.SubFactory(UserFactory) - sub = factory.Sequence(lambda n: f"user{n!s}") - email = factory.Faker("email") - - -class TeamFactory(factory.django.DjangoModelFactory): - """A factory to create teams""" - - class Meta: - model = models.Team - django_get_or_create = ("name",) - - name = factory.Sequence(lambda n: f"team{n}") + title = factory.Sequence(lambda n: f"template{n}") + is_public = factory.Faker("boolean") @factory.post_generation def users(self, create, extracted, **kwargs): - """Add users to team from a given list of users with or without roles.""" + """Add users to template from a given list of users with or without roles.""" if create and extracted: for item in extracted: if isinstance(item, models.User): - TeamAccessFactory(team=self, user=item) + TemplateAccessFactory(template=self, user=item) else: - TeamAccessFactory(team=self, user=item[0], role=item[1]) + TemplateAccessFactory(template=self, user=item[0], role=item[1]) -class TeamAccessFactory(factory.django.DjangoModelFactory): - """Create fake team user accesses for testing.""" +class TemplateAccessFactory(factory.django.DjangoModelFactory): + """Create fake template user accesses for testing.""" class Meta: - model = models.TeamAccess + model = models.TemplateAccess - team = factory.SubFactory(TeamFactory) + template = factory.SubFactory(TemplateFactory) user = factory.SubFactory(UserFactory) role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) diff --git a/src/backend/core/forms.py b/src/backend/core/forms.py deleted file mode 100644 index de8d48e2..00000000 --- a/src/backend/core/forms.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Forms for the core app of Publish""" -from django import forms -from .models import Template - -class DocumentGenerationForm(forms.Form): - body = forms.CharField(widget=forms.Textarea, label="Markdown Body") - template = forms.ModelChoiceField(queryset=Template.objects.all(), label="Choose Template") - diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index b399992d..f2155d66 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0 on 2024-01-06 18:05 +# Generated by Django 5.0.2 on 2024-02-22 20:34 import django.contrib.auth.models import django.core.validators @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), - ('name', models.CharField(max_length=100)), + ('name', models.CharField(max_length=100, unique=True)), ], options={ 'verbose_name': 'Team', @@ -61,7 +61,9 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), + ('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')), + ('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')), ('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), ('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)), ('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')), @@ -79,24 +81,6 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name='Identity', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), - ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), - ('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')), - ('email', models.EmailField(max_length=254, verbose_name='email address')), - ('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'identity', - 'verbose_name_plural': 'identities', - 'db_table': 'publish_identity', - 'ordering': ('-is_main', 'email'), - }, - ), migrations.CreateModel( name='TemplateAccess', fields=[ @@ -114,10 +98,6 @@ class Migration(migrations.Migration): 'db_table': 'publish_template_access', }, ), - migrations.AddConstraint( - model_name='identity', - constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'), - ), migrations.AddConstraint( model_name='templateaccess', constraint=models.UniqueConstraint(fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'), diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 85804ba8..764cb5d5 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1,32 +1,26 @@ """ Declare and configure the models for the publish core application """ -import json -import os import textwrap import uuid from django.conf import settings from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser -from django.core import exceptions, mail, validators +from django.core import mail, validators from django.db import models +from django.template.base import Template as DjangoTemplate +from django.template.context import Context from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ -from django.template.base import Template as DjangoTemplate -from django.template.context import Context -from django.template.engine import Engine - import frontmatter import markdown -from weasyprint import CSS, HTML -from weasyprint.text.fonts import FontConfiguration - -import jsonschema from rest_framework_simplejwt.exceptions import InvalidToken from rest_framework_simplejwt.settings import api_settings from timezone_field import TimeZoneField +from weasyprint import CSS, HTML +from weasyprint.text.fonts import FontConfiguration class RoleChoices(models.TextChoices): @@ -77,7 +71,33 @@ class BaseModel(models.Model): class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): """User model to work with OIDC only authentication.""" - email = models.EmailField(_("email address"), unique=True) + sub_validator = validators.RegexValidator( + regex=r"^[\w.@+-]+\Z", + message=_( + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_ characters." + ), + ) + + sub = models.CharField( + _("sub"), + help_text=_( + "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only." + ), + max_length=255, + unique=True, + validators=[sub_validator], + blank=True, + null=True, + ) + email = models.EmailField(_("identity email address"), blank=True, null=True) + + # Unlike the "email" field which stores the email coming from the OIDC token, this field + # stores the email used by staff users to login to the admin site + admin_email = models.EmailField( + _("admin email address"), unique=True, blank=True, null=True + ) + language = models.CharField( max_length=10, choices=lazy(lambda: settings.LANGUAGES, tuple)(), @@ -112,7 +132,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): objects = auth_models.UserManager() - USERNAME_FIELD = "email" + USERNAME_FIELD = "admin_email" REQUIRED_FIELDS = [] class Meta: @@ -120,80 +140,20 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): verbose_name = _("user") verbose_name_plural = _("users") - -class Identity(BaseModel): - """User identity""" - - sub_validator = validators.RegexValidator( - regex=r"^[\w.@+-]+\Z", - message=_( - "Enter a valid sub. This value may contain only letters, " - "numbers, and @/./+/-/_ characters." - ), - ) - - user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE) - sub = models.CharField( - _("sub"), - help_text=_( - "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only." - ), - max_length=255, - unique=True, - validators=[sub_validator], - blank=True, - null=True, - ) - email = models.EmailField(_("email address")) - is_main = models.BooleanField( - _("main"), - default=False, - help_text=_("Designates whether the email is the main one."), - ) - - class Meta: - db_table = "publish_identity" - ordering = ("-is_main", "email") - verbose_name = _("identity") - verbose_name_plural = _("identities") - constraints = [ - # Uniqueness - models.UniqueConstraint( - fields=["user", "email"], - name="unique_user_email", - violation_error_message=_( - "This email address is already declared for this user." - ), - ), - ] - def __str__(self): - main_str = "[main]" if self.is_main else "" - return f"{self.email:s}{main_str:s}" + return self.email or self.admin_email or str(self.id) - def clean(self): - """Normalize the email field and clean the 'is_main' field.""" - self.email = User.objects.normalize_email(self.email) - if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists(): - if not self.created_at: - self.is_main = True - elif not self.is_main: - raise exceptions.ValidationError( - {"is_main": "A user should have one and only one main identity."} - ) - super().clean() - - def save(self, *args, **kwargs): - """Ensure users always have one and only one main identity.""" - super().save(*args, **kwargs) - if self.is_main is True: - self.user.identities.exclude(id=self.id).update(is_main=False) + def email_user(self, subject, message, from_email=None, **kwargs): + """Email this user.""" + if not self.email: + raise ValueError("User has no email address.") + mail.send_mail(subject, message, from_email, [self.email], **kwargs) class Team(BaseModel): - """Team used for role based access control when matched with teams in OIDC tokens.""" + """Team used for role based access control when matched with templates in OIDC tokens.""" - name = models.CharField(max_length=100) + name = models.CharField(max_length=100, unique=True) class Meta: db_table = "publish_role" @@ -227,11 +187,6 @@ class Template(BaseModel): def __str__(self): return self.title - - if not self.body: - return "" - return markdown.markdown(textwrap.dedent(self.body)) - def generate_document(self, body): """ Generate and return a PDF document for this template around the @@ -240,23 +195,27 @@ class Template(BaseModel): document = frontmatter.loads(body) metadata = document.metadata markdown_body = document.content.strip() - body_html = markdown.markdown(textwrap.dedent(markdown_body)) if markdown_body else "" + body_html = ( + markdown.markdown(textwrap.dedent(markdown_body)) if markdown_body else "" + ) - document_html = HTML(string=DjangoTemplate(self.code).render(Context({"body": body_html, **metadata}))) + document_html = HTML( + string=DjangoTemplate(self.code).render( + Context({"body": body_html, **metadata}) + ) + ) css = CSS( string=self.css, font_config=FontConfiguration(), ) return document_html.write_pdf(stylesheets=[css], zoom=1) - def get_abilities(self, user): """ Compute and return abilities for a given user on the template. """ - is_owner_or_admin = False + # Compute user role role = None - if user.is_authenticated: try: role = self.user_role @@ -266,19 +225,20 @@ class Template(BaseModel): except (TemplateAccess.DoesNotExist, IndexError): role = None - is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + can_get = self.is_public or role is not None return { - "get": True, - "patch": is_owner_or_admin, - "put": is_owner_or_admin, - "delete": role == RoleChoices.OWNER, + "destroy": role == RoleChoices.OWNER, + "generate_document": can_get, "manage_accesses": is_owner_or_admin, + "update": is_owner_or_admin, + "retrieve": can_get, } class TemplateAccess(BaseModel): - """Link table between templates and contacts.""" + """Relation model to give access to a template for a user or a team with a role.""" template = models.ForeignKey( Template, @@ -337,16 +297,17 @@ class TemplateAccess(BaseModel): except AttributeError: try: role = self._meta.model.objects.filter( - template=self.template_id, user=user + template=self.template_id, + user=user, ).values("role")[0]["role"] except (self._meta.model.DoesNotExist, IndexError): role = None - is_template_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + is_template_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] if self.role == RoleChoices.OWNER: can_delete = ( - user.id == self.user_id + role == RoleChoices.OWNER and self.template.accesses.filter(role=RoleChoices.OWNER).count() > 1 ) set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else [] @@ -365,10 +326,9 @@ class TemplateAccess(BaseModel): pass return { - "delete": can_delete, - "get": bool(role), - "patch": bool(set_role_to), - "put": bool(set_role_to), + "destroy": can_delete, + "update": bool(set_role_to), + "retrieve": bool(role), "set_role_to": set_role_to, } @@ -390,9 +350,8 @@ def oidc_user_getter(validated_token): ) from exc try: - user = User.objects.get(identities__sub=user_id) + user = User.objects.get(sub=user_id) except User.DoesNotExist: - user = User.objects.create() - Identities.objects.create(user=user, sub=user_id, email=validated_token["email"]) + user = User.objects.create(sub=user_id, email=validated_token.get("email")) return user diff --git a/src/backend/core/tests/swagger/test_openapi_schema.py b/src/backend/core/tests/swagger/test_openapi_schema.py index 279ac0ac..26b58b71 100644 --- a/src/backend/core/tests/swagger/test_openapi_schema.py +++ b/src/backend/core/tests/swagger/test_openapi_schema.py @@ -23,7 +23,7 @@ def test_openapi_client_schema(): "--api-version", "v1.0", "--urlconf", - "publish.api_urls", + "core.urls", "--format", "openapi-json", "--file", diff --git a/src/backend/core/tests/teams/test_core_api_teams_create.py b/src/backend/core/tests/teams/test_core_api_teams_create.py deleted file mode 100644 index 6e7cb418..00000000 --- a/src/backend/core/tests/teams/test_core_api_teams_create.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Tests for Teams API endpoint in publish's core app: create -""" -import pytest -from rest_framework.test import APIClient -from rest_framework_simplejwt.tokens import AccessToken - -from core.factories import IdentityFactory, TeamFactory, UserFactory -from core.models import Team - -from ..utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_teams_create_anonymous(): - """Anonymous users should not be allowed to create teams.""" - response = APIClient().post( - "/api/v1.0/teams/", - { - "name": "my team", - }, - ) - - assert response.status_code == 401 - assert not Team.objects.exists() - - -def test_api_teams_create_authenticated(): - """ - Authenticated users should be able to create teams and should automatically be declared - as the owner of the newly created team. - """ - identity = IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - response = APIClient().post( - "/api/v1.0/teams/", - { - "name": "my team", - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 201 - team = Team.objects.get() - assert team.name == "my team" - assert team.accesses.filter(role="owner", user=user).exists() diff --git a/src/backend/core/tests/teams/test_core_api_teams_delete.py b/src/backend/core/tests/teams/test_core_api_teams_delete.py deleted file mode 100644 index 51c31784..00000000 --- a/src/backend/core/tests/teams/test_core_api_teams_delete.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Tests for Teams API endpoint in publish's core app: delete -""" -import pytest -from rest_framework.test import APIClient -from rest_framework_simplejwt.tokens import AccessToken - -from core import factories, models - -from ..utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_teams_delete_anonymous(): - """Anonymous users should not be allowed to destroy a team.""" - team = factories.TeamFactory() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/", - ) - - assert response.status_code == 401 - assert models.Team.objects.count() == 1 - - -def test_api_teams_delete_authenticated_unrelated(): - """ - Authenticated users should not be allowed to delete a team to which they are not - related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == 404 - assert response.json() == {"detail": "Not found."} - assert models.Team.objects.count() == 1 - - -def test_api_teams_delete_authenticated_member(): - """ - Authenticated users should not be allowed to delete a team for which they are - only a member. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "member")]) - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - assert models.Team.objects.count() == 1 - - -def test_api_teams_delete_authenticated_administrator(): - """ - Authenticated users should not be allowed to delete a team for which they are - administrator. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - assert models.Team.objects.count() == 1 - - -def test_api_teams_delete_authenticated_owner(): - """ - Authenticated users should be able to delete a team for which they are directly - owner. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == 204 - assert models.Team.objects.exists() is False diff --git a/src/backend/core/tests/teams/test_core_api_teams_list.py b/src/backend/core/tests/teams/test_core_api_teams_list.py deleted file mode 100644 index 5a6441e6..00000000 --- a/src/backend/core/tests/teams/test_core_api_teams_list.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Tests for Teams API endpoint in publish's core app: list -""" -from unittest import mock - -import pytest -from rest_framework.pagination import PageNumberPagination -from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED -from rest_framework.test import APIClient - -from core import factories, models -from core.api import serializers - -from ..utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_teams_list_anonymous(): - """Anonymous users should not be allowed to list teams.""" - factories.TeamFactory.create_batch(2) - - response = APIClient().get("/api/v1.0/teams/") - - assert response.status_code == HTTP_401_UNAUTHORIZED - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_teams_list_authenticated(): - """Authenticated users should be able to list teams they are an owner/administrator/member of.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - expected_ids = { - str(access.team.id) - for access in factories.TeamAccessFactory.create_batch(5, user=user) - } - factories.TeamFactory.create_batch(2) # Other teams - - response = APIClient().get( - "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == HTTP_200_OK - results = response.json()["results"] - assert len(results) == 5 - results_id = {result["id"] for result in results} - assert expected_ids == results_id - - -@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) -def test_api_teams_list_pagination( - _mock_page_size, -): - """Pagination should work as expected.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team_ids = [ - str(access.team.id) - for access in factories.TeamAccessFactory.create_batch(3, user=user) - ] - - # Get page 1 - response = APIClient().get( - "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == HTTP_200_OK - content = response.json() - - assert content["count"] == 3 - assert content["next"] == "http://testserver/api/v1.0/teams/?page=2" - assert content["previous"] is None - - assert len(content["results"]) == 2 - for item in content["results"]: - team_ids.remove(item["id"]) - - # Get page 2 - response = APIClient().get( - "/api/v1.0/teams/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == HTTP_200_OK - content = response.json() - - assert content["count"] == 3 - assert content["next"] is None - assert content["previous"] == "http://testserver/api/v1.0/teams/" - - assert len(content["results"]) == 1 - team_ids.remove(content["results"][0]["id"]) - assert team_ids == [] - - -def test_api_teams_list_authenticated_distinct(): - """A team with several related users should only be listed once.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - other_user = factories.UserFactory() - - team = factories.TeamFactory(users=[user, other_user]) - - response = APIClient().get( - "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - - assert response.status_code == HTTP_200_OK - content = response.json() - assert len(content["results"]) == 1 - assert content["results"][0]["id"] == str(team.id) diff --git a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py deleted file mode 100644 index e5573b58..00000000 --- a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Tests for Teams API endpoint in publish's core app: retrieve -""" -import random -from collections import Counter -from unittest import mock - -import pytest -from rest_framework.test import APIClient - -from core import factories - -from ..utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_teams_retrieve_anonymous(): - """Anonymous users should not be allowed to retrieve a team.""" - team = factories.TeamFactory() - response = APIClient().get(f"/api/v1.0/teams/{team.id}/") - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_teams_retrieve_authenticated_unrelated(): - """ - Authenticated users should not be allowed to retrieve a team to which they are - not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - assert response.status_code == 404 - assert response.json() == {"detail": "Not found."} - - -def test_api_teams_retrieve_authenticated_related(): - """ - Authenticated users should be allowed to retrieve a team to which they - are related whatever the role. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - access1 = factories.TeamAccessFactory(team=team, user=user) - access2 = factories.TeamAccessFactory(team=team) - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" - ) - assert response.status_code == 200 - content = response.json() - assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( - [ - { - "id": str(access1.id), - "user": str(user.id), - "role": access1.role, - "abilities": access1.get_abilities(user), - }, - { - "id": str(access2.id), - "user": str(access2.user.id), - "role": access2.role, - "abilities": access2.get_abilities(user), - }, - ], - key=lambda x: x["user"], - ) - assert response.json() == { - "id": str(team.id), - "name": team.name, - "abilities": team.get_abilities(user), - } diff --git a/src/backend/core/tests/teams/test_core_api_teams_update.py b/src/backend/core/tests/teams/test_core_api_teams_update.py deleted file mode 100644 index 6bc7ae12..00000000 --- a/src/backend/core/tests/teams/test_core_api_teams_update.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Tests for Teams API endpoint in publish's core app: update -""" -import random - -import pytest -from rest_framework.test import APIClient -from rest_framework_simplejwt.tokens import AccessToken - -from core import factories, models -from core.api import serializers - -from ..utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_teams_update_anonymous(): - """Anonymous users should not be allowed to update a team.""" - team = factories.TeamFactory() - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - ) - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - assert team_values == old_team_values - - -def test_api_teams_update_authenticated_unrelated(): - """ - Authenticated users should not be allowed to update a team to which they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 404 - assert response.json() == {"detail": "Not found."} - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - assert team_values == old_team_values - - -def test_api_teams_update_authenticated_members(): - """ - Users who are members of a team but not administrators should - not be allowed to update it. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "member")]) - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - assert team_values == old_team_values - - -def test_api_teams_update_authenticated_administrators(): - """Administrators of a team should be allowed to update it.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 200 - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - for key, value in team_values.items(): - if key in ["id", "accesses"]: - assert value == old_team_values[key] - else: - assert value == new_team_values[key] - - -def test_api_teams_update_authenticated_owners(): - """Administrators of a team should be allowed to update it.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 200 - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - for key, value in team_values.items(): - if key in ["id", "accesses"]: - assert value == old_team_values[key] - else: - assert value == new_team_values[key] - - -def test_api_teams_update_administrator_or_owner_of_another(): - """ - Being administrator or owner of a team should not grant authorization to update - another team. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))]) - team = factories.TeamFactory(name="Old name") - old_team_values = serializers.TeamSerializer(instance=team).data - - new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data - response = APIClient().put( - f"/api/v1.0/teams/{team.id!s}/", - new_team_values, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 404 - assert response.json() == {"detail": "Not found."} - - team.refresh_from_db() - team_values = serializers.TeamSerializer(instance=team).data - assert team_values == old_team_values diff --git a/src/backend/core/tests/templates/test_api_templates_create.py b/src/backend/core/tests/templates/test_api_templates_create.py new file mode 100644 index 00000000..b65d5d08 --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_create.py @@ -0,0 +1,47 @@ +""" +Tests for Templates API endpoint in publish's core app: create +""" +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Template +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_create_anonymous(): + """Anonymous users should not be allowed to create templates.""" + response = APIClient().post( + "/api/v1.0/templates/", + { + "title": "my template", + }, + ) + + assert response.status_code == 401 + assert not Template.objects.exists() + + +def test_api_templates_create_authenticated(): + """ + Authenticated users should be able to create templates and should automatically be declared + as the owner of the newly created template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + response = APIClient().post( + "/api/v1.0/templates/", + { + "title": "my template", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + template = Template.objects.get() + assert template.title == "my template" + assert template.accesses.filter(role="owner", user=user).exists() diff --git a/src/backend/core/tests/templates/test_api_templates_delete.py b/src/backend/core/tests/templates/test_api_templates_delete.py new file mode 100644 index 00000000..7fcade91 --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_delete.py @@ -0,0 +1,84 @@ +""" +Tests for Templates API endpoint in publish's core app: delete +""" +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_delete_anonymous(): + """Anonymous users should not be allowed to destroy a template.""" + template = factories.TemplateFactory() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/", + ) + + assert response.status_code == 401 + assert models.Template.objects.count() == 1 + + +def test_api_templates_delete_authenticated_unrelated(): + """ + Authenticated users should not be allowed to delete a template to which they are not + related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + is_public = random.choice([True, False]) + template = factories.TemplateFactory(is_public=is_public) + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 if is_public else 404 + assert models.Template.objects.count() == 1 + + +@pytest.mark.parametrize("role", ["member", "administrator"]) +def test_api_templates_delete_authenticated_member(role): + """ + Authenticated users should not be allowed to delete a template for which they are + only a member. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, role)]) + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Template.objects.count() == 1 + + +def test_api_templates_delete_authenticated_owner(): + """ + Authenticated users should be able to delete a template for which they are directly + owner. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "owner")]) + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 204 + assert models.Template.objects.exists() is False diff --git a/src/backend/core/tests/templates/test_api_templates_generate_document.py b/src/backend/core/tests/templates/test_api_templates_generate_document.py new file mode 100644 index 00000000..1819a8fb --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_generate_document.py @@ -0,0 +1,107 @@ +""" +Test users API endpoints in the publish core app. +""" +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_generate_document_anonymous_public(): + """Anonymous users can generate pdf document with public templates.""" + template = factories.TemplateFactory(is_public=True) + data = { + "body": "# Test markdown body", + } + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/generate-document/", + data, + format="json", + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + + +def test_api_templates_generate_document_anonymous_not_public(): + """ + Anonymous users should not be allowed to generate pdf document with templates + that are not marked as public. + """ + template = factories.TemplateFactory(is_public=False) + data = { + "body": "# Test markdown body", + } + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/generate-document/", + data, + format="json", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_templates_generate_document_authenticated_public(): + """Authenticated users can generate pdf document with public templates.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(is_public=True) + data = {"body": "# Test markdown body"} + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/generate-document/", + data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + + +def test_api_templates_generate_document_authenticated_not_public(): + """ + Authenticated users should not be allowed to generate pdf document with templates + that are not marked as public. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(is_public=False) + data = {"body": "# Test markdown body"} + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/generate-document/", + data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_templates_generate_document_related(): + """Users related to a template can generate pdf document.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + access = factories.TemplateAccessFactory(user=user) + data = {"body": "# Test markdown body"} + + response = APIClient().post( + f"/api/v1.0/templates/{access.template.id!s}/generate-document/", + data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" diff --git a/src/backend/core/tests/templates/test_api_templates_list.py b/src/backend/core/tests/templates/test_api_templates_list.py new file mode 100644 index 00000000..54011422 --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_list.py @@ -0,0 +1,124 @@ +""" +Tests for Templates API endpoint in publish's core app: list +""" +from unittest import mock + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.status import HTTP_200_OK +from rest_framework.test import APIClient + +from core import factories +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_list_anonymous(): + """Anonymous users should only be able to list public templates.""" + factories.TemplateFactory.create_batch(2, is_public=False) + templates = factories.TemplateFactory.create_batch(2, is_public=True) + expected_ids = {str(template.id) for template in templates} + + response = APIClient().get("/api/v1.0/templates/") + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 2 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_templates_list_authenticated(): + """ + Authenticated users should be able to list templates they are + an owner/administrator/member of. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + related_templates = [ + access.template + for access in factories.TemplateAccessFactory.create_batch(5, user=user) + ] + public_templates = factories.TemplateFactory.create_batch(2, is_public=True) + factories.TemplateFactory.create_batch(2, is_public=False) + + expected_ids = { + str(template.id) for template in related_templates + public_templates + } + + response = APIClient().get( + "/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 7 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_templates_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template_ids = [ + str(access.template.id) + for access in factories.TemplateAccessFactory.create_batch(3, user=user) + ] + + # Get page 1 + response = APIClient().get( + "/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/templates/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + template_ids.remove(item["id"]) + + # Get page 2 + response = APIClient().get( + "/api/v1.0/templates/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/templates/" + + assert len(content["results"]) == 1 + template_ids.remove(content["results"][0]["id"]) + assert template_ids == [] + + +def test_api_templates_list_authenticated_distinct(): + """A template with several related users should only be listed once.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + other_user = factories.UserFactory() + + template = factories.TemplateFactory(users=[user, other_user], is_public=True) + + response = APIClient().get( + "/api/v1.0/templates/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(template.id) diff --git a/src/backend/core/tests/templates/test_api_templates_retrieve.py b/src/backend/core/tests/templates/test_api_templates_retrieve.py new file mode 100644 index 00000000..da90e2a9 --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_retrieve.py @@ -0,0 +1,130 @@ +""" +Tests for Templates API endpoint in publish's core app: retrieve +""" +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_retrieve_anonymous_public(): + """Anonymous users should be allowed to retrieve public templates.""" + template = factories.TemplateFactory(is_public=True) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(template.id), + "abilities": { + "destroy": False, + "generate_document": True, + "manage_accesses": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": template.title, + } + + +def test_api_templates_retrieve_anonymous_not_public(): + """Anonymous users should not be able to retrieve a template that is not public.""" + template = factories.TemplateFactory(is_public=False) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_templates_retrieve_authenticated_unrelated_public(): + """ + Authenticated users should be able to retrieve a public template to which they are + not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(is_public=True) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(template.id), + "abilities": { + "destroy": False, + "generate_document": True, + "manage_accesses": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": template.title, + } + + +def test_api_templates_retrieve_authenticated_unrelated_not_public(): + """ + Authenticated users should not be allowed to retrieve a template that is not public and + to which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(is_public=False) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_templates_retrieve_authenticated_related(): + """ + Authenticated users should be allowed to retrieve a template to which they + are related whatever the role. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + access1 = factories.TemplateAccessFactory(template=template, user=user) + access2 = factories.TemplateAccessFactory(template=template) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( + [ + { + "id": str(access1.id), + "user": str(user.id), + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["user"], + ) + assert response.json() == { + "id": str(template.id), + "title": template.title, + "abilities": template.get_abilities(user), + } diff --git a/src/backend/core/tests/templates/test_api_templates_update.py b/src/backend/core/tests/templates/test_api_templates_update.py new file mode 100644 index 00000000..4f3ef72e --- /dev/null +++ b/src/backend/core/tests/templates/test_api_templates_update.py @@ -0,0 +1,154 @@ +""" +Tests for Templates API endpoint in publish's core app: update +""" +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api import serializers +from core.tests.utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_templates_update_anonymous(): + """Anonymous users should not be allowed to update a template.""" + template = factories.TemplateFactory() + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +def test_api_templates_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a template to which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(is_public=False) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +def test_api_templates_update_authenticated_members(): + """ + Users who are members of a template but not administrators should + not be allowed to update it. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "member")]) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values + + +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_api_templates_update_authenticated_administrators(role): + """Administrators of a template should be allowed to update it.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, role)]) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + for key, value in template_values.items(): + if key in ["id", "accesses"]: + assert value == old_template_values[key] + else: + assert value == new_template_values[key] + + +def test_api_templates_update_administrator_or_owner_of_another(): + """ + Being administrator or owner of a template should not grant authorization to update + another template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + factories.TemplateFactory(users=[(user, random.choice(["administrator", "owner"]))]) + is_public = random.choice([True, False]) + template = factories.TemplateFactory(title="Old title", is_public=is_public) + old_template_values = serializers.TemplateSerializer(instance=template).data + + new_template_values = serializers.TemplateSerializer( + instance=factories.TemplateFactory() + ).data + response = APIClient().put( + f"/api/v1.0/templates/{template.id!s}/", + new_template_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 if is_public else 404 + + template.refresh_from_db() + template_values = serializers.TemplateSerializer(instance=template).data + assert template_values == old_template_values diff --git a/src/backend/core/tests/test_api_team_accesses.py b/src/backend/core/tests/test_api_team_accesses.py deleted file mode 100644 index 89c3a876..00000000 --- a/src/backend/core/tests/test_api_team_accesses.py +++ /dev/null @@ -1,843 +0,0 @@ -""" -Test team accesses API endpoints for users in publish's core app. -""" -import random -from uuid import uuid4 - -import pytest -from rest_framework.test import APIClient - -from core import factories, models -from core.api import serializers - -from .utils import OIDCToken - -pytestmark = pytest.mark.django_db - - -def test_api_team_accesses_list_anonymous(): - """Anonymous users should not be allowed to list team accesses.""" - team = factories.TeamFactory() - factories.TeamAccessFactory.create_batch(2, team=team) - - response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/") - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_team_accesses_list_authenticated_unrelated(): - """ - Authenticated users should not be allowed to list team accesses for a team - to which they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - factories.TeamAccessFactory.create_batch(3, team=team) - - # Accesses for other teams to which the user is related should not be listed either - other_access = factories.TeamAccessFactory(user=user) - factories.TeamAccessFactory(team=other_access.team) - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/accesses/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } - - -def test_api_team_accesses_list_authenticated_related(): - """ - Authenticated users should be able to list team accesses for a team - to which they are related, whatever their role in the team. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - user_access = models.TeamAccess.objects.create(team=team, user=user) # random role - access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team) - - # Accesses for other teams to which the user is related should not be listed either - other_access = factories.TeamAccessFactory(user=user) - factories.TeamAccessFactory(team=other_access.team) - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/accesses/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 200 - content = response.json() - assert len(content["results"]) == 3 - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( - [ - { - "id": str(user_access.id), - "user": str(user.id), - "role": user_access.role, - "abilities": user_access.get_abilities(user), - }, - { - "id": str(access1.id), - "user": str(access1.user.id), - "role": access1.role, - "abilities": access1.get_abilities(user), - }, - { - "id": str(access2.id), - "user": str(access2.user.id), - "role": access2.role, - "abilities": access2.get_abilities(user), - }, - ], - key=lambda x: x["id"], - ) - - -def test_api_team_accesses_retrieve_anonymous(): - """ - Anonymous users should not be allowed to retrieve a team access. - """ - access = factories.TeamAccessFactory() - - response = APIClient().get( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - ) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_team_accesses_retrieve_authenticated_unrelated(): - """ - Authenticated users should not be allowed to retrieve a team access for - a team to which they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - access = factories.TeamAccessFactory(team=team) - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - # Accesses related to another team should be excluded even if the user is related to it - for access in [ - factories.TeamAccessFactory(), - factories.TeamAccessFactory(user=user), - ]: - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 404 - assert response.json() == {"detail": "Not found."} - - -def test_api_team_accesses_retrieve_authenticated_related(): - """ - A user who is related to a team should be allowed to retrieve the - associated team user accesses. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[user]) - access = factories.TeamAccessFactory(team=team) - - response = APIClient().get( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 200 - assert response.json() == { - "id": str(access.id), - "user": str(access.user.id), - "role": access.role, - "abilities": access.get_abilities(user), - } - - -def test_api_team_accesses_create_anonymous(): - """Anonymous users should not be allowed to create team accesses.""" - user = factories.UserFactory() - team = factories.TeamFactory() - - response = APIClient().post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(user.id), - "team": str(team.id), - "role": random.choice(models.RoleChoices.choices)[0], - }, - format="json", - ) - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - assert models.TeamAccess.objects.exists() is False - - -def test_api_team_accesses_create_authenticated_unrelated(): - """ - Authenticated users should not be allowed to create team accesses for a team to - which they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - other_user = factories.UserFactory() - team = factories.TeamFactory() - - response = APIClient().post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(other_user.id), - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You are not allowed to manage accesses for this team." - } - assert not models.TeamAccess.objects.filter(user=other_user).exists() - - -def test_api_team_accesses_create_authenticated_member(): - """Members of a team should not be allowed to create team accesses.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "member")]) - other_user = factories.UserFactory() - - api_client = APIClient() - for role in [role[0] for role in models.RoleChoices.choices]: - response = api_client.post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(other_user.id), - "role": role, - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You are not allowed to manage accesses for this team." - } - - assert not models.TeamAccess.objects.filter(user=other_user).exists() - - -def test_api_team_accesses_create_authenticated_administrator(): - """ - Administrators of a team should be able to create team accesses except for the "owner" role. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - other_user = factories.UserFactory() - - api_client = APIClient() - - # It should not be allowed to create an owner access - response = api_client.post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(other_user.id), - "role": "owner", - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "Only owners of a team can assign other users as owners." - } - - # It should be allowed to create a lower access - role = random.choice( - [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] - ) - - response = api_client.post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(other_user.id), - "role": role, - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 201 - assert models.TeamAccess.objects.filter(user=other_user).count() == 1 - new_team_access = models.TeamAccess.objects.filter(user=other_user).get() - assert response.json() == { - "abilities": new_team_access.get_abilities(user), - "id": str(new_team_access.id), - "role": role, - "user": str(other_user.id), - } - - -def test_api_team_accesses_create_authenticated_owner(): - """ - Owners of a team should be able to create team accesses whatever the role. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - other_user = factories.UserFactory() - - role = random.choice([role[0] for role in models.RoleChoices.choices]) - - response = APIClient().post( - f"/api/v1.0/teams/{team.id!s}/accesses/", - { - "user": str(other_user.id), - "role": role, - }, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 201 - assert models.TeamAccess.objects.filter(user=other_user).count() == 1 - new_team_access = models.TeamAccess.objects.filter(user=other_user).get() - assert response.json() == { - "abilities": new_team_access.get_abilities(user), - "id": str(new_team_access.id), - "role": role, - "user": str(other_user.id), - } - - -def test_api_team_accesses_update_anonymous(): - """Anonymous users should not be allowed to update a team access.""" - access = factories.TeamAccessFactory() - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - response = api_client.put( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - {**old_values, field: value}, - format="json", - ) - assert response.status_code == 401 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_authenticated_unrelated(): - """ - Authenticated users should not be allowed to update a team access for a team to which - they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - access = factories.TeamAccessFactory() - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - response = api_client.put( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - {**old_values, field: value}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 403 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_authenticated_member(): - """Members of a team should not be allowed to update its accesses.""" - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "member")]) - access = factories.TeamAccessFactory(team=team) - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - response = api_client.put( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - {**old_values, field: value}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 403 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_administrator_except_owner(): - """ - A user who is an administrator in a team should be allowed to update a user - access for this team, as long as they don't try to set the role to owner. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - access = factories.TeamAccessFactory( - team=team, - role=random.choice(["administrator", "member"]), - ) - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user_id": factories.UserFactory().id, - "role": random.choice(["administrator", "member"]), - } - - api_client = APIClient() - for field, value in new_values.items(): - new_data = {**old_values, field: value} - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - if ( - new_data["role"] == old_values["role"] - ): # we are not really updating the role - assert response.status_code == 403 - else: - assert response.status_code == 200 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} - else: - assert updated_values == old_values - - -def test_api_team_accesses_update_administrator_from_owner(): - """ - A user who is an administrator in a team, should not be allowed to update - the user access of an "owner" for this team. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - other_user = factories.UserFactory() - access = factories.TeamAccessFactory(team=team, user=other_user, role="owner") - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user_id": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data={**old_values, field: value}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 403 - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_administrator_to_owner(): - """ - A user who is an administrator in a team, should not be allowed to update - the user access of another user to grant team ownership. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - other_user = factories.UserFactory() - access = factories.TeamAccessFactory( - team=team, - user=other_user, - role=random.choice(["administrator", "member"]), - ) - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user_id": factories.UserFactory().id, - "role": "owner", - } - - api_client = APIClient() - for field, value in new_values.items(): - new_data = {**old_values, field: value} - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - # We are not allowed or not really updating the role - if field == "role" or new_data["role"] == old_values["role"]: - assert response.status_code == 403 - else: - assert response.status_code == 200 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_owner_except_owner(): - """ - A user who is an owner in a team should be allowed to update - a user access for this team except for existing "owner" accesses. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - factories.UserFactory() - access = factories.TeamAccessFactory( - team=team, - role=random.choice(["administrator", "member"]), - ) - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user_id": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - new_data = {**old_values, field: value} - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - if ( - new_data["role"] == old_values["role"] - ): # we are not really updating the role - assert response.status_code == 403 - else: - assert response.status_code == 200 - - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - - if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} - else: - assert updated_values == old_values - - -def test_api_team_accesses_update_owner_for_owners(): - """ - A user who is "owner" of a team should not be allowed to update - an existing owner access for this team. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - access = factories.TeamAccessFactory(team=team, role="owner") - old_values = serializers.TeamAccessSerializer(instance=access).data - - new_values = { - "id": uuid4(), - "user_id": factories.UserFactory().id, - "role": random.choice(models.RoleChoices.choices)[0], - } - - api_client = APIClient() - for field, value in new_values.items(): - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data={**old_values, field: value}, - content_type="application/json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - assert response.status_code == 403 - access.refresh_from_db() - updated_values = serializers.TeamAccessSerializer(instance=access).data - assert updated_values == old_values - - -def test_api_team_accesses_update_owner_self(): - """ - A user who is owner of a team should be allowed to update - their own user access provided there are other owners in the team. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - access = factories.TeamAccessFactory(team=team, user=user, role="owner") - old_values = serializers.TeamAccessSerializer(instance=access).data - new_role = random.choice(["administrator", "member"]) - - api_client = APIClient() - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data={**old_values, "role": new_role}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - access.refresh_from_db() - assert access.role == "owner" - - # Add another owner and it should now work - factories.TeamAccessFactory(team=team, role="owner") - - response = api_client.put( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - data={**old_values, "role": new_role}, - format="json", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 200 - access.refresh_from_db() - assert access.role == new_role - - -# Delete - - -def test_api_team_accesses_delete_anonymous(): - """Anonymous users should not be allowed to destroy a team access.""" - access = factories.TeamAccessFactory() - - response = APIClient().delete( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - ) - - assert response.status_code == 401 - assert models.TeamAccess.objects.count() == 1 - - -def test_api_team_accesses_delete_authenticated(): - """ - Authenticated users should not be allowed to delete a team access for a - team to which they are not related. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - access = factories.TeamAccessFactory() - - response = APIClient().delete( - f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert models.TeamAccess.objects.count() == 1 - - -def test_api_team_accesses_delete_member(): - """ - Authenticated users should not be allowed to delete a team access for a - team in which they are a simple member. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "member")]) - access = factories.TeamAccessFactory(team=team) - - assert models.TeamAccess.objects.count() == 2 - assert models.TeamAccess.objects.filter(user=access.user).exists() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert models.TeamAccess.objects.count() == 2 - - -def test_api_team_accesses_delete_administrators(): - """ - Users who are administrators in a team should be allowed to delete an access - from the team provided it is not ownership. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "administrator")]) - access = factories.TeamAccessFactory( - team=team, role=random.choice(["member", "administrator"]) - ) - - assert models.TeamAccess.objects.count() == 2 - assert models.TeamAccess.objects.filter(user=access.user).exists() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 204 - assert models.TeamAccess.objects.count() == 1 - - -def test_api_team_accesses_delete_owners_except_owners(): - """ - Users should be able to delete the team access of another user - for a team of which they are owner provided it is not an owner access. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - access = factories.TeamAccessFactory( - team=team, role=random.choice(["member", "administrator"]) - ) - - assert models.TeamAccess.objects.count() == 2 - assert models.TeamAccess.objects.filter(user=access.user).exists() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 204 - assert models.TeamAccess.objects.count() == 1 - - -def test_api_team_accesses_delete_owners_for_owners(): - """ - Users should not be allowed to delete the team access of another owner - even for a team in which they are direct owner. - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory(users=[(user, "owner")]) - access = factories.TeamAccessFactory(team=team, role="owner") - - assert models.TeamAccess.objects.count() == 2 - assert models.TeamAccess.objects.filter(user=access.user).exists() - - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert models.TeamAccess.objects.count() == 2 - - -def test_api_team_accesses_delete_owners_last_owner(): - """ - It should not be possible to delete the last owner access from a team - """ - identity = factories.IdentityFactory() - user = identity.user - jwt_token = OIDCToken.for_user(user) - - team = factories.TeamFactory() - access = factories.TeamAccessFactory(team=team, user=user, role="owner") - - assert models.TeamAccess.objects.count() == 1 - response = APIClient().delete( - f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", - HTTP_AUTHORIZATION=f"Bearer {jwt_token}", - ) - - assert response.status_code == 403 - assert models.TeamAccess.objects.count() == 1 diff --git a/src/backend/core/tests/test_api_template_accesses.py b/src/backend/core/tests/test_api_template_accesses.py new file mode 100644 index 00000000..cb57e686 --- /dev/null +++ b/src/backend/core/tests/test_api_template_accesses.py @@ -0,0 +1,791 @@ +""" +Test template accesses API endpoints for users in publish's core app. +""" +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers + +from .utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_template_accesses_list_anonymous(): + """Anonymous users should not be allowed to list template accesses.""" + template = factories.TemplateFactory() + factories.TemplateAccessFactory.create_batch(2, template=template) + + response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_template_accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list template accesses for a template + to which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + factories.TemplateAccessFactory.create_batch(3, template=template) + + # Accesses for other templates to which the user is related should not be listed either + other_access = factories.TemplateAccessFactory(user=user) + factories.TemplateAccessFactory(template=other_access.template) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/accesses/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_template_accesses_list_authenticated_related(): + """ + Authenticated users should be able to list template accesses for a template + to which they are related, whatever their role in the template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + user_access = models.TemplateAccess.objects.create( + template=template, user=user + ) # random role + access1, access2 = factories.TemplateAccessFactory.create_batch( + 2, template=template + ) + + # Accesses for other templates to which the user is related should not be listed either + other_access = factories.TemplateAccessFactory(user=user) + factories.TemplateAccessFactory(template=other_access.template) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/accesses/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 3 + assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + [ + { + "id": str(user_access.id), + "user": str(user.id), + "role": user_access.role, + "abilities": user_access.get_abilities(user), + }, + { + "id": str(access1.id), + "user": str(access1.user.id), + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + + +def test_api_template_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a template access. + """ + access = factories.TemplateAccessFactory() + + response = APIClient().get( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_template_accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a template access for + a template to which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + access = factories.TemplateAccessFactory(template=template) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Accesses related to another template should be excluded even if the user is related to it + for access in [ + factories.TemplateAccessFactory(), + factories.TemplateAccessFactory(user=user), + ]: + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_template_accesses_retrieve_authenticated_related(): + """ + A user who is related to a template should be allowed to retrieve the + associated template user accesses. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[user]) + access = factories.TemplateAccessFactory(template=template) + + response = APIClient().get( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "role": access.role, + "abilities": access.get_abilities(user), + } + + +def test_api_template_accesses_create_anonymous(): + """Anonymous users should not be allowed to create template accesses.""" + user = factories.UserFactory() + template = factories.TemplateFactory() + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(user.id), + "template": str(template.id), + "role": random.choice(models.RoleChoices.choices)[0], + }, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.TemplateAccess.objects.exists() is False + + +def test_api_template_accesses_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create template accesses for a template to + which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + other_user = factories.UserFactory() + template = factories.TemplateFactory() + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert not models.TemplateAccess.objects.filter(user=other_user).exists() + + +def test_api_template_accesses_create_authenticated_member(): + """Members of a template should not be allowed to create template accesses.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "member")]) + other_user = factories.UserFactory() + + api_client = APIClient() + for role in [role[0] for role in models.RoleChoices.choices]: + response = api_client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + + assert not models.TemplateAccess.objects.filter(user=other_user).exists() + + +def test_api_template_accesses_create_authenticated_administrator(): + """ + Administrators of a template should be able to create template accesses + except for the "owner" role. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + + api_client = APIClient() + + # It should not be allowed to create an owner access + response = api_client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": "owner", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a template can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + response = api_client.post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + assert models.TemplateAccess.objects.filter(user=other_user).count() == 1 + new_template_access = models.TemplateAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_template_access.get_abilities(user), + "id": str(new_template_access.id), + "role": role, + "user": str(other_user.id), + } + + +def test_api_template_accesses_create_authenticated_owner(): + """ + Owners of a template should be able to create template accesses whatever the role. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "owner")]) + other_user = factories.UserFactory() + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + response = APIClient().post( + f"/api/v1.0/templates/{template.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + assert models.TemplateAccess.objects.filter(user=other_user).count() == 1 + new_template_access = models.TemplateAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_template_access.get_abilities(user), + "id": str(new_template_access.id), + "role": role, + "user": str(other_user.id), + } + + +def test_api_template_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a template access.""" + access = factories.TemplateAccessFactory() + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a template access for a template to which + they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + access = factories.TemplateAccessFactory() + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_authenticated_member(): + """Members of a template should not be allowed to update its accesses.""" + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "member")]) + access = factories.TemplateAccessFactory(template=template) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_administrator_except_owner(): + """ + A user who is an administrator in a template should be allowed to update a user + access for this template, as long as they don't try to set the role to owner. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + access = factories.TemplateAccessFactory( + template=template, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(["administrator", "member"]), + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_template_accesses_update_administrator_from_owner(): + """ + A user who is an administrator in a template, should not be allowed to update + the user access of an "owner" for this template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + access = factories.TemplateAccessFactory( + template=template, user=other_user, role="owner" + ) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_administrator_to_owner(): + """ + A user who is an administrator in a template, should not be allowed to update + the user access of another user to grant template ownership. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + access = factories.TemplateAccessFactory( + template=template, + user=other_user, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": "owner", + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + # We are not allowed or not really updating the role + if field == "role" or new_data["role"] == old_values["role"]: + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_template_accesses_update_owner(): + """ + A user who is an owner in a template should be allowed to update + a user access for this template whatever the role. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "owner")]) + factories.UserFactory() + access = factories.TemplateAccessFactory( + template=template, + ) + old_values = serializers.TemplateAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TemplateAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_template_accesses_update_owner_self(): + """ + A user who is owner of a template should be allowed to update + their own user access provided there are other owners in the template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + access = factories.TemplateAccessFactory(template=template, user=user, role="owner") + old_values = serializers.TemplateAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "member"]) + + api_client = APIClient() + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + factories.TemplateAccessFactory(template=template, role="owner") + + response = api_client.put( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_template_accesses_delete_anonymous(): + """Anonymous users should not be allowed to destroy a template access.""" + access = factories.TemplateAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert models.TemplateAccess.objects.count() == 1 + + +def test_api_template_accesses_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a template access for a + template to which they are not related. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + access = factories.TemplateAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 1 + + +def test_api_template_accesses_delete_member(): + """ + Authenticated users should not be allowed to delete a template access for a + template in which they are a simple member. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "member")]) + access = factories.TemplateAccessFactory(template=template) + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 2 + + +def test_api_template_accesses_delete_administrators_except_owners(): + """ + Users who are administrators in a template should be allowed to delete an access + from the template provided it is not ownership. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + access = factories.TemplateAccessFactory( + template=template, role=random.choice(["member", "administrator"]) + ) + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.TemplateAccess.objects.count() == 1 + + +def test_api_template_accesses_delete_administrators_owners(): + """ + Users who are administrators in a template should not be allowed to delete an ownership + access from the template. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "administrator")]) + access = factories.TemplateAccessFactory(template=template, role="owner") + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 2 + + +def test_api_template_accesses_delete_owners(): + """ + Users should be able to delete the template access of another user + for a template of which they are owner. + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory(users=[(user, "owner")]) + access = factories.TemplateAccessFactory( + template=template, + ) + + assert models.TemplateAccess.objects.count() == 2 + assert models.TemplateAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.TemplateAccess.objects.count() == 1 + + +def test_api_template_accesses_delete_owners_last_owner(): + """ + It should not be possible to delete the last owner access from a template + """ + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) + + template = factories.TemplateFactory() + access = factories.TemplateAccessFactory(template=template, user=user, role="owner") + + assert models.TemplateAccess.objects.count() == 1 + response = APIClient().delete( + f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TemplateAccess.objects.count() == 1 diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index ebd6aeae..25bbc9b6 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -25,8 +25,8 @@ def test_api_users_list_authenticated(): """ Authenticated users should not be able to list users. """ - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) factories.UserFactory.create_batch(2) response = APIClient().get( @@ -49,15 +49,9 @@ def test_api_users_retrieve_me_anonymous(): def test_api_users_retrieve_me_authenticated(): """Authenticated users should be able to retrieve their own user via the "/users/me" path.""" - identity = factories.IdentityFactory() - user = identity.user + user = factories.UserFactory() jwt_token = OIDCToken.for_user(user) - # Define profile contact - contact = factories.ContactFactory(owner=user) - user.profile_contact = contact - user.save() - factories.UserFactory.create_batch(2) response = APIClient().get( "/api/v1.0/users/me/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" @@ -70,7 +64,6 @@ def test_api_users_retrieve_me_authenticated(): "timezone": str(user.timezone), "is_device": False, "is_staff": False, - "data": user.profile_contact.data, } @@ -91,8 +84,7 @@ def test_api_users_retrieve_authenticated_self(): Authenticated users should be allowed to retrieve their own user. The returned object should not contain the password. """ - identity = factories.IdentityFactory() - user = identity.user + user = factories.UserFactory() jwt_token = OIDCToken.for_user(user) response = APIClient().get( @@ -107,8 +99,8 @@ def test_api_users_retrieve_authenticated_other(): Authenticated users should be able to retrieve another user's detail view with limited information. """ - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) other_user = factories.UserFactory() @@ -135,8 +127,7 @@ def test_api_users_create_anonymous(): def test_api_users_create_authenticated(): """Authenticated users should not be able to create users via the API.""" - identity = factories.IdentityFactory() - user = identity.user + user = factories.UserFactory() jwt_token = OIDCToken.for_user(user) response = APIClient().post( @@ -182,8 +173,7 @@ def test_api_users_update_authenticated_self(): Authenticated users should be able to update their own user but only "language" and "timezone" fields. """ - identity = factories.IdentityFactory() - user = identity.user + user = factories.UserFactory() jwt_token = OIDCToken.for_user(user) old_user_values = dict(serializers.UserSerializer(instance=user).data) @@ -210,8 +200,8 @@ def test_api_users_update_authenticated_self(): def test_api_users_update_authenticated_other(): """Authenticated users should not be allowed to update other users.""" - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) user = factories.UserFactory() old_user_values = dict(serializers.UserSerializer(instance=user).data) @@ -262,8 +252,7 @@ def test_api_users_patch_authenticated_self(): Authenticated users should be able to patch their own user but only "language" and "timezone" fields. """ - identity = factories.IdentityFactory() - user = identity.user + user = factories.UserFactory() jwt_token = OIDCToken.for_user(user) old_user_values = dict(serializers.UserSerializer(instance=user).data) @@ -291,8 +280,8 @@ def test_api_users_patch_authenticated_self(): def test_api_users_patch_authenticated_other(): """Authenticated users should not be allowed to patch other users.""" - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) user = factories.UserFactory() old_user_values = dict(serializers.UserSerializer(instance=user).data) @@ -329,8 +318,8 @@ def test_api_users_delete_list_anonymous(): def test_api_users_delete_list_authenticated(): """Authenticated users should not be allowed to delete a list of users.""" factories.UserFactory.create_batch(2) - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) client = APIClient() response = client.delete( @@ -355,8 +344,8 @@ def test_api_users_delete_authenticated(): """ Authenticated users should not be allowed to delete a user other than themselves. """ - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) other_user = factories.UserFactory() response = APIClient().delete( @@ -369,11 +358,11 @@ def test_api_users_delete_authenticated(): def test_api_users_delete_self(): """Authenticated users should not be able to delete their own user.""" - identity = factories.IdentityFactory() - jwt_token = OIDCToken.for_user(identity.user) + user = factories.UserFactory() + jwt_token = OIDCToken.for_user(user) response = APIClient().delete( - f"/api/v1.0/users/{identity.user.id!s}/", + f"/api/v1.0/users/{user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}", ) diff --git a/src/backend/core/tests/test_models_identities.py b/src/backend/core/tests/test_models_identities.py deleted file mode 100644 index 25108bd4..00000000 --- a/src/backend/core/tests/test_models_identities.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -Unit tests for the Identity model -""" -from django.core.exceptions import ValidationError - -import pytest - -from core import factories, models - -pytestmark = pytest.mark.django_db - - -def test_models_identities_str_main(): - """The str representation should be the email address with indication that it is main.""" - identity = factories.IdentityFactory(email="david@example.com") - assert str(identity) == "david@example.com[main]" - - -def test_models_identities_str_secondary(): - """The str representation of a secondary email should be the email address.""" - main_identity = factories.IdentityFactory() - secondary_identity = factories.IdentityFactory( - user=main_identity.user, email="david@example.com" - ) - assert str(secondary_identity) == "david@example.com" - - -def test_models_identities_is_main_automatic(): - """The first identity created for a user should automatically be set as main.""" - user = factories.UserFactory() - identity = models.Identity.objects.create( - user=user, sub="123", email="david@example.com" - ) - assert identity.is_main is True - - -def test_models_identities_is_main_exists(): - """A user should always keep one and only one of its identities as main.""" - user = factories.UserFactory() - main_identity, _secondary_identity = factories.IdentityFactory.create_batch( - 2, user=user - ) - - assert main_identity.is_main is True - - main_identity.is_main = False - with pytest.raises( - ValidationError, match="A user should have one and only one main identity." - ): - main_identity.save() - - -def test_models_identities_is_main_switch(): - """Setting a secondary identity as main should reset the existing main identity.""" - user = factories.UserFactory() - first_identity, second_identity = factories.IdentityFactory.create_batch( - 2, user=user - ) - - assert first_identity.is_main is True - - second_identity.is_main = True - second_identity.save() - - second_identity.refresh_from_db() - assert second_identity.is_main is True - - first_identity.refresh_from_db() - assert first_identity.is_main is False - - -def test_models_identities_email_required(): - """The "email" field is required.""" - user = factories.UserFactory() - with pytest.raises(ValidationError, match="This field cannot be null."): - models.Identity.objects.create(user=user, email=None) - - -def test_models_identities_user_required(): - """The "user" field is required.""" - with pytest.raises(models.User.DoesNotExist, match="Identity has no user."): - models.Identity.objects.create(user=None, email="david@example.com") - - -def test_models_identities_email_unique_same_user(): - """The "email" field should be unique for a given user.""" - email = factories.IdentityFactory() - - with pytest.raises( - ValidationError, - match="Identity with this User and Email address already exists.", - ): - factories.IdentityFactory(user=email.user, email=email.email) - - -def test_models_identities_email_unique_different_users(): - """The "email" field should not be unique among users.""" - email = factories.IdentityFactory() - factories.IdentityFactory(email=email.email) - - -def test_models_identities_email_normalization(): - """The email field should be automatically normalized upon saving.""" - email = factories.IdentityFactory() - email.email = "Thomas.Jefferson@Example.com" - email.save() - assert email.email == "Thomas.Jefferson@example.com" - - -def test_models_identities_ordering(): - """Identitys should be returned ordered by main status then by their email address.""" - user = factories.UserFactory() - factories.IdentityFactory.create_batch(5, user=user) - - emails = models.Identity.objects.all() - - assert emails[0].is_main is True - for i in range(3): - assert emails[i + 1].is_main is False - assert emails[i + 2].email >= emails[i + 1].email - - -def test_models_identities_sub_null(): - """The "sub" field should not be null.""" - user = factories.UserFactory() - with pytest.raises(ValidationError, match="This field cannot be null."): - models.Identity.objects.create(user=user, sub=None) - - -def test_models_identities_sub_blank(): - """The "sub" field should not be blank.""" - user = factories.UserFactory() - with pytest.raises(ValidationError, match="This field cannot be blank."): - models.Identity.objects.create(user=user, email="david@example.com", sub="") - - -def test_models_identities_sub_unique(): - """The "sub" field should be unique.""" - user = factories.UserFactory() - identity = factories.IdentityFactory() - with pytest.raises(ValidationError, match="Identity with this Sub already exists."): - models.Identity.objects.create(user=user, sub=identity.sub) - - -def test_models_identities_sub_max_length(): - """The sub field should be 255 characters maximum.""" - factories.IdentityFactory(sub="a" * 255) - with pytest.raises(ValidationError) as excinfo: - factories.IdentityFactory(sub="a" * 256) - - assert ( - str(excinfo.value) - == "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}" - ) - - -def test_models_identities_sub_special_characters(): - """The sub field should accept periods, dashes, +, @ and underscores.""" - identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000") - assert identity.sub == "dave.bowman-1+2@hal_9000" - - -def test_models_identities_sub_spaces(): - """The sub field should not accept spaces.""" - with pytest.raises(ValidationError) as excinfo: - factories.IdentityFactory(sub="a b") - - assert str(excinfo.value) == ( - "{'sub': ['Enter a valid sub. This value may contain only letters, numbers, " - "and @/./+/-/_ characters.']}" - ) - - -def test_models_identities_sub_upper_case(): - """The sub field should accept upper case characters.""" - identity = factories.IdentityFactory(sub="John") - assert identity.sub == "John" - - -def test_models_identities_sub_ascii(): - """The sub field should accept non ASCII letters.""" - identity = factories.IdentityFactory(sub="rené") - assert identity.sub == "rené" diff --git a/src/backend/core/tests/test_models_team_accesses.py b/src/backend/core/tests/test_models_team_accesses.py deleted file mode 100644 index e53adf80..00000000 --- a/src/backend/core/tests/test_models_team_accesses.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Unit tests for the TeamAccess model -""" -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import ValidationError - -import pytest - -from core import factories - -pytestmark = pytest.mark.django_db - - -def test_models_team_accesses_str(): - """ - The str representation should include user name, team full name and role. - """ - contact = factories.ContactFactory(full_name="David Bowman") - user = contact.owner - user.profile_contact = contact - user.save() - access = factories.TeamAccessFactory( - role="member", - user=user, - team__name="admins", - ) - assert str(access) == "David Bowman is member in team admins" - - -def test_models_team_accesses_unique(): - """Team accesses should be unique for a given couple of user and team.""" - access = factories.TeamAccessFactory() - - with pytest.raises( - ValidationError, - match="Team/user relation with this User and Team already exists.", - ): - factories.TeamAccessFactory(user=access.user, team=access.team) - - -# get_abilities - - -def test_models_team_access_get_abilities_anonymous(): - """Check abilities returned for an anonymous user.""" - access = factories.TeamAccessFactory() - abilities = access.get_abilities(AnonymousUser()) - assert abilities == { - "delete": False, - "get": False, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_authenticated(): - """Check abilities returned for an authenticated user.""" - access = factories.TeamAccessFactory() - user = factories.UserFactory() - abilities = access.get_abilities(user) - assert abilities == { - "delete": False, - "get": False, - "patch": False, - "put": False, - "set_role_to": [], - } - - -# - for owner - - -def test_models_team_access_get_abilities_for_owner_of_self_allowed(): - """ - Check abilities of self access for the owner of a team when there is more than one user left. - """ - access = factories.TeamAccessFactory(role="owner") - factories.TeamAccessFactory(team=access.team, role="owner") - abilities = access.get_abilities(access.user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "set_role_to": ["administrator", "member"], - } - - -def test_models_team_access_get_abilities_for_owner_of_self_last(): - """Check abilities of self access for the owner of a team when there is only one owner left.""" - access = factories.TeamAccessFactory(role="owner") - abilities = access.get_abilities(access.user) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_for_owner_of_owner(): - """Check abilities of owner access for the owner of a team.""" - access = factories.TeamAccessFactory(role="owner") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="owner").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_for_owner_of_administrator(): - """Check abilities of administrator access for the owner of a team.""" - access = factories.TeamAccessFactory(role="administrator") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="owner").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "set_role_to": ["owner", "member"], - } - - -def test_models_team_access_get_abilities_for_owner_of_member(): - """Check abilities of member access for the owner of a team.""" - access = factories.TeamAccessFactory(role="member") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="owner").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "set_role_to": ["owner", "administrator"], - } - - -# - for administrator - - -def test_models_team_access_get_abilities_for_administrator_of_owner(): - """Check abilities of owner access for the administrator of a team.""" - access = factories.TeamAccessFactory(role="owner") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="administrator").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_for_administrator_of_administrator(): - """Check abilities of administrator access for the administrator of a team.""" - access = factories.TeamAccessFactory(role="administrator") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="administrator").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "set_role_to": ["member"], - } - - -def test_models_team_access_get_abilities_for_administrator_of_member(): - """Check abilities of member access for the administrator of a team.""" - access = factories.TeamAccessFactory(role="member") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="administrator").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "set_role_to": ["administrator"], - } - - -# - for member - - -def test_models_team_access_get_abilities_for_member_of_owner(): - """Check abilities of owner access for the member of a team.""" - access = factories.TeamAccessFactory(role="owner") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="member").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_for_member_of_administrator(): - """Check abilities of administrator access for the member of a team.""" - access = factories.TeamAccessFactory(role="administrator") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="member").user - abilities = access.get_abilities(user) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_for_member_of_member_user( - django_assert_num_queries -): - """Check abilities of member access for the member of a team.""" - access = factories.TeamAccessFactory(role="member") - factories.TeamAccessFactory(team=access.team) # another one - user = factories.TeamAccessFactory(team=access.team, role="member").user - - with django_assert_num_queries(1): - abilities = access.get_abilities(user) - - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } - - -def test_models_team_access_get_abilities_preset_role(django_assert_num_queries): - """No query is done if the role is preset, e.g., with a query annotation.""" - access = factories.TeamAccessFactory(role="member") - user = factories.TeamAccessFactory(team=access.team, role="member").user - access.user_role = "member" - - with django_assert_num_queries(0): - abilities = access.get_abilities(user) - - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "set_role_to": [], - } diff --git a/src/backend/core/tests/test_models_teams.py b/src/backend/core/tests/test_models_teams.py deleted file mode 100644 index 7006e5ff..00000000 --- a/src/backend/core/tests/test_models_teams.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Unit tests for the Team model -""" -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import ValidationError - -import pytest - -from core import factories, models - -pytestmark = pytest.mark.django_db - - -def test_models_teams_str(): - """The str representation should be the name of the team.""" - team = factories.TeamFactory(name="admins") - assert str(team) == "admins" - - -def test_models_teams_id_unique(): - """The "id" field should be unique.""" - team = factories.TeamFactory() - with pytest.raises(ValidationError, match="Team with this Id already exists."): - factories.TeamFactory(id=team.id) - - -def test_models_teams_name_null(): - """The "name" field should not be null.""" - with pytest.raises(ValidationError, match="This field cannot be null."): - models.Team.objects.create(name=None) - - -def test_models_teams_name_empty(): - """The "name" field should not be empty.""" - with pytest.raises(ValidationError, match="This field cannot be blank."): - models.Team.objects.create(name="") - - -def test_models_teams_name_max_length(): - """The "name" field should be 100 characters maximum.""" - factories.TeamFactory(name="a " * 50) - with pytest.raises( - ValidationError, - match=r"Ensure this value has at most 100 characters \(it has 102\)\.", - ): - factories.TeamFactory(name="a " * 51) - - -# get_abilities - - -def test_models_teams_get_abilities_anonymous(): - """Check abilities returned for an anonymous user.""" - team = factories.TeamFactory() - abilities = team.get_abilities(AnonymousUser()) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "manage_accesses": False, - } - - -def test_models_teams_get_abilities_authenticated(): - """Check abilities returned for an authenticated user.""" - team = factories.TeamFactory() - abilities = team.get_abilities(factories.UserFactory()) - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "manage_accesses": False, - } - - -def test_models_teams_get_abilities_owner(): - """Check abilities returned for the owner of a team.""" - user = factories.UserFactory() - access = factories.TeamAccessFactory(role="owner", user=user) - abilities = access.team.get_abilities(access.user) - assert abilities == { - "delete": True, - "get": True, - "patch": True, - "put": True, - "manage_accesses": True, - } - - -def test_models_teams_get_abilities_administrator(): - """Check abilities returned for the administrator of a team.""" - access = factories.TeamAccessFactory(role="administrator") - abilities = access.team.get_abilities(access.user) - assert abilities == { - "delete": False, - "get": True, - "patch": True, - "put": True, - "manage_accesses": True, - } - - -def test_models_teams_get_abilities_member_user(django_assert_num_queries): - """Check abilities returned for the member of a team.""" - access = factories.TeamAccessFactory(role="member") - - with django_assert_num_queries(1): - abilities = access.team.get_abilities(access.user) - - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "manage_accesses": False, - } - - -def test_models_teams_get_abilities_preset_role(django_assert_num_queries): - """No query is done if the role is preset e.g. with query annotation.""" - access = factories.TeamAccessFactory(role="member") - access.team.user_role = "member" - - with django_assert_num_queries(0): - abilities = access.team.get_abilities(access.user) - - assert abilities == { - "delete": False, - "get": True, - "patch": False, - "put": False, - "manage_accesses": False, - } diff --git a/src/backend/core/tests/test_models_template_accesses.py b/src/backend/core/tests/test_models_template_accesses.py new file mode 100644 index 00000000..e5a54331 --- /dev/null +++ b/src/backend/core/tests/test_models_template_accesses.py @@ -0,0 +1,256 @@ +""" +Unit tests for the TemplateAccess model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_models_template_accesses_str(): + """ + The str representation should include user email, template title and role. + """ + user = factories.UserFactory(email="david.bowman@example.com") + access = factories.TemplateAccessFactory( + role="member", + user=user, + template__title="admins", + ) + assert str(access) == "david.bowman@example.com is member in template admins" + + +def test_models_template_accesses_unique(): + """Template accesses should be unique for a given couple of user and template.""" + access = factories.TemplateAccessFactory() + + with pytest.raises( + ValidationError, + match="Template/user relation with this User and Template already exists.", + ): + factories.TemplateAccessFactory(user=access.user, template=access.template) + + +# get_abilities + + +def test_models_template_access_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.TemplateAccessFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.TemplateAccessFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "set_role_to": [], + } + + +# - for owner + + +def test_models_template_access_get_abilities_for_owner_of_self_allowed(): + """ + Check abilities of self access for the owner of a template when + there is more than one owner left. + """ + access = factories.TemplateAccessFactory(role="owner") + factories.TemplateAccessFactory(template=access.template, role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator", "member"], + } + + +def test_models_template_access_get_abilities_for_owner_of_self_last(): + """ + Check abilities of self access for the owner of a template when there is only one owner left. + """ + access = factories.TemplateAccessFactory(role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_owner_of_owner(): + """Check abilities of owner access for the owner of a template.""" + access = factories.TemplateAccessFactory(role="owner") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator", "member"], + } + + +def test_models_template_access_get_abilities_for_owner_of_administrator(): + """Check abilities of administrator access for the owner of a template.""" + access = factories.TemplateAccessFactory(role="administrator") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["owner", "member"], + } + + +def test_models_template_access_get_abilities_for_owner_of_member(): + """Check abilities of member access for the owner of a template.""" + access = factories.TemplateAccessFactory(role="member") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["owner", "administrator"], + } + + +# - for administrator + + +def test_models_template_access_get_abilities_for_administrator_of_owner(): + """Check abilities of owner access for the administrator of a template.""" + access = factories.TemplateAccessFactory(role="owner") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_administrator_of_administrator(): + """Check abilities of administrator access for the administrator of a template.""" + access = factories.TemplateAccessFactory(role="administrator") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["member"], + } + + +def test_models_template_access_get_abilities_for_administrator_of_member(): + """Check abilities of member access for the administrator of a template.""" + access = factories.TemplateAccessFactory(role="member") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory( + template=access.template, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator"], + } + + +# - for member + + +def test_models_template_access_get_abilities_for_member_of_owner(): + """Check abilities of owner access for the member of a template.""" + access = factories.TemplateAccessFactory(role="owner") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="member").user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_member_of_administrator(): + """Check abilities of administrator access for the member of a template.""" + access = factories.TemplateAccessFactory(role="administrator") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="member").user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_for_member_of_member_user( + django_assert_num_queries +): + """Check abilities of member access for the member of a template.""" + access = factories.TemplateAccessFactory(role="member") + factories.TemplateAccessFactory(template=access.template) # another one + user = factories.TemplateAccessFactory(template=access.template, role="member").user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_template_access_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset, e.g., with a query annotation.""" + access = factories.TemplateAccessFactory(role="member") + user = factories.TemplateAccessFactory(template=access.template, role="member").user + access.user_role = "member" + + with django_assert_num_queries(0): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py new file mode 100644 index 00000000..36473636 --- /dev/null +++ b/src/backend/core/tests/test_models_templates.py @@ -0,0 +1,161 @@ +""" +Unit tests for the Template model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_templates_str(): + """The str representation should be the title of the template.""" + template = factories.TemplateFactory(title="admins") + assert str(template) == "admins" + + +def test_models_templates_id_unique(): + """The "id" field should be unique.""" + template = factories.TemplateFactory() + with pytest.raises(ValidationError, match="Template with this Id already exists."): + factories.TemplateFactory(id=template.id) + + +def test_models_templates_title_null(): + """The "title" field should not be null.""" + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Template.objects.create(title=None) + + +def test_models_templates_title_empty(): + """The "title" field should not be empty.""" + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.Template.objects.create(title="") + + +def test_models_templates_title_max_length(): + """The "title" field should be 100 characters maximum.""" + factories.TemplateFactory(title="a" * 255) + with pytest.raises( + ValidationError, + match=r"Ensure this value has at most 255 characters \(it has 256\)\.", + ): + factories.TemplateFactory(title="a" * 256) + + +# get_abilities + + +def test_models_templates_get_abilities_anonymous_public(): + """Check abilities returned for an anonymous user if the template is public.""" + template = factories.TemplateFactory(is_public=True) + abilities = template.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_anonymous_not_public(): + """Check abilities returned for an anonymous user if the template is private.""" + template = factories.TemplateFactory(is_public=False) + abilities = template.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "manage_accesses": False, + "generate_document": False, + } + + +def test_models_templates_get_abilities_authenticated_public(): + """Check abilities returned for an authenticated user if the user is public.""" + template = factories.TemplateFactory(is_public=True) + abilities = template.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_authenticated_not_public(): + """Check abilities returned for an authenticated user if the template is private.""" + template = factories.TemplateFactory(is_public=False) + abilities = template.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "manage_accesses": False, + "generate_document": False, + } + + +def test_models_templates_get_abilities_owner(): + """Check abilities returned for the owner of a template.""" + user = factories.UserFactory() + access = factories.TemplateAccessFactory(role="owner", user=user) + abilities = access.template.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "manage_accesses": True, + "generate_document": True, + } + + +def test_models_templates_get_abilities_administrator(): + """Check abilities returned for the administrator of a template.""" + access = factories.TemplateAccessFactory(role="administrator") + abilities = access.template.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": True, + "manage_accesses": True, + "generate_document": True, + } + + +def test_models_templates_get_abilities_member_user(django_assert_num_queries): + """Check abilities returned for the member of a template.""" + access = factories.TemplateAccessFactory(role="member") + + with django_assert_num_queries(1): + abilities = access.template.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + "generate_document": True, + } + + +def test_models_templates_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset e.g. with query annotation.""" + access = factories.TemplateAccessFactory(role="member") + access.template.user_role = "member" + + with django_assert_num_queries(0): + abilities = access.template.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + "generate_document": True, + } diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index 176a36ae..f3e808c4 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -7,19 +7,15 @@ from django.core.exceptions import ValidationError import pytest -from core import factories, models +from core import factories pytestmark = pytest.mark.django_db def test_models_users_str(): - """The str representation should be the full name.""" + """The str representation should be the email.""" user = factories.UserFactory() - contact = factories.ContactFactory(full_name="david bowman", owner=user) - user.profile_contact = contact - user.save() - - assert str(user) == "david bowman" + assert str(user) == user.email def test_models_users_id_unique(): @@ -29,55 +25,21 @@ def test_models_users_id_unique(): factories.UserFactory(id=user.id) -def test_models_users_profile_not_owned(): - """A user cannot declare as profile a contact that not is owned.""" - user = factories.UserFactory() - contact = factories.ContactFactory(base=None, owner=None) - - user.profile_contact = contact - with pytest.raises(ValidationError) as excinfo: - user.save() - - assert ( - str(excinfo.value) - == "{'__all__': ['Users can only declare as profile a contact they own.']}" - ) - - -def test_models_users_profile_owned_by_other(): - """A user cannot declare as profile a contact that is owned by another user.""" - user = factories.UserFactory() - contact = factories.ContactFactory() - - user.profile_contact = contact - with pytest.raises(ValidationError) as excinfo: - user.save() - - assert ( - str(excinfo.value) - == "{'__all__': ['Users can only declare as profile a contact they own.']}" - ) - - def test_models_users_send_mail_main_existing(): - """The "email_user' method should send mail to the user's main email address.""" - main_email = factories.IdentityFactory(email="dave@example.com") - user = main_email.user - factories.IdentityFactory.create_batch(2, user=user) + """The "email_user' method should send mail to the user's email address.""" + user = factories.UserFactory() with mock.patch("django.core.mail.send_mail") as mock_send: user.email_user("my subject", "my message") - mock_send.assert_called_once_with( - "my subject", "my message", None, ["dave@example.com"] - ) + mock_send.assert_called_once_with("my subject", "my message", None, [user.email]) def test_models_users_send_mail_main_missing(): """The "email_user' method should fail if the user has no email address.""" - user = factories.UserFactory() + user = factories.UserFactory(email=None) - with pytest.raises(models.Identity.DoesNotExist) as excinfo: + with pytest.raises(ValueError) as excinfo: user.email_user("my subject", "my message") - assert str(excinfo.value) == "Identity matching query does not exist." + assert str(excinfo.value) == "User has no email address." diff --git a/src/backend/core/tests/utils.py b/src/backend/core/tests/utils.py index b9aa6384..e94be94d 100644 --- a/src/backend/core/tests/utils.py +++ b/src/backend/core/tests/utils.py @@ -7,15 +7,9 @@ class OIDCToken(AccessToken): @classmethod def for_user(cls, user): - token = super().for_user(user) - identity = user.identities.filter(is_main=True).first() - token["first_name"] = ( - user.profile_contact.short_name if user.profile_contact else "David" - ) - token["last_name"] = ( - " ".join(user.profile_contact.full_name.split()[1:]) - if user.profile_contact - else "Bowman" - ) - token["email"] = identity.email + """Returns an authorization token for the given user for testing.""" + token = cls() + token["sub"] = str(user.sub) + token["email"] = user.email + return token diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 7be45b56..4ee4f947 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -1,10 +1,36 @@ """URL configuration for the core app.""" -from django.urls import path +from django.conf import settings +from django.urls import include, path, re_path + +from rest_framework.routers import DefaultRouter + +from core.api import viewsets + +# - Main endpoints +router = DefaultRouter() +router.register("templates", viewsets.TemplateViewSet, basename="templates") +router.register("users", viewsets.UserViewSet, basename="users") + +# - Routes nested under a template +template_related_router = DefaultRouter() +template_related_router.register( + "accesses", + viewsets.TemplateAccessViewSet, + basename="template_accesses", +) -from core.views import generate_document, TemplatesApiView, GenerateDocumentAPIView urlpatterns = [ - path('generate-document/', generate_document, name='generate_document'), - path('api/generate-document/', GenerateDocumentAPIView.as_view(), name='generate-document'), - path('api/templates', TemplatesApiView.as_view()), + path( + f"api/{settings.API_VERSION}/", + include( + [ + *router.urls, + re_path( + r"^templates/(?P[0-9a-z-]*)/", + include(template_related_router.urls), + ), + ] + ), + ), ] diff --git a/src/backend/core/views.py b/src/backend/core/views.py deleted file mode 100644 index bbc37a11..00000000 --- a/src/backend/core/views.py +++ /dev/null @@ -1,79 +0,0 @@ -from django.shortcuts import render, HttpResponse -from .forms import DocumentGenerationForm -from .models import Template -from rest_framework import status - -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import serializers - -from django.http import FileResponse -from io import BytesIO - - -def generate_document(request): - if request.method == 'POST': - form = DocumentGenerationForm(request.POST) - if form.is_valid(): - # Get the selected template from the form - template = form.cleaned_data['template'] - - # Get the body content from the form - body = form.cleaned_data['body'] - - # Call the generate_document method - pdf_content = template.generate_document(body) - - # Return the generated PDF as a response for download - response = HttpResponse(pdf_content, content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename={template.title}.pdf' - return response - else: - form = DocumentGenerationForm() - - return render(request, 'core/generate_document.html', {'form': form}) - - -class DocumentGenerationSerializer(serializers.Serializer): - body = serializers.CharField(label="Markdown Body") - template_id = serializers.UUIDField(format='hex_verbose') - -class GenerateDocumentAPIView(APIView): - def post(self, request): - serializer = DocumentGenerationSerializer(data=request.data) - - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - template_id = serializer.validated_data['template_id'] - body = serializer.validated_data['body'] - - try: - template = Template.objects.get(pk=template_id) - except Template.DoesNotExist: - return Response("Template not found", status=status.HTTP_404_NOT_FOUND) - - pdf_content = template.generate_document(body) - - response = FileResponse(BytesIO(pdf_content), content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename={template.title}.pdf' - return response - - - -class TemplateSerializer(serializers.ModelSerializer): - class Meta: - model = Template - fields = ['id', 'title'] - -class TemplatesApiView(APIView): - """Wip.""" - - def get(self, request, *args, **kwargs): - """Wip.""" - templates = Template.objects.all() - serializer = TemplateSerializer(templates, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - - diff --git a/src/backend/demo/management/commands/createsuperuser.py b/src/backend/demo/management/commands/createsuperuser.py index 5cb0e49c..6ac7cf5e 100644 --- a/src/backend/demo/management/commands/createsuperuser.py +++ b/src/backend/demo/management/commands/createsuperuser.py @@ -1,22 +1,24 @@ +"""Management user to create a superuser.""" from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from django.core.exceptions import ValidationError + +UserModel = get_user_model() class Command(BaseCommand): - help = 'Create a superuser with an email and a password' + """Management command to create a superuser from and email and password.""" + + help = "Create a superuser with an email and a password" def add_arguments(self, parser): """Define required arguments "email" and "password".""" parser.add_argument( "--email", - help=( - "Email for the user." - ), + help=("Email for the user."), ) parser.add_argument( "--password", - help='Password for the user.', + help="Password for the user.", ) def handle(self, *args, **options): @@ -24,13 +26,12 @@ class Command(BaseCommand): Given an email and a password, create a superuser or upgrade the existing user to superuser status. """ - UserModel = get_user_model() - email = options.get('email') + email = options.get("email") try: - user = UserModel.objects.get(email=email) + user = UserModel.objects.get(admin_email=email) except UserModel.DoesNotExist: - user = UserModel(email=email) - message = 'Superuser created successfully.' + user = UserModel(admin_email=email) + message = "Superuser created successfully." else: if user.is_superuser and user.is_staff: message = "Superuser already exists." @@ -39,7 +40,7 @@ class Command(BaseCommand): user.is_superuser = True user.is_staff = True - user.set_password(options['password']) + user.set_password(options["password"]) user.save() self.stdout.write(self.style.SUCCESS(message)) diff --git a/src/backend/demo/utils.py b/src/backend/demo/utils.py deleted file mode 100644 index 4b978e6e..00000000 --- a/src/backend/demo/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.contrib.auth.management.commands.createsuperuser import Command as BaseCommand -from django.core.exceptions import ValidationError - -class Command(BaseCommand): - help = 'Create a superuser without a username field' - - def handle(self, *args, **options): - # Check if a superuser already exists - try: - self.UserModel._default_manager.db_manager(options['database']).get( - is_superuser=True, - ) - except self.UserModel.DoesNotExist: - # If not, create a superuser without a username - email = options.get('email') - password = options.get('password') - self.UserModel._default_manager.db_manager(options['database']).create_superuser( - email=email, - password=password, - ) - self.stdout.write(self.style.SUCCESS('Superuser created successfully.')) - except ValidationError as e: - self.stderr.write(self.style.ERROR(f'Error creating superuser: {", ".join(e.messages)}')) diff --git a/src/backend/publish/api_urls.py b/src/backend/publish/api_urls.py deleted file mode 100644 index d3ddb752..00000000 --- a/src/backend/publish/api_urls.py +++ /dev/null @@ -1,36 +0,0 @@ -"""API URL Configuration""" -from django.conf import settings -from django.urls import include, path, re_path - -from rest_framework.routers import DefaultRouter - -from core.api import viewsets - -# - Main endpoints -router = DefaultRouter() -router.register("contacts", viewsets.ContactViewSet, basename="contacts") -router.register("teams", viewsets.TeamViewSet, basename="teams") -router.register("users", viewsets.UserViewSet, basename="users") - -# - Routes nested under a team -team_related_router = DefaultRouter() -team_related_router.register( - "accesses", - viewsets.TeamAccessViewSet, - basename="team_accesses", -) - -urlpatterns = [ - path( - f"api/{settings.API_VERSION}/", - include( - [ - *router.urls, - re_path( - r"^teams/(?P[0-9a-z-]*)/", - include(team_related_router.urls), - ), - ] - ), - ) -] diff --git a/src/backend/publish/settings.py b/src/backend/publish/settings.py index 5c8ae0be..18c059cc 100755 --- a/src/backend/publish/settings.py +++ b/src/backend/publish/settings.py @@ -83,7 +83,9 @@ class Base(Configuration): environ_name="DB_ENGINE", environ_prefix=None, ), - "NAME": values.Value("publish", environ_name="DB_NAME", environ_prefix=None), + "NAME": values.Value( + "publish", environ_name="DB_NAME", environ_prefix=None + ), "USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None), "PASSWORD": values.Value( "pass", environ_name="DB_PASSWORD", environ_prefix=None diff --git a/src/backend/publish/urls.py b/src/backend/publish/urls.py index 7307f609..120adead 100644 --- a/src/backend/publish/urls.py +++ b/src/backend/publish/urls.py @@ -6,10 +6,15 @@ from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path, re_path +from drf_spectacular.views import ( + SpectacularJSONAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ path("admin/", admin.site.urls), - path("", include('core.urls')), + path("", include("core.urls")), ] if settings.DEBUG: @@ -18,3 +23,26 @@ if settings.DEBUG: + staticfiles_urlpatterns() + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ) + + +if settings.USE_SWAGGER or settings.DEBUG: + urlpatterns += [ + path( + f"{settings.API_VERSION}/swagger.json", + SpectacularJSONAPIView.as_view( + api_version=settings.API_VERSION, + urlconf="core.urls", + ), + name="client-api-schema", + ), + path( + f"{settings.API_VERSION}//swagger/", + SpectacularSwaggerView.as_view(url_name="client-api-schema"), + name="swagger-ui-schema", + ), + re_path( + f"{settings.API_VERSION}//redoc/", + SpectacularRedocView.as_view(url_name="client-api-schema"), + name="redoc-schema", + ), + ] diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 2381e216..2c6f624a 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] description = "An application to print markdown to pdf from a set of managed templates." -keywords = ["Django", "Contacts", "Teams", "RBAC"] +keywords = ["Django", "Contacts", "Templates", "RBAC"] license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.10"