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
This commit is contained in:
Martin Hauser 2026-06-16 14:57:23 +02:00 committed by Jeremy Stretch
parent 0c26f973ff
commit 0e9c99ec7a
4 changed files with 193 additions and 17 deletions

View File

@ -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),
))

View File

@ -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)."""

View File

@ -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, [])