فهرست منبع

test(graphql): Add GraphQL schema coverage test framework

Introduce GraphQLSchemaCoverageTestCase to verify every model-backed
GraphQL type exposed as a root query field is covered by a test. Add
type_class and graphql_test_exempt attributes to GraphQLTestCase for
explicit type declaration and coverage exclusion. Include
graphql_object_permission_assertions flag to gate permission checks for
types not enforcing object permissions.

Fixes #22089
Martin Hauser 2 هفته پیش
والد
کامیت
0e9c99ec7a

+ 0 - 0
netbox/netbox/tests/dummy_plugin/tests/__init__.py


+ 19 - 0
netbox/netbox/tests/dummy_plugin/tests/test_graphql.py

@@ -0,0 +1,19 @@
+from netbox.tests.dummy_plugin.graphql import DummyModelType
+from netbox.tests.dummy_plugin.models import DummyModel
+from utilities.testing import APIViewTestCases
+
+
+class DummyModelGraphQLTestCase(APIViewTestCases.GraphQLTestCase):
+    model = DummyModel
+    type_class = DummyModelType
+    graphql_base_name = 'dummymodel'
+    graphql_auto_filter_required = False
+    graphql_object_permission_assertions = False
+
+    @classmethod
+    def setUpTestData(cls):
+        DummyModel.objects.bulk_create((
+            DummyModel(name='Dummy 1', number=1),
+            DummyModel(name='Dummy 2', number=2),
+            DummyModel(name='Dummy 3', number=3),
+        ))

+ 5 - 1
netbox/netbox/tests/test_graphql.py

@@ -15,7 +15,7 @@ from extras.models import TableConfig, Tag
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from utilities.tables import get_table_for_model
-from utilities.testing import APITestCase, TestCase, disable_warnings
+from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 
 
 class GraphQLTestCase(TestCase):
@@ -507,6 +507,10 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
 
 
+class GraphQLSchemaCoverageTestCase(APIViewTestCases.GraphQLSchemaCoverageTestCase):
+    pass
+
+
 class JSONPathValidationTestCase(TestCase):
     """Unit tests for _validate_json_path (VM-323 security fix)."""
 

+ 169 - 16
netbox/utilities/testing/api.py

@@ -10,14 +10,17 @@ from decimal import Decimal
 
 import strawberry
 import strawberry_django
+from django.apps import apps
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.db import models
 from django.test import override_settings
 from django.urls import reverse
+from graphql import GraphQLList, GraphQLNonNull, GraphQLObjectType
 from rest_framework import status
 from rest_framework.test import APIClient
+from strawberry.schema.schema_converter import GraphQLCoreConverter
 from strawberry.types.base import StrawberryList, StrawberryOptional
 from strawberry.types.lazy_type import LazyType
 from strawberry.types.union import StrawberryUnion
