From a912f587e76508462f85d82e67e7d1f0ca182a9a Mon Sep 17 00:00:00 2001
From: Erwan Rouchet <rouchet@teklia.com>
Date: Thu, 13 Apr 2023 15:36:33 +0200
Subject: [PATCH] Verify WorkerConfiguration name uniqueness on updates

---
 arkindex/process/serializers/workers.py       | 37 +++++------
 .../tests/test_worker_configurations.py       | 61 ++++++++++++++++---
 2 files changed, 73 insertions(+), 25 deletions(-)

diff --git a/arkindex/process/serializers/workers.py b/arkindex/process/serializers/workers.py
index 3e74f28b71..bc6911790a 100644
--- a/arkindex/process/serializers/workers.py
+++ b/arkindex/process/serializers/workers.py
@@ -337,14 +337,19 @@ class WorkerConfigurationListSerializer(serializers.ModelSerializer):
         name = data.get('name')
         configuration = data.get('configuration')
         configuration_hash = hash_object(configuration)
-        data['archived'] = data.get('archived', False)
-        worker = self.context.get('worker')
-        # Since WorkerConfigurationSerializer is built from this serializer, this validation function can be triggered by a call
-        # to the WorkerConfigurationRetrieve API endpoint, updating an existing configuration. In this case, there is no worker
-        # cached property and this validation is not necessary anyway because the name and configuration fields are read_only.
-        if not worker:
+
+        # No further validation required if we only sent `archived`
+        if not name and not configuration:
             return data
-        data['worker_id'] = worker.id
+
+        if 'worker' in self.context:
+            # CreateWorkerConfiguration provides the worker through the context
+            worker = self.context['worker']
+            data['worker_id'] = worker.id
+        elif self.instance is not None:
+            # Use the already set worker in (Partial)UpdateConfiguration to revalidate
+            worker = self.instance.worker
+
         existing_configurations = list(worker.configurations.filter(Q(configuration_hash=configuration_hash) | Q(name=name)))
         errors = defaultdict(list)
         for config in existing_configurations:
@@ -353,6 +358,7 @@ class WorkerConfigurationListSerializer(serializers.ModelSerializer):
                 errors['id'] = config.id
             if name == config.name:
                 errors['name'].append("A worker configuration with this name already exists for this worker.")
+
         # Due to the defaultdict, writing e.g. len(errors['configuration']) > 1 would add {'configuration': []} to
         # the errors dict if the key is not already in the dict.
         if len(errors.get('name', [])) > 1 or len(errors.get('configuration', [])) > 1:
@@ -363,8 +369,15 @@ class WorkerConfigurationListSerializer(serializers.ModelSerializer):
             # exist; but checking the length of existing_configurations wouldn't differentiate between this case
             # and an actual duplicated worker configuration.
             raise WorkerConfiguration.MultipleObjectsReturned
+
+        # Archived configurations cannot be renamed, but un-archiving a configuration and renaming it at the same time is
+        # possible. It's also possible to archive and rename a configuration simultaneously.
+        if self.instance is not None and self.instance.archived and data.get('archived') is not False and name:
+            errors['name'].append('Archived configurations cannot be renamed.')
+
         if errors:
             raise ValidationError(errors)
+
         return data
 
 
@@ -375,16 +388,6 @@ class WorkerConfigurationSerializer(WorkerConfigurationListSerializer):
         # The configuration cannot be updated
         read_only_fields = ('id', 'configuration')
 
-    def validate(self, data):
-        name = data.get('name')
-        data_archived = data.get('archived')
-        instance_archived = self.instance.archived
-        # Archived configurations cannot be renamed, but un-archiving a configuration and renaming it at the same time is
-        # possible. It's also possible to archive and rename a configuration simultaneously.
-        if instance_archived and data_archived is not False and name:
-            raise ValidationError({'name': 'Archived configurations cannot be renamed.'})
-        return data
-
 
 class WorkerConfigurationExistsErrorSerializer(serializers.Serializer):
     id = serializers.UUIDField(required=False, help_text="UUID of an existing worker configuration, if the error comes from a duplicate configuration.")
