diff --git a/netbox/netbox/tests/dummy_plugin/tests/__init__.py b/netbox/netbox/tests/dummy_plugin/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/tests/dummy_plugin/tests/test_graphql.py b/netbox/netbox/tests/dummy_plugin/tests/test_graphql.py new file mode 100644 index 000000000..c76bb0778 --- /dev/null +++ b/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), + )) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index e65aca00f..7535ba119 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/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).""" diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 47a9e3b03..81ef04486 100644 --- a/netbox/utilities/testing/api.py +++ b/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. `.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 .graphql.types.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, [])