test_graphql.py 94 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271
  1. import json
  2. import re
  3. from types import SimpleNamespace
  4. from unittest import mock, skipIf
  5. import strawberry
  6. import strawberry_django
  7. from django.conf import settings
  8. from django.contrib.contenttypes.models import ContentType
  9. from django.core.exceptions import ImproperlyConfigured
  10. from django.db import connection
  11. from django.test import override_settings
  12. from django.test.utils import CaptureQueriesContext
  13. from django.urls import reverse
  14. from django.utils import timezone
  15. from rest_framework import status
  16. from strawberry.extensions import QueryDepthLimiter
  17. from strawberry.schema.config import StrawberryConfig
  18. from strawberry_django.optimizer import DjangoOptimizerExtension
  19. from core.models import DataFile, DataSource, ObjectType
  20. from dcim.choices import LocationStatusChoices
  21. from dcim.models import (
  22. Cable,
  23. Device,
  24. DeviceRole,
  25. DeviceType,
  26. Interface,
  27. Location,
  28. MACAddress,
  29. Manufacturer,
  30. Platform,
  31. Rack,
  32. RackReservation,
  33. Site,
  34. VirtualChassis,
  35. )
  36. from extras.choices import CustomFieldTypeChoices
  37. from extras.models import CustomField, ExportTemplate, JournalEntry, TableConfig, Tag
  38. from ipam.models import ASN, RIR, VLAN, Aggregate, IPAddress, Prefix
  39. from netbox.graphql.pagination import apply_distinct_window_pagination
  40. from netbox.graphql.scalars import BigInt, BigIntScalar
  41. from netbox.graphql.schema import Query, get_schema_extensions, schema
  42. from netbox.graphql.types import PrimaryObjectType
  43. from netbox.graphql.utils import register_model_graphql_type, splice_extension_bases, validate_extension_final_names
  44. from netbox.registry import registry
  45. from netbox.tests.dummy_plugin.models import DummySiteAttachment
  46. from users.models import ObjectPermission, User
  47. from utilities.tables import get_table_for_model
  48. from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
  49. from vpn.choices import L2VPNTypeChoices
  50. from vpn.models import L2VPN, L2VPNTermination
  51. def count_primary_table_queries(queries, table):
  52. """Count queries that read from `table` as the primary relation (not only as a join)."""
  53. pattern = re.compile(rf'FROM "{re.escape(table)}"')
  54. return sum(1 for query_record in queries if pattern.search(query_record['sql']))
  55. class GraphQLTestCase(TestCase):
  56. def _schema_extension_instances(self):
  57. return [factory() for factory in get_schema_extensions()]
  58. @override_settings(GRAPHQL_ENABLED=False)
  59. def test_graphql_enabled(self):
  60. """
  61. The /graphql URL should return a 404 when GRAPHQL_ENABLED=False
  62. """
  63. url = reverse('graphql')
  64. response = self.client.get(url)
  65. self.assertHttpStatus(response, 404)
  66. def test_graphql_max_query_depth_disabled_by_default(self):
  67. """
  68. QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is unset.
  69. """
  70. self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
  71. @override_settings(GRAPHQL_MAX_QUERY_DEPTH=0)
  72. def test_graphql_max_query_depth_disabled_when_zero(self):
  73. """
  74. QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is zero.
  75. """
  76. self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
  77. @override_settings(GRAPHQL_MAX_QUERY_DEPTH=-1)
  78. def test_graphql_max_query_depth_disabled_when_negative(self):
  79. """
  80. QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is negative.
  81. """
  82. self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
  83. @override_settings(GRAPHQL_MAX_QUERY_DEPTH=3)
  84. def test_graphql_max_query_depth_enforced(self):
  85. """
  86. Queries exceeding GRAPHQL_MAX_QUERY_DEPTH should be rejected.
  87. """
  88. extensions = get_schema_extensions()
  89. self.assertTrue(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
  90. # Build a temporary schema with the configured extension factories and execute a deep query
  91. test_schema = strawberry.Schema(
  92. query=Query,
  93. config=StrawberryConfig(auto_camel_case=False, scalar_map={BigInt: BigIntScalar}),
  94. extensions=extensions,
  95. )
  96. deep_query = '{ site_list { tenant { group { parent { parent { parent { name } } } } } } }'
  97. result = test_schema.execute_sync(deep_query)
  98. self.assertIsNotNone(result.errors)
  99. self.assertIn('exceeds maximum operation depth', str(result.errors[0]))
  100. @override_settings(LOGIN_REQUIRED=True)
  101. def test_graphiql_interface(self):
  102. """
  103. Test rendering of the GraphiQL interactive web interface
  104. """
  105. url = reverse('graphql')
  106. header = {
  107. 'HTTP_ACCEPT': 'text/html',
  108. }
  109. # Authenticated request
  110. response = self.client.get(url, **header)
  111. self.assertHttpStatus(response, 200)
  112. # Non-authenticated request
  113. self.client.logout()
  114. response = self.client.get(url, **header)
  115. with disable_warnings('django.request'):
  116. self.assertHttpStatus(response, 302) # Redirect to login page
  117. def test_json_lookup_schema_is_string_backed(self):
  118. """JSONLookup date/time lookups keep the legacy string-backed input types and fields."""
  119. sdl = schema.as_str()
  120. def input_block(name):
  121. match = re.search(rf'^input {re.escape(name)}\b.*?^\}}', sdl, re.DOTALL | re.MULTILINE)
  122. self.assertIsNotNone(match, f'{name} not found in schema')
  123. return match.group(0)
  124. # JSONLookup points at the legacy string-backed lookup type names
  125. json_lookup = input_block('JSONLookup')
  126. self.assertIn('date_lookup: StrDateFilterLookup', json_lookup)
  127. self.assertIn('datetime_lookup: StrDatetimeFilterLookup', json_lookup)
  128. self.assertIn('time_lookup: StrTimeFilterLookup', json_lookup)
  129. # Value fields are string-backed, not Date/DateTime/Time scalars
  130. self.assertIn('exact: String', input_block('StrDateFilterLookup'))
  131. # Legacy date/time sub-lookups remain integer comparison lookups
  132. for name in ('StrTimeFilterLookup', 'StrDatetimeFilterLookup'):
  133. block = input_block(name)
  134. self.assertIn('date: IntComparisonFilterLookup', block)
  135. self.assertIn('time: IntComparisonFilterLookup', block)
  136. class GraphQLAPITestCase(APITestCase):
  137. @classmethod
  138. def setUpTestData(cls):
  139. sites = (
  140. Site(name='Site 1', slug='site-1'),
  141. Site(name='Site 2', slug='site-2'),
  142. Site(name='Site 3', slug='site-3'),
  143. Site(name='Site 4', slug='site-4'),
  144. Site(name='Site 5', slug='site-5'),
  145. Site(name='Site 6', slug='site-6'),
  146. Site(name='Site 7', slug='site-7'),
  147. )
  148. Site.objects.bulk_create(sites)
  149. @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
  150. @override_settings(LOGIN_REQUIRED=True)
  151. def test_graphql_plugin_extensions_execute(self):
  152. """
  153. A plugin-provided filter extension and field extension execute end-to-end against a live query,
  154. exercising the custom filter method's prefix plumbing and the type extension's resolver.
  155. """
  156. self.add_permissions('dcim.view_site')
  157. url = reverse('graphql')
  158. query = '{ site_list(filters: {dummy_plugin_filter: "Site 1"}) { name dummy_plugin_field } }'
  159. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  160. self.assertHttpStatus(response, status.HTTP_200_OK)
  161. data = json.loads(response.content)
  162. self.assertNotIn('errors', data)
  163. sites = data['data']['site_list']
  164. self.assertEqual(len(sites), 1)
  165. self.assertEqual(sites[0]['name'], 'Site 1')
  166. self.assertEqual(sites[0]['dummy_plugin_field'], 'dummy-plugin-value')
  167. @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
  168. @override_settings(LOGIN_REQUIRED=True)
  169. def test_graphql_plugin_extension_preserves_get_queryset(self):
  170. """
  171. An extended core type keeps its own get_queryset() hook working, including its zero-argument super()
  172. call and the unit_count annotation.
  173. """
  174. site = Site.objects.create(name='Reservation Site', slug='reservation-site')
  175. rack = Rack.objects.create(name='Rack 1', site=site)
  176. RackReservation.objects.create(rack=rack, units=[1, 2, 3], user=self.user, description='Test')
  177. self.add_permissions('dcim.view_rackreservation')
  178. url = reverse('graphql')
  179. query = '{ rack_reservation_list { description units unit_count dummy_reservation_note } }'
  180. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  181. self.assertHttpStatus(response, status.HTTP_200_OK)
  182. data = json.loads(response.content)
  183. self.assertNotIn('errors', data)
  184. reservation = next(r for r in data['data']['rack_reservation_list'] if r['description'] == 'Test')
  185. self.assertEqual(reservation['unit_count'], 3)
  186. self.assertEqual(reservation['dummy_reservation_note'], 'dummy-reservation-note')
  187. @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
  188. @override_settings(LOGIN_REQUIRED=True)
  189. def test_graphql_plugin_reverse_relation_scoped(self):
  190. """
  191. A plugin-provided reverse relation resolves through RestrictedPrefetch and returns only related objects
  192. the requesting user may view.
  193. """
  194. site = Site.objects.create(name='Attachment Site', slug='attachment-site')
  195. DummySiteAttachment.objects.create(site=site, name='Attachment A')
  196. DummySiteAttachment.objects.create(site=site, name='Attachment B')
  197. self.add_permissions('dcim.view_site')
  198. obj_perm = ObjectPermission(name='Attachment view', actions=['view'], constraints={'name': 'Attachment A'})
  199. obj_perm.save()
  200. obj_perm.users.add(self.user)
  201. obj_perm.object_types.add(ObjectType.objects.get_for_model(DummySiteAttachment))
  202. url = reverse('graphql')
  203. query = (
  204. '{ site_list(filters: {name: {exact: "Attachment Site"}}) '
  205. '{ name dummy_site_attachments { name } } }'
  206. )
  207. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  208. self.assertHttpStatus(response, status.HTTP_200_OK)
  209. data = json.loads(response.content)
  210. self.assertNotIn('errors', data)
  211. attachments = data['data']['site_list'][0]['dummy_site_attachments']
  212. self.assertEqual([a['name'] for a in attachments], ['Attachment A'])
  213. @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
  214. @override_settings(LOGIN_REQUIRED=True)
  215. def test_graphql_plugin_schema_query_executes(self):
  216. """
  217. A plugin-provided top-level query field returning a core type executes, proving plugin schemas load
  218. at assembly time with extensions applied.
  219. """
  220. self.add_permissions('dcim.view_site')
  221. url = reverse('graphql')
  222. query = '{ dummy_plugin_site_list { name dummy_plugin_field } }'
  223. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  224. self.assertHttpStatus(response, status.HTTP_200_OK)
  225. data = json.loads(response.content)
  226. self.assertNotIn('errors', data)
  227. site = next(s for s in data['data']['dummy_plugin_site_list'] if s['name'] == 'Site 1')
  228. self.assertEqual(site['dummy_plugin_field'], 'dummy-plugin-value')
  229. @skipIf('netbox.tests.dummy_plugin_b' not in settings.PLUGINS, "dummy_plugin_b not in settings.PLUGINS")
  230. @override_settings(LOGIN_REQUIRED=True)
  231. def test_graphql_cross_plugin_extension_applies(self):
  232. """
  233. An extension registered by a later plugin applies to a core type that an earlier plugin's schema
  234. module imports, proving later plugin extensions are registered before earlier plugin schemas load.
  235. """
  236. self.add_permissions('dcim.view_site')
  237. url = reverse('graphql')
  238. query = '{ dummy_plugin_site_list { name dummy_plugin_b_field } }'
  239. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  240. self.assertHttpStatus(response, status.HTTP_200_OK)
  241. data = json.loads(response.content)
  242. self.assertNotIn('errors', data)
  243. site = next(s for s in data['data']['dummy_plugin_site_list'] if s['name'] == 'Site 1')
  244. self.assertEqual(site['dummy_plugin_b_field'], 'dummy-plugin-b-value')
  245. @override_settings(LOGIN_REQUIRED=True)
  246. def test_graphql_filter_objects(self):
  247. """
  248. Test the operation of filters for GraphQL API requests.
  249. """
  250. self.add_permissions('dcim.view_site', 'dcim.view_location')
  251. url = reverse('graphql')
  252. sites = Site.objects.all()[:3]
  253. Location.objects.create(
  254. site=sites[0],
  255. name='Location 1',
  256. slug='location-1',
  257. status=LocationStatusChoices.STATUS_PLANNED
  258. ),
  259. Location.objects.create(
  260. site=sites[1],
  261. name='Location 2',
  262. slug='location-2',
  263. status=LocationStatusChoices.STATUS_STAGING
  264. ),
  265. Location.objects.create(
  266. site=sites[1],
  267. name='Location 3',
  268. slug='location-3',
  269. status=LocationStatusChoices.STATUS_ACTIVE
  270. ),
  271. # A valid request should return the filtered list
  272. query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
  273. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  274. self.assertHttpStatus(response, status.HTTP_200_OK)
  275. data = json.loads(response.content)
  276. self.assertNotIn('errors', data)
  277. self.assertEqual(len(data['data']['location_list']), 1)
  278. self.assertIsNotNone(data['data']['location_list'][0]['site'])
  279. # Test OR and exact logic
  280. query = """{
  281. location_list( filters: {
  282. status: {exact: STATUS_PLANNED},
  283. OR: {status: {exact: STATUS_STAGING}}
  284. }) {
  285. id site {id}
  286. }
  287. }"""
  288. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  289. self.assertHttpStatus(response, status.HTTP_200_OK)
  290. data = json.loads(response.content)
  291. self.assertNotIn('errors', data)
  292. self.assertEqual(len(data['data']['location_list']), 2)
  293. # Test in_list logic
  294. query = """{
  295. location_list( filters: {
  296. status: {in_list: [STATUS_PLANNED, STATUS_STAGING]}
  297. }) {
  298. id site {id}
  299. }
  300. }"""
  301. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  302. self.assertHttpStatus(response, status.HTTP_200_OK)
  303. data = json.loads(response.content)
  304. self.assertNotIn('errors', data)
  305. self.assertEqual(len(data['data']['location_list']), 2)
  306. # An invalid request should return an empty list
  307. query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID
  308. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  309. self.assertHttpStatus(response, status.HTTP_200_OK)
  310. data = json.loads(response.content)
  311. self.assertEqual(len(data['data']['location_list']), 0)
  312. # Removing the permissions from location should result in an empty locations list
  313. self.remove_permissions('dcim.view_location')
  314. query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
  315. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  316. self.assertHttpStatus(response, status.HTTP_200_OK)
  317. data = json.loads(response.content)
  318. self.assertNotIn('errors', data)
  319. self.assertEqual(len(data['data']['site']['locations']), 0)
  320. @override_settings(LOGIN_REQUIRED=True)
  321. def test_graphql_nested_filter_objects(self):
  322. """
  323. Test filtering of nested GraphQL object lists.
  324. """
  325. self.add_permissions('dcim.view_site', 'dcim.view_location', 'extras.view_tag')
  326. site = Site.objects.create(
  327. name='Nested Filter Site',
  328. slug='nested-filter-site'
  329. )
  330. # Location is MPTT-managed; bulk_create skips tree-init hooks. Use per-instance create.
  331. Location.objects.create(
  332. site=site,
  333. name='Nested Active 1',
  334. slug='nested-active-1',
  335. status=LocationStatusChoices.STATUS_ACTIVE,
  336. )
  337. Location.objects.create(
  338. site=site,
  339. name='Nested Active 2',
  340. slug='nested-active-2',
  341. status=LocationStatusChoices.STATUS_ACTIVE,
  342. )
  343. Location.objects.create(
  344. site=site,
  345. name='Nested Planned',
  346. slug='nested-planned',
  347. status=LocationStatusChoices.STATUS_PLANNED,
  348. )
  349. planned = Tag.objects.create(name='Planned', slug='planned')
  350. production = Tag.objects.create(name='Production', slug='production')
  351. staging = Tag.objects.create(name='Staging', slug='staging')
  352. site.tags.add(planned, production, staging)
  353. url = reverse('graphql')
  354. query = f"""
  355. {{
  356. site(id: {site.pk}) {{
  357. locations(filters: {{status: {{exact: STATUS_ACTIVE}}}}) {{
  358. name
  359. }}
  360. tags(filters: {{name: {{i_starts_with: "P"}}}}) {{
  361. name
  362. }}
  363. }}
  364. }}
  365. """
  366. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  367. self.assertHttpStatus(response, status.HTTP_200_OK)
  368. data = json.loads(response.content)
  369. self.assertNotIn('errors', data)
  370. self.assertEqual(
  371. {location['name'] for location in data['data']['site']['locations']},
  372. {'Nested Active 1', 'Nested Active 2'}
  373. )
  374. self.assertEqual(
  375. {tag['name'] for tag in data['data']['site']['tags']},
  376. {'Planned', 'Production'}
  377. )
  378. def test_graphql_integer_range_lookup(self):
  379. """
  380. Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
  381. """
  382. self.add_permissions('dcim.view_device')
  383. url = reverse('graphql')
  384. manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
  385. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
  386. device_role = DeviceRole.objects.create(name='Test Role', slug='test-role')
  387. site = Site.objects.first()
  388. vc = VirtualChassis.objects.create(name='Test VC')
  389. devices = [
  390. Device(name=f'Device {i}', device_type=device_type, role=device_role, site=site,
  391. virtual_chassis=vc, vc_position=i)
  392. for i in range(1, 6)
  393. ]
  394. Device.objects.bulk_create(devices)
  395. # range_lookup should return devices with vc_position between 2 and 4 inclusive
  396. query = """
  397. {
  398. device_list(filters: {vc_position: {range_lookup: {start: 2, end: 4}}}) {
  399. id name
  400. }
  401. }
  402. """
  403. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  404. self.assertHttpStatus(response, status.HTTP_200_OK)
  405. data = json.loads(response.content)
  406. self.assertNotIn('errors', data)
  407. self.assertEqual(len(data['data']['device_list']), 3)
  408. def test_graphql_64bit_object_ids(self):
  409. """
  410. Object IDs beyond the 32-bit range must be accepted by GraphQL output fields and filters. Regression test
  411. for #23423.
  412. """
  413. self.add_permissions(
  414. 'extras.view_journalentry',
  415. 'extras.view_exporttemplate',
  416. 'vpn.view_l2vpntermination',
  417. )
  418. url = reverse('graphql')
  419. object_id = 3_000_000_000
  420. site = Site.objects.create(id=object_id, name='Site 64-bit', slug='site-64-bit')
  421. JournalEntry.objects.create(assigned_object=site, comments='Test')
  422. vlan = VLAN.objects.create(id=object_id, vid=100, name='VLAN 100')
  423. l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN)
  424. L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
  425. data_source = DataSource.objects.create(id=object_id, name='Data Source 1', type='local', source_url='/tmp')
  426. export_template = ExportTemplate.objects.create(
  427. name='Export Template 1',
  428. template_code='{{ queryset }}',
  429. data_source=data_source,
  430. )
  431. export_template.object_types.set([ObjectType.objects.get_for_model(Site)])
  432. # Output field (PositiveBigIntegerField)
  433. query = '{ journal_entry_list { assigned_object_id } }'
  434. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  435. self.assertHttpStatus(response, status.HTTP_200_OK)
  436. data = json.loads(response.content)
  437. self.assertNotIn('errors', data)
  438. self.assertEqual(data['data']['journal_entry_list'], [{'assigned_object_id': object_id}])
  439. # Filter on a generic object ID
  440. query = f'{{ l2vpn_termination_list(filters: {{assigned_object_id: "{object_id}"}}) {{ id }} }}'
  441. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  442. self.assertHttpStatus(response, status.HTTP_200_OK)
  443. data = json.loads(response.content)
  444. self.assertNotIn('errors', data)
  445. self.assertEqual(len(data['data']['l2vpn_termination_list']), 1)
  446. # Filter on a related object ID
  447. query = f'{{ export_template_list(filters: {{data_source_id: "{object_id}"}}) {{ id }} }}'
  448. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  449. self.assertHttpStatus(response, status.HTTP_200_OK)
  450. data = json.loads(response.content)
  451. self.assertNotIn('errors', data)
  452. self.assertEqual(data['data']['export_template_list'], [{'id': str(export_template.pk)}])
  453. def test_graphql_bigint_filter_validation(self):
  454. """
  455. BigInt filter values must be validated as 64-bit integers before the query is executed.
  456. """
  457. self.add_permissions('ipam.view_asn')
  458. url = reverse('graphql')
  459. rir = RIR.objects.create(name='RIR 1', slug='rir-1')
  460. asn = ASN.objects.create(asn=4_200_000_000, rir=rir)
  461. # Integers and numeric strings beyond the 32-bit range are accepted
  462. for value in ('4200000000', '"4200000000"'):
  463. query = f'{{ asn_list(filters: {{asn: {{filter_lookup: {{exact: {value}}}}}}}) {{ id }} }}'
  464. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  465. self.assertHttpStatus(response, status.HTTP_200_OK)
  466. data = json.loads(response.content)
  467. self.assertNotIn('errors', data)
  468. self.assertEqual(data['data']['asn_list'], [{'id': str(asn.pk)}])
  469. # Non-integer and out-of-range values are rejected
  470. for value in ('1.5', '"abc"', 'true', str(2**63)):
  471. query = f'{{ asn_list(filters: {{asn: {{filter_lookup: {{exact: {value}}}}}}}) {{ id }} }}'
  472. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  473. data = json.loads(response.content)
  474. self.assertIn('errors', data, msg=f'{value} was not rejected')
  475. self.assertIn('BigInt cannot represent', data['errors'][0]['message'])
  476. # Values passed as variables are validated the same way
  477. query = 'query ($asn: BigInt) { asn_list(filters: {asn: {filter_lookup: {exact: $asn}}}) { id } }'
  478. for value in (4_200_000_000, '4200000000'):
  479. response = self.client.post(
  480. url, data={'query': query, 'variables': {'asn': value}}, format='json', **self.header
  481. )
  482. self.assertHttpStatus(response, status.HTTP_200_OK)
  483. data = json.loads(response.content)
  484. self.assertNotIn('errors', data)
  485. self.assertEqual(data['data']['asn_list'], [{'id': str(asn.pk)}])
  486. response = self.client.post(
  487. url, data={'query': query, 'variables': {'asn': 'abc'}}, format='json', **self.header
  488. )
  489. data = json.loads(response.content)
  490. self.assertIn('errors', data)
  491. self.assertIn('BigInt cannot represent', data['errors'][0]['message'])
  492. def test_graphql_array_length_lookup(self):
  493. """
  494. The public GraphQL ``length`` array lookup must map to Django's ``len`` transform.
  495. Regression test for #22766 using a standard array field (RackReservation.units).
  496. """
  497. self.add_permissions('dcim.view_rackreservation')
  498. url = reverse('graphql')
  499. site = Site.objects.first()
  500. rack = Rack.objects.create(name='Reservation Rack', site=site)
  501. RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Two units')
  502. RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='Three units')
  503. query = """
  504. {
  505. rack_reservation_list(filters: {units: {length: 2}}) {
  506. id units
  507. }
  508. }
  509. """
  510. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  511. self.assertHttpStatus(response, status.HTTP_200_OK)
  512. data = json.loads(response.content)
  513. self.assertNotIn('errors', data)
  514. self.assertEqual(len(data['data']['rack_reservation_list']), 1)
  515. self.assertEqual(data['data']['rack_reservation_list'][0]['units'], [1, 2])
  516. def test_graphql_array_lookups(self):
  517. """
  518. The ``contains``, ``contained_by``, and ``overlap`` array lookups share their name with the
  519. corresponding ORM transform. Verify they still resolve after #22766 replaced the auto-generated
  520. filter fields with a manual ``filter()`` method.
  521. """
  522. self.add_permissions('dcim.view_rackreservation')
  523. url = reverse('graphql')
  524. site = Site.objects.first()
  525. rack = Rack.objects.create(name='Array Lookup Rack', site=site)
  526. RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Low units')
  527. RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='High units')
  528. def run(lookup):
  529. query = f"""
  530. {{
  531. rack_reservation_list(filters: {{units: {lookup}}}) {{
  532. id units
  533. }}
  534. }}
  535. """
  536. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  537. self.assertHttpStatus(response, status.HTTP_200_OK)
  538. data = json.loads(response.content)
  539. self.assertNotIn('errors', data)
  540. return [r['units'] for r in data['data']['rack_reservation_list']]
  541. # contains: arrays that include all of the given elements
  542. self.assertEqual(run('{contains: [1]}'), [[1, 2]])
  543. # contained_by: arrays whose elements all fall within the given set
  544. self.assertEqual(run('{contained_by: [1, 2, 3]}'), [[1, 2]])
  545. # overlap: arrays sharing at least one element with the given set
  546. self.assertCountEqual(run('{overlap: [2, 3]}'), [[1, 2], [3, 4, 5]])
  547. def test_graphql_tableconfig_object_type_exposes_id(self):
  548. """TableConfigType.object_type must expose ContentType fields (e.g. id)."""
  549. self.add_permissions('extras.view_tableconfig')
  550. url = reverse('graphql')
  551. site_ct = ContentType.objects.get_for_model(Site)
  552. table_config = TableConfig.objects.create(
  553. object_type=site_ct,
  554. table=get_table_for_model(Site).__name__,
  555. name='Test config',
  556. columns=['name'],
  557. )
  558. query = '{ table_config(id: ' + str(table_config.pk) + ') { object_type { id } } }'
  559. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  560. self.assertHttpStatus(response, status.HTTP_200_OK)
  561. data = json.loads(response.content)
  562. self.assertNotIn('errors', data)
  563. self.assertEqual(int(data['data']['table_config']['object_type']['id']), site_ct.pk)
  564. def test_graphql_custom_fields_include_unset_fields(self):
  565. """
  566. CustomFieldsMixin.custom_fields must emit a key for every custom field assigned to the model,
  567. as the REST API does, rather than returning the stored data verbatim. A key is materialized
  568. only once a value is assigned, so an object predating a field carries none; without this such
  569. a field would be absent from the response instead of null. Stale data for a field which no
  570. longer applies is likewise omitted.
  571. """
  572. self.add_permissions('dcim.view_site')
  573. url = reverse('graphql')
  574. cf = CustomField.objects.create(name='cf1', type=CustomFieldTypeChoices.TYPE_TEXT)
  575. cf.object_types.set([ObjectType.objects.get_for_model(Site)])
  576. site = Site.objects.get(slug='site-1')
  577. self.assertNotIn('cf1', site.custom_field_data)
  578. Site.objects.filter(pk=site.pk).update(custom_field_data={'stale': 'value'})
  579. query = '{ site(id: ' + str(site.pk) + ') { custom_fields } }'
  580. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  581. self.assertHttpStatus(response, status.HTTP_200_OK)
  582. data = json.loads(response.content)
  583. self.assertNotIn('errors', data)
  584. self.assertEqual(data['data']['site']['custom_fields'], {'cf1': None})
  585. @override_settings(LOGIN_REQUIRED=True)
  586. def test_graphql_device_list_tags_are_prefetched(self):
  587. """
  588. Requesting tags on device_list must batch tag lookups (no N+1 per device).
  589. """
  590. self.add_permissions('dcim.view_device', 'extras.view_tag')
  591. manufacturer = Manufacturer.objects.create(name='Prefetch Manufacturer', slug='prefetch-manufacturer')
  592. device_type = DeviceType.objects.create(
  593. manufacturer=manufacturer,
  594. model='Prefetch Model',
  595. slug='prefetch-model',
  596. )
  597. device_role = DeviceRole.objects.create(name='Prefetch Role', slug='prefetch-role')
  598. site = Site.objects.first()
  599. tag_alpha = Tag.objects.create(name='Prefetch Alpha', slug='prefetch-alpha')
  600. tag_beta = Tag.objects.create(name='Prefetch Beta', slug='prefetch-beta')
  601. devices = Device.objects.bulk_create([
  602. Device(
  603. name=f'Prefetch Device {index}',
  604. device_type=device_type,
  605. role=device_role,
  606. site=site,
  607. )
  608. for index in range(10)
  609. ])
  610. for device in devices:
  611. device.tags.set([tag_alpha, tag_beta])
  612. query = """
  613. {
  614. device_list(filters: {role: {slug: {exact: "prefetch-role"}}}) {
  615. name
  616. tags {
  617. slug
  618. }
  619. }
  620. }
  621. """
  622. url = reverse('graphql')
  623. with CaptureQueriesContext(connection) as context:
  624. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  625. self.assertHttpStatus(response, status.HTTP_200_OK)
  626. data = json.loads(response.content)
  627. self.assertNotIn('errors', data)
  628. self.assertEqual(len(data['data']['device_list']), 10)
  629. tag_queries = sum(1 for query_record in context.captured_queries if 'extras_tag' in query_record['sql'])
  630. self.assertLessEqual(
  631. tag_queries,
  632. 2,
  633. msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
  634. )
  635. def test_graphql_device_list_tags_pagination(self):
  636. """
  637. Pagination is applied independently to a prefetched list field (tags) for each object, without
  638. resorting to a query per object.
  639. """
  640. self.add_permissions('dcim.view_device', 'extras.view_tag')
  641. manufacturer = Manufacturer.objects.create(name='Pagination Manufacturer', slug='pagination-manufacturer')
  642. device_type = DeviceType.objects.create(
  643. manufacturer=manufacturer,
  644. model='Pagination Model',
  645. slug='pagination-model',
  646. )
  647. device_role = DeviceRole.objects.create(name='Pagination Role', slug='pagination-role')
  648. site = Site.objects.first()
  649. devices = Device.objects.bulk_create([
  650. Device(name=f'Pagination Device {index}', device_type=device_type, role=device_role, site=site)
  651. for index in range(5)
  652. ])
  653. tags = [
  654. Tag.objects.create(name=f'Pagination Tag {index}', slug=f'pagination-tag-{index}')
  655. for index in range(8)
  656. ]
  657. devices[0].tags.set(tags[:5])
  658. devices[1].tags.set(tags[3:])
  659. url = reverse('graphql')
  660. def get_device_tags(tags_field):
  661. query = """
  662. {
  663. device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
  664. name
  665. TAGS_FIELD {
  666. slug
  667. }
  668. }
  669. }
  670. """.replace('TAGS_FIELD', tags_field)
  671. with CaptureQueriesContext(connection) as context:
  672. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  673. self.assertHttpStatus(response, status.HTTP_200_OK)
  674. data = json.loads(response.content)
  675. self.assertNotIn('errors', data)
  676. tag_queries = sum(1 for record in context.captured_queries if 'extras_tag' in record['sql'])
  677. self.assertLessEqual(tag_queries, 2, msg=f'Expected batched tag prefetch, got {tag_queries} tag queries')
  678. return {
  679. device['name']: [tag['slug'] for tag in device['tags']]
  680. for device in data['data']['device_list']
  681. # The remaining devices have no tags, and exist only to detect a tag query per device
  682. if device['name'] in ('Pagination Device 0', 'Pagination Device 1')
  683. }
  684. # Explicit pagination
  685. self.assertEqual(get_device_tags('tags(pagination: {offset: 1, limit: 2})'), {
  686. 'Pagination Device 0': ['pagination-tag-1', 'pagination-tag-2'],
  687. 'Pagination Device 1': ['pagination-tag-4', 'pagination-tag-5'],
  688. })
  689. # No pagination: all tags are returned
  690. self.assertEqual(get_device_tags('tags'), {
  691. 'Pagination Device 0': [tag.slug for tag in tags[:5]],
  692. 'Pagination Device 1': [tag.slug for tag in tags[3:]],
  693. })
  694. # MAX_PAGE_SIZE is enforced when no pagination is specified
  695. with override_settings(MAX_PAGE_SIZE=3):
  696. self.assertEqual(get_device_tags('tags'), {
  697. 'Pagination Device 0': [tag.slug for tag in tags[:3]],
  698. 'Pagination Device 1': [tag.slug for tag in tags[3:6]],
  699. })
  700. # A negative offset is rejected
  701. query = """
  702. {
  703. device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
  704. tags(pagination: {offset: -2}) {
  705. slug
  706. }
  707. }
  708. }
  709. """
  710. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  711. data = json.loads(response.content)
  712. self.assertIn('errors', data)
  713. self.assertIn('Negative indexing is not supported', data['errors'][0]['message'])
  714. def test_graphql_ip_address_list_assigned_object(self):
  715. """
  716. Requesting assigned_object should batch prefetch related objects.
  717. """
  718. self.add_permissions('ipam.view_ipaddress', 'dcim.view_interface', 'dcim.view_device')
  719. site = Site.objects.first()
  720. manufacturer = Manufacturer.objects.create(name='Assigned Object Manufacturer', slug='assigned-object-mfg')
  721. device_type = DeviceType.objects.create(
  722. manufacturer=manufacturer,
  723. model='Assigned Object Model',
  724. slug='assigned-object-model',
  725. )
  726. device_role = DeviceRole.objects.create(name='Assigned Object Role', slug='assigned-object-role')
  727. device = Device.objects.create(
  728. name='Assigned Object Device',
  729. site=site,
  730. device_type=device_type,
  731. role=device_role,
  732. )
  733. interface = Interface.objects.create(name='eth0', device=device, type='1000baset')
  734. ip_addresses = IPAddress.objects.bulk_create([
  735. IPAddress(address=f'192.0.2.{index}/24', assigned_object=interface)
  736. for index in range(1, 6)
  737. ])
  738. ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
  739. query = f"""
  740. {{
  741. ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
  742. address
  743. assigned_object {{
  744. ... on InterfaceType {{
  745. name
  746. device {{
  747. name
  748. }}
  749. }}
  750. }}
  751. }}
  752. }}
  753. """
  754. url = reverse('graphql')
  755. with CaptureQueriesContext(connection) as context:
  756. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  757. self.assertHttpStatus(response, status.HTTP_200_OK)
  758. data = json.loads(response.content)
  759. self.assertNotIn('errors', data)
  760. self.assertEqual(len(data['data']['ip_address_list']), len(ip_addresses))
  761. device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
  762. self.assertLessEqual(
  763. device_queries,
  764. 2,
  765. msg=f'Expected batched assigned_object prefetch, got {device_queries} device queries for 5 IP addresses',
  766. )
  767. def test_graphql_ip_address_list_assigned_object_nested_site(self):
  768. """
  769. Nested assigned_object selections should be optimized on the GFK prefetch queryset.
  770. """
  771. self.add_permissions(
  772. 'ipam.view_ipaddress',
  773. 'dcim.view_interface',
  774. 'dcim.view_device',
  775. 'dcim.view_site',
  776. )
  777. site = Site.objects.first()
  778. manufacturer = Manufacturer.objects.create(
  779. name='Nested Site Manufacturer',
  780. slug='nested-site-mfg',
  781. )
  782. device_type = DeviceType.objects.create(
  783. manufacturer=manufacturer,
  784. model='Nested Site Model',
  785. slug='nested-site-model',
  786. )
  787. device_role = DeviceRole.objects.create(name='Nested Site Role', slug='nested-site-role')
  788. interfaces = []
  789. for index in range(5):
  790. device = Device.objects.create(
  791. name=f'Nested Site Device {index}',
  792. site=site,
  793. device_type=device_type,
  794. role=device_role,
  795. )
  796. interfaces.append(Interface.objects.create(
  797. name=f'eth{index}',
  798. device=device,
  799. type='1000baset',
  800. ))
  801. ip_addresses = IPAddress.objects.bulk_create([
  802. IPAddress(address=f'192.0.2.{index}/24', assigned_object=interfaces[index - 1])
  803. for index in range(1, 6)
  804. ])
  805. ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
  806. query = f"""
  807. {{
  808. ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
  809. address
  810. assigned_object {{
  811. ... on InterfaceType {{
  812. name
  813. device {{
  814. name
  815. site {{
  816. name
  817. }}
  818. }}
  819. }}
  820. }}
  821. }}
  822. }}
  823. """
  824. url = reverse('graphql')
  825. with CaptureQueriesContext(connection) as context:
  826. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  827. self.assertHttpStatus(response, status.HTTP_200_OK)
  828. data = json.loads(response.content)
  829. self.assertNotIn('errors', data)
  830. self.assertEqual(len(data['data']['ip_address_list']), len(ip_addresses))
  831. for ip_data in data['data']['ip_address_list']:
  832. self.assertEqual(ip_data['assigned_object']['device']['site']['name'], site.name)
  833. device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
  834. site_queries = count_primary_table_queries(context.captured_queries, 'dcim_site')
  835. self.assertLessEqual(
  836. device_queries,
  837. 2,
  838. msg=f'Expected batched device prefetch, got {device_queries} device queries for 5 IP addresses',
  839. )
  840. self.assertLessEqual(
  841. site_queries,
  842. 2,
  843. msg=f'Expected optimized site join, got {site_queries} site queries for 5 IP addresses',
  844. )
  845. def test_graphql_plugin_display_does_not_inherit_other_model_columns(self):
  846. """Types decorated directly by plugins must not inherit another model's display hints."""
  847. platform = Platform.objects.create(name='Display Platform', slug='display-platform')
  848. @strawberry_django.type(Platform, fields=['name'])
  849. class PluginPlatformType(PrimaryObjectType):
  850. pass
  851. @strawberry.type
  852. class PluginQuery:
  853. platforms: list[PluginPlatformType] = strawberry_django.field()
  854. plugin_schema = strawberry.Schema(query=PluginQuery, extensions=[DjangoOptimizerExtension])
  855. self.user.is_superuser = True
  856. result = plugin_schema.execute_sync(
  857. '{ platforms { name display } }',
  858. context_value=SimpleNamespace(request=SimpleNamespace(user=self.user)),
  859. )
  860. self.assertIsNone(result.errors)
  861. self.assertEqual(result.data['platforms'], [{'name': platform.name, 'display': str(platform)}])
  862. @override_settings(LOGIN_REQUIRED=True)
  863. def test_graphql_datafile_display_does_not_load_binary_data(self):
  864. """Rendering a file path must not select the file's binary contents."""
  865. self.add_permissions('core.view_datafile')
  866. source = DataSource.objects.create(name='Display Source', type='local', source_url='file:///tmp/display/')
  867. datafile = DataFile.objects.create(
  868. source=source, path='display.txt', last_updated=timezone.now(),
  869. size=7, hash='0' * 64, data=b'content',
  870. )
  871. with CaptureQueriesContext(connection) as context:
  872. response = self.client.post(
  873. reverse('graphql'), data={'query': '{ data_file_list { display } }'}, format='json', **self.header,
  874. )
  875. self.assertHttpStatus(response, status.HTTP_200_OK)
  876. data = response.json()
  877. self.assertNotIn('errors', data)
  878. self.assertEqual(data['data']['data_file_list'], [{'display': datafile.path}])
  879. file_queries = [q['sql'] for q in context.captured_queries if 'FROM "core_datafile"' in q['sql']]
  880. self.assertTrue(file_queries)
  881. for sql in file_queries:
  882. self.assertNotIn('"core_datafile"."data"', sql)
  883. @override_settings(LOGIN_REQUIRED=True)
  884. def test_graphql_nested_display_is_batched(self):
  885. """
  886. Selecting `display` on a nested object must not reload each row to read deferred columns (no N+1).
  887. """
  888. self.add_permissions('dcim.view_device', 'dcim.view_interface', 'dcim.view_macaddress')
  889. site = Site.objects.first()
  890. manufacturer = Manufacturer.objects.create(name='Display Manufacturer', slug='display-manufacturer')
  891. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Display Model', slug='display-model')
  892. device_role = DeviceRole.objects.create(name='Display Role', slug='display-role')
  893. devices = Device.objects.bulk_create([
  894. Device(name=f'Display Device {index}', device_type=device_type, role=device_role, site=site)
  895. for index in range(5)
  896. ])
  897. for index, device in enumerate(devices):
  898. interface = Interface.objects.create(name='eth0', device=device, type='1000baset')
  899. mac = MACAddress.objects.create(mac_address=f'00:00:00:00:00:{index:02x}', assigned_object=interface)
  900. interface.primary_mac_address = mac
  901. interface.save()
  902. query = """
  903. {
  904. device_list(filters: {role: {slug: {exact: "display-role"}}}) {
  905. name
  906. interfaces {
  907. name
  908. primary_mac_address {
  909. display
  910. }
  911. }
  912. }
  913. }
  914. """
  915. url = reverse('graphql')
  916. with CaptureQueriesContext(connection) as context:
  917. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  918. self.assertHttpStatus(response, status.HTTP_200_OK)
  919. data = json.loads(response.content)
  920. self.assertNotIn('errors', data)
  921. self.assertEqual(len(data['data']['device_list']), len(devices))
  922. for device_data in data['data']['device_list']:
  923. self.assertTrue(device_data['interfaces'][0]['primary_mac_address']['display'])
  924. mac_queries = count_primary_table_queries(context.captured_queries, 'dcim_macaddress')
  925. self.assertLessEqual(
  926. mac_queries,
  927. 2,
  928. msg=f'Expected batched MAC address lookup, got {mac_queries} MAC address queries for 5 devices',
  929. )
  930. def test_graphql_deferred_fields_are_batched(self):
  931. """
  932. Deferred columns and relations read by a resolver the optimizer can't analyze (`link_peers` reads `cable`
  933. and `wireless_link`) must load with one query for all objects returned, not one query per object.
  934. """
  935. self.add_permissions('dcim.view_interface')
  936. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  937. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  938. device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  939. devices = Device.objects.bulk_create([
  940. Device(name=f'Device {index}', device_type=device_type, role=device_role, site=Site.objects.first())
  941. for index in range(1, 4)
  942. ])
  943. interfaces = {
  944. device.name: Interface.objects.bulk_create([
  945. Interface(device=device, name=f'eth{index}', type='1000baset') for index in range(5)
  946. ])
  947. for device in devices
  948. }
  949. # Device 1's interfaces have no cable; device 2's are each cabled to the interface of the same name on device 3
  950. for a_interface, b_interface in zip(interfaces['Device 2'], interfaces['Device 3']):
  951. Cable(a_terminations=[a_interface], b_terminations=[b_interface]).save()
  952. query = """
  953. {
  954. interface_list(filters: {device: {name: {exact: "%s"}}}, pagination: {limit: %d}) {
  955. name
  956. link_peers { ... on InterfaceType { name } }
  957. }
  958. }
  959. """
  960. url = reverse('graphql')
  961. def get_link_peers(device_name, limit):
  962. with CaptureQueriesContext(connection) as context:
  963. response = self.client.post(
  964. url, data={'query': query % (device_name, limit)}, format='json', **self.header
  965. )
  966. self.assertHttpStatus(response, status.HTTP_200_OK)
  967. data = json.loads(response.content)
  968. self.assertNotIn('errors', data)
  969. link_peers = {
  970. interface['name']: [peer['name'] for peer in interface['link_peers']]
  971. for interface in data['data']['interface_list']
  972. }
  973. self.assertEqual(len(link_peers), limit)
  974. return link_peers, context.captured_queries
  975. # Deferred columns (cable, wireless_link) are loaded once, however many interfaces are returned
  976. interface_queries = {}
  977. for limit in (1, 5):
  978. link_peers, queries = get_link_peers('Device 1', limit)
  979. self.assertEqual(link_peers, {f'eth{index}': [] for index in range(limit)})
  980. interface_queries[limit] = count_primary_table_queries(queries, 'dcim_interface')
  981. self.assertEqual(interface_queries[5], interface_queries[1])
  982. # Cables are loaded once, however many interfaces are returned
  983. cable_queries = {}
  984. for limit in (1, 5):
  985. link_peers, queries = get_link_peers('Device 2', limit)
  986. self.assertEqual(link_peers, {f'eth{index}': [f'eth{index}'] for index in range(limit)})
  987. cable_queries[limit] = count_primary_table_queries(queries, 'dcim_cable')
  988. self.assertEqual(cable_queries[5], cable_queries[1])
  989. def test_offset_pagination(self):
  990. self.add_permissions('dcim.view_site')
  991. url = reverse('graphql')
  992. # Test `limit` only
  993. query = """
  994. {
  995. site_list(pagination: {limit: 3}) {
  996. id name
  997. }
  998. }
  999. """
  1000. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1001. self.assertHttpStatus(response, status.HTTP_200_OK)
  1002. data = json.loads(response.content)
  1003. self.assertNotIn('errors', data)
  1004. self.assertEqual(len(data['data']['site_list']), 3)
  1005. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
  1006. self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
  1007. self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
  1008. # Test `offset` only
  1009. query = """
  1010. {
  1011. site_list(pagination: {offset: 3}) {
  1012. id name
  1013. }
  1014. }
  1015. """
  1016. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1017. self.assertHttpStatus(response, status.HTTP_200_OK)
  1018. data = json.loads(response.content)
  1019. self.assertNotIn('errors', data)
  1020. self.assertEqual(len(data['data']['site_list']), 4)
  1021. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
  1022. self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
  1023. self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
  1024. self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
  1025. # Test `offset` & `limit`
  1026. query = """
  1027. {
  1028. site_list(pagination: {offset: 3, limit: 3}) {
  1029. id name
  1030. }
  1031. }
  1032. """
  1033. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1034. self.assertHttpStatus(response, status.HTTP_200_OK)
  1035. data = json.loads(response.content)
  1036. self.assertNotIn('errors', data)
  1037. self.assertEqual(len(data['data']['site_list']), 3)
  1038. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
  1039. self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
  1040. self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
  1041. def test_cursor_pagination(self):
  1042. self.add_permissions('dcim.view_site')
  1043. url = reverse('graphql')
  1044. # Page 1
  1045. query = """
  1046. {
  1047. site_list(pagination: {start: 0, limit: 3}) {
  1048. id name
  1049. }
  1050. }
  1051. """
  1052. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1053. self.assertHttpStatus(response, status.HTTP_200_OK)
  1054. data = json.loads(response.content)
  1055. self.assertNotIn('errors', data)
  1056. self.assertEqual(len(data['data']['site_list']), 3)
  1057. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
  1058. self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
  1059. self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
  1060. # Page 2
  1061. start_id = int(data['data']['site_list'][-1]['id']) + 1
  1062. query = """
  1063. {
  1064. site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
  1065. id name
  1066. }
  1067. }
  1068. """
  1069. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1070. self.assertHttpStatus(response, status.HTTP_200_OK)
  1071. data = json.loads(response.content)
  1072. self.assertNotIn('errors', data)
  1073. self.assertEqual(len(data['data']['site_list']), 3)
  1074. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
  1075. self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
  1076. self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
  1077. # Page 3
  1078. start_id = int(data['data']['site_list'][-1]['id']) + 1
  1079. query = """
  1080. {
  1081. site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
  1082. id name
  1083. }
  1084. }
  1085. """
  1086. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1087. self.assertHttpStatus(response, status.HTTP_200_OK)
  1088. data = json.loads(response.content)
  1089. self.assertNotIn('errors', data)
  1090. self.assertEqual(len(data['data']['site_list']), 1)
  1091. self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
  1092. @override_settings(MAX_PAGE_SIZE=3)
  1093. def test_max_page_size(self):
  1094. self.add_permissions('dcim.view_site')
  1095. url = reverse('graphql')
  1096. # Request without explicit limit should be capped by MAX_PAGE_SIZE
  1097. query = """
  1098. {
  1099. site_list {
  1100. id name
  1101. }
  1102. }
  1103. """
  1104. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1105. self.assertHttpStatus(response, status.HTTP_200_OK)
  1106. data = json.loads(response.content)
  1107. self.assertNotIn('errors', data)
  1108. self.assertEqual(len(data['data']['site_list']), 3)
  1109. # Request with limit exceeding MAX_PAGE_SIZE should be capped
  1110. query = """
  1111. {
  1112. site_list(pagination: {limit: 100}) {
  1113. id name
  1114. }
  1115. }
  1116. """
  1117. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1118. self.assertHttpStatus(response, status.HTTP_200_OK)
  1119. data = json.loads(response.content)
  1120. self.assertNotIn('errors', data)
  1121. self.assertEqual(len(data['data']['site_list']), 3)
  1122. # Request with limit under MAX_PAGE_SIZE should be respected
  1123. query = """
  1124. {
  1125. site_list(pagination: {limit: 2}) {
  1126. id name
  1127. }
  1128. }
  1129. """
  1130. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1131. self.assertHttpStatus(response, status.HTTP_200_OK)
  1132. data = json.loads(response.content)
  1133. self.assertNotIn('errors', data)
  1134. self.assertEqual(len(data['data']['site_list']), 2)
  1135. def test_to_one_relation_prefetch(self):
  1136. """
  1137. A prefetched to-one relation should be fetched with a plain `WHERE id IN (...)` query, rather than
  1138. with a window function partitioned by the parent ID (which returns every row sharing the related
  1139. object, regardless of the requested page size).
  1140. """
  1141. self.add_permissions('dcim.view_device', 'dcim.view_site')
  1142. url = reverse('graphql')
  1143. site = Site.objects.first()
  1144. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1145. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1146. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1147. Device.objects.bulk_create([
  1148. Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
  1149. for i in range(1, 21)
  1150. ])
  1151. # Request two of the twenty devices at the site
  1152. query = """
  1153. {
  1154. device_list(pagination: {limit: 2}) {
  1155. name
  1156. site { name }
  1157. }
  1158. }
  1159. """
  1160. with CaptureQueriesContext(connection) as ctx:
  1161. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1162. self.assertHttpStatus(response, status.HTTP_200_OK)
  1163. data = json.loads(response.content)
  1164. self.assertNotIn('errors', data)
  1165. self.assertEqual(len(data['data']['device_list']), 2)
  1166. self.assertEqual(data['data']['device_list'][0]['site']['name'], site.name)
  1167. # The site should have been fetched by exactly one query. (Asserting that it exists keeps the
  1168. # assertions below from silently passing if the site is ever fetched some other way.)
  1169. site_queries = [q['sql'] for q in ctx.captured_queries if 'FROM "dcim_site"' in q['sql']]
  1170. self.assertEqual(len(site_queries), 1, msg=f'Expected one query against dcim_site, got {site_queries}')
  1171. # That query should not apply window pagination, nor join back to the devices table (which would
  1172. # return one row per device at the site)
  1173. self.assertNotIn('ROW_NUMBER', site_queries[0])
  1174. self.assertNotIn('dcim_device', site_queries[0])
  1175. @override_settings(MAX_PAGE_SIZE=3)
  1176. def test_max_page_size_nested_list(self):
  1177. """
  1178. MAX_PAGE_SIZE should still be enforced on a nested list relation.
  1179. """
  1180. self.add_permissions('dcim.view_device', 'dcim.view_site')
  1181. url = reverse('graphql')
  1182. site = Site.objects.first()
  1183. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1184. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1185. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1186. Device.objects.bulk_create([
  1187. Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
  1188. for i in range(1, 6)
  1189. ])
  1190. query = """
  1191. {
  1192. site_list(pagination: {limit: 1}) {
  1193. name
  1194. devices { name }
  1195. }
  1196. }
  1197. """
  1198. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1199. self.assertHttpStatus(response, status.HTTP_200_OK)
  1200. data = json.loads(response.content)
  1201. self.assertNotIn('errors', data)
  1202. self.assertEqual(len(data['data']['site_list'][0]['devices']), 3)
  1203. def test_distinct_nested_list(self):
  1204. """
  1205. The `DISTINCT` filter should deduplicate a nested list field which is filtered across a to-many
  1206. relation, just as it does for the equivalent top-level list field.
  1207. """
  1208. self.add_permissions('dcim.view_device', 'dcim.view_site')
  1209. url = reverse('graphql')
  1210. site = Site.objects.get(slug='site-1')
  1211. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1212. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1213. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1214. devices = Device.objects.bulk_create([
  1215. Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
  1216. for i in range(1, 3)
  1217. ])
  1218. Interface.objects.bulk_create([
  1219. Interface(device=device, name=f'eth{i}', type='1000base-t')
  1220. for device in devices
  1221. for i in range(3)
  1222. ])
  1223. # Each device should be returned exactly once, despite having three matching interfaces
  1224. query = """
  1225. {
  1226. site_list(filters: {slug: {exact: "site-1"}}) {
  1227. name
  1228. devices(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
  1229. name
  1230. }
  1231. }
  1232. }
  1233. """
  1234. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1235. self.assertHttpStatus(response, status.HTTP_200_OK)
  1236. data = json.loads(response.content)
  1237. self.assertNotIn('errors', data)
  1238. self.assertEqual(
  1239. [device['name'] for device in data['data']['site_list'][0]['devices']],
  1240. ['Device 1', 'Device 2']
  1241. )
  1242. # The equivalent top-level query should return the same devices
  1243. query = """
  1244. {
  1245. device_list(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
  1246. name
  1247. }
  1248. }
  1249. """
  1250. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1251. self.assertHttpStatus(response, status.HTTP_200_OK)
  1252. data = json.loads(response.content)
  1253. self.assertNotIn('errors', data)
  1254. self.assertEqual(
  1255. [device['name'] for device in data['data']['device_list']],
  1256. ['Device 1', 'Device 2']
  1257. )
  1258. @override_settings(MAX_PAGE_SIZE=2)
  1259. def test_distinct_nested_list_max_page_size(self):
  1260. """
  1261. MAX_PAGE_SIZE should still be enforced on a deduplicated nested list field, and should be applied
  1262. to the number of distinct objects returned (not to the number of joined rows).
  1263. """
  1264. self.add_permissions('dcim.view_device', 'dcim.view_site')
  1265. url = reverse('graphql')
  1266. site = Site.objects.get(slug='site-1')
  1267. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1268. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1269. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1270. devices = Device.objects.bulk_create([
  1271. Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
  1272. for i in range(1, 5)
  1273. ])
  1274. Interface.objects.bulk_create([
  1275. Interface(device=device, name=f'eth{i}', type='1000base-t')
  1276. for device in devices
  1277. for i in range(3)
  1278. ])
  1279. query = """
  1280. {
  1281. site_list(filters: {slug: {exact: "site-1"}}) {
  1282. name
  1283. devices(filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}) {
  1284. name
  1285. }
  1286. }
  1287. }
  1288. """
  1289. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1290. self.assertHttpStatus(response, status.HTTP_200_OK)
  1291. data = json.loads(response.content)
  1292. self.assertNotIn('errors', data)
  1293. self.assertEqual(
  1294. [device['name'] for device in data['data']['site_list'][0]['devices']],
  1295. ['Device 1', 'Device 2']
  1296. )
  1297. # An explicit offset should likewise be applied to the distinct objects
  1298. query = """
  1299. {
  1300. site_list(filters: {slug: {exact: "site-1"}}) {
  1301. name
  1302. devices(
  1303. pagination: {offset: 1, limit: 2},
  1304. filters: {DISTINCT: true, interfaces: {name: {starts_with: "eth"}}}
  1305. ) {
  1306. name
  1307. }
  1308. }
  1309. }
  1310. """
  1311. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1312. self.assertHttpStatus(response, status.HTTP_200_OK)
  1313. data = json.loads(response.content)
  1314. self.assertNotIn('errors', data)
  1315. self.assertEqual(
  1316. [device['name'] for device in data['data']['site_list'][0]['devices']],
  1317. ['Device 2', 'Device 3']
  1318. )
  1319. def test_distinct_window_pagination_tied_ordering(self):
  1320. """
  1321. Two rows which represent *different* objects must never be assigned the same rank, even when they
  1322. compare equal under the queryset's ordering. `DENSE_RANK()` ties such rows by definition, so the
  1323. primary key is appended to the window ordering to separate them; without it every device below
  1324. would be assigned rank 1 and the limit of two would return all four of them.
  1325. """
  1326. site = Site.objects.get(slug='site-1')
  1327. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1328. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1329. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1330. devices = Device.objects.bulk_create([
  1331. Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
  1332. for i in range(1, 5)
  1333. ])
  1334. Interface.objects.bulk_create([
  1335. Interface(device=device, name=f'eth{i}', type='1000base-t')
  1336. for device in devices
  1337. for i in range(3)
  1338. ])
  1339. # Order by a column whose value is identical for every device, so that the ordering alone cannot
  1340. # distinguish them. Each device additionally matches three interfaces, so the join emits three
  1341. # duplicate rows per device which DISTINCT must still collapse.
  1342. queryset = Device.objects.filter(
  1343. site=site, interfaces__name__startswith='eth'
  1344. ).order_by('status').distinct()
  1345. queryset = apply_distinct_window_pagination(queryset, related_field_id='site_id', limit=2)
  1346. results = list(queryset)
  1347. self.assertEqual(sorted(device.name for device in results), ['Device 1', 'Device 2'])
  1348. self.assertEqual(sorted(device._strawberry_row_number for device in results), [1, 2])
  1349. def test_pagination_conflict(self):
  1350. url = reverse('graphql')
  1351. query = """
  1352. {
  1353. site_list(pagination: {start: 1, offset: 1}) {
  1354. id name
  1355. }
  1356. }
  1357. """
  1358. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1359. self.assertHttpStatus(response, status.HTTP_200_OK)
  1360. data = json.loads(response.content)
  1361. self.assertIn('errors', data)
  1362. self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
  1363. class GraphQLDeferredColumnTestCase(APITestCase):
  1364. """
  1365. A GraphQL field backed by a custom resolver is opaque to the query optimizer, which narrows column
  1366. selection with .only() based on the fields named in the GraphQL document. Any column such a resolver
  1367. reads must therefore be declared via an `only` hint; otherwise the column is deferred and reading it
  1368. costs an extra query (see #22813).
  1369. Each test below asserts both that no single-row reload occurs and that the model's table is read
  1370. exactly once per request, regardless of the number of objects returned.
  1371. """
  1372. OBJECT_COUNT = 10
  1373. @classmethod
  1374. def setUpTestData(cls):
  1375. site = Site.objects.create(name='Site 1', slug='site-1')
  1376. # Devices, for CustomFieldsMixin.custom_fields
  1377. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  1378. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  1379. device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  1380. custom_field = CustomField.objects.create(name='cf1', type=CustomFieldTypeChoices.TYPE_TEXT)
  1381. custom_field.object_types.set([ObjectType.objects.get_for_model(Device)])
  1382. Device.objects.bulk_create([
  1383. Device(
  1384. name=f'Device {i}',
  1385. device_type=device_type,
  1386. role=device_role,
  1387. site=site,
  1388. custom_field_data={'cf1': f'value {i}'},
  1389. )
  1390. for i in range(cls.OBJECT_COUNT)
  1391. ])
  1392. # Rack reservations, for RackReservationType.unit_count. Reservations within a rack may not claim
  1393. # overlapping units, so each is allocated a distinct run of them. The length of each run varies so
  1394. # that unit_count is asserted per object rather than against a single expected value.
  1395. rack = Rack.objects.create(name='Rack 1', site=site)
  1396. user = User.objects.create(username='Reservation user')
  1397. reservations, next_unit = [], 1
  1398. for i in range(cls.OBJECT_COUNT):
  1399. unit_count = i % 3 + 1
  1400. reservations.append(RackReservation(
  1401. rack=rack,
  1402. units=list(range(next_unit, next_unit + unit_count)),
  1403. user=user,
  1404. description=f'Reservation {i}',
  1405. ))
  1406. next_unit += unit_count
  1407. cls.expected_unit_counts = {
  1408. reservation.pk: len(reservation.units)
  1409. for reservation in RackReservation.objects.bulk_create(reservations)
  1410. }
  1411. # IPAM objects, for the `family` field of each type which exposes one
  1412. IPAddress.objects.bulk_create([
  1413. IPAddress(address=f'10.0.0.{i + 1}/24') for i in range(cls.OBJECT_COUNT)
  1414. ])
  1415. Prefix.objects.bulk_create([
  1416. Prefix(prefix=f'10.{i}.0.0/16') for i in range(cls.OBJECT_COUNT)
  1417. ])
  1418. rir = RIR.objects.create(name='RIR 1', slug='rir-1')
  1419. Aggregate.objects.bulk_create([
  1420. Aggregate(prefix=f'{i + 20}.0.0.0/8', rir=rir) for i in range(cls.OBJECT_COUNT)
  1421. ])
  1422. def _execute(self, query):
  1423. url = reverse('graphql')
  1424. with CaptureQueriesContext(connection) as context:
  1425. response = self.client.post(url, data={'query': query}, format='json', **self.header)
  1426. self.assertHttpStatus(response, status.HTTP_200_OK)
  1427. data = json.loads(response.content)
  1428. self.assertNotIn('errors', data)
  1429. return data['data'], context.captured_queries
  1430. def assertNoDeferredColumnReloads(self, query_template, list_field, table, validate):
  1431. """
  1432. Execute `query_template` (which must accept a `limit` interpolation) for a single object and for
  1433. OBJECT_COUNT objects, asserting that no row of `table` is re-fetched by primary key and that the
  1434. table is read exactly once per request regardless of the number of objects returned. `validate`
  1435. is called with the returned objects.
  1436. Assert on the number of reads of `table` rather than on the total query count: the capture window
  1437. also holds request processing queries which may be repeated when a cache key shared by the other
  1438. parallel test workers is evicted mid-test, which would otherwise fail the test spuriously.
  1439. """
  1440. for limit in (1, self.OBJECT_COUNT):
  1441. data, queries = self._execute(query_template % {'limit': limit})
  1442. objects = data[list_field]
  1443. self.assertEqual(len(objects), limit)
  1444. validate(objects)
  1445. reloads = [q['sql'] for q in queries if f'FROM "{table}" WHERE "{table}"."id" = ' in q['sql']]
  1446. self.assertEqual(
  1447. reloads, [], msg=f'{len(reloads)} deferred-column reload(s) for {limit} object(s): {reloads[:1]}'
  1448. )
  1449. reads = [q['sql'] for q in queries if f'FROM "{table}"' in q['sql']]
  1450. self.assertEqual(
  1451. len(reads), 1,
  1452. msg=f'{table} must be read once for {limit} object(s), got {len(reads)} read(s): {reads}'
  1453. )
  1454. def test_custom_fields(self):
  1455. """
  1456. Regression test for #22813: CustomFieldsMixin.custom_fields must not defer `custom_field_data`.
  1457. """
  1458. self.add_permissions('dcim.view_device')
  1459. query = """
  1460. {
  1461. device_list(pagination: {limit: %(limit)s}) {
  1462. id
  1463. custom_fields
  1464. }
  1465. }
  1466. """
  1467. expected_values = {f'value {i}' for i in range(self.OBJECT_COUNT)}
  1468. def validate(devices):
  1469. for device in devices:
  1470. self.assertEqual(list(device['custom_fields']), ['cf1'])
  1471. self.assertIn(device['custom_fields']['cf1'], expected_values)
  1472. self.assertNoDeferredColumnReloads(query, 'device_list', 'dcim_device', validate)
  1473. def test_rack_reservation_unit_count(self):
  1474. """
  1475. Regression test for #22822: RackReservationType.unit_count must not defer `units`.
  1476. """
  1477. self.add_permissions('dcim.view_rackreservation')
  1478. query = """
  1479. {
  1480. rack_reservation_list(pagination: {limit: %(limit)s}) {
  1481. id
  1482. unit_count
  1483. }
  1484. }
  1485. """
  1486. def validate(reservations):
  1487. for reservation in reservations:
  1488. self.assertEqual(
  1489. reservation['unit_count'], self.expected_unit_counts[int(reservation['id'])]
  1490. )
  1491. self.assertNoDeferredColumnReloads(query, 'rack_reservation_list', 'dcim_rackreservation', validate)
  1492. def test_ip_address_family(self):
  1493. """
  1494. Regression test for #22823: IPAddressType.family must not defer `address`.
  1495. """
  1496. self.add_permissions('ipam.view_ipaddress')
  1497. query = """
  1498. {
  1499. ip_address_list(pagination: {limit: %(limit)s}) {
  1500. id
  1501. family { value label }
  1502. }
  1503. }
  1504. """
  1505. self.assertNoDeferredColumnReloads(
  1506. query, 'ip_address_list', 'ipam_ipaddress', self._validate_ipv4_family
  1507. )
  1508. def test_prefix_family(self):
  1509. """
  1510. Regression test for #22823: PrefixType.family must not defer `prefix`.
  1511. """
  1512. self.add_permissions('ipam.view_prefix')
  1513. query = """
  1514. {
  1515. prefix_list(pagination: {limit: %(limit)s}) {
  1516. id
  1517. family { value label }
  1518. }
  1519. }
  1520. """
  1521. self.assertNoDeferredColumnReloads(query, 'prefix_list', 'ipam_prefix', self._validate_ipv4_family)
  1522. def test_aggregate_family(self):
  1523. """
  1524. Regression test for #22823: AggregateType.family must not defer `prefix`.
  1525. """
  1526. self.add_permissions('ipam.view_aggregate')
  1527. query = """
  1528. {
  1529. aggregate_list(pagination: {limit: %(limit)s}) {
  1530. id
  1531. family { value label }
  1532. }
  1533. }
  1534. """
  1535. self.assertNoDeferredColumnReloads(query, 'aggregate_list', 'ipam_aggregate', self._validate_ipv4_family)
  1536. def _validate_ipv4_family(self, objects):
  1537. for obj in objects:
  1538. self.assertEqual(obj['family'], {'value': 4, 'label': 'IPv4'})
  1539. class GraphQLSchemaCoverageTestCase(APIViewTestCases.GraphQLSchemaCoverageTestCase):
  1540. pass
  1541. class JSONPathValidationTestCase(TestCase):
  1542. """Unit tests for _validate_json_path (VM-323 security fix)."""
  1543. def setUp(self):
  1544. from netbox.graphql.filter_lookups import _validate_json_path
  1545. self.validate = _validate_json_path
  1546. # --- Valid paths ---
  1547. def test_single_key(self):
  1548. self.assertEqual(self.validate('key'), 'key')
  1549. def test_nested_key(self):
  1550. self.assertEqual(self.validate('parent__child'), 'parent__child')
  1551. def test_deeply_nested(self):
  1552. self.assertEqual(self.validate('a__b__c'), 'a__b__c')
  1553. def test_key_with_underscores(self):
  1554. self.assertEqual(self.validate('my_key'), 'my_key')
  1555. def test_key_with_hyphens(self):
  1556. self.assertEqual(self.validate('my-key'), 'my-key')
  1557. def test_numeric_array_index(self):
  1558. self.assertEqual(self.validate('items__0'), 'items__0')
  1559. def test_alphanumeric_segment(self):
  1560. self.assertEqual(self.validate('key123'), 'key123')
  1561. def test_key_with_leading_underscore(self):
  1562. # JSON keys may start with underscore (e.g. _foo)
  1563. self.assertEqual(self.validate('_key'), '_key')
  1564. def test_orm_operator_name_as_key(self):
  1565. # 'date', 'regex' etc. are valid JSON key names; the path validator
  1566. # must not block them. The ORM injection risk is neutralised by the
  1567. # trailing __ that JSONFilter always appends before process_filters.
  1568. self.assertEqual(self.validate('date'), 'date')
  1569. self.assertEqual(self.validate('key__regex'), 'key__regex')
  1570. self.assertEqual(self.validate('key__exact'), 'key__exact')
  1571. # --- Invalid paths ---
  1572. def test_rejects_empty_string(self):
  1573. with self.assertRaises(ValueError):
  1574. self.validate('')
  1575. def test_rejects_all_underscores(self):
  1576. # '___' splits into segments ['', '', ''] via '__' — empty segments rejected
  1577. with self.assertRaises(ValueError):
  1578. self.validate('___')
  1579. def test_accepts_trailing_single_underscore(self):
  1580. # A single trailing underscore is a valid JSON key character
  1581. self.assertEqual(self.validate('key_'), 'key_')
  1582. def test_rejects_trailing_double_underscore(self):
  1583. with self.assertRaises(ValueError):
  1584. self.validate('key__')
  1585. def test_rejects_leading_double_underscore(self):
  1586. with self.assertRaises(ValueError):
  1587. self.validate('__key')
  1588. def test_rejects_consecutive_double_underscores(self):
  1589. with self.assertRaises(ValueError):
  1590. self.validate('key1____key2')
  1591. def test_rejects_segment_starting_with_special_char(self):
  1592. with self.assertRaises(ValueError):
  1593. self.validate('$secret')
  1594. def test_rejects_path_with_spaces(self):
  1595. with self.assertRaises(ValueError):
  1596. self.validate('key one')
  1597. def test_rejects_path_with_dot(self):
  1598. with self.assertRaises(ValueError):
  1599. self.validate('key.subkey')
  1600. class JSONStringLookupTestCase(TestCase):
  1601. """Verify JSONStringLookup exposes the expected set of string operators."""
  1602. def test_string_operators_present(self):
  1603. from netbox.graphql.filter_lookups import JSONStringLookup
  1604. field_names = {f.name for f in JSONStringLookup.__strawberry_definition__.fields}
  1605. for expected in ('exact', 'i_exact', 'contains', 'i_contains',
  1606. 'starts_with', 'i_starts_with', 'ends_with', 'i_ends_with',
  1607. 'in_', 'isnull', 'regex', 'i_regex'):
  1608. self.assertIn(expected, field_names, f"{expected!r} must be present on JSONStringLookup")
  1609. class SpliceExtensionBasesTestCase(TestCase):
  1610. """Verify splice_extension_bases() composition and the strictly additive extension contract."""
  1611. @staticmethod
  1612. def _make_core():
  1613. @strawberry.type
  1614. class CoreBase:
  1615. description: str # inherited (non-protected) field
  1616. @classmethod
  1617. def get_queryset(cls, queryset, info, **kwargs):
  1618. return queryset
  1619. @strawberry.type
  1620. class CoreType(CoreBase):
  1621. name: str # defined directly in the core type's own body
  1622. return CoreType
  1623. def test_no_extensions_is_passthrough(self):
  1624. CoreType = self._make_core()
  1625. self.assertIs(splice_extension_bases(CoreType, []), CoreType)
  1626. self.assertIs(splice_extension_bases(CoreType, None), CoreType)
  1627. def test_extension_spliced_into_bases(self):
  1628. @strawberry.type
  1629. class Extension:
  1630. models = ['dcim.device']
  1631. extra: str
  1632. CoreType = self._make_core()
  1633. result = splice_extension_bases(CoreType, [Extension])
  1634. self.assertIsNot(result, CoreType)
  1635. self.assertEqual(result.__name__, CoreType.__name__)
  1636. self.assertIn(Extension, result.__mro__)
  1637. self.assertTrue(issubclass(result, CoreType))
  1638. # The extension is appended *after* the core bases in the MRO (additive, core wins collisions)
  1639. self.assertGreater(result.__mro__.index(Extension), result.__mro__.index(CoreType.__bases__[0]))
  1640. def test_extension_cannot_shadow_core_own_field(self):
  1641. @strawberry.type
  1642. class Extension:
  1643. models = ['dcim.device']
  1644. name: str
  1645. with self.assertRaises(ImproperlyConfigured):
  1646. splice_extension_bases(self._make_core(), [Extension])
  1647. def test_extension_cannot_shadow_inherited_field(self):
  1648. @strawberry.type
  1649. class Extension:
  1650. models = ['dcim.device']
  1651. description: str
  1652. with self.assertRaises(ImproperlyConfigured):
  1653. splice_extension_bases(self._make_core(), [Extension])
  1654. def test_extension_cannot_shadow_core_hook(self):
  1655. @strawberry.type
  1656. class Extension:
  1657. models = ['dcim.device']
  1658. @classmethod
  1659. def get_queryset(cls, queryset, info, **kwargs):
  1660. return queryset
  1661. with self.assertRaises(ImproperlyConfigured):
  1662. splice_extension_bases(self._make_core(), [Extension])
  1663. def test_two_extensions_cannot_declare_same_new_field(self):
  1664. @strawberry.type
  1665. class ExtensionA:
  1666. models = ['dcim.device']
  1667. widgets: str
  1668. @strawberry.type
  1669. class ExtensionB:
  1670. models = ['dcim.device']
  1671. widgets: str
  1672. with self.assertRaises(ImproperlyConfigured):
  1673. splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
  1674. def test_extension_cannot_interleave_ahead_of_core_ancestor(self):
  1675. """Shared ancestry is rejected because C3 could resolve an inherited hook to the extension side."""
  1676. class CoreBase:
  1677. @classmethod
  1678. def get_queryset(cls, queryset, info, **kwargs):
  1679. return 'core'
  1680. class CoreType(CoreBase):
  1681. pass
  1682. class ExtensionBase(CoreBase):
  1683. @classmethod
  1684. def get_queryset(cls, queryset, info, **kwargs):
  1685. return 'plugin'
  1686. @strawberry.type
  1687. class Extension(ExtensionBase):
  1688. models = ['dcim.site']
  1689. with self.assertRaises(ImproperlyConfigured):
  1690. splice_extension_bases(CoreType, [Extension])
  1691. def test_extension_cannot_inherit_core_hook_name(self):
  1692. """An inherited plain method colliding with a core name fails instead of being silently ignored."""
  1693. class PluginBase:
  1694. @classmethod
  1695. def get_queryset(cls, queryset, info, **kwargs):
  1696. return queryset
  1697. @strawberry.type
  1698. class Extension(PluginBase):
  1699. models = ['dcim.site']
  1700. with self.assertRaises(ImproperlyConfigured):
  1701. splice_extension_bases(self._make_core(), [Extension])
  1702. def test_extensions_may_share_a_helper_base(self):
  1703. """Two extensions inheriting the same helper method compose without a collision error."""
  1704. class Helper:
  1705. @classmethod
  1706. def _helper(cls):
  1707. return 'x'
  1708. @strawberry.type
  1709. class ExtensionA(Helper):
  1710. models = ['dcim.site']
  1711. field_a: str
  1712. @strawberry.type
  1713. class ExtensionB(Helper):
  1714. models = ['dcim.site']
  1715. field_b: str
  1716. composed = splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
  1717. self.assertTrue(issubclass(composed, ExtensionA))
  1718. def test_extension_may_inherit_annotation_only_name_from_plain_base(self):
  1719. """An annotation on a plain base is neither a Strawberry field nor an attribute, so it shadows nothing."""
  1720. class Helper:
  1721. name: str
  1722. @strawberry.type
  1723. class Extension(Helper):
  1724. models = ['dcim.site']
  1725. plugin_field: str
  1726. composed = splice_extension_bases(self._make_core(), [Extension])
  1727. self.assertTrue(issubclass(composed, Extension))
  1728. def test_extensions_may_share_a_plain_annotated_helper_base(self):
  1729. """Two extensions inheriting one annotation-only helper base do not collide with each other."""
  1730. class Helper:
  1731. internal_state: str
  1732. @strawberry.type
  1733. class ExtensionA(Helper):
  1734. models = ['dcim.site']
  1735. field_a: str
  1736. @strawberry.type
  1737. class ExtensionB(Helper):
  1738. models = ['dcim.site']
  1739. field_b: str
  1740. composed = splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
  1741. self.assertTrue(issubclass(composed, ExtensionA))
  1742. self.assertTrue(issubclass(composed, ExtensionB))
  1743. def test_two_extensions_cannot_bind_the_same_helper_function(self):
  1744. """Sharing one base is legal, but two declarations of a name are not, even for the same function object."""
  1745. def shared_helper(self):
  1746. return 'x'
  1747. @strawberry.type
  1748. class ExtensionA:
  1749. models = ['dcim.site']
  1750. field_a: str
  1751. helper = shared_helper
  1752. @strawberry.type
  1753. class ExtensionB:
  1754. models = ['dcim.site']
  1755. field_b: str
  1756. helper = shared_helper
  1757. with self.assertRaises(ImproperlyConfigured):
  1758. splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
  1759. def test_extension_cannot_inherit_field_named_for_core_hook(self):
  1760. """A field inherited from a decorated base still collides with a core name that is only a plain hook."""
  1761. @strawberry.type
  1762. class ExtensionBase:
  1763. get_queryset: str
  1764. @strawberry.type
  1765. class Extension(ExtensionBase):
  1766. models = ['dcim.site']
  1767. with self.assertRaises(ImproperlyConfigured):
  1768. splice_extension_bases(self._make_core(), [Extension])
  1769. def test_extensions_cannot_collide_via_inherited_field_and_plain_method(self):
  1770. """One extension's inherited Strawberry field collides with another's plain method of the same name."""
  1771. @strawberry.type
  1772. class BaseWithField:
  1773. shared: str
  1774. @strawberry.type
  1775. class ExtensionA(BaseWithField):
  1776. models = ['dcim.site']
  1777. @strawberry.type
  1778. class ExtensionB:
  1779. models = ['dcim.site']
  1780. def shared(self):
  1781. return 'x'
  1782. with self.assertRaises(ImproperlyConfigured):
  1783. splice_extension_bases(self._make_core(), [ExtensionA, ExtensionB])
  1784. def test_conflicting_extension_bases_raise_clear_error(self):
  1785. class A:
  1786. pass
  1787. class B:
  1788. pass
  1789. class Core:
  1790. name = 'core'
  1791. @strawberry.type
  1792. class Ext1(A, B):
  1793. models = ['dcim.device']
  1794. field_1: str
  1795. @strawberry.type
  1796. class Ext2(B, A):
  1797. models = ['dcim.device']
  1798. field_2: str
  1799. with self.assertRaises(ImproperlyConfigured) as ctx:
  1800. splice_extension_bases(Core, [Ext1, Ext2])
  1801. self.assertIn('Failed to compose', str(ctx.exception))
  1802. def test_zero_arg_super_and_own_fields_survive_composition(self):
  1803. """Composition preserves the core class's own annotated fields and its zero-argument super() calls."""
  1804. @strawberry.type
  1805. class Extension:
  1806. models = ['dcim.rackreservation']
  1807. @strawberry_django.field
  1808. def extension_field(self) -> str:
  1809. return 'x'
  1810. @strawberry.type
  1811. class CoreBase:
  1812. @classmethod
  1813. def get_queryset(cls, queryset, info, **kwargs):
  1814. return queryset
  1815. class RackReservationProto(CoreBase):
  1816. units: list[int]
  1817. description: str
  1818. @classmethod
  1819. def get_queryset(cls, queryset, info, **kwargs):
  1820. return super().get_queryset(queryset, info, **kwargs)
  1821. core_type = strawberry_django.type(RackReservation, fields='__all__')(RackReservationProto)
  1822. composed = splice_extension_bases(core_type, [Extension])
  1823. self.assertTrue(issubclass(composed, RackReservationProto))
  1824. result = strawberry_django.type(RackReservation, fields='__all__')(composed)
  1825. names = {f.name for f in result.__strawberry_definition__.fields}
  1826. self.assertIn('units', names)
  1827. self.assertIn('description', names)
  1828. self.assertIn('extension_field', names)
  1829. self.assertIs(result.get_queryset('QS', None), 'QS')
  1830. def test_extension_cannot_replace_generated_model_field(self):
  1831. @strawberry.type
  1832. class CoreBase:
  1833. pass
  1834. class CoreType(CoreBase):
  1835. pass
  1836. @strawberry.type
  1837. class Extension:
  1838. models = ['dcim.site']
  1839. name: str
  1840. core_type = strawberry_django.type(Site, fields='__all__')(CoreType)
  1841. with self.assertRaises(ImproperlyConfigured):
  1842. validate_extension_final_names(core_type, [Extension])
  1843. def test_extension_cannot_alias_onto_existing_name(self):
  1844. @strawberry.type
  1845. class Extension:
  1846. models = ['dcim.site']
  1847. @strawberry.field(name='description')
  1848. def plugin_description(self) -> str:
  1849. return 'x'
  1850. core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
  1851. with self.assertRaises(ImproperlyConfigured):
  1852. validate_extension_final_names(core_type, [Extension])
  1853. def test_extension_cannot_alias_away_generated_model_field(self):
  1854. """An aliased extension field still claims its python name, which would suppress the generated field."""
  1855. @strawberry.type
  1856. class Extension:
  1857. models = ['dcim.site']
  1858. slug: str = strawberry.field(name='plugin_slug')
  1859. core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
  1860. with self.assertRaises(ImproperlyConfigured):
  1861. validate_extension_final_names(core_type, [Extension])
  1862. def test_filter_extension_cannot_alias_away_core_filter_field(self):
  1863. """The python-name check applies to the filter path as well."""
  1864. class CoreFilter:
  1865. name: str | None = strawberry_django.filter_field()
  1866. @strawberry.type
  1867. class Extension:
  1868. models = ['dcim.site']
  1869. name: str | None = strawberry_django.filter_field(name='plugin_name')
  1870. core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
  1871. with self.assertRaises(ImproperlyConfigured):
  1872. validate_extension_final_names(core_filter, [Extension])
  1873. def test_filter_extension_cannot_alias_away_logical_field(self):
  1874. """A generated logical filter field is protected from python-name capture through an alias."""
  1875. class CoreFilter:
  1876. pass
  1877. @strawberry.type
  1878. class Extension:
  1879. models = ['dcim.site']
  1880. AND: str | None = strawberry_django.filter_field(name='plugin_and')
  1881. core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
  1882. with self.assertRaises(ImproperlyConfigured):
  1883. validate_extension_final_names(core_filter, [Extension])
  1884. def test_extension_cannot_alias_onto_core_filter_alias(self):
  1885. class CoreFilter:
  1886. _custom: str | None = strawberry_django.filter_field(name='custom')
  1887. @strawberry.type
  1888. class Extension:
  1889. models = ['dcim.site']
  1890. custom: str | None = strawberry_django.filter_field()
  1891. core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
  1892. with self.assertRaises(ImproperlyConfigured):
  1893. validate_extension_final_names(core_filter, [Extension])
  1894. def test_extension_cannot_redefine_filter_logical_fields(self):
  1895. class CoreFilter:
  1896. pass
  1897. @strawberry.type
  1898. class Extension:
  1899. models = ['dcim.site']
  1900. AND: str | None = None
  1901. core_filter = strawberry_django.filter_type(Site, lookups=True)(CoreFilter)
  1902. with self.assertRaises(ImproperlyConfigured):
  1903. validate_extension_final_names(core_filter, [Extension])
  1904. def test_two_extensions_cannot_inherit_same_python_name(self):
  1905. """Inherited fields collide by python name even when their GraphQL aliases differ."""
  1906. @strawberry.type
  1907. class ExtensionBaseA:
  1908. value: str = strawberry.field(name='plugin_a_value')
  1909. @strawberry.type
  1910. class ExtensionA(ExtensionBaseA):
  1911. models = ['dcim.site']
  1912. @strawberry.type
  1913. class ExtensionBaseB:
  1914. value: str = strawberry.field(name='plugin_b_value')
  1915. @strawberry.type
  1916. class ExtensionB(ExtensionBaseB):
  1917. models = ['dcim.site']
  1918. core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
  1919. with self.assertRaises(ImproperlyConfigured):
  1920. validate_extension_final_names(core_type, [ExtensionA, ExtensionB])
  1921. def test_extension_composition_preserves_core_is_type_of(self):
  1922. """A core-defined is_type_of survives composition instead of being shadowed by the injected default."""
  1923. @strawberry.type
  1924. class Extension:
  1925. models = ['dcim.rackreservation']
  1926. @strawberry_django.field
  1927. def marker_field(self) -> str:
  1928. return 'x'
  1929. @strawberry.type
  1930. class CoreBase:
  1931. pass
  1932. class Proto(CoreBase):
  1933. @classmethod
  1934. def is_type_of(cls, obj, info):
  1935. return True
  1936. custom = vars(Proto)['is_type_of']
  1937. with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.rackreservation': [Extension]}):
  1938. final = register_model_graphql_type(
  1939. RackReservation, strawberry_django.type, 'graphql_type_extensions', fields='__all__'
  1940. )(Proto)
  1941. self.assertIs(vars(final).get('is_type_of'), custom)
  1942. def test_defaulted_extension_field_composes_onto_required_core_fields(self):
  1943. """Strawberry keyword-only fields let a defaulted extension field precede required core fields."""
  1944. @strawberry.type
  1945. class Extension:
  1946. models = ['dcim.rackreservation']
  1947. plugin_note: str = strawberry.field(default='note')
  1948. @strawberry.type
  1949. class Proto:
  1950. pass
  1951. with mock.patch.dict(registry['plugins']['graphql_type_extensions'], {'dcim.rackreservation': [Extension]}):
  1952. final = register_model_graphql_type(
  1953. RackReservation, strawberry_django.type, 'graphql_type_extensions', fields='__all__'
  1954. )(Proto)
  1955. names = {field.python_name for field in final.__strawberry_definition__.fields}
  1956. self.assertIn('plugin_note', names)
  1957. self.assertIn('units', names)
  1958. def test_two_extensions_cannot_alias_same_final_name(self):
  1959. @strawberry.type
  1960. class ExtensionA:
  1961. models = ['dcim.site']
  1962. widgets: str
  1963. @strawberry.type
  1964. class ExtensionB:
  1965. models = ['dcim.site']
  1966. @strawberry.field(name='widgets')
  1967. def other_name(self) -> str:
  1968. return 'x'
  1969. core_type = strawberry_django.type(Site, fields='__all__')(self._make_core())
  1970. with self.assertRaises(ImproperlyConfigured):
  1971. validate_extension_final_names(core_type, [ExtensionA, ExtensionB])