From b4d67a0221cc74dbbbfe9da4d00fef35c62f9ff3 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 16:53:57 +0100 Subject: [PATCH 01/25] Write EbiosRMStudy ViewSet and Serializer classes Co-authored-by: Mohamed-Hacene --- backend/ebios_rm/serializers.py | 47 +++++++++++++++++++++++++++++++++ backend/ebios_rm/views.py | 26 ++++++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 backend/ebios_rm/serializers.py diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py new file mode 100644 index 000000000..2c95f71eb --- /dev/null +++ b/backend/ebios_rm/serializers.py @@ -0,0 +1,47 @@ +from core.serializers import ( + BaseModelSerializer, + FieldsRelatedField, + AssessmentReadSerializer, +) +from core.models import StoredLibrary, RiskMatrix +from .models import EbiosRMStudy +from rest_framework import serializers +import logging + + +class EbiosRMStudyWriteSerializer(BaseModelSerializer): + def create(self, validated_data): + if not validated_data.get("risk_matrix"): + try: + ebios_matrix_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:risk-matrix-4x4-ebios-rm" + ) + ebios_matrix_library.load() + + validated_data["risk_matrix"] = RiskMatrix.objects.get( + urn="urn:intuitem:risk:matrix:risk-matrix-4x4-ebios-rm" + ) + except (StoredLibrary.DoesNotExist, RiskMatrix.DoesNotExist) as e: + logging.error(f"Error loading risk matrix: {str(e)}") + raise serializers.ValidationError( + "An error occurred while loading the risk matrix." + ) + return super().create(validated_data) + + class Meta: + model = EbiosRMStudy + exclude = ["created_at", "updated_at"] + + +class EbiosRMStudyReadSerializer(AssessmentReadSerializer): + str = serializers.CharField(source="__str__") + project = FieldsRelatedField(["id", "folder"]) + folder = FieldsRelatedField() + risk_matrix = FieldsRelatedField() + assets = FieldsRelatedField(many=True) + compliance_assessments = FieldsRelatedField(many=True) + risk_assessments = FieldsRelatedField(many=True) + + class Meta: + model = EbiosRMStudy + fields = "__all__" diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 91ea44a21..50a2f2d10 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,3 +1,25 @@ -from django.shortcuts import render +from core.views import BaseModelViewSet as AbstractBaseModelViewSet +from .models import EbiosRMStudy +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from rest_framework.decorators import action +from rest_framework.response import Response -# Create your views here. +LONG_CACHE_TTL = 60 # mn + + +class BaseModelViewSet(AbstractBaseModelViewSet): + serializers_module = "ebios_rm.serializers" + + +class EbiosRMStudyViewSet(BaseModelViewSet): + """ + API endpoint that allows ebios rm studies to be viewed or edited. + """ + + model = EbiosRMStudy + + @method_decorator(cache_page(60 * LONG_CACHE_TTL)) + @action(detail=False, name="Get status choices") + def status(self, request): + return Response(dict(EbiosRMStudy.Status.choices)) From 28882ebe64baf7150fadaddb61d6a0a26eb0b5f7 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 16:58:18 +0100 Subject: [PATCH 02/25] Create ebios-rm-studies routes --- backend/ebios_rm/urls.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/ebios_rm/urls.py diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py new file mode 100644 index 000000000..0e81b0fe6 --- /dev/null +++ b/backend/ebios_rm/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework import routers + +from ebios_rm.views import EbiosRMStudyViewSet + +router = routers.DefaultRouter() + +router.register(r"ebios-rm-studies", EbiosRMStudyViewSet, basename="ebios-rm-studies") + +urlpatterns = [ + path("", include(router.urls)), +] From 266567fbb89a5fcf9bdb9aea4b76536dc68161b1 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 16:58:40 +0100 Subject: [PATCH 03/25] Include ebios-rm routes to core urls --- backend/core/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/core/urls.py b/backend/core/urls.py index 5c5c5ddec..d0aa4054d 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -83,6 +83,7 @@ path("iam/", include("iam.urls")), path("serdes/", include("serdes.urls")), path("settings/", include("global_settings.urls")), + path("ebios-rm/", include("ebios_rm.urls")), path("csrf/", get_csrf_token, name="get_csrf_token"), path("build/", get_build, name="get_build"), path("evidences//upload/", UploadAttachmentView.as_view(), name="upload"), From 68e30138121f20f82f8cb98b9c51403464f2d954 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 16:59:51 +0100 Subject: [PATCH 04/25] Rename ebios-rm-studies route to studies So that the URL is /api/ebios-rm/studies --- backend/ebios_rm/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 0e81b0fe6..264a41f2a 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -5,7 +5,7 @@ router = routers.DefaultRouter() -router.register(r"ebios-rm-studies", EbiosRMStudyViewSet, basename="ebios-rm-studies") +router.register(r"studies", EbiosRMStudyViewSet, basename="studies") urlpatterns = [ path("", include(router.urls)), From ffee2195ceacf9abe3dcaa72f95b5a3c5eb7c08f Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 17:22:55 +0100 Subject: [PATCH 05/25] Make ref_id optional --- ...rmstudy_ref_id_alter_fearedevent_ref_id.py | 22 +++++++++++++++++++ backend/ebios_rm/models.py | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py diff --git a/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py b/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py new file mode 100644 index 000000000..9559d23f0 --- /dev/null +++ b/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-12-03 16:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ebios_rm", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="ebiosrmstudy", + name="ref_id", + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name="fearedevent", + name="ref_id", + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/backend/ebios_rm/models.py b/backend/ebios_rm/models.py index c800aa705..60a535154 100644 --- a/backend/ebios_rm/models.py +++ b/backend/ebios_rm/models.py @@ -64,7 +64,7 @@ class Status(models.TextChoices): default=Entity.get_main_entity, ) - ref_id = models.CharField(max_length=100) + ref_id = models.CharField(max_length=100, blank=True) version = models.CharField( max_length=100, blank=True, @@ -122,7 +122,7 @@ class FearedEvent(NameDescriptionMixin): help_text=_("Qualifications carried by the feared event"), ) - ref_id = models.CharField(max_length=100) + ref_id = models.CharField(max_length=100, blank=True) gravity = models.SmallIntegerField(default=-1, verbose_name=_("Gravity")) is_selected = models.BooleanField(verbose_name=_("Is selected"), default=False) justification = models.TextField(verbose_name=_("Justification"), blank=True) From b9ad9da8be87f1dace94499fdb436c3d06b96ffc Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 17:29:12 +0100 Subject: [PATCH 06/25] Set default matrix for studies on create at the serializer level --- backend/ebios_rm/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 2c95f71eb..9b3a71cb6 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -10,6 +10,10 @@ class EbiosRMStudyWriteSerializer(BaseModelSerializer): + risk_matrix = serializers.PrimaryKeyRelatedField( + queryset=RiskMatrix.objects.all(), required=False + ) + def create(self, validated_data): if not validated_data.get("risk_matrix"): try: From 9845f188d2617fd635e0fdf963c330e48966e97b Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 17:29:37 +0100 Subject: [PATCH 07/25] Add ebios-rm permissions to administrator role --- backend/core/startup.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/core/startup.py b/backend/core/startup.py index d74419221..833cff177 100644 --- a/backend/core/startup.py +++ b/backend/core/startup.py @@ -339,6 +339,30 @@ "view_filteringlabel", "change_filteringlabel", "delete_filteringlabel", + "add_ebiosrmstudy", + "view_ebiosrmstudy", + "change_ebiosrmstudy", + "delete_ebiosrmstudy", + "add_fearedevent", + "view_fearedevent", + "change_fearedevent", + "delete_fearedevent", + "add_roto", + "view_roto", + "change_roto", + "delete_roto", + "add_stakeholder", + "view_stakeholder", + "change_stakeholder", + "delete_stakeholder", + "add_attackpath", + "view_attackpath", + "change_attackpath", + "delete_attackpath", + "add_operationalscenario", + "view_operationalscenario", + "change_operationalscenario", + "delete_operationalscenario", ] THIRD_PARTY_RESPONDENT_PERMISSIONS_LIST = [ From 7fd435344c6ce49353632451c60a8f68325b7262 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 17:32:25 +0100 Subject: [PATCH 08/25] Try to get EBIOS RM matrix before loading the library --- backend/ebios_rm/serializers.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 9b3a71cb6..4dd2f6ccc 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -17,14 +17,18 @@ class EbiosRMStudyWriteSerializer(BaseModelSerializer): def create(self, validated_data): if not validated_data.get("risk_matrix"): try: - ebios_matrix_library = StoredLibrary.objects.get( - urn="urn:intuitem:risk:library:risk-matrix-4x4-ebios-rm" - ) - ebios_matrix_library.load() - - validated_data["risk_matrix"] = RiskMatrix.objects.get( + ebios_matrix = RiskMatrix.objects.filter( urn="urn:intuitem:risk:matrix:risk-matrix-4x4-ebios-rm" - ) + ).first() + if not ebios_matrix: + ebios_matrix_library = StoredLibrary.objects.get( + urn="urn:intuitem:risk:library:risk-matrix-4x4-ebios-rm" + ) + ebios_matrix_library.load() + ebios_matrix = RiskMatrix.objects.get( + urn="urn:intuitem:risk:matrix:risk-matrix-4x4-ebios-rm" + ) + validated_data["risk_matrix"] = ebios_matrix except (StoredLibrary.DoesNotExist, RiskMatrix.DoesNotExist) as e: logging.error(f"Error loading risk matrix: {str(e)}") raise serializers.ValidationError( From 479eacd57e789966cc2ccba830c9f4b98c6e06b8 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 17:38:00 +0100 Subject: [PATCH 09/25] Improve EbiosRMStudyReadSerializer --- backend/ebios_rm/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 4dd2f6ccc..918c36f64 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -41,14 +41,17 @@ class Meta: exclude = ["created_at", "updated_at"] -class EbiosRMStudyReadSerializer(AssessmentReadSerializer): +class EbiosRMStudyReadSerializer(BaseModelSerializer): str = serializers.CharField(source="__str__") project = FieldsRelatedField(["id", "folder"]) folder = FieldsRelatedField() risk_matrix = FieldsRelatedField() + reference_entity = FieldsRelatedField() assets = FieldsRelatedField(many=True) compliance_assessments = FieldsRelatedField(many=True) risk_assessments = FieldsRelatedField(many=True) + authors = FieldsRelatedField(many=True) + reviewers = FieldsRelatedField(many=True) class Meta: model = EbiosRMStudy From 60ed0751206624527e81e8df6f9d8873dc90e16a Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 19:45:27 +0100 Subject: [PATCH 10/25] Add FolderMixin to all EBIOS RM models --- ...path_folder_fearedevent_folder_and_more.py | 41 +++++++++++++++++++ backend/ebios_rm/models.py | 30 +++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py diff --git a/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py b/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py new file mode 100644 index 000000000..96645fd64 --- /dev/null +++ b/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.1 on 2024-12-03 18:38 + +import django.db.models.deletion +import iam.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ebios_rm', '0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id'), + ('iam', '0009_create_allauth_emailaddress_objects'), + ] + + operations = [ + migrations.AddField( + model_name='attackpath', + name='folder', + field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + ), + migrations.AddField( + model_name='fearedevent', + name='folder', + field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + ), + migrations.AddField( + model_name='operationalscenario', + name='folder', + field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + ), + migrations.AddField( + model_name='roto', + name='folder', + field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + ), + migrations.AddField( + model_name='stakeholder', + name='folder', + field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + ), + ] diff --git a/backend/ebios_rm/models.py b/backend/ebios_rm/models.py index 60a535154..99104508d 100644 --- a/backend/ebios_rm/models.py +++ b/backend/ebios_rm/models.py @@ -101,7 +101,7 @@ class Meta: ordering = ["created_at"] -class FearedEvent(NameDescriptionMixin): +class FearedEvent(NameDescriptionMixin, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -132,8 +132,12 @@ class Meta: verbose_name_plural = _("Feared events") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) -class RoTo(AbstractBaseModel): + +class RoTo(AbstractBaseModel, FolderMixin): class RiskOrigin(models.TextChoices): STATE = "state", _("State") ORGANIZED_CRIME = "organized_crime", _("Organized crime") @@ -206,8 +210,12 @@ class Meta: verbose_name_plural = _("RO/TO couples") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) + -class Stakeholder(AbstractBaseModel): +class Stakeholder(AbstractBaseModel, FolderMixin): class Category(models.TextChoices): CLIENT = "client", _("Client") PARTNER = "partner", _("Partner") @@ -289,6 +297,10 @@ class Meta: verbose_name_plural = _("Stakeholders") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) + @staticmethod def _compute_criticality( dependency: int, penetration: int, maturity: int, trust: int @@ -316,7 +328,7 @@ def residual_criticality(self): ) -class AttackPath(AbstractBaseModel): +class AttackPath(AbstractBaseModel, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -344,8 +356,12 @@ class Meta: verbose_name_plural = _("Attack paths") ordering = ["created_at"] + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) -class OperationalScenario(AbstractBaseModel): + +class OperationalScenario(AbstractBaseModel, FolderMixin): ebios_rm_study = models.ForeignKey( EbiosRMStudy, verbose_name=_("EBIOS RM study"), @@ -375,3 +391,7 @@ class Meta: verbose_name = _("Operational scenario") verbose_name_plural = _("Operational scenarios") ordering = ["created_at"] + + def save(self, *args, **kwargs): + self.folder = self.ebios_rm_study.folder + super().save(*args, **kwargs) From 9cedce48ec079fe145e96b4f82678f2706020f7f Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 19:45:34 +0100 Subject: [PATCH 11/25] Serialize FearedEvent --- backend/ebios_rm/serializers.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 918c36f64..3507bf69f 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -4,7 +4,7 @@ AssessmentReadSerializer, ) from core.models import StoredLibrary, RiskMatrix -from .models import EbiosRMStudy +from .models import EbiosRMStudy, FearedEvent from rest_framework import serializers import logging @@ -56,3 +56,19 @@ class EbiosRMStudyReadSerializer(BaseModelSerializer): class Meta: model = EbiosRMStudy fields = "__all__" + + +class FearedEventWriteSerializer(BaseModelSerializer): + class Meta: + model = FearedEvent + exclude = ["created_at", "updated_at", "folder"] + + +class FearedEventReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + + class Meta: + model = FearedEvent + fields = "__all__" From 1805f11f1809f4db9b93311fc83ba86c6e6f126c Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 19:45:46 +0100 Subject: [PATCH 12/25] Add feared-events routes --- backend/ebios_rm/urls.py | 3 ++- backend/ebios_rm/views.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 264a41f2a..47ab37bcf 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -1,11 +1,12 @@ from django.urls import include, path from rest_framework import routers -from ebios_rm.views import EbiosRMStudyViewSet +from ebios_rm.views import EbiosRMStudyViewSet, FearedEventViewSet router = routers.DefaultRouter() router.register(r"studies", EbiosRMStudyViewSet, basename="studies") +router.register(r"feared-events", FearedEventViewSet, basename="feared-events") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 50a2f2d10..69676bf77 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,5 +1,5 @@ from core.views import BaseModelViewSet as AbstractBaseModelViewSet -from .models import EbiosRMStudy +from .models import EbiosRMStudy, FearedEvent from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from rest_framework.decorators import action @@ -23,3 +23,7 @@ class EbiosRMStudyViewSet(BaseModelViewSet): @action(detail=False, name="Get status choices") def status(self, request): return Response(dict(EbiosRMStudy.Status.choices)) + + +class FearedEventViewSet(BaseModelViewSet): + model = FearedEvent From 365b6c63975d289bb00f3bbc14968bd2375e32eb Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 19:46:10 +0100 Subject: [PATCH 13/25] Fit queryset regex to ebios-rm routes --- backend/core/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/core/views.py b/backend/core/views.py index ba3aadc7f..be431f545 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -102,7 +102,10 @@ def get_queryset(self): return None object_ids_view = None if self.request.method == "GET": - if q := re.match("/api/[\w-]+/([0-9a-f-]+)", self.request.path): + if q := re.match( + "/api/[\w-]+/([\w-]+/)?([0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}(,[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})+)", + self.request.path, + ): """"get_queryset is called by Django even for an individual object via get_object https://stackoverflow.com/questions/74048193/why-does-a-retrieve-request-end-up-calling-get-queryset""" id = UUID(q.group(1)) From 11dfb1f0f466218ab47dcfc6879f7541fadc38b2 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Tue, 3 Dec 2024 19:46:20 +0100 Subject: [PATCH 14/25] chore: ruff format --- ...path_folder_fearedevent_folder_and_more.py | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py b/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py index 96645fd64..088a7c77b 100644 --- a/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py +++ b/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py @@ -6,36 +6,60 @@ class Migration(migrations.Migration): - dependencies = [ - ('ebios_rm', '0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id'), - ('iam', '0009_create_allauth_emailaddress_objects'), + ("ebios_rm", "0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id"), + ("iam", "0009_create_allauth_emailaddress_objects"), ] operations = [ migrations.AddField( - model_name='attackpath', - name='folder', - field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + model_name="attackpath", + name="folder", + field=models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), ), migrations.AddField( - model_name='fearedevent', - name='folder', - field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + model_name="fearedevent", + name="folder", + field=models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), ), migrations.AddField( - model_name='operationalscenario', - name='folder', - field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + model_name="operationalscenario", + name="folder", + field=models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), ), migrations.AddField( - model_name='roto', - name='folder', - field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + model_name="roto", + name="folder", + field=models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), ), migrations.AddField( - model_name='stakeholder', - name='folder', - field=models.ForeignKey(default=iam.models.Folder.get_root_folder_id, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + model_name="stakeholder", + name="folder", + field=models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), ), ] From bc11fb0a335fd360ab578350d5c11ecacd4f0f12 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 14:03:25 +0100 Subject: [PATCH 15/25] chore: Squash migrations --- backend/ebios_rm/migrations/0001_initial.py | 51 ++++++++++++++- ...rmstudy_ref_id_alter_fearedevent_ref_id.py | 22 ------- ...path_folder_fearedevent_folder_and_more.py | 65 ------------------- 3 files changed, 48 insertions(+), 90 deletions(-) delete mode 100644 backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py delete mode 100644 backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py diff --git a/backend/ebios_rm/migrations/0001_initial.py b/backend/ebios_rm/migrations/0001_initial.py index b0fb9dc58..66af48c5f 100644 --- a/backend/ebios_rm/migrations/0001_initial.py +++ b/backend/ebios_rm/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-12-03 12:57 +# Generated by Django 5.1.1 on 2024-12-04 13:02 import django.core.validators import django.db.models.deletion @@ -54,7 +54,7 @@ class Migration(migrations.Migration): "due_date", models.DateField(blank=True, null=True, verbose_name="Due date"), ), - ("ref_id", models.CharField(max_length=100)), + ("ref_id", models.CharField(blank=True, max_length=100)), ( "version", models.CharField( @@ -205,6 +205,15 @@ class Migration(migrations.Migration): "justification", models.TextField(blank=True, verbose_name="Justification"), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "ebios_rm_study", models.ForeignKey( @@ -249,7 +258,7 @@ class Migration(migrations.Migration): "description", models.TextField(blank=True, null=True, verbose_name="Description"), ), - ("ref_id", models.CharField(max_length=100)), + ("ref_id", models.CharField(blank=True, max_length=100)), ( "gravity", models.SmallIntegerField(default=-1, verbose_name="Gravity"), @@ -280,6 +289,15 @@ class Migration(migrations.Migration): verbose_name="EBIOS RM study", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "qualifications", models.ManyToManyField( @@ -352,6 +370,15 @@ class Migration(migrations.Migration): verbose_name="EBIOS RM study", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ( "threats", models.ManyToManyField( @@ -488,6 +515,15 @@ class Migration(migrations.Migration): verbose_name="Feared events", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ], options={ "verbose_name": "RO/TO couple", @@ -655,6 +691,15 @@ class Migration(migrations.Migration): verbose_name="Entity", ), ), + ( + "folder", + models.ForeignKey( + default=iam.models.Folder.get_root_folder_id, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_folder", + to="iam.folder", + ), + ), ], options={ "verbose_name": "Stakeholder", diff --git a/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py b/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py deleted file mode 100644 index 9559d23f0..000000000 --- a/backend/ebios_rm/migrations/0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.1 on 2024-12-03 16:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ebios_rm", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="ebiosrmstudy", - name="ref_id", - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name="fearedevent", - name="ref_id", - field=models.CharField(blank=True, max_length=100), - ), - ] diff --git a/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py b/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py deleted file mode 100644 index 088a7c77b..000000000 --- a/backend/ebios_rm/migrations/0003_attackpath_folder_fearedevent_folder_and_more.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 5.1.1 on 2024-12-03 18:38 - -import django.db.models.deletion -import iam.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ebios_rm", "0002_alter_ebiosrmstudy_ref_id_alter_fearedevent_ref_id"), - ("iam", "0009_create_allauth_emailaddress_objects"), - ] - - operations = [ - migrations.AddField( - model_name="attackpath", - name="folder", - field=models.ForeignKey( - default=iam.models.Folder.get_root_folder_id, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(class)s_folder", - to="iam.folder", - ), - ), - migrations.AddField( - model_name="fearedevent", - name="folder", - field=models.ForeignKey( - default=iam.models.Folder.get_root_folder_id, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(class)s_folder", - to="iam.folder", - ), - ), - migrations.AddField( - model_name="operationalscenario", - name="folder", - field=models.ForeignKey( - default=iam.models.Folder.get_root_folder_id, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(class)s_folder", - to="iam.folder", - ), - ), - migrations.AddField( - model_name="roto", - name="folder", - field=models.ForeignKey( - default=iam.models.Folder.get_root_folder_id, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(class)s_folder", - to="iam.folder", - ), - ), - migrations.AddField( - model_name="stakeholder", - name="folder", - field=models.ForeignKey( - default=iam.models.Folder.get_root_folder_id, - on_delete=django.db.models.deletion.CASCADE, - related_name="%(class)s_folder", - to="iam.folder", - ), - ), - ] From 484d117629d9cccce1a775a4624ed93be014958c Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:05:26 +0100 Subject: [PATCH 16/25] Create RO/TO endpoints --- backend/ebios_rm/serializers.py | 20 ++++++++++++++++++-- backend/ebios_rm/urls.py | 3 ++- backend/ebios_rm/views.py | 6 +++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 3507bf69f..7f64cea7a 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -1,10 +1,9 @@ from core.serializers import ( BaseModelSerializer, FieldsRelatedField, - AssessmentReadSerializer, ) from core.models import StoredLibrary, RiskMatrix -from .models import EbiosRMStudy, FearedEvent +from .models import EbiosRMStudy, FearedEvent, RoTo from rest_framework import serializers import logging @@ -72,3 +71,20 @@ class FearedEventReadSerializer(BaseModelSerializer): class Meta: model = FearedEvent fields = "__all__" + + +class RoToWriteSerializer(BaseModelSerializer): + class Meta: + model = RoTo + exclude = ["created_at", "updated_at", "folder"] + + +class RoToReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + fearead_events = FieldsRelatedField(many=True) + + class Meta: + model = RoTo + fields = "__all__" diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 47ab37bcf..4037ddecf 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -1,12 +1,13 @@ from django.urls import include, path from rest_framework import routers -from ebios_rm.views import EbiosRMStudyViewSet, FearedEventViewSet +from ebios_rm.views import EbiosRMStudyViewSet, FearedEventViewSet, RoToViewSet router = routers.DefaultRouter() router.register(r"studies", EbiosRMStudyViewSet, basename="studies") router.register(r"feared-events", FearedEventViewSet, basename="feared-events") +router.register(r"ro-to", RoToViewSet, basename="ro-to") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 69676bf77..32377350d 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,5 +1,5 @@ from core.views import BaseModelViewSet as AbstractBaseModelViewSet -from .models import EbiosRMStudy, FearedEvent +from .models import EbiosRMStudy, FearedEvent, RoTo from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from rest_framework.decorators import action @@ -27,3 +27,7 @@ def status(self, request): class FearedEventViewSet(BaseModelViewSet): model = FearedEvent + + +class RoToViewSet(BaseModelViewSet): + model = RoTo From e7c9cb87ff6e5f235943651f52e98a6d9eb712dc Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:05:42 +0100 Subject: [PATCH 17/25] Create endpoints for RO/TO choice fields --- backend/ebios_rm/views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 32377350d..630ea25d2 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -31,3 +31,19 @@ class FearedEventViewSet(BaseModelViewSet): class RoToViewSet(BaseModelViewSet): model = RoTo + + @action(detail=False, name="Get risk origin choices", url_path="risk-origin") + def risk_origin(self, request): + return Response(dict(RoTo.RiskOrigin.choices)) + + @action(detail=False, name="Get motivation choices") + def motivation(self, request): + return Response(dict(RoTo.Motivation.choices)) + + @action(detail=False, name="Get resources choices") + def resources(self, request): + return Response(dict(RoTo.Resources.choices)) + + @action(detail=False, name="Get pertinence choices") + def pertinence(self, request): + return Response(dict(RoTo.Pertinence.choices)) From 6a91b2b31bcc9e241755696cbc808cfb2a245055 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:15:32 +0100 Subject: [PATCH 18/25] Fix typo --- backend/ebios_rm/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 7f64cea7a..fe5d3aedc 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -83,7 +83,7 @@ class RoToReadSerializer(BaseModelSerializer): str = serializers.CharField(source="__str__") ebios_rm_study = FieldsRelatedField() folder = FieldsRelatedField() - fearead_events = FieldsRelatedField(many=True) + feared_events = FieldsRelatedField(many=True) class Meta: model = RoTo From face6c4e0413618a680dca474879528686995284 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:15:18 +0100 Subject: [PATCH 19/25] Create serializers for Stakeholder model --- backend/ebios_rm/serializers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index fe5d3aedc..e19dd2c2a 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -88,3 +88,21 @@ class RoToReadSerializer(BaseModelSerializer): class Meta: model = RoTo fields = "__all__" + + +class StakeholderWriteSerializer(BaseModelSerializer): + class Meta: + model = Stakeholder + exclude = ["created_at", "updated_at", "folder"] + + +class StakeholderReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + entity = FieldsRelatedField() + applied_controls = FieldsRelatedField(many=True) + + class Meta: + model = Stakeholder + fields = "__all__" From ce82e67c059a34c0348ff3d9cffb6589497d3fb4 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:25:04 +0100 Subject: [PATCH 20/25] Create Stakeholder endpoints --- backend/ebios_rm/urls.py | 8 +++++++- backend/ebios_rm/views.py | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 4037ddecf..9e5ec6454 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -1,13 +1,19 @@ from django.urls import include, path from rest_framework import routers -from ebios_rm.views import EbiosRMStudyViewSet, FearedEventViewSet, RoToViewSet +from ebios_rm.views import ( + EbiosRMStudyViewSet, + FearedEventViewSet, + RoToViewSet, + StakeholderViewSet, +) router = routers.DefaultRouter() router.register(r"studies", EbiosRMStudyViewSet, basename="studies") router.register(r"feared-events", FearedEventViewSet, basename="feared-events") router.register(r"ro-to", RoToViewSet, basename="ro-to") +router.register(r"stakeholders", StakeholderViewSet, basename="stakeholders") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 630ea25d2..f7e01ddaf 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,5 +1,5 @@ from core.views import BaseModelViewSet as AbstractBaseModelViewSet -from .models import EbiosRMStudy, FearedEvent, RoTo +from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from rest_framework.decorators import action @@ -47,3 +47,11 @@ def resources(self, request): @action(detail=False, name="Get pertinence choices") def pertinence(self, request): return Response(dict(RoTo.Pertinence.choices)) + + +class StakeholderViewSet(BaseModelViewSet): + model = Stakeholder + + @action(detail=False, name="Get category choices") + def category(self, request): + return Response(dict(Stakeholder.Category.choices)) From fcb3cdc5a1cabb8862e05855db6b84ec4ea5afde Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:25:15 +0100 Subject: [PATCH 21/25] Serialize stakeholder criticality --- backend/ebios_rm/serializers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index e19dd2c2a..f60204853 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -3,7 +3,7 @@ FieldsRelatedField, ) from core.models import StoredLibrary, RiskMatrix -from .models import EbiosRMStudy, FearedEvent, RoTo +from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder from rest_framework import serializers import logging @@ -91,6 +91,9 @@ class Meta: class StakeholderWriteSerializer(BaseModelSerializer): + current_criticality = serializers.IntegerField(read_only=True) + residual_criticality = serializers.IntegerField(read_only=True) + class Meta: model = Stakeholder exclude = ["created_at", "updated_at", "folder"] @@ -103,6 +106,9 @@ class StakeholderReadSerializer(BaseModelSerializer): entity = FieldsRelatedField() applied_controls = FieldsRelatedField(many=True) + current_criticality = serializers.IntegerField() + residual_criticality = serializers.IntegerField() + class Meta: model = Stakeholder fields = "__all__" From d7091197404810f43b4faaec3a0995fa919f52fd Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:45:10 +0100 Subject: [PATCH 22/25] Create AttackPath endpoints --- backend/ebios_rm/serializers.py | 20 +++++++++++++++++++- backend/ebios_rm/urls.py | 2 ++ backend/ebios_rm/views.py | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index f60204853..462a35d75 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -3,7 +3,7 @@ FieldsRelatedField, ) from core.models import StoredLibrary, RiskMatrix -from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder +from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder, AttackPath from rest_framework import serializers import logging @@ -112,3 +112,21 @@ class StakeholderReadSerializer(BaseModelSerializer): class Meta: model = Stakeholder fields = "__all__" + + +class AttackPathWriteSerializer(BaseModelSerializer): + class Meta: + model = AttackPath + exclude = ["created_at", "updated_at", "folder"] + + +class AttackPathReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + ro_to_couple = FieldsRelatedField() + stakeholders = FieldsRelatedField(many=True) + + class Meta: + model = AttackPath + fields = "__all__" diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 9e5ec6454..8214b799e 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -6,6 +6,7 @@ FearedEventViewSet, RoToViewSet, StakeholderViewSet, + AttackPathViewSet, ) router = routers.DefaultRouter() @@ -14,6 +15,7 @@ router.register(r"feared-events", FearedEventViewSet, basename="feared-events") router.register(r"ro-to", RoToViewSet, basename="ro-to") router.register(r"stakeholders", StakeholderViewSet, basename="stakeholders") +router.register(r"attack-paths", AttackPathViewSet, basename="attack-paths") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index f7e01ddaf..6237f6c86 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,5 +1,5 @@ from core.views import BaseModelViewSet as AbstractBaseModelViewSet -from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder +from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder, AttackPath from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from rest_framework.decorators import action @@ -55,3 +55,7 @@ class StakeholderViewSet(BaseModelViewSet): @action(detail=False, name="Get category choices") def category(self, request): return Response(dict(Stakeholder.Category.choices)) + + +class AttackPathViewSet(BaseModelViewSet): + model = AttackPath From e16a1f6a0156ae844cdad258226fa126b2a273c9 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Wed, 4 Dec 2024 15:51:55 +0100 Subject: [PATCH 23/25] Create OperationalScenario endpoints --- backend/ebios_rm/serializers.py | 27 ++++++++++++++++++++++++++- backend/ebios_rm/urls.py | 6 ++++++ backend/ebios_rm/views.py | 13 ++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/ebios_rm/serializers.py b/backend/ebios_rm/serializers.py index 462a35d75..e77c5c153 100644 --- a/backend/ebios_rm/serializers.py +++ b/backend/ebios_rm/serializers.py @@ -3,7 +3,14 @@ FieldsRelatedField, ) from core.models import StoredLibrary, RiskMatrix -from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder, AttackPath +from .models import ( + EbiosRMStudy, + FearedEvent, + RoTo, + Stakeholder, + AttackPath, + OperationalScenario, +) from rest_framework import serializers import logging @@ -130,3 +137,21 @@ class AttackPathReadSerializer(BaseModelSerializer): class Meta: model = AttackPath fields = "__all__" + + +class OperationalScenarioWriteSerializer(BaseModelSerializer): + class Meta: + model = OperationalScenario + exclude = ["created_at", "updated_at", "folder"] + + +class OperationalScenarioReadSerializer(BaseModelSerializer): + str = serializers.CharField(source="__str__") + ebios_rm_study = FieldsRelatedField() + folder = FieldsRelatedField() + attack_paths = FieldsRelatedField(many=True) + threats = FieldsRelatedField(many=True) + + class Meta: + model = OperationalScenario + fields = "__all__" diff --git a/backend/ebios_rm/urls.py b/backend/ebios_rm/urls.py index 8214b799e..76f7b3e2e 100644 --- a/backend/ebios_rm/urls.py +++ b/backend/ebios_rm/urls.py @@ -7,6 +7,7 @@ RoToViewSet, StakeholderViewSet, AttackPathViewSet, + OperationalScenarioViewSet, ) router = routers.DefaultRouter() @@ -16,6 +17,11 @@ router.register(r"ro-to", RoToViewSet, basename="ro-to") router.register(r"stakeholders", StakeholderViewSet, basename="stakeholders") router.register(r"attack-paths", AttackPathViewSet, basename="attack-paths") +router.register( + r"operational-scenarios", + OperationalScenarioViewSet, + basename="operational-scenarios", +) urlpatterns = [ path("", include(router.urls)), diff --git a/backend/ebios_rm/views.py b/backend/ebios_rm/views.py index 6237f6c86..ca0d048dd 100644 --- a/backend/ebios_rm/views.py +++ b/backend/ebios_rm/views.py @@ -1,5 +1,12 @@ from core.views import BaseModelViewSet as AbstractBaseModelViewSet -from .models import EbiosRMStudy, FearedEvent, RoTo, Stakeholder, AttackPath +from .models import ( + EbiosRMStudy, + FearedEvent, + RoTo, + Stakeholder, + AttackPath, + OperationalScenario, +) from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from rest_framework.decorators import action @@ -59,3 +66,7 @@ def category(self, request): class AttackPathViewSet(BaseModelViewSet): model = AttackPath + + +class OperationalScenarioViewSet(BaseModelViewSet): + model = OperationalScenario From 1790825393661d3e3649a987132850dd754b9178 Mon Sep 17 00:00:00 2001 From: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:50:01 +0100 Subject: [PATCH 24/25] use private extension to avoid safari decompression (#1122) Co-authored-by: Nassim Tabchiche --- backend/.meta | 2 -- backend/ciso_assistant/.meta | 2 -- .../src/routes/(app)/(internal)/backup-restore/+page.svelte | 2 +- .../routes/(app)/(internal)/backup-restore/dump-db/+server.ts | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 backend/.meta delete mode 100644 backend/ciso_assistant/.meta diff --git a/backend/.meta b/backend/.meta deleted file mode 100644 index 3c01d68cb..000000000 --- a/backend/.meta +++ /dev/null @@ -1,2 +0,0 @@ -CISO_ASSISTANT_VERSION=dev -CISO_ASSISTANT_BUILD=dev diff --git a/backend/ciso_assistant/.meta b/backend/ciso_assistant/.meta deleted file mode 100644 index 3c01d68cb..000000000 --- a/backend/ciso_assistant/.meta +++ /dev/null @@ -1,2 +0,0 @@ -CISO_ASSISTANT_VERSION=dev -CISO_ASSISTANT_BUILD=dev diff --git a/frontend/src/routes/(app)/(internal)/backup-restore/+page.svelte b/frontend/src/routes/(app)/(internal)/backup-restore/+page.svelte index d88138047..78d868755 100644 --- a/frontend/src/routes/(app)/(internal)/backup-restore/+page.svelte +++ b/frontend/src/routes/(app)/(internal)/backup-restore/+page.svelte @@ -22,7 +22,7 @@ $: uploadButtonStyles = file ? '' : 'chip-disabled'; - const authorizedExtensions = ['.json', '.gz']; + const authorizedExtensions = ['.bak']; const user = $page.data.user; const canBackup: boolean = Object.hasOwn(user.permissions, 'backup'); diff --git a/frontend/src/routes/(app)/(internal)/backup-restore/dump-db/+server.ts b/frontend/src/routes/(app)/(internal)/backup-restore/dump-db/+server.ts index 1e25b769e..a934a2e8a 100644 --- a/frontend/src/routes/(app)/(internal)/backup-restore/dump-db/+server.ts +++ b/frontend/src/routes/(app)/(internal)/backup-restore/dump-db/+server.ts @@ -10,7 +10,7 @@ export const GET: RequestHandler = async ({ fetch }) => { error(400, 'Error fetching the dump file'); } - const fileName = `ciso-assistant-db-${new Date().toISOString()}.json.gz`; + const fileName = `ciso-assistant-db-${new Date().toISOString()}.bak`; return new Response(await res.blob(), { headers: { From ac1610397d32b8c108d481dc1e0a567f409f178b Mon Sep 17 00:00:00 2001 From: monsieurswag <143810744+monsieurswag@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:02:25 +0100 Subject: [PATCH 25/25] Make language choice persistent for users (#1075) --- .github/workflows/functional-tests.yml | 1 - .github/workflows/startup-tests.yml | 8 ++- backend/ciso_assistant/settings.py | 2 +- backend/core/urls.py | 1 + backend/core/views.py | 44 +++++++++--- ...009_create_allauth_emailaddress_objects.py | 4 +- .../iam/migrations/0010_user_preferences.py | 17 +++++ backend/iam/models.py | 1 + backend/iam/views.py | 1 + enterprise/frontend/src/routes/+layout.svelte | 6 ++ .../components/SideBar/SideBarFooter.svelte | 6 ++ frontend/src/routes/(app)/+page.server.ts | 5 +- .../(authentication)/login/+page.server.ts | 71 ++++++++++++------- frontend/src/routes/+layout.svelte | 7 +- .../routes/api/user-preferences/+server.ts | 36 ++++++++++ frontend/tests/functional/startup.test.ts | 4 +- frontend/tests/utils/base-page.ts | 23 +++++- 17 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 backend/iam/migrations/0010_user_preferences.py create mode 100644 frontend/src/routes/api/user-preferences/+server.ts diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 1cb1db33b..54104981b 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -70,7 +70,6 @@ jobs: run: | touch .env echo PUBLIC_BACKEND_API_URL=http://localhost:8000/api >> .env - - name: Create backend environment variables file working-directory: ${{ env.backend-directory }} run: | diff --git a/.github/workflows/startup-tests.yml b/.github/workflows/startup-tests.yml index 3fb3bb1bd..ac3a48c24 100644 --- a/.github/workflows/startup-tests.yml +++ b/.github/workflows/startup-tests.yml @@ -136,7 +136,9 @@ jobs: working-directory: ${{ env.frontend-directory }} run: | response=$(curl -d "username=admin@tests.com&password=1234" -H "Origin: https://localhost:8443" https://localhost:8443/login\?/login -k) - server_reponse='{"type":"redirect","status":302,"location":""}' + server_reponse='{"type":"redirect","status":302,"location":"/"}' + echo "[SERVER_RESPONSE] $response" + echo "[EXPECTED_RESPONSE] $server_reponse" if [[ "$response" == "$server_reponse" ]]; then echo "Success" exit 0 @@ -265,7 +267,9 @@ jobs: working-directory: ${{ env.frontend-directory }} run: | response=$(curl -d "username=admin@tests.com&password=1234" -H "Origin: https://localhost:8443" https://localhost:8443/login\?/login -k) - server_reponse='{"type":"redirect","status":302,"location":""}' + server_reponse='{"type":"redirect","status":302,"location":"/"}' + echo "[SERVER_RESPONSE] $response" + echo "[EXPECTED_RESPONSE] $server_reponse" if [[ "$response" == "$server_reponse" ]]; then echo "Success" exit 0 diff --git a/backend/ciso_assistant/settings.py b/backend/ciso_assistant/settings.py index 708220052..bff8abcd7 100644 --- a/backend/ciso_assistant/settings.py +++ b/backend/ciso_assistant/settings.py @@ -306,7 +306,7 @@ def set_ciso_assistant_url(_, __, event_dict): ("es", "Spanish"), ("de", "German"), ("it", "Italian"), - ("nd", "Dutch"), + ("nl", "Dutch"), ("pl", "Polish"), ("pt", "Portuguese"), ("ar", "Arabic"), diff --git a/backend/core/urls.py b/backend/core/urls.py index d0aa4054d..57b55901e 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -83,6 +83,7 @@ path("iam/", include("iam.urls")), path("serdes/", include("serdes.urls")), path("settings/", include("global_settings.urls")), + path("user-preferences/", UserPreferencesView.as_view(), name="user-preferences"), path("ebios-rm/", include("ebios_rm.urls")), path("csrf/", get_csrf_token, name="get_csrf_token"), path("build/", get_build, name="get_build"), diff --git a/backend/core/views.py b/backend/core/views.py index be431f545..73a74924e 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -50,7 +50,6 @@ from rest_framework.renderers import JSONRenderer from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.utils.serializer_helpers import ReturnDict from rest_framework.views import APIView from rest_framework.permissions import AllowAny @@ -299,7 +298,7 @@ def quality_check_detail(self, request, pk): } return Response(res) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=False, methods=["get"]) def ids(self, request): @@ -606,7 +605,7 @@ def quality_check_detail(self, request, pk): risk_assessment = self.get_object() return Response(risk_assessment.quality_check()) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=True, methods=["get"], name="Get treatment plan data") def plan(self, request, pk): @@ -639,7 +638,7 @@ def plan(self, request, pk): return Response(risk_assessment) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=True, name="Get treatment plan CSV") def treatment_plan_csv(self, request, pk): @@ -699,7 +698,9 @@ def treatment_plan_csv(self, request, pk): return response else: - return Response({"error": "Permission denied"}, status=HTTP_403_FORBIDDEN) + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) @action(detail=True, name="Get risk assessment CSV") def risk_assessment_csv(self, request, pk): @@ -761,7 +762,9 @@ def risk_assessment_csv(self, request, pk): return response else: - return Response({"error": "Permission denied"}, status=HTTP_403_FORBIDDEN) + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) @action(detail=True, name="Get risk assessment PDF") def risk_assessment_pdf(self, request, pk): @@ -1324,7 +1327,7 @@ def update(self, request, *args, **kwargs): _data = { "non_field_errors": "The justification can only be edited by the approver" } - return Response(data=_data, status=HTTP_400_BAD_REQUEST) + return Response(data=_data, status=status.HTTP_400_BAD_REQUEST) else: return super().update(request, *args, **kwargs) @@ -1436,7 +1439,7 @@ def update(self, request: Request, *args, **kwargs) -> Response: if str(admin_group.pk) not in new_user_groups: return Response( {"error": "attemptToRemoveOnlyAdminUserGroup"}, - status=HTTP_403_FORBIDDEN, + status=status.HTTP_403_FORBIDDEN, ) return super().update(request, *args, **kwargs) @@ -1448,7 +1451,7 @@ def destroy(self, request, *args, **kwargs): if number_of_admin_users == 1: return Response( {"error": "attemptToDeleteOnlyAdminAccountError"}, - status=HTTP_403_FORBIDDEN, + status=status.HTTP_403_FORBIDDEN, ) return super().destroy(request, *args, **kwargs) @@ -1677,6 +1680,29 @@ def my_assignments(self, request): ) +class UserPreferencesView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request) -> Response: + return Response(request.user.preferences, status=status.HTTP_200_OK) + + def patch(self, request) -> Response: + new_language = request.data.get("lang") + if new_language is None or new_language not in ( + lang[0] for lang in settings.LANGUAGES + ): + logger.error( + f"Error in UserPreferencesView: new_language={new_language} available languages={[lang[0] for lang in settings.LANGUAGES]}" + ) + return Response( + {"error": "This language doesn't exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + request.user.preferences["lang"] = new_language + request.user.save() + return Response({}, status=status.HTTP_200_OK) + + @cache_page(60 * SHORT_CACHE_TTL) @vary_on_cookie @api_view(["GET"]) diff --git a/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py b/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py index e2d220c2b..0da343def 100644 --- a/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py +++ b/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py @@ -5,8 +5,8 @@ def create_emailaddress_objects(apps, schema_editor): try: - from allauth.account.models import EmailAddress - from iam.models import User + EmailAddress = apps.get_model("account", "EmailAddress") + User = apps.get_model("iam", "User") for user in User.objects.all(): EmailAddress.objects.create( diff --git a/backend/iam/migrations/0010_user_preferences.py b/backend/iam/migrations/0010_user_preferences.py new file mode 100644 index 000000000..6bd439eb1 --- /dev/null +++ b/backend/iam/migrations/0010_user_preferences.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-12-04 10:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iam", "0009_create_allauth_emailaddress_objects"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="preferences", + field=models.JSONField(default=dict), + ), + ] diff --git a/backend/iam/models.py b/backend/iam/models.py index 15b3725b8..e67ce56ec 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -343,6 +343,7 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin): first_name = models.CharField(_("first name"), max_length=150, blank=True) email = models.CharField(max_length=100, unique=True) first_login = models.BooleanField(default=True) + preferences = models.JSONField(default=dict) is_sso = models.BooleanField(default=False) is_third_party = models.BooleanField(default=False) is_active = models.BooleanField( diff --git a/backend/iam/views.py b/backend/iam/views.py index db7f7d5e8..ee057dea4 100644 --- a/backend/iam/views.py +++ b/backend/iam/views.py @@ -61,6 +61,7 @@ def post(self, request) -> Response: class CurrentUserView(views.APIView): + # Is this condition really necessary if we have permission_classes = [permissions.IsAuthenticated] ? permission_classes = [permissions.IsAuthenticated] def get(self, request) -> Response: diff --git a/enterprise/frontend/src/routes/+layout.svelte b/enterprise/frontend/src/routes/+layout.svelte index 80a90947f..56d4c5c56 100644 --- a/enterprise/frontend/src/routes/+layout.svelte +++ b/enterprise/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import '../app.postcss'; import '@fortawesome/fontawesome-free/css/all.min.css'; import ParaglideSvelte from './ParaglideJsProvider.svelte'; + import { browser } from '$app/environment'; import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'; @@ -97,6 +98,11 @@ ? `data:${$faviconB64.mimeType};base64, ${$faviconB64.data}` : favicon; }); + + $: if (browser && $page.url.searchParams.has('refresh')) { + $page.url.searchParams.delete('refresh'); + window.location.href = $page.url.href; + } diff --git a/frontend/src/lib/components/SideBar/SideBarFooter.svelte b/frontend/src/lib/components/SideBar/SideBarFooter.svelte index 222db555d..7f65e4adb 100644 --- a/frontend/src/lib/components/SideBar/SideBarFooter.svelte +++ b/frontend/src/lib/components/SideBar/SideBarFooter.svelte @@ -50,6 +50,12 @@ event.preventDefault(); value = event?.target?.value; setLanguageTag(value); + fetch('/api/user-preferences', { + method: 'PATCH', + body: JSON.stringify({ + lang: value + }) + }); // sessionStorage.setItem('lang', value); setCookie('ciso_lang', value); window.location.reload(); diff --git a/frontend/src/routes/(app)/+page.server.ts b/frontend/src/routes/(app)/+page.server.ts index d139b4b57..07b2f1091 100644 --- a/frontend/src/routes/(app)/+page.server.ts +++ b/frontend/src/routes/(app)/+page.server.ts @@ -1,6 +1,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - redirect(301, '/analytics'); +export const load: PageServerLoad = async ({ url }) => { + const queryParams = url.searchParams.has('refresh') ? '?refresh=1' : ''; + redirect(301, `/analytics${queryParams}`); }; diff --git a/frontend/src/routes/(authentication)/login/+page.server.ts b/frontend/src/routes/(authentication)/login/+page.server.ts index 0c5ab5392..7b118aa89 100644 --- a/frontend/src/routes/(authentication)/login/+page.server.ts +++ b/frontend/src/routes/(authentication)/login/+page.server.ts @@ -1,7 +1,6 @@ import { getSecureRedirect } from '$lib/utils/helpers'; import { ALLAUTH_API_URL, BASE_API_URL } from '$lib/utils/constants'; -import { csrfToken } from '$lib/utils/csrf'; import { loginSchema } from '$lib/utils/schemas'; import type { LoginRequestBody } from '$lib/utils/types'; import { fail, redirect, type Actions } from '@sveltejs/kit'; @@ -9,7 +8,6 @@ import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; import type { PageServerLoad } from './$types'; import { mfaAuthenticateSchema } from './mfa/utils/schemas'; -import { setFlash } from 'sveltekit-flash-message/server'; interface AuthenticationFlow { id: @@ -27,6 +25,15 @@ interface AuthenticationFlow { types: 'totp' | 'recovery_codes'; } +function makeRedirectURL(currentLang: string, preferedLang: string, url: URL): string { + const next = url.searchParams.get('next'); + const secureNext = getSecureRedirect(next) || '/'; + if (currentLang === preferedLang) { + return secureNext; + } + return secureNext ? `${secureNext}?refresh=1` : `/?refresh=1`; +} + export const load: PageServerLoad = async ({ fetch, request, locals }) => { // redirect user if already logged in if (locals.user) { @@ -74,29 +81,27 @@ export const actions: Actions = { }); return fail(res.status, { form }); } - if (res.status === 401) { + if (res.status === 401 && res.data) { // User is not authenticated - if (res.data) { - const flows: AuthenticationFlow[] = res.data.flows; - if (flows.length > 0) { - const mfaFlow = flows.find((flow) => flow.id === 'mfa_authenticate'); - const sessionToken = res.meta.session_token; - if (sessionToken) { - cookies.set('allauth_session_token', sessionToken, { - httpOnly: true, - sameSite: 'lax', - path: '/', - secure: true - }); - } - - if (mfaFlow) { - return { - form, - mfa: true, - mfaFlow - }; - } + const flows: AuthenticationFlow[] = res.data.flows; + if (flows.length > 0) { + const mfaFlow = flows.find((flow) => flow.id === 'mfa_authenticate'); + const sessionToken = res.meta.session_token; + if (sessionToken) { + cookies.set('allauth_session_token', sessionToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: true + }); + } + + if (mfaFlow) { + return { + form, + mfa: true, + mfaFlow + }; } } } @@ -117,8 +122,22 @@ export const actions: Actions = { secure: true }); - const next = url.searchParams.get('next') || '/'; - redirect(302, getSecureRedirect(next)); + const preferencesRes = await fetch(`${BASE_API_URL}/user-preferences/`); + const preferences = await preferencesRes.json(); + + const currentLang = cookies.get('ciso_lang') || 'en'; + const preferedLang = preferences.lang || 'en'; + + if (currentLang !== preferedLang) { + cookies.set('ciso_lang', preferedLang, { + httpOnly: false, + sameSite: 'lax', + path: '/', + secure: true + }); + } + + redirect(302, makeRedirectURL(currentLang, preferedLang, url)); }, mfaAuthenticate: async (event) => { const formData = await event.request.formData(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index fc51d8241..51b1a7cc2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,7 +2,7 @@ // Most of your app wide CSS should be put in this file import '../app.postcss'; import '@fortawesome/fontawesome-free/css/all.min.css'; - import ParaglideSvelte from './ParaglideJsProvider.svelte'; + import { browser } from '$app/environment'; import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'; @@ -77,6 +77,11 @@ createModal: { ref: CreateModal }, deleteConfirmModal: { ref: DeleteConfirmModal } }; + + $: if (browser && $page.url.searchParams.has('refresh')) { + $page.url.searchParams.delete('refresh'); + window.location.href = $page.url.href; + } diff --git a/frontend/src/routes/api/user-preferences/+server.ts b/frontend/src/routes/api/user-preferences/+server.ts new file mode 100644 index 000000000..3c58fdc79 --- /dev/null +++ b/frontend/src/routes/api/user-preferences/+server.ts @@ -0,0 +1,36 @@ +import { BASE_API_URL } from '$lib/utils/constants'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ fetch, request }) => { + const endpoint = `${BASE_API_URL}/user-preferences/`; + const req = await fetch(endpoint); + const status = await req.status; + const responseData = await req.json(); + + return new Response(JSON.stringify(responseData), { + status: status, + headers: { + 'Content-Type': 'application/json' + } + }); +}; + +export const PATCH: RequestHandler = async ({ fetch, request }) => { + const newPreferences = await request.text(); + const requestInitOptions: RequestInit = { + method: 'PATCH', + body: newPreferences + }; + + const endpoint = `${BASE_API_URL}/user-preferences/`; + const req = await fetch(endpoint, requestInitOptions); + const status = await req.status; + const responseData = await req.text(); + + return new Response(responseData, { + status: status, + headers: { + 'Content-Type': 'application/json' + } + }); +}; diff --git a/frontend/tests/functional/startup.test.ts b/frontend/tests/functional/startup.test.ts index a56fee59e..1dc86a631 100644 --- a/frontend/tests/functional/startup.test.ts +++ b/frontend/tests/functional/startup.test.ts @@ -5,11 +5,11 @@ test('startup tests', async ({ loginPage, analyticsPage, page }) => { await page.goto('/'); await loginPage.hasUrl(1); await loginPage.login(); - await analyticsPage.hasUrl(); + await analyticsPage.hasUrl(false); }); await test.step('proper redirection to the analytics page after login', async () => { - await analyticsPage.hasUrl(); + await analyticsPage.hasUrl(false); await analyticsPage.hasTitle(); }); }); diff --git a/frontend/tests/utils/base-page.ts b/frontend/tests/utils/base-page.ts index 0a6505446..e50b607b2 100644 --- a/frontend/tests/utils/base-page.ts +++ b/frontend/tests/utils/base-page.ts @@ -1,5 +1,15 @@ import { expect, type Locator, type Page } from './test-utils.js'; +/** + * Escape the characters of `string` to safely insert it in a regex. + * + * @param {string} string - The string to escape. + * @returns {string} The escaped string. + */ +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export abstract class BasePage { readonly url: string; readonly name: string | RegExp; @@ -28,8 +38,17 @@ export abstract class BasePage { await expect.soft(this.pageTitle).toHaveText(title); } - async hasUrl() { - await expect(this.page).toHaveURL(this.url); + /** + * Check whether the browser's URL match the `this.url` value. + * + * @param {boolean} [strict=true] - Determines the URL matching mode. + * If `strict` is `true`, the function checks if `this.url` is strictly equal to the browser's URL. + * Otherwise, it checks if the browser's URL starts with `this.url`. + * @returns {void} + */ + async hasUrl(strict: boolean = true) { + const URLPattern = strict ? this.url : new RegExp(escapeRegex(this.url) + '.*'); + await expect(this.page).toHaveURL(URLPattern); } async hasBreadcrumbPath(paths: (string | RegExp)[], fullPath = true, origin = 'Home') {