diff --git a/arkindex/process/tests/test_worker_configurations.py b/arkindex/process/tests/test_worker_configurations.py
index 11921ec841..a43e80f17d 100644
--- a/arkindex/process/tests/test_worker_configurations.py
+++ b/arkindex/process/tests/test_worker_configurations.py
@@ -444,7 +444,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
 
         self.assertFalse(self.worker_config.archived)
 
-        with self.assertNumQueries(7):
+        with self.assertNumQueries(8):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={'archived': True, 'name': 'new name'}
@@ -467,7 +467,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
 
         self.assertFalse(self.worker_config.archived)
 
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={'archived': True, 'name': 'new name'}
@@ -489,7 +489,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
 
         self.assertFalse(self.worker_config.archived)
 
-        with self.assertNumQueries(4):
+        with self.assertNumQueries(5):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={'archived': True, 'name': 'a new name'}
@@ -513,7 +513,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
 
         self.assertFalse(self.worker_config.archived)
 
-        with self.assertNumQueries(4):
+        with self.assertNumQueries(5):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={
@@ -540,7 +540,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
         self.worker_config.save()
         self.assertTrue(self.worker_config.archived)
 
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={
@@ -568,7 +568,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
         self.worker_config.save()
         self.assertTrue(self.worker_config.archived)
 
-        with self.assertNumQueries(9):
+        with self.assertNumQueries(10):
             response = self.client.put(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={
@@ -583,6 +583,29 @@ class TestWorkerConfigurations(FixtureAPITestCase):
         self.assertEqual(self.worker_config.name, 'new name')
         self.assertFalse(self.worker_config.archived)
 
+    def test_update_name_exists(self):
+        """
+        Updating the name of a configuration to the name of another existing configuration on the same worker
+        should fail as this would break the unique constraint
+        """
+        self.worker_1.configurations.create(name='new name', configuration={'key': 'value', 'key2': 'value2'})
+        self.client.force_login(self.user)
+
+        with self.assertNumQueries(9):
+            response = self.client.put(
+                reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
+                data={
+                    'name': 'new name',
+                    'archived': False,
+                },
+                format='json',
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        self.assertEqual(response.json(), {
+            'name': ['A worker configuration with this name already exists for this worker.'],
+        })
+
     def test_partial_update_requires_login(self):
         with self.assertNumQueries(0):
             response = self.client.patch(
@@ -721,7 +744,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
 
         self.assertFalse(self.worker_config.archived)
 
-        with self.assertNumQueries(4):
+        with self.assertNumQueries(5):
             response = self.client.patch(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={
@@ -748,7 +771,7 @@ class TestWorkerConfigurations(FixtureAPITestCase):
         self.worker_config.save()
         self.assertTrue(self.worker_config.archived)
 
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             response = self.client.patch(
                 reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
                 data={
@@ -765,3 +788,25 @@ class TestWorkerConfigurations(FixtureAPITestCase):
         self.worker_config.refresh_from_db()
         self.assertEqual(self.worker_config.name, 'config time')
         self.assertTrue(self.worker_config.archived)
+
+    def test_partial_update_name_exists(self):
+        """
+        Updating the name of a configuration to the name of another existing configuration on the same worker
+        should fail as this would break the unique constraint
+        """
+        self.worker_1.configurations.create(name='new name', configuration={'key': 'value', 'key2': 'value2'})
+        self.client.force_login(self.user)
+
+        with self.assertNumQueries(9):
+            response = self.client.patch(
+                reverse('api:configuration-retrieve', kwargs={'pk': str(self.worker_config.id)}),
+                data={
+                    'name': 'new name',
+                },
+                format='json',
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        self.assertEqual(response.json(), {
+            'name': ['A worker configuration with this name already exists for this worker.'],
+        })
-- 
GitLab