Repository: nautobot/nautobot

Issue:
TypeError when creating IPAddress with prefix of /0
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.6+
* Nautobot version: beta4 (develop)

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Go to add new IP Address
2. Fill out IP w/ `/0` prefix (e.g. `10.11.12.0/0`) + status
3. Click "Create"

<!-- What did you expect to happen? -->
### Expected Behavior

Should fail validation as /0 is not permitted for IP addresses.

<!-- What happened instead? -->
### Observed Behavior

- Object gets created, but repr is`None` as address and causes `TypeError` to be raised in detail view
- Form validation seems to be failing for this condition

Traceback

```python
TypeError: unexpected type <class 'NoneType'> for addr arg
  File "django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "nautobot/utilities/views.py", line 94, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "nautobot/core/views/generic.py", line 88, in get
    **self.get_extra_context(request, instance),
  File "nautobot/ipam/views.py", line 588, in get_extra_context
    Prefix.objects.restrict(request.user, "view")
  File "nautobot/ipam/querysets.py", line 61, in net_contains_or_equals
    prefix = netaddr.IPNetwork(prefix)
  File "netaddr/ip/__init__.py", line 942, in __init__
    value, prefixlen = parse_ip_network(module, addr,
  File "netaddr/ip/__init__.py", line 831, in parse_ip_network
    raise TypeError('unexpected type %s for addr arg' % type(addr))
```


Discussion:

Resolving patch:
diff --git a/nautobot/ipam/formfields.py b/nautobot/ipam/formfields.py
--- a/nautobot/ipam/formfields.py
+++ b/nautobot/ipam/formfields.py
@@ -44,6 +44,15 @@ class IPNetworkFormField(forms.Field):
         "invalid": "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
     }
 
+    def __init__(self, allow_zero_prefix=True, *args, **kwargs):
+        """Initialize any arguments that will be used for field validation.
+
+        Args:
+            allow_zero_prefix (bool, optional): Use for IPAddress validation to invalidate /0 CIDR masks. Defaults to True.
+        """
+        self.allow_zero_prefix = allow_zero_prefix
+        super().__init__(*args, **kwargs)
+
     def to_python(self, value):
         if not value:
             return None
@@ -56,6 +65,13 @@ def to_python(self, value):
             raise ValidationError("CIDR mask (e.g. /24) is required.")
 
         try:
-            return IPNetwork(value)
+            address = IPNetwork(value)
         except AddrFormatError:
             raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
+
+        # If we're expecting an IPAddress, we set self.allow_zero_prefix to false
+        # to validate that IP address does not have a 0 CIDR mask
+        if not self.allow_zero_prefix and address.prefixlen == 0:
+            raise ValidationError("Cannot create IP address with /0 mask.")
+
+        return address
diff --git a/nautobot/ipam/forms.py b/nautobot/ipam/forms.py
--- a/nautobot/ipam/forms.py
+++ b/nautobot/ipam/forms.py
@@ -559,7 +559,7 @@ class IPAddressForm(
     CustomFieldModelForm,
     RelationshipModelForm,
 ):
-    address = IPNetworkFormField()
+    address = IPNetworkFormField(allow_zero_prefix=False)
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -714,6 +714,10 @@ def __init__(self, *args, **kwargs):
     def clean(self):
         super().clean()
 
+        # Need to set instance attribute to address field
+        # to run proper address validation on Model.clean()
+        self.instance.address = self.cleaned_data.get("address")
+
         # Cannot select both a device interface and a VM interface
         if self.cleaned_data.get("interface") and self.cleaned_data.get("vminterface"):
             raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
diff --git a/nautobot/ipam/models.py b/nautobot/ipam/models.py
--- a/nautobot/ipam/models.py
+++ b/nautobot/ipam/models.py
@@ -851,10 +851,6 @@ def clean(self):
 
         if self.address:
 
-            # /0 masks are not acceptable
-            if self.address.prefixlen == 0:
-                raise ValidationError({"address": "Cannot create IP address with /0 mask."})
-
             # Enforce unique IP space (if applicable)
             if self.role not in IPADDRESS_ROLES_NONUNIQUE and (
                 (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique)


Test patch:
diff --git a/nautobot/ipam/tests/test_forms.py b/nautobot/ipam/tests/test_forms.py
new file mode 100644
--- /dev/null
+++ b/nautobot/ipam/tests/test_forms.py
@@ -0,0 +1,39 @@
+"""Test IPAM forms."""
+from django.test import TestCase
+
+from nautobot.extras.models.statuses import Status
+from nautobot.ipam.forms import IPAddressForm
+
+
+class IPAddressFormTest(TestCase):
+    def test_valid_ip_address(self):
+        form = IPAddressForm(data={"address": "192.168.1.0/24", "status": Status.objects.get(slug="dhcp")})
+        self.assertTrue(form.is_valid())
+        self.assertTrue(form.save())
+
+    def test_slaac_valid_ipv6(self):
+        form = IPAddressForm(
+            data={"address": "2001:0db8:0000:0000:0000:ff00:0042:8329/128", "status": Status.objects.get(slug="slaac")}
+        )
+        self.assertTrue(form.is_valid())
+        self.assertTrue(form.save())
+
+    def test_slaac_status_invalid_ipv4(self):
+        form = IPAddressForm(data={"address": "192.168.0.1/32", "status": Status.objects.get(slug="slaac")})
+        self.assertFalse(form.is_valid())
+        self.assertEquals("Only IPv6 addresses can be assigned SLAAC status", form.errors["status"][0])
+
+    def test_address_invalid_ipv4(self):
+        form = IPAddressForm(data={"address": "192.168.0.1/64", "status": Status.objects.get(slug="dhcp")})
+        self.assertFalse(form.is_valid())
+        self.assertEquals("Please specify a valid IPv4 or IPv6 address.", form.errors["address"][0])
+
+    def test_address_zero_mask(self):
+        form = IPAddressForm(data={"address": "192.168.0.1/0", "status": Status.objects.get(slug="dhcp")})
+        self.assertFalse(form.is_valid())
+        self.assertEquals("Cannot create IP address with /0 mask.", form.errors["address"][0])
+
+    def test_address_missing_mask(self):
+        form = IPAddressForm(data={"address": "192.168.0.1", "status": Status.objects.get(slug="dhcp")})
+        self.assertFalse(form.is_valid())
+        self.assertEquals("CIDR mask (e.g. /24) is required.", form.errors["address"][0])