Repository: nautobot/nautobot

Issue:
JSON Schema "format" property not enforced
<!--
    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.9
* Nautobot version: 1.1.0
<!--
    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. Create & save the following config context schema
```json
{
    "additionalProperties": false,
    "properties": {
        "ntp-servers": {
            "items": {
                "format": "ipv4",
                "type": "string"
            },
            "type": "array"
        }
    },
    "type": "object"
}
```
2. Create the following config context, assign the previous schema:
```json
{"ntp-servers": ["hi"]}
```
3. Click save

<!-- What did you expect to happen? -->
### Expected Behavior
As per the documentation https://nautobot.readthedocs.io/en/stable/additional-features/config-contexts/#config-context-schemas, the specified `format` should be validated and the save refused

<!-- What happened instead? -->
### Observed Behavior
Save is accepted

### Additional informations
https://python-jsonschema.readthedocs.io/en/stable/validate/#validating-formats indicates that by default there's no `format` validation, you can use : 
```py
jsonschema.Draft7Validator(config_context_schema.data_schema, format_checker=jsonschema.draft7_format_checker)
```



Discussion:
Thanks @u1735067 for the report! Apparently we had this one line in there and it dropped out somehow. 

Resolving patch:
diff --git a/nautobot/extras/api/views.py b/nautobot/extras/api/views.py
--- a/nautobot/extras/api/views.py
+++ b/nautobot/extras/api/views.py
@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
+from django.forms import ValidationError as FormsValidationError
 from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
@@ -283,11 +284,19 @@ def run(self, request, class_path):
         input_serializer = serializers.JobInputSerializer(data=request.data)
         input_serializer.is_valid(raise_exception=True)
 
-        data = input_serializer.data["data"]
+        data = input_serializer.data["data"] or {}
         commit = input_serializer.data["commit"]
         if commit is None:
             commit = getattr(job_class.Meta, "commit_default", True)
 
+        try:
+            job.validate_data(data)
+        except FormsValidationError as e:
+            # message_dict can only be accessed if ValidationError got a dict
+            # in the constructor (saved as error_dict). Otherwise we get a list
+            # of errors under messages
+            return Response({"errors": e.message_dict if hasattr(e, "error_dict") else e.messages}, status=400)
+
         job_content_type = ContentType.objects.get(app_label="extras", model="job")
 
         job_result = JobResult.enqueue_job(
diff --git a/nautobot/extras/jobs.py b/nautobot/extras/jobs.py
--- a/nautobot/extras/jobs.py
+++ b/nautobot/extras/jobs.py
@@ -20,6 +20,7 @@
 from django.db.models import Model
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models.query import QuerySet
+from django.forms import ValidationError
 from django.utils import timezone
 from django.utils.functional import classproperty
 import netaddr
@@ -364,7 +365,7 @@ def deserialize_data(cls, data):
                 return_data[field_name] = var.field_attrs["queryset"].filter(pk__in=value)
 
             elif isinstance(var, ObjectVar):
-                if type(value) is dict:
+                if isinstance(value, dict):
                     return_data[field_name] = var.field_attrs["queryset"].get(**value)
                 else:
                     return_data[field_name] = var.field_attrs["queryset"].get(pk=value)
@@ -381,6 +382,21 @@ def deserialize_data(cls, data):
 
         return return_data
 
+    def validate_data(self, data):
+        vars = self._get_vars()
+
+        if not isinstance(data, dict):
+            raise ValidationError("Job data needs to be a dict")
+
+        for k, v in data.items():
+            if k not in vars:
+                raise ValidationError({k: "Job data contained an unknown property"})
+
+        # defer validation to the form object
+        f = self.as_form(data=data)
+        if not f.is_valid():
+            raise ValidationError(f.errors)
+
     @staticmethod
     def load_file(pk):
         """Load a file proxy stored in the database by primary key.


Test patch:
diff --git a/nautobot/extras/tests/test_api.py b/nautobot/extras/tests/test_api.py
--- a/nautobot/extras/tests/test_api.py
+++ b/nautobot/extras/tests/test_api.py
@@ -424,7 +424,7 @@ class Meta:
             name = "Test job"
 
         var1 = StringVar()
-        var2 = IntegerVar()
+        var2 = IntegerVar(required=True)
         var3 = BooleanVar()
         var4 = ObjectVar(model=DeviceRole)
 
@@ -581,6 +581,61 @@ def test_run_job_object_var_lookup(self):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data["data"]["vars"]["var4"], d.pk)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[], JOBS_ROOT=THIS_DIRECTORY)
+    def test_run_job_with_invalid_data(self):
+        self.add_permissions("extras.run_job")
+
+        data = {
+            "data": "invalid",
+            "commit": True,
+        }
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data, {"errors": ["Job data needs to be a dict"]})
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[], JOBS_ROOT=THIS_DIRECTORY)
+    def test_run_job_with_wrong_data(self):
+        self.add_permissions("extras.run_job")
+        job_data = {
+            "var1": "FooBar",
+            "var2": 123,
+            "var3": False,
+            "var5": "wrong",
+        }
+
+        data = {
+            "data": job_data,
+            "commit": True,
+        }
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data, {"errors": {"var5": ["Job data contained an unknown property"]}})
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[], JOBS_ROOT=THIS_DIRECTORY)
+    def test_run_job_with_missing_data(self):
+        self.add_permissions("extras.run_job")
+
+        job_data = {
+            "var1": "FooBar",
+            "var3": False,
+        }
+
+        data = {
+            "data": job_data,
+            "commit": True,
+        }
+
+        url = reverse("extras-api:job-run", kwargs={"class_path": "local/test_api/TestJob"})
+        response = self.client.post(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.data, {"errors": {"var2": ["This field is required."], "var4": ["This field is required."]}}
+        )
+
 
 class JobResultTest(APITestCase):
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])