Просмотр исходного кода

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 недель назад
Родитель
Сommit
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.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from utilities.tables import get_table_for_model
 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):
 class GraphQLTestCase(TestCase):
@@ -507,6 +507,10 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
         self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
 
 
 
 
+class GraphQLSchemaCoverageTestCase(APIViewTestCases.GraphQLSchemaCoverageTestCase):
+    pass
+
+
 class JSONPathValidationTestCase(TestCase):
 class JSONPathValidationTestCase(TestCase):
     """Unit tests for _validate_json_path (VM-323 security fix)."""
     """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
 import strawberry_django
 import strawberry_django
+from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.db import models
 from django.db import models
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
+from graphql import GraphQLList, GraphQLNonNull, GraphQLObjectType
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
+from strawberry.schema.schema_converter import GraphQLCoreConverter
 from strawberry.types.base import StrawberryList, StrawberryOptional
 from strawberry.types.base import StrawberryList, StrawberryOptional
 from strawberry.types.lazy_type import LazyType
 from strawberry.types.lazy_type import LazyType
 from strawberry.types.union import StrawberryUnion
 from strawberry.types.union import StrawberryUnion
@@ -35,6 +38,7 @@ from strawberry_django import (
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from ipam.graphql.types import IPAddressFamilyType
 from ipam.graphql.types import IPAddressFamilyType
+from netbox.api.exceptions import GraphQLTypeNotFound
 from netbox.graphql.filter_lookups import (
 from netbox.graphql.filter_lookups import (
     ArrayLookup,
     ArrayLookup,
     BigIntegerLookup,
     BigIntegerLookup,
@@ -49,7 +53,7 @@ from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token, User
 from users.models import ObjectPermission, Token, User
 from utilities.api import get_graphql_type_for_model
 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 .query_counts import assert_expected_query_count
 from .utils import disable_logging, disable_warnings, get_random_string
 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.
         # Fail when auto mode is on and no tests were generated.
         graphql_auto_filter_required = True
         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.
         # Additional explicit-list filter cases as GraphQLFilterTest instances.
         graphql_filter_tests = ()
         graphql_filter_tests = ()
 
 
         # Additional full-query cases (e.g. nested filters) as GraphQLQueryTest instances.
         # Additional full-query cases (e.g. nested filters) as GraphQLQueryTest instances.
         graphql_query_tests = ()
         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):
         def _get_graphql_base_name(self):
             """
             """
             Return graphql_base_name, if set. Otherwise, construct the base name for the query
             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
             Called by either _build_query or _build_filtered_query - construct the actual
             query given a name and filter string
             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
             # Compile list of fields to include
             fields_string = ''
             fields_string = ''
@@ -783,7 +807,7 @@ class APIViewTestCases:
             Subscription) omit ``id`` from the output type; for those, the
             Subscription) omit ``id`` from the output type; for those, the
             assertion path falls back to length-only comparison.
             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)
             strawberry_definition = getattr(type_class, '__strawberry_definition__', None)
             if strawberry_definition is None:
             if strawberry_definition is None:
                 return False
                 return False
@@ -1370,13 +1394,14 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
             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
             # Remove permission constraint
             obj_perm.constraints = None
             obj_perm.constraints = None
@@ -1413,12 +1438,13 @@ class APIViewTestCases:
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
             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
             # Remove permission constraint
             obj_perm.constraints = None
             obj_perm.constraints = None
@@ -1526,3 +1552,130 @@ class APIViewTestCases:
         GraphQLTestCase
         GraphQLTestCase
     ):
     ):
         pass
         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, [])