Jelajahi Sumber

Validate a MAC prefix's uniqueness after clearing its host bits, and expose its size and utilization via the API

Brian Tiemann 8 jam lalu
induk
melakukan
223287431b

+ 8 - 3
netbox/dcim/api/serializers_/devices.py

@@ -364,16 +364,21 @@ class MACAddressSerializer(PrimaryModelSerializer):
 
 
 class MACPrefixSerializer(PrimaryModelSerializer):
+    last_address = serializers.CharField(read_only=True)
+    size = serializers.IntegerField(read_only=True)
     mac_address_count = serializers.IntegerField(read_only=True)
+    utilization = serializers.FloatField(read_only=True)
 
     class Meta:
         model = MACPrefix
         fields = [
-            'id', 'url', 'display_url', 'display', 'prefix', 'prefix_length', 'name', 'enforce_unique',
-            'mac_address_count', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated',
+            'id', 'url', 'display_url', 'display', 'prefix', 'prefix_length', 'last_address', 'size', 'name',
+            'enforce_unique', 'mac_address_count', 'utilization', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'prefix', 'prefix_length', 'name', 'description')
+        # Preserve the prefix as normalized by MACPrefix.clean()
+        model_clean_fields = ('prefix',)
 
 
 class AvailableMACAddressSerializer(serializers.Serializer):

+ 8 - 0
netbox/dcim/models/devices.py

@@ -1692,6 +1692,11 @@ class MACPrefix(PrimaryModel):
     def clean(self):
         super().clean()
 
+        # Clear any host bits from the prefix, so that its uniqueness is validated against the value to be saved
+        if (first_address := self.first_address) is not None:
+            self.prefix = first_address
+            self._last_address = self.last_address
+
         # Uniqueness cannot be enforced if the prefix already contains duplicate MAC addresses
         if self.enforce_unique and self.first_address is not None:
             duplicate = self.get_mac_addresses().values('mac_address').annotate(
@@ -1705,6 +1710,9 @@ class MACPrefix(PrimaryModel):
                 })
 
     def save(self, *args, **kwargs):
+        if self.first_address is None:
+            raise ValueError(f"Invalid MAC prefix: {self.prefix}/{self.prefix_length}")
+
         # Clear any host bits from the prefix, and cache the last address in the block
         self.prefix = self.first_address
         self._last_address = self.last_address

+ 20 - 5
netbox/dcim/tests/test_api.py

@@ -7435,17 +7435,32 @@ class MACPrefixTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
-    def test_mac_address_count(self):
+    def test_read_only_fields(self):
         self.add_permissions('dcim.view_macprefix')
-        mac_prefix = MACPrefix.objects.get(name='Prefix 1')
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
         MACAddress.objects.bulk_create((
-            MACAddress(mac_address='BC:24:11:00:00:01'),
-            MACAddress(mac_address='BC:24:11:00:00:01'),
-            MACAddress(mac_address='BC:24:11:00:00:02'),
+            MACAddress(mac_address='BC:24:16:00:00:01'),
+            MACAddress(mac_address='BC:24:16:00:00:01'),
+            MACAddress(mac_address='BC:24:16:00:00:02'),
         ))
         response = self.client.get(self._get_detail_url(mac_prefix), **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['last_address'], 'BC:24:16:00:00:03')
+        self.assertEqual(response.data['size'], 4)
         self.assertEqual(response.data['mac_address_count'], 2)
+        self.assertEqual(response.data['utilization'], 50)
+
+    def test_create_normalizes_prefix(self):
+        self.add_permissions('dcim.add_macprefix')
+        data = {'prefix': 'BC:24:16:AA:BB:CC', 'prefix_length': 24}
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['prefix'], 'BC:24:16:00:00:00')
+
+        # A prefix whose host bits differ from an existing prefix is a duplicate
+        data = {'prefix': 'BC:24:11:AA:BB:CC', 'prefix_length': 24}
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
     def test_list_available_mac_addresses(self):
         mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=45)

+ 10 - 0
netbox/dcim/tests/test_models.py

@@ -147,6 +147,16 @@ class MACPrefixTestCase(TestCase):
             with self.assertRaises(ValidationError):
                 mac_prefix.full_clean()
 
+    def test_duplicate_prefix_with_host_bits(self):
+        MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24)
+        mac_prefix = MACPrefix(prefix='BC:24:11:AA:BB:CC', prefix_length=24)
+        with self.assertRaises(ValidationError):
+            mac_prefix.full_clean()
+
+    def test_save_invalid_prefix(self):
+        with self.assertRaisesMessage(ValueError, 'Invalid MAC prefix'):
+            MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=0)
+
     def test_size(self):
         self.assertEqual(MACPrefix(prefix='BC:24:11:00:00:00', prefix_length=24).size, 2 ** 24)
         self.assertEqual(MACPrefix(prefix='BC:24:11:50:00:00', prefix_length=28).size, 2 ** 20)

+ 8 - 0
netbox/dcim/tests/test_views.py

@@ -7015,6 +7015,14 @@ class MACPrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    def test_create_duplicate_prefix_with_host_bits(self):
+        self.add_permissions('dcim.add_macprefix')
+        response = self.client.post(
+            reverse('dcim:macprefix_add'), {'prefix': 'BC:24:11:AA:BB:CC', 'prefix_length': 24}
+        )
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(MACPrefix.objects.filter(prefix='BC:24:11:00:00:00', prefix_length=24).count(), 1)
+
     def test_mac_addresses_panel(self):
         """
         The MAC prefix page offers to add the first available MAC address within the prefix.