test_api.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. import hashlib
  2. import uuid
  3. from unittest.mock import patch
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.core.exceptions import NON_FIELD_ERRORS
  6. from django.db import DEFAULT_DB_ALIAS, connection, connections
  7. from django.db.backends.postgresql.psycopg_any import NumericRange
  8. from django.db.models import ProtectedError
  9. from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings, tag
  10. from django.test.utils import CaptureQueriesContext
  11. from django.urls import reverse
  12. from django.utils.timezone import now
  13. from rest_framework.exceptions import ValidationError
  14. from rest_framework.request import Request
  15. from rest_framework.settings import api_settings
  16. from rest_framework.test import APIClient
  17. from core.models import DataFile, DataSource, ObjectType
  18. from dcim.api.serializers import InterfaceSerializer, RackSerializer
  19. from dcim.api.views import DeviceTypeViewSet, SiteViewSet
  20. from dcim.models import Device, Rack, Site
  21. from extras.models import ExportTemplate
  22. from ipam.api.serializers import RouteTargetSerializer, VRFSerializer
  23. from ipam.models import VRF, RouteTarget
  24. from netbox.api.exceptions import QuerySetNotOrdered, SerializerNotFound
  25. from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
  26. from netbox.api.pagination import NetBoxPagination
  27. from netbox.api.serializers import ValidatedModelSerializer, WritableNestedSerializer
  28. from netbox.api.viewsets.mixins import BulkUpdateModelMixin
  29. from users.constants import TOKEN_PREFIX
  30. from users.models import ObjectPermission, Token, User
  31. from utilities.api import get_serializer_for_model
  32. from utilities.testing import APITestCase, create_test_device
  33. from vpn.api.serializers import L2VPNSerializer
  34. class AppTestCase(APITestCase):
  35. def test_http_headers(self):
  36. response = self.client.get(reverse('api-root'), **self.header)
  37. # Check that all custom response headers are present and valid
  38. self.assertEqual(response.status_code, 200)
  39. request_id = response.headers['X-Request-ID']
  40. uuid.UUID(request_id)
  41. def test_root(self):
  42. url = reverse('api-root')
  43. response = self.client.get(f'{url}?format=api', **self.header)
  44. self.assertEqual(response.status_code, 200)
  45. def test_status(self):
  46. url = reverse('api-status')
  47. response = self.client.get(f'{url}?format=api', **self.header)
  48. self.assertEqual(response.status_code, 200)
  49. def test_authentication_check(self):
  50. url = reverse('api-authentication-check')
  51. # Test an unauthenticated request
  52. response = self.client.get(f'{url}')
  53. self.assertEqual(response.status_code, 403)
  54. # Test an authenticated request
  55. response = self.client.get(f'{url}', **self.header)
  56. self.assertEqual(response.status_code, 200)
  57. self.assertEqual(response.data['id'], self.user.pk)
  58. class NonFieldErrorKeyTestCase(APITestCase):
  59. """
  60. REST framework's key for errors which pertain to no particular field is configured to match
  61. Django's, so that the API reports such an error under `__all__` regardless of which layer
  62. rejected the request (see REST_FRAMEWORK['NON_FIELD_ERRORS_KEY'] in settings). Model validation
  63. errors are keyed by Django, having reached the response by way of full_clean(); errors raised by
  64. a serializer or field are keyed by REST framework.
  65. """
  66. def setUp(self):
  67. super().setUp()
  68. self.add_permissions('dcim.add_site', 'dcim.view_site', 'dcim.change_site')
  69. self.url = reverse('dcim-api:site-list')
  70. def test_setting_matches_django(self):
  71. self.assertEqual(api_settings.NON_FIELD_ERRORS_KEY, NON_FIELD_ERRORS)
  72. def test_serializer_error_uses_all_key(self):
  73. """An error from REST framework's own machinery (here, a non-dictionary item)."""
  74. response = self.client.post(self.url, ['not an object'], format='json', **self.header)
  75. self.assertEqual(response.status_code, 400)
  76. self.assertIn(NON_FIELD_ERRORS, response.data['errors'][0]['errors'])
  77. def test_model_validation_error_uses_all_key(self):
  78. """An error from Django's full_clean(), which uses this key of its own accord."""
  79. site = Site.objects.create(name='Site 1', slug='site-1')
  80. # A Location's name must be unique within its Site, enforced by a model constraint
  81. location_url = reverse('dcim-api:location-list')
  82. self.add_permissions('dcim.add_location', 'dcim.view_location')
  83. data = {'name': 'Location 1', 'slug': 'location-1', 'site': site.pk}
  84. self.assertEqual(self.client.post(location_url, data, format='json', **self.header).status_code, 201)
  85. response = self.client.post(location_url, data, format='json', **self.header)
  86. self.assertEqual(response.status_code, 400)
  87. self.assertIn(NON_FIELD_ERRORS, response.data)
  88. class RelatedObjectCountFieldTestCase(TestCase):
  89. """
  90. RelatedObjectCountFields are populated by annotations applied to a viewset's queryset, which are only
  91. added when serializing an object via its own endpoint (including ?brief=1). They are never annotated when
  92. the object is rendered as a nested related object, so those without a fallback must be omitted from nested
  93. representations to keep the generated OpenAPI schema honest. See #22154.
  94. """
  95. def test_count_field_omitted_when_nested(self):
  96. """A nested serializer must drop RelatedObjectCountFields (e.g. RackSerializer.device_count)."""
  97. serializer = RackSerializer(nested=True)
  98. count_fields = [
  99. name for name, field in serializer.fields.items() if isinstance(field, RelatedObjectCountField)
  100. ]
  101. self.assertEqual(count_fields, [])
  102. self.assertNotIn('device_count', serializer.fields)
  103. def test_count_field_retained_in_brief_mode(self):
  104. """?brief=1 (fields=brief_fields, not nested) must retain RelatedObjectCountFields."""
  105. serializer = RackSerializer(fields=RackSerializer.Meta.brief_fields)
  106. self.assertIn('device_count', serializer.fields)
  107. self.assertIsInstance(serializer.fields['device_count'], RelatedObjectCountField)
  108. def test_count_field_with_fallback_retained_when_nested(self):
  109. """A nested serializer keeps a requested count field that has a fallback."""
  110. serializer = InterfaceSerializer(nested=True, fields=['id', 'count_ipaddresses'])
  111. self.assertEqual(set(serializer.fields), {'id', 'count_ipaddresses'})
  112. def test_count_field_without_fallback_omitted_when_unannotated(self):
  113. """A count field without a fallback is left out of an unannotated object's representation."""
  114. serializer = RackSerializer(Rack(pk=1), fields=['id', 'device_count'])
  115. self.assertEqual(serializer.data, {'id': 1})
  116. class NetBoxPaginationTestCase(TestCase):
  117. def setUp(self):
  118. self.paginator = NetBoxPagination()
  119. self.factory = RequestFactory()
  120. def _make_drf_request(self, path='/', query_params=None):
  121. """Helper to create a proper DRF Request object"""
  122. return Request(self.factory.get(path, query_params or {}))
  123. def test_raises_exception_for_unordered_queryset(self):
  124. """Should raise QuerySetNotOrdered for unordered QuerySet"""
  125. queryset = Token.objects.all().order_by()
  126. request = self._make_drf_request()
  127. with self.assertRaises(QuerySetNotOrdered) as cm:
  128. self.paginator.paginate_queryset(queryset, request)
  129. error_msg = str(cm.exception)
  130. self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
  131. self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
  132. def test_allows_ordered_queryset(self):
  133. """Should not raise exception for ordered QuerySet"""
  134. queryset = Token.objects.all().order_by('created')
  135. request = self._make_drf_request()
  136. self.paginator.paginate_queryset(queryset, request) # Should not raise exception
  137. def test_allows_non_queryset_iterables(self):
  138. """Should not raise exception for non-QuerySet iterables"""
  139. iterable = [1, 2, 3, 4, 5]
  140. request = self._make_drf_request()
  141. self.paginator.paginate_queryset(iterable, request) # Should not raise exception
  142. def test_get_start_returns_none_when_absent(self):
  143. """get_start() returns None when start param is not in the request"""
  144. request = self._make_drf_request()
  145. self.assertIsNone(self.paginator.get_start(request))
  146. def test_get_start_returns_integer(self):
  147. """get_start() returns an integer when start param is present"""
  148. request = self._make_drf_request(query_params={'start': '42'})
  149. self.assertEqual(self.paginator.get_start(request), 42)
  150. def test_get_start_raises_for_negative(self):
  151. """get_start() raises ValidationError for negative values"""
  152. request = self._make_drf_request(query_params={'start': '-1'})
  153. with self.assertRaises(ValidationError):
  154. self.paginator.get_start(request)
  155. def test_cursor_and_offset_conflict_raises_validation_error(self):
  156. """paginate_queryset() raises ValidationError when both start and offset are specified"""
  157. queryset = Token.objects.all().order_by('created')
  158. request = self._make_drf_request(query_params={'start': '1', 'offset': '10'})
  159. with self.assertRaises(ValidationError):
  160. self.paginator.paginate_queryset(queryset, request)
  161. def test_cursor_and_ordering_conflict_raises_validation_error(self):
  162. """paginate_queryset() raises ValidationError when both start and ordering are specified"""
  163. queryset = Token.objects.all().order_by('created')
  164. request = self._make_drf_request(query_params={'start': '1', 'ordering': 'created'})
  165. with self.assertRaises(ValidationError):
  166. self.paginator.paginate_queryset(queryset, request)
  167. class IntegerRangeSerializerTestCase(TestCase):
  168. def test_to_representation_emits_inclusive_bounds_for_non_canonical_range(self):
  169. """A NumericRange with bounds='[]' must serialize to its inclusive (lower, upper) pair."""
  170. serializer = IntegerRangeSerializer()
  171. self.assertEqual(
  172. serializer.to_representation(NumericRange(100, 199, bounds='[]')),
  173. (100, 199)
  174. )
  175. self.assertEqual(
  176. serializer.to_representation(NumericRange(100, 200, bounds='[)')),
  177. (100, 199)
  178. )
  179. def test_to_internal_value_produces_canonical_half_open_range(self):
  180. """An inclusive [lo, hi] pair is normalized to NumericRange(lo, hi+1, '[)')."""
  181. serializer = IntegerRangeSerializer()
  182. self.assertEqual(
  183. serializer.to_internal_value([100, 199]),
  184. NumericRange(100, 200, bounds='[)')
  185. )
  186. def test_to_internal_value_rejects_malformed_input(self):
  187. """Input must be a two-element list or tuple of ints."""
  188. serializer = IntegerRangeSerializer()
  189. with self.assertRaises(ValidationError):
  190. serializer.to_internal_value('100-200')
  191. with self.assertRaises(ValidationError):
  192. serializer.to_internal_value([100])
  193. with self.assertRaises(ValidationError):
  194. serializer.to_internal_value([100, 200, 300])
  195. def test_to_internal_value_rejects_non_integer_bounds(self):
  196. """Range boundaries must be integers, not strings or floats."""
  197. serializer = IntegerRangeSerializer()
  198. with self.assertRaises(ValidationError):
  199. serializer.to_internal_value(['100', '200'])
  200. with self.assertRaises(ValidationError):
  201. serializer.to_internal_value([100.5, 200.5])
  202. class ContentTypeFieldTestCase(TestCase):
  203. def test_to_internal_value_resolves_content_type_within_queryset(self):
  204. """A content type present in the field's declared queryset resolves successfully."""
  205. site_ct = ContentType.objects.get_for_model(Site)
  206. field = ContentTypeField(queryset=ContentType.objects.filter(pk=site_ct.pk))
  207. self.assertEqual(field.to_internal_value('dcim.site'), site_ct)
  208. def test_to_internal_value_rejects_content_type_outside_queryset(self):
  209. """
  210. Regression test for #22748: ContentTypeField.to_internal_value() previously resolved
  211. against the raw, unfiltered ContentType table via get_by_natural_key(), ignoring the
  212. field's own declared queryset entirely. A content type that is real and resolvable in
  213. general, but falls outside the specific queryset a given field declares, must be
  214. rejected rather than silently accepted.
  215. """
  216. site_ct = ContentType.objects.get_for_model(Site)
  217. device_ct = ContentType.objects.get_for_model(Device)
  218. # Scope the field to a single, unrelated content type so `dcim.site` is guaranteed to
  219. # fall outside it.
  220. field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk))
  221. with self.assertRaises(ValidationError):
  222. field.to_internal_value('dcim.site')
  223. # Sanity check: the rejected content type is a genuine, generally-resolvable content
  224. # type, so the rejection above is attributable to queryset scoping and not a bogus value.
  225. self.assertTrue(ContentType.objects.filter(pk=site_ct.pk).exists())
  226. def test_to_internal_value_rejects_malformed_input(self):
  227. """Input must be exactly '<app_label>.<model>'."""
  228. field = ContentTypeField(queryset=ContentType.objects.all())
  229. with self.assertRaises(ValidationError):
  230. field.to_internal_value('not-a-valid-format')
  231. with self.assertRaises(ValidationError):
  232. field.to_internal_value('too.many.dots')
  233. def test_to_internal_value_rejects_nonexistent_content_type(self):
  234. """A syntactically valid but nonexistent content type must be rejected."""
  235. field = ContentTypeField(queryset=ContentType.objects.all())
  236. with self.assertRaises(ValidationError):
  237. field.to_internal_value('nonexistent_app.nonexistent_model')
  238. def test_to_internal_value_many_rejects_content_type_outside_queryset(self):
  239. """
  240. many=True wraps the field in a ManyRelatedField, which delegates per-item validation to
  241. the child field's to_internal_value() unconditionally; the same queryset scoping must
  242. hold there too.
  243. """
  244. device_ct = ContentType.objects.get_for_model(Device)
  245. field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk), many=True)
  246. self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
  247. with self.assertRaises(ValidationError):
  248. field.to_internal_value(['dcim.device', 'dcim.site'])
  249. class SerializedPKRelatedFieldTestCase(APITestCase):
  250. @classmethod
  251. def setUpTestData(cls):
  252. route_targets = (
  253. RouteTarget(name='65000:1'),
  254. RouteTarget(name='65000:2'),
  255. RouteTarget(name='65000:3'),
  256. )
  257. RouteTarget.objects.bulk_create(route_targets)
  258. vrfs = (
  259. VRF(name='VRF 1'),
  260. VRF(name='VRF 2'),
  261. )
  262. VRF.objects.bulk_create(vrfs)
  263. vrfs[0].import_targets.set(route_targets[:2])
  264. vrfs[1].import_targets.set(route_targets[1:])
  265. def test_to_representation_reuses_nested_serializer(self):
  266. """A field builds one nested serializer for all its objects, lazily, in nested and full mode."""
  267. context = {'request': RequestFactory().get('/')}
  268. route_targets = RouteTarget.objects.order_by('name')
  269. for serializer_class, nested in ((L2VPNSerializer, True), (VRFSerializer, False)):
  270. with self.subTest(serializer=serializer_class.__name__):
  271. expected = [
  272. RouteTargetSerializer(route_target, nested=nested, context=context).data
  273. for route_target in route_targets
  274. ]
  275. with patch.object(
  276. RouteTargetSerializer, '__init__', autospec=True, side_effect=RouteTargetSerializer.__init__
  277. ) as init:
  278. field = serializer_class(context=context).fields['import_targets']
  279. self.assertEqual(field.to_representation([]), [])
  280. self.assertEqual(init.call_count, 0)
  281. self.assertEqual(field.to_representation(route_targets), expected)
  282. self.assertEqual(init.call_count, 1)
  283. def test_list_reuses_nested_serializer_per_request(self):
  284. """Each list request builds its own nested serializer, also through the browsable API."""
  285. self.add_permissions('ipam.view_vrf')
  286. url = reverse('ipam-api:vrf-list')
  287. def get(params, host):
  288. with patch.object(
  289. RouteTargetSerializer, '__init__', autospec=True, side_effect=RouteTargetSerializer.__init__
  290. ) as init:
  291. response = self.client.get(
  292. url, {'fields': 'name,import_targets', **params}, HTTP_HOST=host, **self.header
  293. )
  294. self.assertEqual(response.status_code, 200)
  295. return init.call_count, response.data['results']
  296. one, _ = get({'name': 'VRF 1'}, 'a.example.com')
  297. two, vrfs = get({}, 'b.example.com')
  298. self.assertEqual(one, two)
  299. self.assertEqual(
  300. {vrf['name']: [target['name'] for target in vrf['import_targets']] for vrf in vrfs},
  301. {'VRF 1': ['65000:1', '65000:2'], 'VRF 2': ['65000:2', '65000:3']},
  302. )
  303. for vrf in vrfs:
  304. for target in vrf['import_targets']:
  305. self.assertTrue(target['url'].startswith('http://b.example.com/'))
  306. response = self.client.get(url, {'format': 'api'}, **self.header)
  307. self.assertContains(response, '65000:3')
  308. class ValidatedModelSerializerTestCase(TestCase):
  309. def test_serializers_declare_model_clean_fields(self):
  310. """Serializers accepting a data file must declare the fields SyncedDataMixin.clean() normalizes."""
  311. normalized_fields = {'data_source', 'data_path', 'auto_sync_enabled', 'data_synced'}
  312. for object_type in ObjectType.objects.with_feature('synced_data'):
  313. model = object_type.model_class()
  314. if model is None:
  315. continue
  316. try:
  317. serializer = get_serializer_for_model(model)
  318. except SerializerNotFound:
  319. continue
  320. # Only serializers that let a client bind a data file have normalization to preserve
  321. data_file = serializer().fields.get('data_file')
  322. if data_file is None or data_file.read_only:
  323. continue
  324. with self.subTest(model=model._meta.label):
  325. declared = set(getattr(serializer.Meta, 'model_clean_fields', ()))
  326. self.assertTrue(
  327. normalized_fields.issubset(declared),
  328. f'{serializer.__name__}.Meta.model_clean_fields is missing '
  329. f'{sorted(normalized_fields - declared)}'
  330. )
  331. def test_model_clean_fields_is_opt_in(self):
  332. """A declared field takes its value from the cleaned instance, while an undeclared one keeps the input."""
  333. datasource = DataSource.objects.create(
  334. name='Data Source 1',
  335. type='local',
  336. source_url='file:///tmp/netbox-datasource/',
  337. )
  338. file_data = b'{{ synced }}'
  339. datafile = DataFile.objects.create(
  340. source=datasource,
  341. path='exports/devices.j2',
  342. last_updated=now(),
  343. size=len(file_data),
  344. hash=hashlib.sha256(file_data).hexdigest(),
  345. data=file_data,
  346. )
  347. class PlainSerializer(ValidatedModelSerializer):
  348. class Meta:
  349. model = ExportTemplate
  350. fields = ['name', 'template_code', 'data_file']
  351. class OptedInSerializer(PlainSerializer):
  352. class Meta(PlainSerializer.Meta):
  353. model_clean_fields = ('template_code',)
  354. payload = {
  355. 'name': 'Export Template X',
  356. 'template_code': '{# placeholder #}',
  357. 'data_file': datafile.pk,
  358. }
  359. plain = PlainSerializer(data=payload)
  360. self.assertTrue(plain.is_valid(), plain.errors)
  361. self.assertEqual(plain.validated_data['template_code'], '{# placeholder #}')
  362. opted_in = OptedInSerializer(data=payload)
  363. self.assertTrue(opted_in.is_valid(), opted_in.errors)
  364. self.assertEqual(opted_in.validated_data['template_code'], '{{ synced }}')
  365. def test_nested_serializer_skips_model_validation(self):
  366. """A serializer representing a nested object resolves its input without calling validate()."""
  367. class OptedInSerializer(ValidatedModelSerializer):
  368. class Meta:
  369. model = ExportTemplate
  370. fields = ['name', 'template_code']
  371. brief_fields = ('name',)
  372. model_clean_fields = ('template_code',)
  373. export_template = ExportTemplate.objects.create(name='Export Template 1', template_code='{# untouched #}')
  374. serializer = OptedInSerializer(nested=True)
  375. with patch.object(OptedInSerializer, 'validate') as validate:
  376. self.assertEqual(serializer.run_validation(export_template.pk), export_template)
  377. validate.assert_not_called()
  378. class WritableNestedSerializerTestCase(TestCase):
  379. def test_skips_validation(self):
  380. """A WritableNestedSerializer resolves its input without running validators or validate()."""
  381. class NestedExportTemplateSerializer(WritableNestedSerializer):
  382. class Meta:
  383. model = ExportTemplate
  384. fields = ['id', 'name']
  385. export_template = ExportTemplate.objects.create(name='Export Template 1', template_code='{# untouched #}')
  386. serializer = NestedExportTemplateSerializer()
  387. with (
  388. patch.object(NestedExportTemplateSerializer, 'run_validators') as run_validators,
  389. patch.object(NestedExportTemplateSerializer, 'validate') as validate,
  390. ):
  391. self.assertEqual(serializer.run_validation(export_template.pk), export_template)
  392. run_validators.assert_not_called()
  393. validate.assert_not_called()
  394. ROUTED_ALIAS = 'routed'
  395. class SiteRouter:
  396. """
  397. Route Site queries to a second connection, as a plugin's router may.
  398. """
  399. def db_for_read(self, model, **hints):
  400. return ROUTED_ALIAS if model is Site else None
  401. db_for_write = db_for_read
  402. def allow_relation(self, obj1, obj2, **hints):
  403. return True
  404. @override_settings(DATABASE_ROUTERS=[SiteRouter()])
  405. class BulkOperationRoutingTestCase(TransactionTestCase):
  406. """
  407. Exercise the bulk operations on a connection which DATABASE_ROUTERS selects in place of the default one, as
  408. netbox-branching does for an active branch. Uses TransactionTestCase so that the default connection is not in
  409. a transaction while the request is served, as is the case outside of tests.
  410. Note: TransactionTestCase teardown flushes all tables, which removes rows seeded by data migrations from a
  411. --keepdb database (e.g. the dcim.0206 ModuleTypeProfiles). A fresh test database restores them.
  412. """
  413. client_class = APIClient
  414. def setUp(self):
  415. # A second connection to the test database, standing in for e.g. a branch schema
  416. routed = connections[DEFAULT_DB_ALIAS].copy(ROUTED_ALIAS)
  417. connections[ROUTED_ALIAS] = routed
  418. self.addCleanup(connections.__delitem__, ROUTED_ALIAS)
  419. self.addCleanup(routed.close)
  420. # A superuser, as this case covers transaction handling rather than permission enforcement
  421. user = User.objects.create_user(username='testuser', is_superuser=True)
  422. token = Token.objects.create(user=user)
  423. self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
  424. self.url = reverse('dcim-api:site-list')
  425. @tag('regression') # Ref: #23367
  426. def test_bulk_create_rollback(self):
  427. """Roll back a bulk create on the routed connection when one object is invalid."""
  428. data = [
  429. {'name': 'Site 1', 'slug': 'site-1'},
  430. {'name': 'Site 2'},
  431. ]
  432. with patch('netbox.context_managers.flush_events') as flush_events:
  433. response = self.client.post(self.url, data, format='json', **self.header)
  434. self.assertEqual(response.status_code, 400)
  435. self.assertEqual([e['index'] for e in response.data['errors']], [1])
  436. self.assertIn('slug', response.data['errors'][0]['errors'])
  437. self.assertFalse(Site.objects.exists())
  438. flush_events.assert_not_called()
  439. @tag('regression') # Ref: #23367
  440. def test_bulk_update_rollback(self):
  441. """Roll back a bulk update on the routed connection when one object is invalid."""
  442. sites = (
  443. Site(name='Site 1', slug='site-1'),
  444. Site(name='Site 2', slug='site-2'),
  445. )
  446. Site.objects.bulk_create(sites)
  447. data = [
  448. {'id': sites[0].pk, 'description': 'Updated'},
  449. {'id': sites[1].pk, 'status': 'invalid'},
  450. ]
  451. with patch('netbox.context_managers.flush_events') as flush_events:
  452. response = self.client.patch(self.url, data, format='json', **self.header)
  453. self.assertEqual(response.status_code, 400)
  454. self.assertEqual([e['id'] for e in response.data['errors']], [sites[1].pk])
  455. self.assertIn('status', response.data['errors'][0]['errors'])
  456. self.assertEqual(Site.objects.get(pk=sites[0].pk).description, '')
  457. flush_events.assert_not_called()
  458. @tag('regression') # Ref: #23367
  459. def test_bulk_delete_rollback(self):
  460. """Roll back a bulk delete on the routed connection when one object is protected."""
  461. sites = (
  462. Site(name='Site 1', slug='site-1'),
  463. Site(name='Site 2', slug='site-2'),
  464. )
  465. Site.objects.bulk_create(sites)
  466. # Protect the first Site, as its failed delete clears the event queue before the second is deleted
  467. create_test_device('Device 1', site=sites[0])
  468. data = [{'id': site.pk} for site in sites]
  469. with patch('netbox.context_managers.flush_events') as flush_events:
  470. response = self.client.delete(self.url, data, format='json', **self.header)
  471. self.assertEqual(response.status_code, 409)
  472. self.assertEqual([e['id'] for e in response.data['errors']], [sites[0].pk])
  473. self.assertEqual(Site.objects.count(), 2)
  474. flush_events.assert_not_called()
  475. class ObjectUpdateLockingTestCase(APITestCase):
  476. """
  477. An update must lock the rows of the objects being updated before retrieving them, and hold the locks until the
  478. objects have been saved. A concurrent update to the same objects then waits for this one to complete, rather than
  479. having its changes reverted when this one saves copies of the objects retrieved before they were committed.
  480. """
  481. @classmethod
  482. def setUpTestData(cls):
  483. cls.sites = (
  484. Site(name='Site 1', slug='site-1'),
  485. Site(name='Site 2', slug='site-2'),
  486. )
  487. Site.objects.bulk_create(cls.sites)
  488. def setUp(self):
  489. super().setUp()
  490. self.add_permissions('dcim.change_site')
  491. def assertLockedUntilSaved(self, queries):
  492. """
  493. Assert that the given queries lock the rows of the Sites being updated (once) before retrieving them, within a
  494. transaction which does not end until they have been saved.
  495. """
  496. table = Site._meta.db_table
  497. locks = [
  498. i for i, sql in enumerate(queries)
  499. if f'FROM "{table}"' in sql and sql.endswith(('FOR UPDATE', 'FOR NO KEY UPDATE'))
  500. ]
  501. self.assertEqual(len(locks), 1, "Expected exactly one query to lock the Sites' rows")
  502. lock = locks[0]
  503. # The first query to select the Sites' fields (rather than only their PKs)
  504. retrieval = next(i for i, sql in enumerate(queries) if sql.startswith(f'SELECT "{table}"."id", '))
  505. self.assertLess(lock, retrieval, "The Sites were retrieved before their rows were locked")
  506. last_save = max(i for i, sql in enumerate(queries) if sql.startswith(f'UPDATE "{table}"'))
  507. # Find the transaction which took the lock: the innermost savepoint open at that point (as each test runs
  508. # within a transaction, a transaction opened by the view appears as a savepoint)
  509. savepoints = []
  510. for sql in queries[:lock]:
  511. if sql.startswith('SAVEPOINT '):
  512. savepoints.append(sql.removeprefix('SAVEPOINT '))
  513. elif sql.startswith(('RELEASE SAVEPOINT ', 'ROLLBACK TO SAVEPOINT ')):
  514. savepoints.remove(sql.split()[-1])
  515. self.assertTrue(savepoints, "The Sites' rows were not locked within a transaction")
  516. self.assertIn(
  517. f'RELEASE SAVEPOINT {savepoints[-1]}',
  518. queries[last_save + 1:],
  519. "The transaction which locked the Sites' rows ended before they were saved",
  520. )
  521. @tag('regression') # Ref: #23445
  522. def test_update_locks_object_before_retrieving_it(self):
  523. """
  524. Lock the row of an object before retrieving it for an update. The If-Match ETag is then checked against the
  525. object retrieved, rather than against a copy re-fetched under a second lock.
  526. """
  527. site = Site.objects.get(pk=self.sites[0].pk)
  528. url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
  529. header = {**self.header, 'HTTP_IF_MATCH': SiteViewSet._get_etag(site)}
  530. with CaptureQueriesContext(connection) as ctx:
  531. response = self.client.patch(url, {'description': 'Description'}, format='json', **header)
  532. self.assertHttpStatus(response, 200)
  533. self.assertLockedUntilSaved([q['sql'] for q in ctx.captured_queries])
  534. @tag('regression') # Ref: #23445
  535. def test_bulk_update_locks_objects_before_retrieving_them(self):
  536. """
  537. Lock the rows of all objects being updated in bulk before retrieving them. The If-Match ETag is then checked
  538. against each object retrieved, rather than against a copy re-fetched under a second lock.
  539. """
  540. sites = Site.objects.filter(pk__in=[site.pk for site in self.sites])
  541. data = [{'id': site.pk, 'description': 'Description'} for site in sites]
  542. header = {**self.header, 'HTTP_IF_MATCH': ', '.join(SiteViewSet._get_etag(site) for site in sites)}
  543. with CaptureQueriesContext(connection) as ctx:
  544. response = self.client.patch(reverse('dcim-api:site-list'), data, format='json', **header)
  545. self.assertHttpStatus(response, 200)
  546. self.assertLockedUntilSaved([q['sql'] for q in ctx.captured_queries])
  547. def test_bulk_update_with_stale_etag(self):
  548. """Reject a bulk update if any object's current ETag does not match the If-Match header."""
  549. data = [{'id': site.pk, 'description': 'Description'} for site in self.sites]
  550. header = {**self.header, 'HTTP_IF_MATCH': 'W/"stale"'}
  551. response = self.client.patch(reverse('dcim-api:site-list'), data, format='json', **header)
  552. self.assertHttpStatus(response, 412)
  553. self.assertFalse(Site.objects.filter(description='Description').exists())
  554. @tag('regression') # Ref: #23445
  555. def test_bulk_update_with_object_deleted_concurrently(self):
  556. """
  557. Reject a bulk update if any object can no longer be found once the objects have been locked (e.g. because it
  558. was deleted after the view checked that each object exists), rather than silently omitting it.
  559. """
  560. data = [{'id': site.pk, 'description': 'Description'} for site in self.sites]
  561. perform_bulk_update = BulkUpdateModelMixin.perform_bulk_update
  562. def delete_then_perform_bulk_update(viewset, *args, **kwargs):
  563. # Delete a Site between the view's check for missing objects and the bulk update
  564. Site.objects.filter(pk=self.sites[1].pk).delete()
  565. return perform_bulk_update(viewset, *args, **kwargs)
  566. with patch.object(BulkUpdateModelMixin, 'perform_bulk_update', delete_then_perform_bulk_update):
  567. response = self.client.patch(reverse('dcim-api:site-list'), data, format='json', **self.header)
  568. self.assertHttpStatus(response, 400)
  569. self.assertEqual([e['id'] for e in response.data['errors']], [self.sites[1].pk])
  570. self.assertFalse(Site.objects.filter(description='Description').exists())
  571. def test_lock_object(self):
  572. """
  573. Lock the requested object for the duration of the block if it can be found by its URL lookup, and otherwise
  574. take no lock.
  575. """
  576. for attrs, locked_pks in (
  577. ({'kwargs': {'pk': self.sites[0].pk}}, {self.sites[0].pk}),
  578. # Not a valid PK (e.g. for a viewset whose get_object() accepts a slug)
  579. ({'kwargs': {'pk': 'site-1'}}, set()),
  580. # No lookup value
  581. ({'kwargs': {}}, set()),
  582. # Not a model field (e.g. for a viewset whose get_object() filters on an annotation)
  583. ({'kwargs': {'circuit_count': 0}, 'lookup_field': 'circuit_count'}, set()),
  584. ):
  585. with self.subTest(**attrs):
  586. viewset = SiteViewSet(**attrs)
  587. with viewset.lock_object():
  588. self.assertEqual(viewset.locked_pks, locked_pks)
  589. self.assertEqual(viewset.locked_pks, set())
  590. class ProtectedObjectsMessageTestCase(APITestCase):
  591. """
  592. The error for a deletion prevented by dependent objects must name only those dependent objects which the user is
  593. permitted to view. (Ref: #23398)
  594. """
  595. @classmethod
  596. def setUpTestData(cls):
  597. cls.devices = (
  598. create_test_device('Device 1'),
  599. create_test_device('Device 2'),
  600. )
  601. cls.device_type = cls.devices[0].device_type
  602. cls.expected_message = (
  603. f'Unable to delete object. 2 dependent objects were found: Device 1 ({cls.devices[0].pk}), '
  604. '1 object you do not have permission to view'
  605. )
  606. def setUp(self):
  607. super().setUp()
  608. self.add_permissions('dcim.delete_devicetype')
  609. # Permit viewing only the first Device
  610. obj_perm = ObjectPermission.objects.create(
  611. name='View Device 1', actions=['view'], constraints={'pk': self.devices[0].pk}
  612. )
  613. obj_perm.users.add(self.user)
  614. obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
  615. def test_delete_object(self):
  616. url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.device_type.pk})
  617. with self.assertLogs('netbox.api.views.DeviceTypeViewSet', 'WARNING') as logs:
  618. response = self.client.delete(url, **self.header)
  619. self.assertHttpStatus(response, 409)
  620. self.assertEqual(response.data['detail'], self.expected_message)
  621. # The log lists all dependent objects
  622. self.assertIn(f'Device 2 ({self.devices[1].pk})', logs.output[0])
  623. def test_exception_to_response(self):
  624. viewset = DeviceTypeViewSet()
  625. viewset.request = Request(RequestFactory().delete('/'))
  626. viewset.request.user = self.user
  627. response = viewset.exception_to_response(ProtectedError('', set(self.devices)))
  628. self.assertEqual(response.status_code, 409)
  629. self.assertEqual(response.data['detail'], self.expected_message)