api.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. import copy
  2. import inspect
  3. import json
  4. import strawberry_django
  5. from django.conf import settings
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.test import override_settings
  8. from django.urls import reverse
  9. from rest_framework import status
  10. from rest_framework.test import APIClient
  11. from strawberry.types.base import StrawberryList, StrawberryOptional
  12. from strawberry.types.lazy_type import LazyType
  13. from strawberry.types.union import StrawberryUnion
  14. from core.choices import ObjectChangeActionChoices
  15. from core.models import ObjectChange, ObjectType
  16. from ipam.graphql.types import IPAddressFamilyType
  17. from netbox.models.features import ChangeLoggingMixin
  18. from users.constants import TOKEN_PREFIX
  19. from users.models import ObjectPermission, Token, User
  20. from utilities.api import get_graphql_type_for_model
  21. from .base import ModelTestCase
  22. from .utils import disable_logging, disable_warnings, get_random_string
  23. __all__ = (
  24. 'APITestCase',
  25. 'APIViewTestCases',
  26. )
  27. #
  28. # REST/GraphQL API Tests
  29. #
  30. class APITestCase(ModelTestCase):
  31. """
  32. Base test case for API requests.
  33. client_class: Test client class
  34. view_namespace: Namespace for API views. If None, the model's app_label will be used.
  35. """
  36. client_class = APIClient
  37. view_namespace = None
  38. def setUp(self):
  39. """
  40. Create a user and token for API calls.
  41. """
  42. # Create the test user and assign permissions
  43. self.user = User.objects.create_user(username='testuser')
  44. self.add_permissions(*self.user_permissions)
  45. self.token = Token.objects.create(user=self.user)
  46. self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
  47. def _get_view_namespace(self):
  48. return f'{self.view_namespace or self.model._meta.app_label}-api'
  49. def _get_detail_url(self, instance):
  50. viewname = f'{self._get_view_namespace()}:{instance._meta.model_name}-detail'
  51. return reverse(viewname, kwargs={'pk': instance.pk})
  52. def _get_list_url(self):
  53. viewname = f'{self._get_view_namespace()}:{self.model._meta.model_name}-list'
  54. return reverse(viewname)
  55. class APIViewTestCases:
  56. class GetObjectViewTestCase(APITestCase):
  57. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
  58. def test_get_object_anonymous(self):
  59. """
  60. GET a single object as an unauthenticated user.
  61. """
  62. url = self._get_detail_url(self._get_queryset().first())
  63. if (self.model._meta.app_label, self.model._meta.model_name) in settings.EXEMPT_EXCLUDE_MODELS:
  64. # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
  65. with disable_warnings('django.request'):
  66. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  67. else:
  68. response = self.client.get(url, **self.header)
  69. self.assertHttpStatus(response, status.HTTP_200_OK)
  70. def test_get_object_without_permission(self):
  71. """
  72. GET a single object as an authenticated user without the required permission.
  73. """
  74. url = self._get_detail_url(self._get_queryset().first())
  75. # Try GET without permission
  76. with disable_warnings('django.request'):
  77. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  78. def test_get_object(self):
  79. """
  80. GET a single object as an authenticated user with permission to view the object.
  81. """
  82. self.assertGreaterEqual(self._get_queryset().count(), 2,
  83. f"Test requires the creation of at least two {self.model} instances")
  84. instance1, instance2 = self._get_queryset()[:2]
  85. # Add object-level permission
  86. obj_perm = ObjectPermission(
  87. name='Test permission',
  88. constraints={'pk': instance1.pk},
  89. actions=['view']
  90. )
  91. obj_perm.save()
  92. obj_perm.users.add(self.user)
  93. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  94. # Try GET to permitted object
  95. url = self._get_detail_url(instance1)
  96. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
  97. # Try GET to non-permitted object
  98. url = self._get_detail_url(instance2)
  99. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
  100. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
  101. def test_options_object(self):
  102. """
  103. Make an OPTIONS request for a single object.
  104. """
  105. url = self._get_detail_url(self._get_queryset().first())
  106. response = self.client.options(url, **self.header)
  107. self.assertHttpStatus(response, status.HTTP_200_OK)
  108. class ListObjectsViewTestCase(APITestCase):
  109. brief_fields = []
  110. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
  111. def test_list_objects_anonymous(self):
  112. """
  113. GET a list of objects as an unauthenticated user.
  114. """
  115. url = self._get_list_url()
  116. if (self.model._meta.app_label, self.model._meta.model_name) in settings.EXEMPT_EXCLUDE_MODELS:
  117. # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
  118. with disable_warnings('django.request'):
  119. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  120. else:
  121. response = self.client.get(url, **self.header)
  122. self.assertHttpStatus(response, status.HTTP_200_OK)
  123. self.assertEqual(len(response.data['results']), self._get_queryset().count())
  124. def test_list_objects_brief(self):
  125. """
  126. GET a list of objects using the "brief" parameter.
  127. """
  128. self.add_permissions(f'{self.model._meta.app_label}.view_{self.model._meta.model_name}')
  129. url = f'{self._get_list_url()}?brief=1'
  130. response = self.client.get(url, **self.header)
  131. self.assertHttpStatus(response, status.HTTP_200_OK)
  132. self.assertEqual(len(response.data['results']), self._get_queryset().count())
  133. self.assertEqual(sorted(response.data['results'][0]), self.brief_fields)
  134. def test_list_objects_without_permission(self):
  135. """
  136. GET a list of objects as an authenticated user without the required permission.
  137. """
  138. url = self._get_list_url()
  139. # Try GET without permission
  140. with disable_warnings('django.request'):
  141. self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN)
  142. def test_list_objects(self):
  143. """
  144. GET a list of objects as an authenticated user with permission to view the objects.
  145. """
  146. self.assertGreaterEqual(self._get_queryset().count(), 3,
  147. f"Test requires the creation of at least three {self.model} instances")
  148. instance1, instance2 = self._get_queryset()[:2]
  149. # Add object-level permission
  150. obj_perm = ObjectPermission(
  151. name='Test permission',
  152. constraints={'pk__in': [instance1.pk, instance2.pk]},
  153. actions=['view']
  154. )
  155. obj_perm.save()
  156. obj_perm.users.add(self.user)
  157. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  158. # Try GET to permitted objects
  159. response = self.client.get(self._get_list_url(), **self.header)
  160. self.assertHttpStatus(response, status.HTTP_200_OK)
  161. self.assertEqual(len(response.data['results']), 2)
  162. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
  163. def test_options_objects(self):
  164. """
  165. Make an OPTIONS request for a list endpoint.
  166. """
  167. response = self.client.options(self._get_list_url(), **self.header)
  168. self.assertHttpStatus(response, status.HTTP_200_OK)
  169. class CreateObjectViewTestCase(APITestCase):
  170. create_data = []
  171. validation_excluded_fields = []
  172. def test_create_object_without_permission(self):
  173. """
  174. POST a single object without permission.
  175. """
  176. url = self._get_list_url()
  177. # Try POST without permission
  178. with disable_warnings('django.request'):
  179. response = self.client.post(url, self.create_data[0], format='json', **self.header)
  180. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  181. def test_create_object(self):
  182. """
  183. POST a single object with permission.
  184. """
  185. # Add object-level permission
  186. obj_perm = ObjectPermission(
  187. name='Test permission',
  188. actions=['add']
  189. )
  190. obj_perm.save()
  191. obj_perm.users.add(self.user)
  192. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  193. data = copy.deepcopy(self.create_data[0])
  194. # If supported, add a changelog message
  195. if issubclass(self.model, ChangeLoggingMixin):
  196. data['changelog_message'] = get_random_string(10)
  197. initial_count = self._get_queryset().count()
  198. response = self.client.post(self._get_list_url(), data, format='json', **self.header)
  199. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  200. self.assertEqual(self._get_queryset().count(), initial_count + 1)
  201. instance = self._get_queryset().get(pk=response.data['id'])
  202. self.assertInstanceEqual(
  203. instance,
  204. self.create_data[0],
  205. exclude=self.validation_excluded_fields,
  206. api=True
  207. )
  208. # Verify ObjectChange creation
  209. if issubclass(self.model, ChangeLoggingMixin):
  210. objectchange = ObjectChange.objects.get(
  211. changed_object_type=ContentType.objects.get_for_model(instance),
  212. changed_object_id=instance.pk,
  213. action=ObjectChangeActionChoices.ACTION_CREATE,
  214. )
  215. self.assertEqual(objectchange.message, data['changelog_message'])
  216. self.assertObjectChangeData(objectchange, prechange_is_none=True, postchange_is_none=False)
  217. def test_bulk_create_objects(self):
  218. """
  219. POST a set of objects in a single request.
  220. """
  221. # Add object-level permission
  222. obj_perm = ObjectPermission(
  223. name='Test permission',
  224. actions=['add']
  225. )
  226. obj_perm.save()
  227. obj_perm.users.add(self.user)
  228. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  229. # If supported, add a changelog message
  230. changelog_message = get_random_string(10)
  231. if issubclass(self.model, ChangeLoggingMixin):
  232. for obj_data in self.create_data:
  233. obj_data['changelog_message'] = changelog_message
  234. initial_count = self._get_queryset().count()
  235. response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header)
  236. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  237. self.assertEqual(len(response.data), len(self.create_data))
  238. self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
  239. for i, obj in enumerate(response.data):
  240. for field in self.create_data[i]:
  241. if field == 'changelog_message':
  242. # Write-only field
  243. continue
  244. if field not in self.validation_excluded_fields:
  245. self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response")
  246. for i, obj in enumerate(response.data):
  247. self.assertInstanceEqual(
  248. self._get_queryset().get(pk=obj['id']),
  249. self.create_data[i],
  250. exclude=self.validation_excluded_fields,
  251. api=True
  252. )
  253. # Verify ObjectChange creation
  254. if issubclass(self.model, ChangeLoggingMixin):
  255. id_list = [
  256. obj['id'] for obj in response.data
  257. ]
  258. objectchanges = ObjectChange.objects.filter(
  259. changed_object_type=ContentType.objects.get_for_model(self.model),
  260. changed_object_id__in=id_list,
  261. action=ObjectChangeActionChoices.ACTION_CREATE,
  262. )
  263. self.assertEqual(len(objectchanges), len(self.create_data))
  264. for oc in objectchanges:
  265. self.assertEqual(oc.message, changelog_message)
  266. self.assertObjectChangeData(oc, prechange_is_none=True, postchange_is_none=False)
  267. class UpdateObjectViewTestCase(APITestCase):
  268. update_data = {}
  269. bulk_update_data = None
  270. validation_excluded_fields = []
  271. def test_update_object_without_permission(self):
  272. """
  273. PATCH a single object without permission.
  274. """
  275. url = self._get_detail_url(self._get_queryset().first())
  276. update_data = self.update_data or getattr(self, 'create_data')[0]
  277. # Try PATCH without permission
  278. with disable_warnings('django.request'):
  279. response = self.client.patch(url, update_data, format='json', **self.header)
  280. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  281. def test_update_object(self):
  282. """
  283. PATCH a single object identified by its numeric ID.
  284. """
  285. instance = self._get_queryset().first()
  286. url = self._get_detail_url(instance)
  287. update_data = self.update_data or getattr(self, 'create_data')[0]
  288. # Add object-level permission
  289. obj_perm = ObjectPermission(
  290. name='Test permission',
  291. actions=['change']
  292. )
  293. obj_perm.save()
  294. obj_perm.users.add(self.user)
  295. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  296. data = copy.deepcopy(update_data)
  297. # If supported, add a changelog message
  298. if issubclass(self.model, ChangeLoggingMixin):
  299. data['changelog_message'] = get_random_string(10)
  300. response = self.client.patch(url, data, format='json', **self.header)
  301. self.assertHttpStatus(response, status.HTTP_200_OK)
  302. instance.refresh_from_db()
  303. self.assertInstanceEqual(
  304. instance,
  305. data,
  306. exclude=self.validation_excluded_fields,
  307. api=True
  308. )
  309. # Verify ObjectChange creation
  310. if hasattr(self.model, 'to_objectchange'):
  311. objectchange = ObjectChange.objects.get(
  312. changed_object_type=ContentType.objects.get_for_model(instance),
  313. changed_object_id=instance.pk
  314. )
  315. self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
  316. self.assertEqual(objectchange.message, data['changelog_message'])
  317. self.assertObjectChangeData(objectchange, prechange_is_none=False, postchange_is_none=False)
  318. self.assertNotEqual(objectchange.prechange_data, objectchange.postchange_data)
  319. def test_bulk_update_objects(self):
  320. """
  321. PATCH a set of objects in a single request.
  322. """
  323. if self.bulk_update_data is None:
  324. self.skipTest("Bulk update data not set")
  325. # Add object-level permission
  326. obj_perm = ObjectPermission(
  327. name='Test permission',
  328. actions=['change']
  329. )
  330. obj_perm.save()
  331. obj_perm.users.add(self.user)
  332. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  333. id_list = list(self._get_queryset().values_list('id', flat=True)[:3])
  334. self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
  335. data = [
  336. {'id': id, **self.bulk_update_data} for id in id_list
  337. ]
  338. # If supported, add a changelog message
  339. changelog_message = get_random_string(10)
  340. if issubclass(self.model, ChangeLoggingMixin):
  341. for obj_data in data:
  342. obj_data['changelog_message'] = changelog_message
  343. response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
  344. self.assertHttpStatus(response, status.HTTP_200_OK)
  345. for i, obj in enumerate(response.data):
  346. for field in self.bulk_update_data:
  347. if field == 'changelog_data':
  348. # Write-only field
  349. continue
  350. self.assertIn(field, obj, f"Bulk update field '{field}' missing from object {i} in response")
  351. for instance in self._get_queryset().filter(pk__in=id_list):
  352. self.assertInstanceEqual(instance, self.bulk_update_data, api=True)
  353. # Verify ObjectChange creation
  354. if issubclass(self.model, ChangeLoggingMixin):
  355. objectchanges = ObjectChange.objects.filter(
  356. changed_object_type=ContentType.objects.get_for_model(self.model),
  357. changed_object_id__in=id_list
  358. )
  359. self.assertEqual(len(objectchanges), len(data))
  360. for oc in objectchanges:
  361. self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
  362. self.assertEqual(oc.message, changelog_message)
  363. self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=False)
  364. self.assertNotEqual(oc.prechange_data, oc.postchange_data)
  365. class DeleteObjectViewTestCase(APITestCase):
  366. def test_delete_object_without_permission(self):
  367. """
  368. DELETE a single object without permission.
  369. """
  370. url = self._get_detail_url(self._get_queryset().first())
  371. # Try DELETE without permission
  372. with disable_warnings('django.request'):
  373. response = self.client.delete(url, **self.header)
  374. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  375. def test_delete_object(self):
  376. """
  377. DELETE a single object identified by its numeric ID.
  378. """
  379. instance = self._get_queryset().first()
  380. url = self._get_detail_url(instance)
  381. # Add object-level permission
  382. obj_perm = ObjectPermission(
  383. name='Test permission',
  384. actions=['delete']
  385. )
  386. obj_perm.save()
  387. obj_perm.users.add(self.user)
  388. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  389. data = {}
  390. # If supported, add a changelog message
  391. if issubclass(self.model, ChangeLoggingMixin):
  392. data['changelog_message'] = get_random_string(10)
  393. response = self.client.delete(url, data, **self.header)
  394. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  395. self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
  396. # Verify ObjectChange creation
  397. if hasattr(self.model, 'to_objectchange'):
  398. objectchange = ObjectChange.objects.get(
  399. changed_object_type=ContentType.objects.get_for_model(instance),
  400. changed_object_id=instance.pk
  401. )
  402. self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
  403. self.assertEqual(objectchange.message, data['changelog_message'])
  404. self.assertObjectChangeData(objectchange, prechange_is_none=False, postchange_is_none=True)
  405. def test_bulk_delete_objects(self):
  406. """
  407. DELETE a set of objects in a single request.
  408. """
  409. # Add object-level permission
  410. obj_perm = ObjectPermission(
  411. name='Test permission',
  412. actions=['delete']
  413. )
  414. obj_perm.save()
  415. obj_perm.users.add(self.user)
  416. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  417. # Target the three most recently created objects to avoid triggering recursive deletions
  418. # (e.g. with MPTT objects)
  419. id_list = list(self._get_queryset().order_by('-id').values_list('id', flat=True)[:3])
  420. self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
  421. data = [{"id": id} for id in id_list]
  422. # If supported, add a changelog message
  423. changelog_message = get_random_string(10)
  424. if issubclass(self.model, ChangeLoggingMixin):
  425. for obj_data in data:
  426. obj_data['changelog_message'] = changelog_message
  427. initial_count = self._get_queryset().count()
  428. response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
  429. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  430. self.assertEqual(self._get_queryset().count(), initial_count - 3)
  431. # Verify ObjectChange creation
  432. if issubclass(self.model, ChangeLoggingMixin):
  433. objectchanges = ObjectChange.objects.filter(
  434. changed_object_type=ContentType.objects.get_for_model(self.model),
  435. changed_object_id__in=id_list
  436. )
  437. self.assertEqual(len(objectchanges), len(data))
  438. for oc in objectchanges:
  439. self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
  440. self.assertEqual(oc.message, changelog_message)
  441. self.assertObjectChangeData(oc, prechange_is_none=False, postchange_is_none=True)
  442. class GraphQLTestCase(APITestCase):
  443. def _get_graphql_base_name(self):
  444. """
  445. Return graphql_base_name, if set. Otherwise, construct the base name for the query
  446. field from the model's verbose name.
  447. """
  448. base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
  449. return getattr(self, 'graphql_base_name', base_name)
  450. def _build_query_with_filter(self, name, filter_string):
  451. """
  452. Called by either _build_query or _build_filtered_query - construct the actual
  453. query given a name and filter string
  454. """
  455. type_class = get_graphql_type_for_model(self.model)
  456. # Compile list of fields to include
  457. fields_string = ''
  458. file_fields = (
  459. strawberry_django.fields.types.DjangoFileType,
  460. strawberry_django.fields.types.DjangoImageType,
  461. )
  462. for field in type_class.__strawberry_definition__.fields:
  463. if (
  464. field.type in file_fields or (
  465. type(field.type) is StrawberryOptional and field.type.of_type in file_fields
  466. )
  467. ):
  468. # image / file fields nullable or not...
  469. fields_string += f'{field.name} {{ name }}\n'
  470. elif type(field.type) is StrawberryList and type(field.type.of_type) is LazyType:
  471. # List of related objects (queryset)
  472. fields_string += f'{field.name} {{ id }}\n'
  473. elif type(field.type) is StrawberryList and type(field.type.of_type) is StrawberryUnion:
  474. # this would require a fragment query
  475. continue
  476. elif type(field.type) is StrawberryUnion:
  477. # this would require a fragment query
  478. continue
  479. elif type(field.type) is StrawberryOptional and type(field.type.of_type) is StrawberryUnion:
  480. # this would require a fragment query
  481. continue
  482. elif type(field.type) is StrawberryOptional and type(field.type.of_type) is LazyType:
  483. fields_string += f'{field.name} {{ id }}\n'
  484. elif hasattr(field, 'is_relation') and field.is_relation:
  485. # Ignore private fields
  486. if field.name.startswith('_'):
  487. continue
  488. # Note: StrawberryField types do not have is_relation
  489. fields_string += f'{field.name} {{ id }}\n'
  490. elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
  491. fields_string += f'{field.name} {{ value, label }}\n'
  492. else:
  493. fields_string += f'{field.name}\n'
  494. query = f"""
  495. {{
  496. {name}{filter_string} {{
  497. {fields_string}
  498. }}
  499. }}
  500. """
  501. return query
  502. def _build_filtered_query(self, name, **filters):
  503. """
  504. Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
  505. """
  506. # TODO: This should be extended to support AND, OR multi-lookups
  507. if filters:
  508. for field_name, params in filters.items():
  509. lookup = params['lookup']
  510. value = params['value']
  511. if lookup:
  512. query = f'{{{lookup}: "{value}"}}'
  513. filter_string = f'{field_name}: {query}'
  514. else:
  515. filter_string = f'{field_name}: "{value}"'
  516. filter_string = f'(filters: {{{filter_string}}})'
  517. else:
  518. filter_string = ''
  519. return self._build_query_with_filter(name, filter_string)
  520. def _build_query(self, name, **filters):
  521. """
  522. Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
  523. """
  524. if filters:
  525. filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
  526. filter_string = f'({filter_string})'
  527. else:
  528. filter_string = ''
  529. return self._build_query_with_filter(name, filter_string)
  530. @override_settings(LOGIN_REQUIRED=True)
  531. def test_graphql_get_object(self):
  532. url = reverse('graphql')
  533. field_name = self._get_graphql_base_name()
  534. object_id = self._get_queryset().first().pk
  535. query = self._build_query(field_name, id=object_id)
  536. # Non-authenticated requests should fail
  537. header = {
  538. 'HTTP_ACCEPT': 'application/json',
  539. }
  540. with disable_warnings('django.request'):
  541. response = self.client.post(url, data={'query': query}, format="json", **header)
  542. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  543. # Add constrained permission
  544. obj_perm = ObjectPermission(
  545. name='Test permission',
  546. actions=['view'],
  547. constraints={'id': 0} # Impossible constraint
  548. )
  549. obj_perm.save()
  550. obj_perm.users.add(self.user)
  551. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  552. # Request should succeed but return empty result
  553. with disable_logging():
  554. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  555. self.assertHttpStatus(response, status.HTTP_200_OK)
  556. data = json.loads(response.content)
  557. self.assertIn('errors', data)
  558. self.assertIsNone(data['data'])
  559. # Remove permission constraint
  560. obj_perm.constraints = None
  561. obj_perm.save()
  562. # Request should return requested object
  563. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  564. self.assertHttpStatus(response, status.HTTP_200_OK)
  565. data = json.loads(response.content)
  566. self.assertNotIn('errors', data)
  567. self.assertIsNotNone(data['data'])
  568. @override_settings(LOGIN_REQUIRED=True)
  569. def test_graphql_list_objects(self):
  570. url = reverse('graphql')
  571. field_name = f'{self._get_graphql_base_name()}_list'
  572. query = self._build_query(field_name)
  573. # Non-authenticated requests should fail
  574. header = {
  575. 'HTTP_ACCEPT': 'application/json',
  576. }
  577. with disable_warnings('django.request'):
  578. response = self.client.post(url, data={'query': query}, format="json", **header)
  579. self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
  580. # Add constrained permission
  581. obj_perm = ObjectPermission(
  582. name='Test permission',
  583. actions=['view'],
  584. constraints={'id': 0} # Impossible constraint
  585. )
  586. obj_perm.save()
  587. obj_perm.users.add(self.user)
  588. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  589. # Request should succeed but return empty results list
  590. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  591. self.assertHttpStatus(response, status.HTTP_200_OK)
  592. data = json.loads(response.content)
  593. self.assertNotIn('errors', data)
  594. self.assertEqual(len(data['data'][field_name]), 0)
  595. # Remove permission constraint
  596. obj_perm.constraints = None
  597. obj_perm.save()
  598. # Request should return all objects
  599. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  600. self.assertHttpStatus(response, status.HTTP_200_OK)
  601. data = json.loads(response.content)
  602. self.assertNotIn('errors', data)
  603. self.assertEqual(len(data['data'][field_name]), self.model.objects.count())
  604. @override_settings(LOGIN_REQUIRED=True)
  605. def test_graphql_filter_objects(self):
  606. if not hasattr(self, 'graphql_filter'):
  607. return
  608. url = reverse('graphql')
  609. field_name = f'{self._get_graphql_base_name()}_list'
  610. query = self._build_filtered_query(field_name, **self.graphql_filter)
  611. # Add object-level permission
  612. obj_perm = ObjectPermission(
  613. name='Test permission',
  614. actions=['view']
  615. )
  616. obj_perm.save()
  617. obj_perm.users.add(self.user)
  618. obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
  619. response = self.client.post(url, data={'query': query}, format="json", **self.header)
  620. self.assertHttpStatus(response, status.HTTP_200_OK)
  621. data = json.loads(response.content)
  622. self.assertNotIn('errors', data)
  623. self.assertGreater(len(data['data'][field_name]), 0)
  624. class APIViewTestCase(
  625. GetObjectViewTestCase,
  626. ListObjectsViewTestCase,
  627. CreateObjectViewTestCase,
  628. UpdateObjectViewTestCase,
  629. DeleteObjectViewTestCase,
  630. GraphQLTestCase
  631. ):
  632. pass