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:
parent
0c26f973ff
commit
0e9c99ec7a
|
|
@ -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),
|
||||
))
|
||||
|
|
@ -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)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
Loading…
Reference in New Issue