diff --git a/netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py new file mode 100644 index 0000000..5eff753 --- /dev/null +++ b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.12 on 2026-04-08 01:04 + +import netbox_custom_objects.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('netbox_custom_objects', '0008_backfill_schema_ids'), + ] + + operations = [ + migrations.AlterField( + model_name='customobjecttype', + name='version', + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models.validate_pep440], + ), + ), + migrations.AlterField( + model_name='customobjecttypefield', + name='deprecated_since', + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models.validate_pep440], + ), + ), + migrations.AlterField( + model_name='customobjecttypefield', + name='scheduled_removal', + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models.validate_pep440], + ), + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index a0438e8..01cf3dd 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -3,6 +3,8 @@ import threading from datetime import date, datetime +from packaging.version import Version, InvalidVersion + import django_filters from core.models import ObjectType, ObjectChange from core.models.object_types import ObjectTypeManager @@ -162,6 +164,19 @@ def _get_action_url(cls, action=None, rest_api=False, kwargs=None): return reverse(cls._get_viewname(action, rest_api), kwargs=kwargs) +def validate_pep440(value): + """Validate that *value* is a valid PEP 440 version string.""" + if not value: + return + try: + Version(value) + except InvalidVersion: + raise ValidationError( + _("'%(value)s' is not a valid version string (expected e.g. '1.0.0')."), + params={"value": value}, + ) + + class CustomObjectType(NetBoxModel): # Class-level cache for generated models _model_cache = {} @@ -197,7 +212,7 @@ class CustomObjectType(NetBoxModel): verbose_name=_('comments'), blank=True ) - version = models.CharField(max_length=50, blank=True) + version = models.CharField(max_length=50, blank=True, validators=[validate_pep440]) verbose_name = models.CharField(max_length=100, blank=True) verbose_name_plural = models.CharField(max_length=100, blank=True) slug = models.SlugField(max_length=100, unique=True, db_index=True, blank=False) @@ -997,12 +1012,14 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode blank=True, verbose_name=_("deprecated since"), help_text=_("Schema version in which this field was marked deprecated (e.g. '2.0.0')."), + validators=[validate_pep440], ) scheduled_removal = models.CharField( max_length=50, blank=True, verbose_name=_("scheduled removal"), help_text=_("Schema version in which this field is planned to be removed (e.g. '3.0.0')."), + validators=[validate_pep440], ) clone_fields = ("custom_object_type",) diff --git a/netbox_custom_objects/tests/test_api.py b/netbox_custom_objects/tests/test_api.py index 32ec716..8c43d42 100644 --- a/netbox_custom_objects/tests/test_api.py +++ b/netbox_custom_objects/tests/test_api.py @@ -6,6 +6,7 @@ from utilities.testing import APIViewTestCases, create_test_user from rest_framework import status +from rest_framework.test import APIClient from netbox_custom_objects.models import CustomObjectType from .base import CustomObjectsTestCase @@ -791,7 +792,8 @@ class ContextFieldApiTestCase(CustomObjectsTestCase, TestCase): def setUp(self): self.user = create_test_user('ctxapiuser') token_key = create_token(self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {token_key}'} + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}') # --- COT A: primary field + context field --- self.cot_with_primary = CustomObjectsTestCase.create_custom_object_type( @@ -889,7 +891,7 @@ def test_display_equals_primary_field_value(self): """display must be the primary field value, not the fallback.""" instance = self.model_with_primary.objects.create(name='Route-A', owner='Alice') response = self.client.get( - self._detail_url(self.cot_with_primary, instance), **self.header + self._detail_url(self.cot_with_primary, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['display'], 'Route-A') @@ -898,7 +900,7 @@ def test_context_display_value_with_primary_field(self): """_context.display must equal the context field value when primary is set.""" instance = self.model_with_primary.objects.create(name='Route-A', owner='Alice') response = self.client.get( - self._detail_url(self.cot_with_primary, instance), **self.header + self._detail_url(self.cot_with_primary, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.data['_context']) @@ -908,7 +910,7 @@ def test_context_null_when_context_field_has_no_value(self): """_context must be null when the context field carries no value.""" instance = self.model_with_primary.objects.create(name='Route-B') response = self.client.get( - self._detail_url(self.cot_with_primary, instance), **self.header + self._detail_url(self.cot_with_primary, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNone(response.data['_context']) @@ -920,7 +922,7 @@ def test_display_uses_fallback_when_no_primary_field(self): instance = self.model_no_primary.objects.create(owner='Bob') expected = f"{self.cot_no_primary.display_name} {instance.id}" response = self.client.get( - self._detail_url(self.cot_no_primary, instance), **self.header + self._detail_url(self.cot_no_primary, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['display'], expected) @@ -929,7 +931,7 @@ def test_context_display_value_with_fallback_display(self): """_context.display must work correctly even when display uses the fallback name.""" instance = self.model_no_primary.objects.create(owner='Bob') response = self.client.get( - self._detail_url(self.cot_no_primary, instance), **self.header + self._detail_url(self.cot_no_primary, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.data['_context']) @@ -943,7 +945,7 @@ def test_multiple_context_fields_joined_in_display(self): name='Route-C', owner='Carol', region='EU' ) response = self.client.get( - self._detail_url(self.cot_multi_ctx, instance), **self.header + self._detail_url(self.cot_multi_ctx, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.data['_context']) @@ -954,8 +956,97 @@ def test_multiple_context_fields_omits_empty_values(self): instance = self.model_multi_ctx.objects.create(name='Route-D', owner='Dave') # region (second context field) is not set response = self.client.get( - self._detail_url(self.cot_multi_ctx, instance), **self.header + self._detail_url(self.cot_multi_ctx, instance) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.data['_context']) self.assertEqual(response.data['_context']['display'], 'Dave') + + +# --------------------------------------------------------------------------- +# PEP 440 version string validation — API layer (issue #392) +# --------------------------------------------------------------------------- + +class Pep440APIValidationTestCase(CustomObjectsTestCase, TestCase): + """ + Verify that ``validate_pep440`` surfaces as a 400 at the API layer for + ``CustomObjectType.version`` and ``CustomObjectTypeField.deprecated_since`` + / ``scheduled_removal``. + + DRF's ModelSerializer copies model-field validators into the serializer + field, so these should be enforced during deserialization without any + extra serializer code. + """ + + def setUp(self): + super().setUp() + token_key = create_token(self.user) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}') + + # Permission to create/change CustomObjectType records. + add_cot_perm = ObjectPermission(name='pep440_add_cot', actions=['add', 'change']) + add_cot_perm.save() + add_cot_perm.users.add(self.user) + add_cot_perm.object_types.add(ObjectType.objects.get_for_model(CustomObjectType)) + + # Permission to change CustomObjectTypeField records. + from netbox_custom_objects.models import CustomObjectTypeField # noqa: PLC0415 + change_field_perm = ObjectPermission(name='pep440_change_field', actions=['add', 'change']) + change_field_perm.save() + change_field_perm.users.add(self.user) + change_field_perm.object_types.add(ObjectType.objects.get_for_model(CustomObjectTypeField)) + + # ------------------------------------------------------------------ + # CustomObjectType.version + # ------------------------------------------------------------------ + + def test_create_cot_invalid_version_returns_400(self): + url = reverse('plugins-api:netbox_custom_objects-api:customobjecttype-list') + data = {'name': 'vertest', 'slug': 'ver-test', 'version': 'not-a-version'} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('version', response.data) + + def test_create_cot_valid_version_accepted(self): + url = reverse('plugins-api:netbox_custom_objects-api:customobjecttype-list') + data = {'name': 'vertest2', 'slug': 'ver-test-2', 'version': '1.2.3'} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # ------------------------------------------------------------------ + # CustomObjectTypeField.deprecated_since / scheduled_removal + # ------------------------------------------------------------------ + + def test_patch_field_invalid_deprecated_since_returns_400(self): + cot = self.create_custom_object_type(name='pep440cot', slug='pep440-cot') + field = self.create_custom_object_type_field(cot, name='alpha', type='text') + url = reverse( + 'plugins-api:netbox_custom_objects-api:customobjecttypefield-detail', + kwargs={'pk': field.pk}, + ) + response = self.client.patch(url, {'deprecated_since': 'latest'}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('deprecated_since', response.data) + + def test_patch_field_invalid_scheduled_removal_returns_400(self): + cot = self.create_custom_object_type(name='pep440cot2', slug='pep440-cot-2') + field = self.create_custom_object_type_field(cot, name='beta', type='text') + url = reverse( + 'plugins-api:netbox_custom_objects-api:customobjecttypefield-detail', + kwargs={'pk': field.pk}, + ) + response = self.client.patch(url, {'scheduled_removal': '1.x.0'}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('scheduled_removal', response.data) + + def test_patch_cot_valid_version_accepted(self): + # PATCH CustomObjectType.version (no DDL on COT update) verifies the + # validator doesn't reject a valid PEP 440 string. + cot = self.create_custom_object_type(name='pep440cot3', slug='pep440-cot-3') + url = reverse( + 'plugins-api:netbox_custom_objects-api:customobjecttype-detail', + kwargs={'pk': cot.pk}, + ) + response = self.client.patch(url, {'version': '2.0.0'}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index 324c9c0..b7d816e 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -1190,3 +1190,81 @@ def test_get_model_skips_db_with_migrate_in_argv(self): finally: sys.argv = original_argv nco._migrations_checked = None + + +# --------------------------------------------------------------------------- +# Semver / version string validation (issue #392) +# --------------------------------------------------------------------------- + +class SemverValidationTestCase(CustomObjectsTestCase, TestCase): + """Validate that version-string fields reject non-PEP-440 values.""" + + # ------------------------------------------------------------------ + # CustomObjectType.version + # ------------------------------------------------------------------ + + def test_cot_version_blank_is_valid(self): + cot = self.create_custom_object_type(name='semver_cot', slug='semver-cot') + cot.version = '' + cot.full_clean() # must not raise + + def test_cot_version_valid_semver(self): + cot = self.create_custom_object_type(name='semver_cot2', slug='semver-cot-2') + for v in ('1.0.0', '2.3.4', '0.0.1', '1.0.0.post1', '1.0.0a1'): + cot.version = v + cot.full_clean() # must not raise + + def test_cot_version_invalid_raises_validation_error(self): + cot = self.create_custom_object_type(name='semver_cot3', slug='semver-cot-3') + for bad in ('not-a-version', '1.x.0', 'latest', '!!invalid!!'): + cot.version = bad + with self.assertRaises(ValidationError, msg=f"Expected ValidationError for version={bad!r}"): + cot.full_clean() + + # ------------------------------------------------------------------ + # CustomObjectTypeField.deprecated_since + # ------------------------------------------------------------------ + + def test_field_deprecated_since_blank_is_valid(self): + cot = self.create_custom_object_type(name='semver_f1', slug='semver-f1') + field = self.create_custom_object_type_field(cot, name='alpha', type='text') + field.deprecated_since = '' + field.full_clean() + + def test_field_deprecated_since_valid_semver(self): + cot = self.create_custom_object_type(name='semver_f2', slug='semver-f2') + field = self.create_custom_object_type_field(cot, name='beta', type='text') + field.deprecated_since = '2.0.0' + field.full_clean() + + def test_field_deprecated_since_invalid_raises(self): + cot = self.create_custom_object_type(name='semver_f3', slug='semver-f3') + field = self.create_custom_object_type_field(cot, name='gamma', type='text') + for bad in ('not-a-version', '1.x.0', 'latest', '!!invalid!!'): + field.deprecated_since = bad + with self.assertRaises(ValidationError, msg=f"Expected ValidationError for deprecated_since={bad!r}"): + field.full_clean() + + # ------------------------------------------------------------------ + # CustomObjectTypeField.scheduled_removal + # ------------------------------------------------------------------ + + def test_field_scheduled_removal_blank_is_valid(self): + cot = self.create_custom_object_type(name='semver_f4', slug='semver-f4') + field = self.create_custom_object_type_field(cot, name='delta', type='text') + field.scheduled_removal = '' + field.full_clean() + + def test_field_scheduled_removal_valid_semver(self): + cot = self.create_custom_object_type(name='semver_f5', slug='semver-f5') + field = self.create_custom_object_type_field(cot, name='epsilon', type='text') + field.scheduled_removal = '3.0.0' + field.full_clean() + + def test_field_scheduled_removal_invalid_raises(self): + cot = self.create_custom_object_type(name='semver_f6', slug='semver-f6') + field = self.create_custom_object_type_field(cot, name='zeta', type='text') + for bad in ('v-bad', '1.x.0', 'latest', '!!invalid!!'): + field.scheduled_removal = bad + with self.assertRaises(ValidationError, msg=f"Expected ValidationError for scheduled_removal={bad!r}"): + field.full_clean() diff --git a/pyproject.toml b/pyproject.toml index 7b967ef..fb393cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "Django", + "packaging", ] [project.optional-dependencies]