@@ -35,6 +38,7 @@ from strawberry_django import (
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from ipam.graphql.types import IPAddressFamilyType
+from netbox.api.exceptions import GraphQLTypeNotFound
 from netbox.graphql.filter_lookups import (
     ArrayLookup,
     BigIntegerLookup,
@@ -49,7 +53,7 @@ from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token, User
 from utilities.api import get_graphql_type_for_model
 
-from .base import ModelTestCase
+from .base import ModelTestCase, TestCase
 from .query_counts import assert_expected_query_count
 from .utils import disable_logging, disable_warnings, get_random_string
 
@@ -633,12 +637,32 @@ class APIViewTestCases:
         # Fail when auto mode is on and no tests were generated.
         graphql_auto_filter_required = True
 
+        # Gate the negative constrained-permission check in the get/list tests; the positive
+        # query still runs. Set False for types not enforcing object permissions (e.g. no BaseObjectType).
+        graphql_object_permission_assertions = True
+
         # Additional explicit-list filter cases as GraphQLFilterTest instances.
         graphql_filter_tests = ()
 
         # Additional full-query cases (e.g. nested filters) as GraphQLQueryTest instances.
         graphql_query_tests = ()
 
+        # GraphQL type under test. Defaults to the type derived from `model` via the naming
+        # convention; set explicitly when the convention does not apply (e.g. plugin types).
+        type_class = None
+
+        # Exclude this test case from GraphQL schema coverage.
+        graphql_test_exempt = False
+
+        @classmethod
+        def get_graphql_type_class(cls):
+            if getattr(cls, 'type_class', None) is not None:
+                return cls.type_class
+            model = getattr(cls, 'model', None)
+            if model is None:
+                return None
+            return get_graphql_type_for_model(model)
+
         def _get_graphql_base_name(self):
             """
             Return graphql_base_name, if set. Otherwise, construct the base name for the query
@@ -652,7 +676,7 @@ class APIViewTestCases:
             Called by either _build_query or _build_filtered_query - construct the actual
             query given a name and filter string
             """
-            type_class = get_graphql_type_for_model(self.model)
+            type_class = self.get_graphql_type_class()
 
             # Compile list of fields to include
             fields_string = ''
@@ -783,7 +807,7 @@ class APIViewTestCases:
             Subscription) omit ``id`` from the output type; for those, the
             assertion path falls back to length-only comparison.
             """
-            type_class = get_graphql_type_for_model(self.model)
+            type_class = self.get_graphql_type_class()
             strawberry_definition = getattr(type_class, '__strawberry_definition__', None)
             if strawberry_definition is None:
                 return False
@@ -1370,13 +1394,14 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
-            # Request should succeed but return empty result
-            with disable_logging():
-                response = self.client.post(url, data={'query': query}, format="json", **self.header)
-            self.assertHttpStatus(response, status.HTTP_200_OK)
-            data = json.loads(response.content)
-            self.assertIn('errors', data)
-            self.assertIsNone(data['data'])
+            if self.graphql_object_permission_assertions:
+                # Request should succeed but return empty result
+                with disable_logging():
+                    response = self.client.post(url, data={'query': query}, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                data = json.loads(response.content)
+                self.assertIn('errors', data)
+                self.assertIsNone(data['data'])
 
             # Remove permission constraint
             obj_perm.constraints = None
@@ -1413,12 +1438,13 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
-            # Request should succeed but return empty results list
-            response = self.client.post(url, data={'query': query}, format="json", **self.header)
-            self.assertHttpStatus(response, status.HTTP_200_OK)
-            data = json.loads(response.content)
-            self.assertNotIn('errors', data)
-            self.assertEqual(len(data['data'][field_name]), 0)
+            if self.graphql_object_permission_assertions:
+                # Request should succeed but return empty results list
+                response = self.client.post(url, data={'query': query}, format="json", **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                data = json.loads(response.content)
+                self.assertNotIn('errors', data)
+                self.assertEqual(len(data['data'][field_name]), 0)
 
             # Remove permission constraint
             obj_perm.constraints = None
@@ -1526,3 +1552,130 @@ class APIViewTestCases:
         GraphQLTestCase
     ):
         pass
+
+    class GraphQLSchemaCoverageTestCase(TestCase):
+        """
+        Assert every model-backed GraphQL type exposed as a root query field is covered by a
+        concrete GraphQLTestCase subclass. Subclass this in a test module to run the audit.
+
+        Scope is intentionally limited to types reachable as root query fields (e.g. ``site``,
+        ``site_list``); these are exactly the types the detail/list GraphQLTestCase methods can
+        exercise. Types reachable only as nested object fields are out of scope.
+        """
+        # Per-app test submodules to import so their GraphQLTestCase subclasses are defined.
+        graphql_test_modules = ('test_api', 'test_graphql')
+
+        # GraphQL type classes intentionally excluded from coverage.
+        graphql_exempt_type_classes = ()
+
+        def get_graphql_schema(self):
+            # Imported lazily so importing this testing utility does not eagerly build the schema.
+            from netbox.graphql.schema import schema
+            return schema._schema
+
+        def iter_test_module_names(self):
+            # Import test modules only for apps exposing model-backed root query types;
+            # coverage classes are expected to live with the app whose type they cover.
+            app_labels = {model._meta.app_label for model in self.get_schema_type_classes().values()}
+            for app_label in sorted(app_labels):
+                app_config = apps.get_app_config(app_label)
+                for module_name in self.graphql_test_modules:
+                    yield f'{app_config.name}.tests.{module_name}'
+
+        def import_graphql_test_modules(self):
+            for module_name in self.iter_test_module_names():
+                self.import_graphql_test_module(module_name)
+
+        def import_graphql_test_module(self, module_name):
+            try:
+                importlib.import_module(module_name)
+            except ModuleNotFoundError as exc:
+                # A missing test module, or a missing parent package (e.g. `<app>.tests`),
+                # is fine. An import error raised from inside an existing test module
+                # should still fail loudly.
+                if exc.name == module_name or module_name.startswith(f'{exc.name}.'):
+                    return
+                raise
+
+        def unwrap_graphql_type(self, graphql_type):
+            while isinstance(graphql_type, (GraphQLNonNull, GraphQLList)):
+                graphql_type = graphql_type.of_type
+            return graphql_type
+
+        def get_schema_field_type_class(self, field):
+            graphql_type = self.unwrap_graphql_type(field.type)
+            if not isinstance(graphql_type, GraphQLObjectType):
+                return None
+            extensions = getattr(graphql_type, 'extensions', None) or {}
+            definition = extensions.get(GraphQLCoreConverter.DEFINITION_BACKREF)
+            return getattr(definition, 'origin', None)
+
+        def get_graphql_type_model(self, type_class):
+            django_definition = getattr(type_class, '__strawberry_django_definition__', None)
+            return getattr(django_definition, 'model', None)
+
+        def get_schema_type_classes(self):
+            """Return {type_class: model} for every model-backed root query type (cached per instance)."""
+            cached = getattr(self, '_schema_type_classes', None)
+            if cached is not None:
+                return cached
+            type_classes = {}
+            for field in self.get_graphql_schema().query_type.fields.values():
+                type_class = self.get_schema_field_type_class(field)
+                if type_class is None:
+                    continue
+                model = self.get_graphql_type_model(type_class)
+                if model is None:
+                    continue
+                type_classes[type_class] = model
+            self._schema_type_classes = type_classes
+            return type_classes
+
+        def iter_graphql_testcase_classes(self, base_class=None):
+            base_class = base_class or APIViewTestCases.GraphQLTestCase
+            for subclass in base_class.__subclasses__():
+                yield subclass
+                yield from self.iter_graphql_testcase_classes(subclass)
+
+        def get_testcase_type_class(self, testcase):
+            if getattr(testcase, 'graphql_test_exempt', False):
+                return None
+            try:
+                return testcase.get_graphql_type_class()
+            except GraphQLTypeNotFound as exc:
+                model = getattr(testcase, 'model', None)
+                model_label = model._meta.label if model is not None else 'unknown model'
+                self.fail(
+                    f'{testcase.__module__}.{testcase.__name__} sets model = {model_label} '
+                    f'but no GraphQL type could be resolved. Set type_class if the type lives '
+                    f'outside the conventional <app>.graphql.types.<Model>Type path, or set '
+                    f'graphql_test_exempt = True if this test case should not count toward '
+                    f'schema coverage. Original error: {exc}'
+                )
+
+        def get_testcase_type_classes(self):
+            self.import_graphql_test_modules()
+            type_classes = set()
+            for testcase in self.iter_graphql_testcase_classes():
+                type_class = self.get_testcase_type_class(testcase)
+                if type_class is not None:
+                    type_classes.add(type_class)
+            return type_classes
+
+        def format_type_class(self, type_class):
+            model = self.get_graphql_type_model(type_class)
+            label = f' ({model._meta.label})' if model is not None else ''
+            return f'{type_class.__module__}.{type_class.__name__}{label}'
+
+        def test_schema_types_have_graphql_test_coverage(self):
+            """Every model-backed root query type is covered by a GraphQLTestCase."""
+            expected = set(self.get_schema_type_classes())
+            self.assertGreater(
+                len(expected), 0,
+                'No model-backed root query GraphQL types were discovered; schema '
+                'introspection may have broken.'
+            )
+            actual = self.get_testcase_type_classes()
+            exempt = set(self.graphql_exempt_type_classes)
+            missing = sorted(self.format_type_class(tc) for tc in expected - actual - exempt)
+            self.assertEqual(missing, [])