api.py 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738
  1. import copy
  2. import importlib
  3. import inspect
  4. import json
  5. import types
  6. import typing
  7. from collections.abc import Callable
  8. from dataclasses import dataclass
  9. from decimal import Decimal
  10. import strawberry
  11. import strawberry_django
  12. from django.apps import apps
  13. from django.conf import settings
  14. from django.contrib.contenttypes.models import ContentType
  15. from django.contrib.postgres.fields import ArrayField
  16. from django.db import models
  17. from django.test import override_settings
  18. from django.urls import reverse
  19. from graphql import GraphQLList, GraphQLNonNull, GraphQLObjectType
  20. from rest_framework import status
  21. from rest_framework.test import APIClient
  22. from strawberry.schema.schema_converter import GraphQLCoreConverter
  23. from strawberry.types.base import StrawberryList, StrawberryOptional
  24. from strawberry.types.lazy_type import LazyType
  25. from strawberry.types.union import StrawberryUnion
  26. from strawberry_django import (
  27. BaseFilterLookup,
  28. ComparisonFilterLookup,
  29. DateFilterLookup,
  30. DatetimeFilterLookup,
  31. FilterLookup,
  32. RangeLookup,
  33. StrFilterLookup,
  34. TimeFilterLookup,
  35. )
  36. from core.choices import ObjectChangeActionChoices
  37. from core.models import ObjectChange, ObjectType
  38. from ipam.graphql.types import IPAddressFamilyType
  39. from netbox.api.exceptions import GraphQLTypeNotFound
  40. from netbox.graphql.filter_lookups import (
  41. ArrayLookup,
  42. BigIntegerLookup,
  43. FloatLookup,
  44. IntegerLookup,
  45. IntegerRangeArrayLookup,
  46. JSONFilter,
  47. TreeNodeFilter,
  48. )
  49. from netbox.models.features import ChangeLoggingMixin
  50. from users.constants import TOKEN_PREFIX
  51. from users.models import ObjectPermission, Token, User
  52. from utilities.api import get_graphql_type_for_model
  53. from .base import ModelTestCase, TestCase
  54. from .query_counts import assert_expected_query_count
  55. from .utils import disable_logging, disable_warnings, get_random_string
  56. __all__ = (
  57. 'APITestCase',
  58. 'APIViewTestCases',
  59. 'GraphQLFilterTest',
  60. 'GraphQLQueryTest',
  61. )
  62. @dataclass(frozen=True)
  63. class GraphQLFilterTest:
  64. """
  65. Declarative GraphQL filter test case for APIViewTestCases.GraphQLTestCase.
  66. ``filters`` is the raw content to place inside the GraphQL ``filters`` input,
  67. e.g. ``name: {i_contains: "site"}``.
  68. ``expected`` may be a callable accepting the model queryset, an ORM filter
  69. dict, a queryset, an iterable of model instances, or an iterable of object
  70. IDs. When omitted, the test only asserts that the filter returns at least one
  71. result; this preserves compatibility with the legacy ``graphql_filter``
  72. attribute.
  73. """
  74. name: str
  75. filters: str
  76. expected: object = None
  77. permissions: tuple[str, ...] = ()
  78. @dataclass(frozen=True)
  79. class GraphQLQueryTest:
  80. """
  81. Declarative GraphQL query test case for model-specific complex queries.
  82. ``assert_result`` is called as ``assert_result(testcase, data)`` where
  83. ``testcase`` is the running ``GraphQLTestCase`` instance (use it for
  84. ``testcase.assertEqual`` etc.) and ``data`` is the decoded GraphQL
  85. ``data`` object (the inner ``response.json()['data']``, not the full HTTP
  86. response).
  87. """
  88. name: str
  89. query: str
  90. assert_result: Callable
  91. permissions: tuple[str, ...] = ()
  92. #
  93. # REST/GraphQL API Tests
  94. #
  95. class APITestCase(ModelTestCase):
  96. """
  97. Base test case for API requests.
  98. client_class: Test client class
  99. view_namespace: Namespace for API views. If None, the model's app_label will be used.
  100. """
  101. client_class = APIClient
  102. view_namespace = None
  103. def setUp(self):
  104. """
  105. Create a user and token for API calls.
  106. """
  107. # Create the test user and assign permissions
  108. self.user = User.objects.create_user(username='testuser')
  109. self.add_permissions(*self.user_permissions)
  110. self.token = Token.objects.create(user=self.user)
  111. self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
  112. def _get_view_namespace(self):
  113. return f'{self.view_namespace or self.model._meta.app_label}-api'
  114. def _get_detail_url(self, instance):
  115. viewname = f'{self._get_view_namespace()}:{instance._meta.model_name}-detail'
  116. return reverse(viewname, kwargs={'pk': instance.pk})
  117. def _get_list_url(self):
  118. viewname = f'{self._get_view_namespace()}:{self.model._meta.model_name}-list'
  119. return reverse(viewname)
  120. class APIViewTestCases:
  121. class GetObjectViewTestCase(APITestCase):
  122. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
  123. def test_get_object_anonymous(self):
  124. """
  125. GET a single object as an unauthenticated user.
  126. """
  127. url = self._get_detail_url(self._get_queryset().first())
  128. if (self.model._meta.app_label, self.model._meta.model_name) in settings.EXEMPT_EXCLUDE_MODELS:
  129. # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
  130. with disable_warnings('django.request'):
  131. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  132. else:
  133. response = self.client.get(url, **self.header)
  134. self.assertHttpStatus(response, status.HTTP_200_OK)
  135. def test_get_object_without_permission(self):
  136. """
  137. GET a single object as an authenticated user without the required permission.
  138. """
  139. url = self._get_detail_url(self._get_queryset().first())
  140. # Try GET without permission
  141. with disable_warnings('django.request'):
  142. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  143. def test_get_object(self):
  144. """
  145. GET a single object as an authenticated user with permission to view the object.
  146. """
  147. self.assertGreaterEqual(self._get_queryset().count(), 2,
  148. f"Test requires the creation of at least two {self.model} instances")
  149. instance1, instance2 = self._get_queryset()[:2]
  150. # Add object-level permission
  151. obj_perm = ObjectPermission(
  152. name='Test permission',
  153. constraints={'pk': instance1.pk},
  154. actions=['view']
  155. )
  156. obj_perm.save()
  157. obj_perm.users.add(self.user)
  158. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  159. # Try GET to permitted object
  160. url = self._get_detail_url(instance1)
  161. response = self.client.get(url, **self.header)
  162. self.assertHttpStatus(response, status.HTTP_200_OK)
  163. # Verify ETag header is present for objects with timestamps
  164. if issubclass(self.model, ChangeLoggingMixin):
  165. self.assertIn('ETag', response, "ETag header missing from detail response")
  166. # Try GET to non-permitted object
  167. url = self._get_detail_url(instance2)
  168. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
  169. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
  170. def test_options_object(self):
  171. """
  172. Make an OPTIONS request for a single object.
  173. """
  174. url = self._get_detail_url(self._get_queryset().first())
  175. response = self.client.options(url, **self.header)
  176. self.assertHttpStatus(response, status.HTTP_200_OK)
  177. class ListObjectsViewTestCase(APITestCase):
  178. brief_fields = []
  179. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
  180. def test_list_objects_anonymous(self):
  181. """
  182. GET a list of objects as an unauthenticated user.
  183. """
  184. url = self._get_list_url()
  185. if (self.model._meta.app_label, self.model._meta.model_name) in settings.EXEMPT_EXCLUDE_MODELS:
  186. # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
  187. with disable_warnings('django.request'):
  188. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  189. else:
  190. response = self.client.get(url, **self.header)
  191. self.assertHttpStatus(response, status.HTTP_200_OK)
  192. self.assertEqual(len(response.data['results']), self._get_queryset().count())
  193. def test_list_objects_brief(self):
  194. """
  195. GET a list of objects using the "brief" parameter.
  196. """
  197. self.add_permissions(f'{self.model._meta.app_label}.view_{self.model._meta.model_name}')
  198. url = f'{self._get_list_url()}?brief=1'
  199. response = self.client.get(url, **self.header)
  200. self.assertHttpStatus(response, status.HTTP_200_OK)
  201. self.assertEqual(len(response.data['results']), self._get_queryset().count())
  202. self.assertEqual(sorted(response.data['results'][0]), self.brief_fields)
  203. def test_list_objects_without_permission(self):
  204. """
  205. GET a list of objects as an authenticated user without the required permission.
  206. """
  207. url = self._get_list_url()
  208. # Try GET without permission
  209. with disable_warnings('django.request'):
  210. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  211. def test_list_objects(self):
  212. """
  213. GET a list of objects as an authenticated user with permission to view the objects.
  214. """
  215. self.assertGreaterEqual(self._get_queryset().count(), 3,
  216. f"Test requires the creation of at least three {self.model} instances")
  217. instance1, instance2 = self._get_queryset()[:2]
  218. # Add object-level permission
  219. obj_perm = ObjectPermission(
  220. name='Test permission',
  221. constraints={'pk__in': [instance1.pk, instance2.pk]},
  222. actions=['view']
  223. )
  224. obj_perm.save()
  225. obj_perm.users.add(self.user)
  226. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  227. # Try GET to permitted objects
  228. with assert_expected_query_count(self, 'api_list_objects'):
  229. response = self.client.get(self._get_list_url(), **self.header)
  230. self.assertHttpStatus(response, status.HTTP_200_OK)
  231. self.assertEqual(len(response.data['results']), 2)
  232. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
  233. def test_options_objects(self):
  234. """
  235. Make an OPTIONS request for a list endpoint.
  236. """
  237. response = self.client.options(self._get_list_url(), **self.header)
  238. self.assertHttpStatus(response, status.HTTP_200_OK)
  239. class CreateObjectViewTestCase(APITestCase):
  240. create_data = []
  241. validation_excluded_fields = []
  242. def test_create_object_without_permission(self):
  243. """
  244. POST a single object without permission.
  245. """
  246. url = self._get_list_url()
  247. # Try POST without permission
  248. with disable_warnings('django.request'):
  249. response = self.client.post(url, self.create_data[0], format='json', **self.header)
  250. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  251. def test_create_object(self):
  252. """
  253. POST a single object with permission.
  254. """
  255. # Add object-level permission
  256. obj_perm = ObjectPermission(
  257. name='Test permission',
  258. actions=['add']
  259. )
  260. obj_perm.save()
  261. obj_perm.users.add(self.user)
  262. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  263. self.add_related_view_permissions(self.create_data[0])
  264. data = copy.deepcopy(self.create_data[0])
  265. # If supported, add a changelog message
  266. if issubclass(self.model, ChangeLoggingMixin):
  267. data['changelog_message'] = get_random_string(10)
  268. initial_count = self._get_queryset().count()
  269. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  270. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  271. self.assertEqual(self._get_queryset().count(), initial_count + 1)
  272. instance = self._get_queryset().get(pk=response.data['id'])
  273. self.assertInstanceEqual(
  274. instance,
  275. self.create_data[0],
  276. exclude=self.validation_excluded_fields,
  277. api=True
  278. )
  279. # Verify ObjectChange creation
  280. if issubclass(self.model, ChangeLoggingMixin):
  281. objectchange = ObjectChange.objects.get(
  282. changed_object_type=ContentType.objects.get_for_model(instance),
  283. changed_object_id=instance.pk,
  284. action=ObjectChangeActionChoices.ACTION_CREATE,
  285. )
  286. self.assertObjectChange(objectchange, action=ObjectChangeActionChoices.ACTION_CREATE,
  287. message=data['changelog_message'])
  288. def test_bulk_create_objects(self):
  289. """
  290. POST a set of objects in a single request.
  291. """
  292. # Add object-level permission
  293. obj_perm = ObjectPermission(
  294. name='Test permission',
  295. actions=['add']
  296. )
  297. obj_perm.save()
  298. obj_perm.users.add(self.user)
  299. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  300. self.add_related_view_permissions(*self.create_data)
  301. # If supported, add a changelog message
  302. changelog_message = get_random_string(10)
  303. if issubclass(self.model, ChangeLoggingMixin):
  304. for obj_data in self.create_data:
  305. obj_data['changelog_message'] = changelog_message
  306. initial_count = self._get_queryset().count()
  307. response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header)
  308. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  309. self.assertEqual(len(response.data), len(self.create_data))
  310. self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
  311. for i, obj in enumerate(response.data):
  312. for field in self.create_data[i]:
  313. if field in ('changelog_message', 'add_tags', 'remove_tags'):
  314. # Write-only field
  315. continue
  316. if field not in self.validation_excluded_fields:
  317. self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response")
  318. for i, obj in enumerate(response.data):
  319. self.assertInstanceEqual(
  320. self._get_queryset().get(pk=obj['id']),
  321. self.create_data[i],
  322. exclude=self.validation_excluded_fields,
  323. api=True
  324. )
  325. # Verify ObjectChange creation
  326. if issubclass(self.model, ChangeLoggingMixin):
  327. id_list = [
  328. obj['id'] for obj in response.data
  329. ]
  330. objectchanges = ObjectChange.objects.filter(
  331. changed_object_type=ContentType.objects.get_for_model(self.model),
  332. changed_object_id__in=id_list,
  333. action=ObjectChangeActionChoices.ACTION_CREATE,
  334. )
  335. self.assertEqual(len(objectchanges), len(self.create_data))
  336. for oc in objectchanges:
  337. self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_CREATE,
  338. message=changelog_message)
  339. class UpdateObjectViewTestCase(APITestCase):
  340. update_data = {}
  341. bulk_update_data = None
  342. bulk_update_invalid_data = None
  343. validation_excluded_fields = []
  344. def test_update_object_without_permission(self):
  345. """
  346. PATCH a single object without permission.
  347. """
  348. url = self._get_detail_url(self._get_queryset().first())
  349. update_data = self.update_data or getattr(self, 'create_data')[0]
  350. # Try PATCH without permission
  351. with disable_warnings('django.request'):
  352. response = self.client.patch(url, update_data, format='json', **self.header)
  353. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  354. def test_update_object(self):
  355. """
  356. PATCH a single object identified by its numeric ID.
  357. """
  358. instance = self._get_queryset().first()
  359. url = self._get_detail_url(instance)
  360. update_data = self.update_data or getattr(self, 'create_data')[0]
  361. # Add object-level permission
  362. obj_perm = ObjectPermission(
  363. name='Test permission',
  364. actions=['change']
  365. )
  366. obj_perm.save()
  367. obj_perm.users.add(self.user)
  368. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  369. self.add_related_view_permissions(update_data)
  370. data = copy.deepcopy(update_data)
  371. # If supported, add a changelog message
  372. if issubclass(self.model, ChangeLoggingMixin):
  373. data['changelog_message'] = get_random_string(10)
  374. response = self.client.patch(url, data, format='json', **self.header)
  375. self.assertHttpStatus(response, status.HTTP_200_OK)
  376. instance.refresh_from_db()
  377. self.assertInstanceEqual(
  378. instance,
  379. data,
  380. exclude=self.validation_excluded_fields,
  381. api=True
  382. )
  383. # Verify ObjectChange creation
  384. if hasattr(self.model, 'to_objectchange'):
  385. objectchange = ObjectChange.objects.get(
  386. changed_object_type=ContentType.objects.get_for_model(instance),
  387. changed_object_id=instance.pk
  388. )
  389. self.assertObjectChange(objectchange, action=ObjectChangeActionChoices.ACTION_UPDATE,
  390. message=data['changelog_message'])
  391. def test_update_object_with_etag(self):
  392. """
  393. PATCH an object using a valid If-Match ETag → expect 200.
  394. PATCH again with the now-stale ETag → expect 412.
  395. """
  396. if not issubclass(self.model, ChangeLoggingMixin):
  397. self.skipTest("Model does not support ETags")
  398. self.add_permissions(
  399. f'{self.model._meta.app_label}.view_{self.model._meta.model_name}',
  400. f'{self.model._meta.app_label}.change_{self.model._meta.model_name}',
  401. )
  402. instance = self._get_queryset().first()
  403. url = self._get_detail_url(instance)
  404. update_data = self.update_data or getattr(self, 'create_data')[0]
  405. self.add_related_view_permissions(update_data)
  406. # Fetch current ETag
  407. get_response = self.client.get(url, **self.header)
  408. self.assertHttpStatus(get_response, status.HTTP_200_OK)
  409. etag = get_response.get('ETag')
  410. self.assertIsNotNone(etag, "No ETag returned by GET")
  411. # PATCH with correct ETag → 200
  412. response = self.client.patch(
  413. url, update_data, format='json',
  414. **{**self.header, 'HTTP_IF_MATCH': etag}
  415. )
  416. self.assertHttpStatus(response, status.HTTP_200_OK)
  417. new_etag = response.get('ETag')
  418. self.assertIsNotNone(new_etag)
  419. self.assertNotEqual(etag, new_etag) # ETag must change after update
  420. # PATCH with the old (stale) ETag → 412
  421. with disable_warnings('django.request'):
  422. response = self.client.patch(
  423. url, update_data, format='json',
  424. **{**self.header, 'HTTP_IF_MATCH': etag}
  425. )
  426. self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED)
  427. def test_bulk_update_objects(self):
  428. """
  429. PATCH a set of objects in a single request.
  430. """
  431. if self.bulk_update_data is None:
  432. self.skipTest("Bulk update data not set")
  433. # Add object-level permission
  434. obj_perm = ObjectPermission(
  435. name='Test permission',
  436. actions=['change']
  437. )
  438. obj_perm.save()
  439. obj_perm.users.add(self.user)
  440. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  441. self.add_related_view_permissions(self.bulk_update_data)
  442. id_list = list(self._get_queryset().values_list('id', flat=True)[:3])
  443. self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
  444. data = [
  445. {'id': id, **self.bulk_update_data} for id in id_list
  446. ]
  447. # If supported, add a changelog message
  448. changelog_message = get_random_string(10)
  449. if issubclass(self.model, ChangeLoggingMixin):
  450. for obj_data in data:
  451. obj_data['changelog_message'] = changelog_message
  452. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  453. self.assertHttpStatus(response, status.HTTP_200_OK)
  454. for i, obj in enumerate(response.data):
  455. for field in self.bulk_update_data:
  456. if field in ('changelog_message', 'add_tags', 'remove_tags'):
  457. # Write-only field
  458. continue
  459. self.assertIn(field, obj, f"Bulk update field '{field}' missing from object {i} in response")
  460. for instance in self._get_queryset().filter(pk__in=id_list):
  461. self.assertInstanceEqual(instance, self.bulk_update_data, api=True)
  462. # Verify ObjectChange creation
  463. if issubclass(self.model, ChangeLoggingMixin):
  464. objectchanges = ObjectChange.objects.filter(
  465. changed_object_type=ContentType.objects.get_for_model(self.model),
  466. changed_object_id__in=id_list
  467. )
  468. self.assertEqual(len(objectchanges), len(data))
  469. for oc in objectchanges:
  470. self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_UPDATE,
  471. message=changelog_message)
  472. def test_bulk_update_objects_validation_error(self):
  473. """
  474. PATCH a set of objects where one fails validation. Verify the structured per-object error
  475. response and that no objects are modified (atomic rollback).
  476. """
  477. if self.bulk_update_data is None or self.bulk_update_invalid_data is None:
  478. self.skipTest('Bulk update data not set')
  479. obj_perm = ObjectPermission(name='Test permission', actions=['change'])
  480. obj_perm.save()
  481. obj_perm.users.add(self.user)
  482. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  483. id_list = list(self._get_queryset().values_list('id', flat=True)[:2])
  484. self.assertEqual(len(id_list), 2, 'Insufficient number of objects to test bulk update validation error')
  485. # First object: valid data; second: invalid data that must fail validation
  486. data = [
  487. {'id': id_list[0], **self.bulk_update_data},
  488. {'id': id_list[1], **self.bulk_update_invalid_data},
  489. ]
  490. # Snapshot field values before the request so we can verify atomicity afterward
  491. instance0_before = self._get_queryset().get(pk=id_list[0])
  492. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  493. self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
  494. self.assertIn('detail', response.data)
  495. self.assertIn('results', response.data)
  496. self.assertEqual(len(response.data['results']), 2)
  497. self.assertEqual(response.data['results'][0]['id'], id_list[0])
  498. self.assertNotIn('errors', response.data['results'][0])
  499. self.assertEqual(response.data['results'][1]['id'], id_list[1])
  500. self.assertIn('errors', response.data['results'][1])
  501. # Verify atomicity: object 0 passed validation but must not have been modified
  502. instance0_after = self._get_queryset().get(pk=id_list[0])
  503. for field in self.bulk_update_data:
  504. if field in ('changelog_message', 'add_tags', 'remove_tags'):
  505. continue
  506. self.assertEqual(
  507. getattr(instance0_after, field, None),
  508. getattr(instance0_before, field, None),
  509. f'Field {field!r} of object {id_list[0]} was modified — atomic rollback may be broken',
  510. )
  511. class DeleteObjectViewTestCase(APITestCase):
  512. def test_delete_object_without_permission(self):
  513. """
  514. DELETE a single object without permission.
  515. """
  516. url = self._get_detail_url(self._get_queryset().first())
  517. # Try DELETE without permission
  518. with disable_warnings('django.request'):
  519. response = self.client.delete(url, **self.header)
  520. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  521. def test_delete_object(self):
  522. """
  523. DELETE a single object identified by its numeric ID.
  524. """
  525. instance = self._get_queryset().first()
  526. url = self._get_detail_url(instance)
  527. # Add object-level permission
  528. obj_perm = ObjectPermission(
  529. name='Test permission',
  530. actions=['delete']
  531. )
  532. obj_perm.save()
  533. obj_perm.users.add(self.user)
  534. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  535. data = {}
  536. # If supported, add a changelog message
  537. if issubclass(self.model, ChangeLoggingMixin):
  538. data['changelog_message'] = get_random_string(10)
  539. response = self.client.delete(url, data, **self.header)
  540. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  541. self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
  542. # Verify ObjectChange creation
  543. if hasattr(self.model, 'to_objectchange'):
  544. objectchange = ObjectChange.objects.get(
  545. changed_object_type=ContentType.objects.get_for_model(instance),
  546. changed_object_id=instance.pk
  547. )
  548. self.assertObjectChange(objectchange, action=ObjectChangeActionChoices.ACTION_DELETE,
  549. message=data['changelog_message'])
  550. def test_bulk_delete_objects(self):
  551. """
  552. DELETE a set of objects in a single request.
  553. """
  554. # Add object-level permission
  555. obj_perm = ObjectPermission(
  556. name='Test permission',
  557. actions=['delete']
  558. )
  559. obj_perm.save()
  560. obj_perm.users.add(self.user)
  561. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  562. # Target the three most recently created objects to avoid triggering recursive deletions
  563. # (e.g. with MPTT objects)
  564. id_list = list(self._get_queryset().order_by('-id').values_list('id', flat=True)[:3])
  565. self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
  566. data = [{"id": id} for id in id_list]
  567. # If supported, add a changelog message
  568. changelog_message = get_random_string(10)
  569. if issubclass(self.model, ChangeLoggingMixin):
  570. for obj_data in data:
  571. obj_data['changelog_message'] = changelog_message
  572. initial_count = self._get_queryset().count()
  573. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  574. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  575. self.assertEqual(self._get_queryset().count(), initial_count - 3)
  576. # Verify ObjectChange creation
  577. if issubclass(self.model, ChangeLoggingMixin):
  578. objectchanges = ObjectChange.objects.filter(
  579. changed_object_type=ContentType.objects.get_for_model(self.model),
  580. changed_object_id__in=id_list
  581. )
  582. self.assertEqual(len(objectchanges), len(data))
  583. for oc in objectchanges:
  584. self.assertObjectChange(oc, action=ObjectChangeActionChoices.ACTION_DELETE,
  585. message=changelog_message)
  586. class GraphQLTestCase(APITestCase):
  587. graphql_auto_filter_tests = True
  588. graphql_auto_filter_exclude = ()
  589. # Cap fields per lookup kind to keep test counts balanced across kinds
  590. # (string fields shouldn't crowd out numeric/date/array fields).
  591. graphql_auto_filter_fields_per_kind = 2
  592. # Fail when auto mode is on and no tests were generated.
  593. graphql_auto_filter_required = True
  594. # Gate the negative constrained-permission check in the get/list tests; the positive
  595. # query still runs. Set False for types not enforcing object permissions (e.g. no BaseObjectType).
  596. graphql_object_permission_assertions = True
  597. # Additional explicit-list filter cases as GraphQLFilterTest instances.
  598. graphql_filter_tests = ()
  599. # Additional full-query cases (e.g. nested filters) as GraphQLQueryTest instances.
  600. graphql_query_tests = ()
  601. # GraphQL type under test. Defaults to the type derived from `model` via the naming
  602. # convention; set explicitly when the convention does not apply (e.g. plugin types).
  603. type_class = None
  604. # Exclude this test case from GraphQL schema coverage.
  605. graphql_test_exempt = False
  606. @classmethod
  607. def get_graphql_type_class(cls):
  608. if getattr(cls, 'type_class', None) is not None:
  609. return cls.type_class
  610. model = getattr(cls, 'model', None)
  611. if model is None:
  612. return None
  613. return get_graphql_type_for_model(model)
  614. def _get_graphql_base_name(self):
  615. """
  616. Return graphql_base_name, if set. Otherwise, construct the base name for the query
  617. field from the model's verbose name.
  618. """
  619. base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
  620. return getattr(self, 'graphql_base_name', base_name)
  621. def _build_query_with_filter(self, name, filter_string):
  622. """
  623. Called by either _build_query or _build_filtered_query - construct the actual
  624. query given a name and filter string
  625. """
  626. type_class = self.get_graphql_type_class()
  627. # Compile list of fields to include
  628. fields_string = ''
  629. file_fields = (
  630. strawberry_django.fields.types.DjangoFileType,
  631. strawberry_django.fields.types.DjangoImageType,
  632. )
  633. for field in type_class.__strawberry_definition__.fields:
  634. if (
  635. field.type in file_fields or (
  636. type(field.type) is StrawberryOptional and field.type.of_type in file_fields
  637. )
  638. ):
  639. # image / file fields nullable or not...
  640. fields_string += f'{field.name} {{ name }}\n'
  641. elif type(field.type) is StrawberryList and type(field.type.of_type) is LazyType:
  642. # List of related objects (queryset)
  643. fields_string += f'{field.name} {{ id }}\n'
  644. elif type(field.type) is StrawberryList and type(field.type.of_type) is StrawberryUnion:
  645. # this would require a fragment query
  646. continue
  647. elif type(field.type) is StrawberryUnion:
  648. # this would require a fragment query
  649. continue
  650. elif type(field.type) is StrawberryOptional and type(field.type.of_type) is StrawberryUnion:
  651. # this would require a fragment query
  652. continue
  653. elif type(field.type) is StrawberryOptional and type(field.type.of_type) is LazyType:
  654. fields_string += f'{field.name} {{ id }}\n'
  655. elif hasattr(field, 'is_relation') and field.is_relation:
  656. # Ignore private fields
  657. if field.name.startswith('_'):
  658. continue
  659. # Note: StrawberryField types do not have is_relation
  660. fields_string += f'{field.name} {{ id }}\n'
  661. elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
  662. fields_string += f'{field.name} {{ value, label }}\n'
  663. else:
  664. fields_string += f'{field.name}\n'
  665. query = f"""
  666. {{
  667. {name}{filter_string} {{
  668. {fields_string}
  669. }}
  670. }}
  671. """
  672. return query
  673. @staticmethod
  674. def _graphql_literal(value):
  675. """
  676. Render a Python value as a GraphQL literal.
  677. """
  678. if value is None:
  679. return 'null'
  680. if isinstance(value, bool):
  681. return 'true' if value else 'false'
  682. if isinstance(value, (int, float)):
  683. return str(value)
  684. if isinstance(value, Decimal):
  685. return str(float(value))
  686. if isinstance(value, (list, tuple)):
  687. items = ', '.join(
  688. APIViewTestCases.GraphQLTestCase._graphql_literal(v) for v in value
  689. )
  690. return f'[{items}]'
  691. if isinstance(value, str):
  692. return json.dumps(value)
  693. return json.dumps(str(value))
  694. def _render_graphql_filter_value(self, params):
  695. """
  696. Render the legacy graphql_filter dict value to a GraphQL filter value.
  697. """
  698. if isinstance(params, str):
  699. return params
  700. if not isinstance(params, dict):
  701. return self._graphql_literal(params)
  702. lookup = params.get('lookup')
  703. value = params['value']
  704. if lookup:
  705. return f'{{{lookup}: {self._graphql_literal(value)}}}'
  706. return self._graphql_literal(value)
  707. def _build_graphql_filter_string(self, **filters):
  708. if not filters:
  709. return ''
  710. filter_expressions = [
  711. f'{field_name}: {self._render_graphql_filter_value(params)}'
  712. for field_name, params in filters.items()
  713. ]
  714. return f'(filters: {{{", ".join(filter_expressions)}}})'
  715. def _build_filtered_query(self, name, **filters):
  716. """
  717. Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
  718. """
  719. filter_string = self._build_graphql_filter_string(**filters)
  720. return self._build_query_with_filter(name, filter_string)
  721. def _build_graphql_id_list_query(self, name, filters):
  722. filter_string = f'(filters: {{{filters}}})' if filters else ''
  723. selection = 'id' if self._graphql_type_exposes_id() else '__typename'
  724. return f"""
  725. {{
  726. {name}{filter_string} {{
  727. {selection}
  728. }}
  729. }}
  730. """
  731. def _graphql_type_exposes_id(self):
  732. """
  733. Return True when the model's GraphQL type exposes ``id`` as a
  734. queryable selection. Some NetBox types (e.g. Notification,
  735. Subscription) omit ``id`` from the output type; for those, the
  736. assertion path falls back to length-only comparison.
  737. """
  738. type_class = self.get_graphql_type_class()
  739. strawberry_definition = getattr(type_class, '__strawberry_definition__', None)
  740. if strawberry_definition is None:
  741. return False
  742. return any(field.name == 'id' for field in strawberry_definition.fields)
  743. def _get_model_graphql_filter_class(self, model=None):
  744. """
  745. Return the model's GraphQL filter class, if one follows NetBox's
  746. conventional <app>.graphql.filters.<Model>Filter path. ``None`` if
  747. the filter module (or any of its parent packages) is absent or the
  748. class is not present in the module. Import errors originating
  749. inside an existing filter module are re-raised.
  750. """
  751. model = model or self.model
  752. module_path = f'{model._meta.app_label}.graphql.filters'
  753. class_name = f'{model.__name__}Filter'
  754. try:
  755. module = importlib.import_module(module_path)
  756. except ModuleNotFoundError as exc:
  757. # Treat both "<app>.graphql.filters" absent and any missing
  758. # parent (e.g. "<app>.graphql" or "<app>") as "no conventional
  759. # filter class". Real ImportErrors from inside an existing
  760. # filter module still propagate.
  761. if exc.name == module_path or module_path.startswith(f'{exc.name}.'):
  762. return None
  763. raise
  764. return getattr(module, class_name, None)
  765. def _get_graphql_filter_field_names(self):
  766. """
  767. Return the names exposed by the model's GraphQL filter input, sourced
  768. only from the conventional <app>.graphql.filters.<Model>Filter path.
  769. """
  770. filter_class = self._get_model_graphql_filter_class()
  771. if filter_class is None:
  772. return set()
  773. return self._collect_filter_class_annotation_names(filter_class)
  774. @staticmethod
  775. def _collect_filter_class_annotation_names(filter_class):
  776. field_names = set()
  777. for cls in reversed(getattr(filter_class, '__mro__', ())):
  778. field_names.update(
  779. field_name for field_name in getattr(cls, '__annotations__', {})
  780. if not field_name.startswith('_')
  781. )
  782. return field_names
  783. def _assert_graphql_filter_class_present(self, filter_fields, handwritten_tests=()):
  784. """
  785. Raise when the model has no discoverable filter class or the class
  786. declares no fields. Skipped when auto-filter generation is disabled,
  787. the per-model opt-out attribute is set, or hand-written (legacy or
  788. explicit) filter tests are declared for the model.
  789. """
  790. if handwritten_tests:
  791. return
  792. if not getattr(self, 'graphql_auto_filter_required', True):
  793. return
  794. if not getattr(self, 'graphql_auto_filter_tests', True):
  795. return
  796. label = self.model._meta.label
  797. path = f'{self.model._meta.app_label}.graphql.filters.{self.model.__name__}Filter'
  798. filter_class = self._get_model_graphql_filter_class()
  799. self.assertIsNotNone(
  800. filter_class,
  801. f'No GraphQL filter class found for {label} at {path}. '
  802. f'Set graphql_auto_filter_required = False on this test case if intentional.'
  803. )
  804. self.assertTrue(
  805. filter_fields,
  806. f'GraphQL filter class for {label} declares no fields. '
  807. f'Set graphql_auto_filter_required = False on this test case if intentional.'
  808. )
  809. def _get_nonempty_field_value(self, field):
  810. queryset = self._get_queryset()
  811. if getattr(field, 'null', False):
  812. queryset = queryset.exclude(**{f'{field.name}__isnull': True})
  813. if isinstance(field, (models.CharField, models.TextField)):
  814. queryset = queryset.exclude(**{field.name: ''})
  815. return queryset.values_list(field.name, flat=True).first()
  816. def _get_model_field_for_filter_field(self, field_name):
  817. """
  818. Find the Django model field matching a filter field name. Filter
  819. fields are declared with either the model field name (e.g. `name`)
  820. or the FK attname (e.g. `tenant_id`).
  821. """
  822. for field in self.model._meta.fields:
  823. if field.name == field_name or getattr(field, 'attname', None) == field_name:
  824. return field
  825. return None
  826. def _iter_filter_class_annotations(self, filter_class):
  827. """
  828. Yield (field_name, annotation) pairs for the filter class, walking
  829. its MRO so inherited fields surface. Subclass annotations override
  830. inherited ones (private `_`-prefixed names are skipped).
  831. """
  832. annotations = {}
  833. for cls in reversed(filter_class.__mro__):
  834. annotations.update({
  835. name: ann for name, ann in getattr(cls, '__annotations__', {}).items()
  836. if not name.startswith('_')
  837. })
  838. yield from annotations.items()
  839. @staticmethod
  840. def _unwrap_filter_annotation(annotation):
  841. """
  842. Strip ``X | None`` / ``Optional[X]`` and ``Annotated[X, ...]``
  843. layers. Resolve `strawberry.lazy('...')` metadata so lazily-annotated
  844. lookup types (e.g. ``Annotated['FloatLookup', strawberry.lazy('mod')] | None``)
  845. are returned as the actual class. When an ``Annotated`` layer carries
  846. multiple metadata entries, the first ``module``-bearing entry wins.
  847. Returns None when the inner type cannot be resolved.
  848. """
  849. if annotation is None:
  850. return None
  851. lazy_module = None
  852. lazy_package = None
  853. # Cap iterations at 8: typical NetBox annotations nest at most 3 layers
  854. # (Union > Annotated > ForwardRef). 8 is a generous safety net to
  855. # prevent infinite loops on pathological / future annotation shapes.
  856. for _ in range(8):
  857. origin = typing.get_origin(annotation)
  858. args = typing.get_args(annotation)
  859. if origin in (typing.Union, types.UnionType):
  860. non_none = [a for a in args if a is not type(None)]
  861. if len(non_none) != 1:
  862. return None
  863. annotation = non_none[0]
  864. continue
  865. if hasattr(annotation, '__metadata__'):
  866. for meta in annotation.__metadata__:
  867. module_name = getattr(meta, 'module', None)
  868. if module_name:
  869. lazy_module = module_name
  870. # strawberry.lazy('.relative') records the anchor package
  871. # needed to resolve the leading-dot module path.
  872. lazy_package = getattr(meta, 'package', None)
  873. break
  874. inner = args[0] if args else None
  875. if inner is None:
  876. return None
  877. annotation = inner
  878. continue
  879. break
  880. if isinstance(annotation, (str, typing.ForwardRef)):
  881. if lazy_module is None:
  882. return None
  883. name = annotation.__forward_arg__ if isinstance(annotation, typing.ForwardRef) else annotation
  884. # Resolve via import_module(module, package) rather than import_string()
  885. # so relative lazy modules (e.g. strawberry.lazy('.filters')) resolve
  886. # against their anchor package, as strawberry itself does.
  887. try:
  888. module = importlib.import_module(lazy_module, lazy_package)
  889. return getattr(module, name)
  890. except (ImportError, AttributeError):
  891. return None
  892. return annotation
  893. @classmethod
  894. def _classify_filter_annotation(cls, annotation):
  895. """
  896. Resolve a filter field annotation to a (kind, kind_arg) tuple keyed
  897. on the declared GraphQL lookup type. Returns (None, None) for
  898. annotations the dispatcher does not handle (those fields are
  899. silently skipped).
  900. """
  901. annotation = cls._unwrap_filter_annotation(annotation)
  902. if annotation is None or isinstance(annotation, str):
  903. return None, None
  904. if annotation is strawberry.ID:
  905. return 'id', None
  906. origin = typing.get_origin(annotation)
  907. target = origin if isinstance(origin, type) else annotation
  908. type_args = typing.get_args(annotation)
  909. if not isinstance(target, type):
  910. return None, None
  911. if target in (IntegerLookup, BigIntegerLookup, FloatLookup):
  912. return 'numeric', target
  913. # TreeNodeFilter schema requires {id, match_type}; skip auto-emit.
  914. if target is TreeNodeFilter:
  915. return None, None
  916. if issubclass(target, (DateFilterLookup, DatetimeFilterLookup, TimeFilterLookup)):
  917. return 'date_lookup', None
  918. if target is RangeLookup or issubclass(target, RangeLookup):
  919. return 'range_lookup', type_args[0] if type_args else None
  920. if issubclass(target, ArrayLookup):
  921. return 'array_lookup', None
  922. if target is IntegerRangeArrayLookup or issubclass(target, IntegerRangeArrayLookup):
  923. return 'range_array_lookup', None
  924. if target is JSONFilter:
  925. # JSONFilter requires explicit (path, typed lookup); no general auto shape.
  926. return None, None
  927. if issubclass(target, StrFilterLookup):
  928. return 'str_lookup', None
  929. if issubclass(target, ComparisonFilterLookup):
  930. return 'comparison_lookup', type_args[0] if type_args else None
  931. if issubclass(target, FilterLookup):
  932. return 'filter_lookup', type_args[0] if type_args else None
  933. # Enum-typed BaseFilterLookup needs an enum literal; skip auto-emit.
  934. if issubclass(target, BaseFilterLookup):
  935. return None, None
  936. return None, None
  937. def _emit_id_filter_tests(self, field_name, _kind_arg):
  938. if field_name == 'id':
  939. instance = self._get_queryset().first()
  940. if instance is None:
  941. return
  942. yield GraphQLFilterTest(
  943. name='id__exact',
  944. filters=f'id: {self._graphql_literal(str(instance.pk))}',
  945. expected=lambda qs, pk=instance.pk: qs.filter(pk=pk),
  946. )
  947. return
  948. model_field = self._get_model_field_for_filter_field(field_name)
  949. if model_field is None or not isinstance(model_field, models.ForeignKey):
  950. return
  951. queryset = self._get_queryset().exclude(**{f'{model_field.name}__isnull': True})
  952. value = queryset.values_list(model_field.attname, flat=True).first()
  953. if value is None:
  954. return
  955. yield GraphQLFilterTest(
  956. name=f'{field_name}__exact',
  957. filters=f'{field_name}: {self._graphql_literal(str(value))}',
  958. expected=lambda qs, attname=model_field.attname, v=value: qs.filter(**{attname: v}),
  959. )
  960. def _emit_str_lookup_filter_tests(self, field_name, _kind_arg):
  961. model_field = self._get_model_field_for_filter_field(field_name)
  962. if model_field is None:
  963. return
  964. value = self._get_nonempty_field_value(model_field)
  965. if value in (None, ''):
  966. return
  967. value = str(value)
  968. token = max(1, min(3, len(value)))
  969. lookups = (
  970. ('exact', 'exact', value),
  971. ('i_contains', 'icontains', value[:token]),
  972. ('i_starts_with', 'istartswith', value[:token]),
  973. ('i_ends_with', 'iendswith', value[-token:]),
  974. )
  975. for lookup, orm_lookup, filter_value in lookups:
  976. yield GraphQLFilterTest(
  977. name=f'{field_name}__{lookup}',
  978. filters=f'{field_name}: {{{lookup}: {self._graphql_literal(filter_value)}}}',
  979. expected=(
  980. lambda qs, fn=model_field.name, ol=orm_lookup, v=filter_value:
  981. qs.filter(**{f'{fn}__{ol}': v})
  982. ),
  983. )
  984. def _emit_filter_lookup_filter_tests(self, field_name, type_arg):
  985. model_field = self._get_model_field_for_filter_field(field_name)
  986. if model_field is None:
  987. return
  988. value = self._get_nonempty_field_value(model_field)
  989. if value is None:
  990. return
  991. if type_arg is bool or isinstance(value, bool):
  992. yield GraphQLFilterTest(
  993. name=f'{field_name}__exact',
  994. filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
  995. expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{fn: v}),
  996. )
  997. return
  998. yield GraphQLFilterTest(
  999. name=f'{field_name}__exact',
  1000. filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
  1001. expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
  1002. )
  1003. def _emit_comparison_lookup_filter_tests(self, field_name, _type_arg):
  1004. model_field = self._get_model_field_for_filter_field(field_name)
  1005. if model_field is None:
  1006. return
  1007. value = self._get_nonempty_field_value(model_field)
  1008. if value is None:
  1009. return
  1010. yield GraphQLFilterTest(
  1011. name=f'{field_name}__exact',
  1012. filters=f'{field_name}: {{exact: {self._graphql_literal(value)}}}',
  1013. expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
  1014. )
  1015. def _emit_numeric_filter_tests(self, field_name, _type_arg):
  1016. # NetBox numeric wrapper: {filter_lookup: {exact: N}}.
  1017. model_field = self._get_model_field_for_filter_field(field_name)
  1018. if model_field is None:
  1019. return
  1020. if isinstance(model_field, ArrayField):
  1021. return
  1022. value = self._get_nonempty_field_value(model_field)
  1023. if value is None:
  1024. return
  1025. if isinstance(value, Decimal):
  1026. value = float(value)
  1027. yield GraphQLFilterTest(
  1028. name=f'{field_name}__filter_lookup__exact',
  1029. filters=(
  1030. f'{field_name}: {{filter_lookup: '
  1031. f'{{exact: {self._graphql_literal(value)}}}}}'
  1032. ),
  1033. expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{f'{fn}__exact': v}),
  1034. )
  1035. def _emit_date_lookup_filter_tests(self, field_name, _kind_arg):
  1036. model_field = self._get_model_field_for_filter_field(field_name)
  1037. if model_field is None:
  1038. return
  1039. value = self._get_nonempty_field_value(model_field)
  1040. if value is None:
  1041. return
  1042. iso_value = value.isoformat() if hasattr(value, 'isoformat') else str(value)
  1043. yield GraphQLFilterTest(
  1044. name=f'{field_name}__exact',
  1045. filters=f'{field_name}: {{exact: "{iso_value}"}}',
  1046. expected=lambda qs, fn=model_field.name, v=value: qs.filter(**{fn: v}),
  1047. )
  1048. def _emit_range_lookup_filter_tests(self, field_name, _kind_arg):
  1049. model_field = self._get_model_field_for_filter_field(field_name)
  1050. if model_field is None:
  1051. return
  1052. aggregates = self._get_queryset().aggregate(
  1053. _min=models.Min(model_field.name), _max=models.Max(model_field.name),
  1054. )
  1055. start, end = aggregates['_min'], aggregates['_max']
  1056. if start is None or end is None or start == end:
  1057. return
  1058. yield GraphQLFilterTest(
  1059. name=f'{field_name}__range_lookup',
  1060. filters=(
  1061. f'{field_name}: {{range_lookup: '
  1062. f'{{start: {self._graphql_literal(start)}, end: {self._graphql_literal(end)}}}}}'
  1063. ),
  1064. expected=(
  1065. lambda qs, fn=model_field.name, lo=start, hi=end:
  1066. qs.filter(**{f'{fn}__gte': lo, f'{fn}__lte': hi})
  1067. ),
  1068. )
  1069. def _emit_array_lookup_filter_tests(self, field_name, _kind_arg):
  1070. model_field = self._get_model_field_for_filter_field(field_name)
  1071. if model_field is None:
  1072. return
  1073. if not isinstance(model_field, ArrayField):
  1074. return
  1075. queryset = self._get_queryset().exclude(**{field_name: []})
  1076. sample = queryset.values_list(field_name, flat=True).first()
  1077. if not sample:
  1078. return
  1079. element = sample[0]
  1080. yield GraphQLFilterTest(
  1081. name=f'{field_name}__contains',
  1082. filters=(
  1083. f'{field_name}: {{contains: [{self._graphql_literal(element)}]}}'
  1084. ),
  1085. expected=(
  1086. lambda qs, fn=model_field.name, v=element: qs.filter(**{f'{fn}__contains': [v]})
  1087. ),
  1088. )
  1089. def _emit_range_array_lookup_filter_tests(self, field_name, _kind_arg):
  1090. model_field = self._get_model_field_for_filter_field(field_name)
  1091. if model_field is None:
  1092. return
  1093. queryset = self._get_queryset().exclude(**{f'{field_name}__isnull': True})
  1094. sample = queryset.values_list(field_name, flat=True).first()
  1095. if not sample:
  1096. return
  1097. first_range = sample[0]
  1098. lower = getattr(first_range, 'lower', None)
  1099. if lower is None:
  1100. return
  1101. yield GraphQLFilterTest(
  1102. name=f'{field_name}__contains',
  1103. filters=f'{field_name}: {{contains: {self._graphql_literal(lower)}}}',
  1104. expected=(
  1105. lambda qs, fn=model_field.name, v=lower: qs.filter(**{f'{fn}__range_contains': v})
  1106. ),
  1107. )
  1108. def _iter_auto_graphql_filter_tests(self):
  1109. if not getattr(self, 'graphql_auto_filter_tests', True):
  1110. return
  1111. filter_class = self._get_model_graphql_filter_class()
  1112. if filter_class is None:
  1113. return
  1114. exclude = set(getattr(self, 'graphql_auto_filter_exclude', ()))
  1115. per_kind = self.graphql_auto_filter_fields_per_kind
  1116. # Bucket eligible fields by lookup kind so per-kind budgeting balances coverage.
  1117. by_kind: dict[str, list[tuple[str, object]]] = {}
  1118. for field_name, annotation in self._iter_filter_class_annotations(filter_class):
  1119. if field_name in exclude:
  1120. continue
  1121. kind, kind_arg = self._classify_filter_annotation(annotation)
  1122. if kind is None:
  1123. continue
  1124. by_kind.setdefault(kind, []).append((field_name, kind_arg))
  1125. # Emit per-kind; the cap counts SUCCESSFUL emissions, not candidate fields, so
  1126. # early null/empty fields don't shadow later fields with usable fixture data.
  1127. for kind, fields in by_kind.items():
  1128. emitter = getattr(self, f'_emit_{kind}_filter_tests', None)
  1129. if emitter is None:
  1130. continue
  1131. emitted_fields = 0
  1132. for field_name, kind_arg in fields:
  1133. tests = list(emitter(field_name, kind_arg))
  1134. if not tests:
  1135. continue
  1136. yield from tests
  1137. emitted_fields += 1
  1138. if emitted_fields >= per_kind:
  1139. break
  1140. def _iter_legacy_graphql_filter_tests(self):
  1141. if not hasattr(self, 'graphql_filter'):
  1142. return
  1143. filter_expressions = [
  1144. f'{field_name}: {self._render_graphql_filter_value(params)}'
  1145. for field_name, params in self.graphql_filter.items()
  1146. ]
  1147. yield GraphQLFilterTest(
  1148. name='graphql_filter',
  1149. filters=', '.join(filter_expressions),
  1150. )
  1151. def _coerce_graphql_filter_test(self, filter_test):
  1152. if isinstance(filter_test, GraphQLFilterTest):
  1153. return filter_test
  1154. filter_test = dict(filter_test)
  1155. if 'filter' in filter_test and 'filters' not in filter_test:
  1156. filter_test['filters'] = filter_test.pop('filter')
  1157. return GraphQLFilterTest(**filter_test)
  1158. def _iter_explicit_graphql_filter_tests(self):
  1159. for filter_test in getattr(self, 'graphql_filter_tests', ()):
  1160. yield self._coerce_graphql_filter_test(filter_test)
  1161. def _get_expected_id_set(self, filter_test):
  1162. expected = filter_test.expected
  1163. if callable(expected):
  1164. expected = expected(self._get_queryset())
  1165. if isinstance(expected, dict):
  1166. expected = self._get_queryset().filter(**expected)
  1167. if hasattr(expected, 'values_list'):
  1168. values = expected.distinct().values_list('pk', flat=True)
  1169. else:
  1170. values = [getattr(value, 'pk', value) for value in expected]
  1171. return {str(value) for value in values}
  1172. def _assert_graphql_filter_test(self, url, field_name, filter_test):
  1173. query = self._build_graphql_id_list_query(field_name, filter_test.filters)
  1174. for permission in filter_test.permissions:
  1175. self.add_permissions(permission)
  1176. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  1177. self.assertHttpStatus(response, status.HTTP_200_OK)
  1178. data = json.loads(response.content)
  1179. self.assertNotIn('errors', data)
  1180. results = data['data'][field_name]
  1181. if filter_test.expected is None:
  1182. self.assertGreater(len(results), 0)
  1183. return
  1184. expected_ids = self._get_expected_id_set(filter_test)
  1185. self.assertGreater(
  1186. len(expected_ids), 0,
  1187. msg=(
  1188. f'{self.model._meta.label}: filter "{filter_test.name}" produced an empty '
  1189. f'expected set; the test would tautologically pass. Adjust fixtures or the '
  1190. f'filter so the expected ORM queryset is non-empty.'
  1191. ),
  1192. )
  1193. if self._graphql_type_exposes_id():
  1194. result_ids = [str(result['id']) for result in results]
  1195. self.assertEqual(
  1196. set(result_ids), expected_ids,
  1197. msg=f'{self.model._meta.label}: filter "{filter_test.name}" ID set mismatch',
  1198. )
  1199. self.assertEqual(
  1200. len(results), len(expected_ids),
  1201. msg=(
  1202. f'{self.model._meta.label}: filter "{filter_test.name}" result count mismatch '
  1203. f'(GraphQL type does not expose id; comparing by length).'
  1204. ),
  1205. )
  1206. def _coerce_graphql_query_test(self, query_test):
  1207. if isinstance(query_test, GraphQLQueryTest):
  1208. return query_test
  1209. query_test = dict(query_test)
  1210. if 'assertion' in query_test and 'assert_result' not in query_test:
  1211. query_test['assert_result'] = query_test.pop('assertion')
  1212. return GraphQLQueryTest(**query_test)
  1213. def _build_query(self, name, **filters):
  1214. """
  1215. Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
  1216. """
  1217. if filters:
  1218. filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
  1219. filter_string = f'({filter_string})'
  1220. else:
  1221. filter_string = ''
  1222. return self._build_query_with_filter(name, filter_string)
  1223. @override_settings(LOGIN_REQUIRED=True)
  1224. def test_graphql_get_object(self):
  1225. url = reverse('graphql')
  1226. field_name = self._get_graphql_base_name()
  1227. object_id = self._get_queryset().first().pk
  1228. query = self._build_query(field_name, id=object_id)
  1229. # Non-authenticated requests should fail
  1230. header = {
  1231. 'HTTP_ACCEPT': 'application/json',
  1232. }
  1233. with disable_warnings('django.request'):
  1234. response = self.client.post(url, data={'query': query}, format="json", **header)
  1235. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1236. # Add constrained permission
  1237. obj_perm = ObjectPermission(
  1238. name='Test permission',
  1239. actions=['view'],
  1240. constraints={'id': 0} # Impossible constraint
  1241. )
  1242. obj_perm.save()
  1243. obj_perm.users.add(self.user)
  1244. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  1245. if self.graphql_object_permission_assertions:
  1246. # Request should succeed but return empty result
  1247. with disable_logging():
  1248. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  1249. self.assertHttpStatus(response, status.HTTP_200_OK)
  1250. data = json.loads(response.content)
  1251. self.assertIn('errors', data)
  1252. self.assertIsNone(data['data'])
  1253. # Remove permission constraint
  1254. obj_perm.constraints = None
  1255. obj_perm.save()
  1256. # Request should return requested object
  1257. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  1258. self.assertHttpStatus(response, status.HTTP_200_OK)
  1259. data = json.loads(response.content)
  1260. self.assertNotIn('errors', data)
  1261. self.assertIsNotNone(data['data'])
  1262. @override_settings(LOGIN_REQUIRED=True)
  1263. def test_graphql_list_objects(self):
  1264. url = reverse('graphql')
  1265. field_name = f'{self._get_graphql_base_name()}_list'
  1266. query = self._build_query(field_name)
  1267. # Non-authenticated requests should fail
  1268. header = {
  1269. 'HTTP_ACCEPT': 'application/json',
  1270. }
  1271. with disable_warnings('django.request'):
  1272. response = self.client.post(url, data={'query': query}, format="json", **header)
  1273. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  1274. # Add constrained permission
  1275. obj_perm = ObjectPermission(
  1276. name='Test permission',
  1277. actions=['view'],
  1278. constraints={'id': 0} # Impossible constraint
  1279. )
  1280. obj_perm.save()
  1281. obj_perm.users.add(self.user)
  1282. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  1283. if self.graphql_object_permission_assertions:
  1284. # Request should succeed but return empty results list
  1285. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  1286. self.assertHttpStatus(response, status.HTTP_200_OK)
  1287. data = json.loads(response.content)
  1288. self.assertNotIn('errors', data)
  1289. self.assertEqual(len(data['data'][field_name]), 0)
  1290. # Remove permission constraint
  1291. obj_perm.constraints = None
  1292. obj_perm.save()
  1293. # Request should return all objects
  1294. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  1295. self.assertHttpStatus(response, status.HTTP_200_OK)
  1296. data = json.loads(response.content)
  1297. self.assertNotIn('errors', data)
  1298. self.assertEqual(len(data['data'][field_name]), self.model.objects.count())
  1299. def _assert_graphql_filter_tests_exist(self, auto_tests, legacy_tests, explicit_tests):
  1300. """
  1301. Fail loudly when auto mode is required and no GraphQL filter tests
  1302. (auto, legacy, or explicit) exist for the current model.
  1303. """
  1304. if (
  1305. getattr(self, 'graphql_auto_filter_tests', True)
  1306. and getattr(self, 'graphql_auto_filter_required', True)
  1307. and not auto_tests
  1308. and not legacy_tests
  1309. and not explicit_tests
  1310. ):
  1311. self.fail(
  1312. f'No GraphQL filter tests were generated for {self.model._meta.label}. '
  1313. f'Set graphql_auto_filter_required = False or add explicit graphql_filter_tests '
  1314. f'if intentional.'
  1315. )
  1316. @override_settings(LOGIN_REQUIRED=True)
  1317. def test_graphql_filter_objects(self):
  1318. legacy_tests = list(self._iter_legacy_graphql_filter_tests())
  1319. explicit_tests = list(self._iter_explicit_graphql_filter_tests())
  1320. filter_fields = self._get_graphql_filter_field_names()
  1321. self._assert_graphql_filter_class_present(
  1322. filter_fields, handwritten_tests=[*legacy_tests, *explicit_tests]
  1323. )
  1324. auto_tests = list(self._iter_auto_graphql_filter_tests())
  1325. self._assert_graphql_filter_tests_exist(auto_tests, legacy_tests, explicit_tests)
  1326. filter_tests = [*auto_tests, *legacy_tests, *explicit_tests]
  1327. if not filter_tests:
  1328. return
  1329. url = reverse('graphql')
  1330. field_name = f'{self._get_graphql_base_name()}_list'
  1331. # Add object-level permission
  1332. obj_perm = ObjectPermission(
  1333. name='Test permission',
  1334. actions=['view']
  1335. )
  1336. obj_perm.save()
  1337. obj_perm.users.add(self.user)
  1338. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  1339. for filter_test in filter_tests:
  1340. with self.subTest(filter=filter_test.name):
  1341. self._assert_graphql_filter_test(url, field_name, filter_test)
  1342. @override_settings(LOGIN_REQUIRED=True)
  1343. def test_graphql_extra_queries(self):
  1344. query_tests = [
  1345. self._coerce_graphql_query_test(query_test)
  1346. for query_test in getattr(self, 'graphql_query_tests', ())
  1347. ]
  1348. if not query_tests:
  1349. return
  1350. url = reverse('graphql')
  1351. # Add object-level permission for this model. Additional permissions
  1352. # required by the query can be declared on the GraphQLQueryTest.
  1353. obj_perm = ObjectPermission(
  1354. name='Test permission',
  1355. actions=['view']
  1356. )
  1357. obj_perm.save()
  1358. obj_perm.users.add(self.user)
  1359. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  1360. for query_test in query_tests:
  1361. with self.subTest(query=query_test.name):
  1362. for permission in query_test.permissions:
  1363. self.add_permissions(permission)
  1364. response = self.client.post(url, data={'query': query_test.query}, format="json", **self.header)
  1365. self.assertHttpStatus(response, status.HTTP_200_OK)
  1366. data = json.loads(response.content)
  1367. self.assertNotIn('errors', data)
  1368. query_test.assert_result(self, data['data'])
  1369. class APIViewTestCase(
  1370. GetObjectViewTestCase,
  1371. ListObjectsViewTestCase,
  1372. CreateObjectViewTestCase,
  1373. UpdateObjectViewTestCase,
  1374. DeleteObjectViewTestCase,
  1375. GraphQLTestCase
  1376. ):
  1377. pass
  1378. class GraphQLSchemaCoverageTestCase(TestCase):
  1379. """
  1380. Assert every model-backed GraphQL type exposed as a root query field is covered by a
  1381. concrete GraphQLTestCase subclass. Subclass this in a test module to run the audit.
  1382. Scope is intentionally limited to types reachable as root query fields (e.g. ``site``,
  1383. ``site_list``); these are exactly the types the detail/list GraphQLTestCase methods can
  1384. exercise. Types reachable only as nested object fields are out of scope.
  1385. """
  1386. # Per-app test submodules to import so their GraphQLTestCase subclasses are defined.
  1387. graphql_test_modules = ('test_api', 'test_graphql')
  1388. # GraphQL type classes intentionally excluded from coverage.
  1389. graphql_exempt_type_classes = ()
  1390. def get_graphql_schema(self):
  1391. # Imported lazily so importing this testing utility does not eagerly build the schema.
  1392. from netbox.graphql.schema import schema
  1393. return schema._schema
  1394. def iter_test_module_names(self):
  1395. # Import test modules only for apps exposing model-backed root query types;
  1396. # coverage classes are expected to live with the app whose type they cover.
  1397. app_labels = {model._meta.app_label for model in self.get_schema_type_classes().values()}
  1398. for app_label in sorted(app_labels):
  1399. app_config = apps.get_app_config(app_label)
  1400. for module_name in self.graphql_test_modules:
  1401. yield f'{app_config.name}.tests.{module_name}'
  1402. def import_graphql_test_modules(self):
  1403. for module_name in self.iter_test_module_names():
  1404. self.import_graphql_test_module(module_name)
  1405. def import_graphql_test_module(self, module_name):
  1406. try:
  1407. importlib.import_module(module_name)
  1408. except ModuleNotFoundError as exc:
  1409. # A missing test module, or a missing parent package (e.g. `<app>.tests`),
  1410. # is fine. An import error raised from inside an existing test module
  1411. # should still fail loudly.
  1412. if exc.name == module_name or module_name.startswith(f'{exc.name}.'):
  1413. return
  1414. raise
  1415. def unwrap_graphql_type(self, graphql_type):
  1416. while isinstance(graphql_type, (GraphQLNonNull, GraphQLList)):
  1417. graphql_type = graphql_type.of_type
  1418. return graphql_type
  1419. def get_schema_field_type_class(self, field):
  1420. graphql_type = self.unwrap_graphql_type(field.type)
  1421. if not isinstance(graphql_type, GraphQLObjectType):
  1422. return None
  1423. extensions = getattr(graphql_type, 'extensions', None) or {}
  1424. definition = extensions.get(GraphQLCoreConverter.DEFINITION_BACKREF)
  1425. return getattr(definition, 'origin', None)
  1426. def get_graphql_type_model(self, type_class):
  1427. django_definition = getattr(type_class, '__strawberry_django_definition__', None)
  1428. return getattr(django_definition, 'model', None)
  1429. def get_schema_type_classes(self):
  1430. """Return {type_class: model} for every model-backed root query type (cached per instance)."""
  1431. cached = getattr(self, '_schema_type_classes', None)
  1432. if cached is not None:
  1433. return cached
  1434. type_classes = {}
  1435. for field in self.get_graphql_schema().query_type.fields.values():
  1436. type_class = self.get_schema_field_type_class(field)
  1437. if type_class is None:
  1438. continue
  1439. model = self.get_graphql_type_model(type_class)
  1440. if model is None:
  1441. continue
  1442. type_classes[type_class] = model
  1443. self._schema_type_classes = type_classes
  1444. return type_classes
  1445. def iter_graphql_testcase_classes(self, base_class=None):
  1446. base_class = base_class or APIViewTestCases.GraphQLTestCase
  1447. for subclass in base_class.__subclasses__():
  1448. yield subclass
  1449. yield from self.iter_graphql_testcase_classes(subclass)
  1450. def get_testcase_type_class(self, testcase):
  1451. if getattr(testcase, 'graphql_test_exempt', False):
  1452. return None
  1453. try:
  1454. return testcase.get_graphql_type_class()
  1455. except GraphQLTypeNotFound as exc:
  1456. model = getattr(testcase, 'model', None)
  1457. model_label = model._meta.label if model is not None else 'unknown model'
  1458. self.fail(
  1459. f'{testcase.__module__}.{testcase.__name__} sets model = {model_label} '
  1460. f'but no GraphQL type could be resolved. Set type_class if the type lives '
  1461. f'outside the conventional <app>.graphql.types.<Model>Type path, or set '
  1462. f'graphql_test_exempt = True if this test case should not count toward '
  1463. f'schema coverage. Original error: {exc}'
  1464. )
  1465. def get_testcase_type_classes(self):
  1466. self.import_graphql_test_modules()
  1467. type_classes = set()
  1468. for testcase in self.iter_graphql_testcase_classes():
  1469. type_class = self.get_testcase_type_class(testcase)
  1470. if type_class is not None:
  1471. type_classes.add(type_class)
  1472. return type_classes
  1473. def format_type_class(self, type_class):
  1474. model = self.get_graphql_type_model(type_class)
  1475. label = f' ({model._meta.label})' if model is not None else ''
  1476. return f'{type_class.__module__}.{type_class.__name__}{label}'
  1477. def test_schema_types_have_graphql_test_coverage(self):
  1478. """Every model-backed root query type is covered by a GraphQLTestCase."""
  1479. expected = set(self.get_schema_type_classes())
  1480. self.assertGreater(
  1481. len(expected), 0,
  1482. 'No model-backed root query GraphQL types were discovered; schema '
  1483. 'introspection may have broken.'
  1484. )
  1485. actual = self.get_testcase_type_classes()
  1486. exempt = set(self.graphql_exempt_type_classes)
  1487. missing = sorted(self.format_type_class(tc) for tc in expected - actual - exempt)
  1488. self.assertEqual(missing, [])