test_openapi_schema.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. """
  2. Unit tests for OpenAPI schema generation.
  3. Refs: #20638
  4. """
  5. import json
  6. from django.test import SimpleTestCase, TestCase, override_settings
  7. from core.api.schema import FixSerializedPKRelatedField, NetBoxAutoSchema
  8. from dcim.api.serializers import SiteSerializer
  9. from dcim.models import Site
  10. from ipam.api.serializers import ServiceSerializer
  11. from netbox.api.fields import SerializedPKRelatedField
  12. from netbox.api.serializers import BulkOperationErrorSerializer
  13. @override_settings(CACHES={
  14. 'default': {
  15. 'BACKEND': 'django.core.cache.backends.dummy.DummyCache'
  16. }
  17. })
  18. class OpenAPISchemaTestCase(TestCase):
  19. """Tests for OpenAPI schema generation."""
  20. @classmethod
  21. def setUpClass(cls):
  22. """
  23. Fetch the schema via the API endpoint. Schema generation is expensive and its output is
  24. immutable across these tests, so do this once for the class rather than per test method.
  25. """
  26. super().setUpClass()
  27. response = cls.client_class().get('/api/schema/', {'format': 'json'})
  28. assert response.status_code == 200, f'Failed to generate OpenAPI schema (HTTP {response.status_code})'
  29. cls.schema = json.loads(response.content)
  30. def test_post_operation_documents_single_or_array(self):
  31. """
  32. POST operations on NetBoxModelViewSet endpoints should document
  33. support for both single objects and arrays via oneOf.
  34. Refs: #20638
  35. """
  36. # Test representative endpoints across different apps
  37. test_paths = [
  38. '/api/core/data-sources/',
  39. '/api/dcim/sites/',
  40. '/api/users/users/',
  41. '/api/ipam/ip-addresses/',
  42. ]
  43. for path in test_paths:
  44. with self.subTest(path=path):
  45. operation = self.schema['paths'][path]['post']
  46. # Get the request body schema
  47. request_schema = operation['requestBody']['content']['application/json']['schema']
  48. # Should have oneOf with two options
  49. self.assertIn('oneOf', request_schema, f"POST {path} should have oneOf schema")
  50. self.assertEqual(
  51. len(request_schema['oneOf']), 2,
  52. f"POST {path} oneOf should have exactly 2 options"
  53. )
  54. # First option: single object (has $ref or properties)
  55. single_schema = request_schema['oneOf'][0]
  56. self.assertTrue(
  57. '$ref' in single_schema or 'properties' in single_schema,
  58. f"POST {path} first oneOf option should be single object"
  59. )
  60. # Second option: array of objects
  61. array_schema = request_schema['oneOf'][1]
  62. self.assertEqual(
  63. array_schema['type'], 'array',
  64. f"POST {path} second oneOf option should be array"
  65. )
  66. self.assertIn('items', array_schema, f"POST {path} array should have items")
  67. def test_bulk_update_operations_require_array_only(self):
  68. """
  69. Bulk update/patch operations should require arrays only, not oneOf.
  70. They don't support single object input.
  71. Refs: #20638
  72. """
  73. test_paths = [
  74. '/api/dcim/sites/',
  75. '/api/users/users/',
  76. ]
  77. for path in test_paths:
  78. for method in ['put', 'patch']:
  79. with self.subTest(path=path, method=method):
  80. operation = self.schema['paths'][path][method]
  81. request_schema = operation['requestBody']['content']['application/json']['schema']
  82. # Should be array-only, not oneOf
  83. self.assertNotIn(
  84. 'oneOf', request_schema,
  85. f"{method.upper()} {path} should NOT have oneOf (array-only)"
  86. )
  87. self.assertEqual(
  88. request_schema['type'], 'array',
  89. f"{method.upper()} {path} should require array"
  90. )
  91. self.assertIn(
  92. 'items', request_schema,
  93. f"{method.upper()} {path} array should have items"
  94. )
  95. def test_bulk_delete_requires_array(self):
  96. """
  97. Bulk delete operations should require arrays.
  98. Refs: #20638
  99. """
  100. path = '/api/dcim/sites/'
  101. operation = self.schema['paths'][path]['delete']
  102. request_schema = operation['requestBody']['content']['application/json']['schema']
  103. # Should be array-only
  104. self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
  105. self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
  106. self.assertIn('items', request_schema, "DELETE array should have items")
  107. def _get_response_schema(self, path, method, code):
  108. """Return the JSON response schema documented for the given operation and status code."""
  109. responses = self.schema['paths'][path][method]['responses']
  110. self.assertIn(code, responses, f"{method.upper()} {path} should document a {code} response")
  111. return responses[code]['content']['application/json']['schema']
  112. def test_bulk_error_component_is_defined(self):
  113. """
  114. The structured error body returned by a failed bulk operation should be a named component,
  115. so that generated clients have a type for it.
  116. Refs: #20054
  117. """
  118. components = self.schema['components']['schemas']
  119. self.assertIn('BulkOperationError', components)
  120. envelope = components['BulkOperationError']
  121. self.assertEqual(sorted(envelope['properties']), ['detail', 'errors'])
  122. # `errors` is absent where the request could not be attributed to individual entries
  123. self.assertEqual(envelope['required'], ['detail'])
  124. self.assertEqual(
  125. envelope['properties']['errors']['items']['$ref'],
  126. '#/components/schemas/BulkOperationEntryError',
  127. )
  128. self.assertIn('BulkOperationEntryError', components)
  129. entry = components['BulkOperationEntryError']
  130. # An entry is correlated by `id` or by `index`, so neither is required; `errors` always is
  131. self.assertEqual(sorted(entry['properties']), ['errors', 'id', 'index'])
  132. self.assertEqual(entry['required'], ['errors'])
  133. def test_bulk_update_documents_error_response(self):
  134. """
  135. Bulk update operations should document the structured 400 response.
  136. Refs: #20054
  137. """
  138. ref = {'$ref': '#/components/schemas/BulkOperationError'}
  139. for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
  140. for method in ('put', 'patch'):
  141. with self.subTest(path=path, method=method):
  142. self.assertEqual(self._get_response_schema(path, method, '400'), ref)
  143. def test_bulk_delete_documents_error_responses(self):
  144. """
  145. Bulk delete operations should document the 400 (unresolvable request or protection rule), the
  146. 403 (not permitted) and the 409 (dependent object) responses.
  147. Refs: #20054
  148. """
  149. ref = {'$ref': '#/components/schemas/BulkOperationError'}
  150. for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
  151. with self.subTest(path=path):
  152. self.assertEqual(self._get_response_schema(path, 'delete', '400'), ref)
  153. self.assertEqual(self._get_response_schema(path, 'delete', '403'), ref)
  154. self.assertEqual(self._get_response_schema(path, 'delete', '409'), ref)
  155. def test_bulk_write_operations_document_forbidden_response(self):
  156. """
  157. Every bulk write should document the 403 returned when an object-level permission refuses one
  158. of the objects specified.
  159. Refs: #20054
  160. """
  161. ref = {'$ref': '#/components/schemas/BulkOperationError'}
  162. for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
  163. for method in ('post', 'put', 'patch', 'delete'):
  164. with self.subTest(path=path, method=method):
  165. self.assertEqual(self._get_response_schema(path, method, '403'), ref)
  166. def test_create_documents_error_response_for_either_shape(self):
  167. """
  168. A POST to a list endpoint accepts either a single object or a list, so its 400 response
  169. should document both the field-keyed and the bulk error shapes.
  170. Refs: #20054
  171. """
  172. for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
  173. with self.subTest(path=path):
  174. schema = self._get_response_schema(path, 'post', '400')
  175. self.assertEqual(
  176. schema['oneOf'],
  177. [
  178. {'type': 'object', 'additionalProperties': {}},
  179. {'$ref': '#/components/schemas/BulkOperationError'},
  180. ],
  181. )
  182. def test_detail_operations_omit_bulk_error_response(self):
  183. """
  184. The bulk error body applies only to list endpoints; detail endpoints must not advertise it.
  185. Refs: #20054
  186. """
  187. path = '/api/dcim/sites/{id}/'
  188. for method in ('get', 'put', 'patch', 'delete'):
  189. with self.subTest(method=method):
  190. responses = self.schema['paths'][path][method]['responses']
  191. self.assertNotIn('409', responses)
  192. self.assertNotIn('403', responses)
  193. for code, response in responses.items():
  194. schema = response.get('content', {}).get('application/json', {}).get('schema', {})
  195. self.assertNotEqual(
  196. schema.get('$ref'), '#/components/schemas/BulkOperationError',
  197. f"{method.upper()} {path} ({code}) should not reference the bulk error body"
  198. )
  199. def test_service_request_documents_legacy_protocol_and_ports(self):
  200. """
  201. The deprecated protocol/ports pair remains writable on application services (the serializer
  202. translates it into port_mappings), so both must appear in the request body alongside
  203. port_mappings. protocol is backed by a read-only model property rather than a model field,
  204. which previously caused it to be dropped from the generated writable variant.
  205. Refs: #20285
  206. """
  207. for path in ('/api/ipam/services/', '/api/ipam/service-templates/'):
  208. with self.subTest(path=path):
  209. schema = self.schema['paths'][path]['post']['requestBody']['content']['application/json']['schema']
  210. ref = schema['oneOf'][0]['$ref'].split('/')[-1]
  211. properties = self.schema['components']['schemas'][ref]['properties']
  212. for field in ('port_mappings', 'protocol', 'ports'):
  213. self.assertIn(field, properties, f"{ref} should document the '{field}' field")
  214. def test_nested_related_fields_reference_brief_components(self):
  215. """
  216. A SerializedPKRelatedField declared with nested=True must reference the brief component in
  217. response schemas, as that is what the API returns.
  218. Refs: #22989
  219. """
  220. components = self.schema['components']['schemas']
  221. for component, field, ref in (
  222. ('Site', 'asns', 'BriefASN'),
  223. ('ConfigContext', 'sites', 'BriefSite'),
  224. ('Interface', 'tagged_vlans', 'BriefVLAN'),
  225. ):
  226. with self.subTest(component=component, field=field):
  227. self.assertEqual(
  228. components[component]['properties'][field]['items']['$ref'],
  229. f'#/components/schemas/{ref}'
  230. )
  231. # The brief component must advertise only the serializer's brief fields
  232. self.assertEqual(
  233. set(components['BriefASN']['properties']),
  234. {'id', 'url', 'display', 'asn', 'description'}
  235. )
  236. def test_ref_name_exempts_serializer_from_brief_prefix(self):
  237. """
  238. A serializer which declares an explicit Meta.ref_name keeps that name when nested, rather than
  239. acquiring a Brief prefix. These serializers are brief by design and have no complete form in the
  240. schema, so prefixing them would rename an existing component to no purpose.
  241. Refs: #22989
  242. """
  243. components = self.schema['components']['schemas']
  244. for component, field, ref in (
  245. ('ASN', 'sites', 'ASNSite'),
  246. ('ObjectPermission', 'groups', 'NestedGroup'),
  247. ('ObjectPermission', 'users', 'NestedUser'),
  248. ):
  249. with self.subTest(component=component, field=field):
  250. self.assertEqual(
  251. components[component]['properties'][field]['items']['$ref'],
  252. f'#/components/schemas/{ref}'
  253. )
  254. self.assertNotIn(f'Brief{ref}', components)
  255. def test_non_nested_related_fields_reference_full_components(self):
  256. """
  257. A SerializedPKRelatedField declared without nested=True must continue to reference the
  258. complete component.
  259. Refs: #22989
  260. """
  261. components = self.schema['components']['schemas']
  262. for field in ('import_targets', 'export_targets'):
  263. with self.subTest(field=field):
  264. self.assertEqual(
  265. components['VRF']['properties'][field]['items']['$ref'],
  266. '#/components/schemas/RouteTarget'
  267. )
  268. def test_nested_related_fields_accept_pks_on_write(self):
  269. """
  270. Request schemas for a SerializedPKRelatedField must continue to accept an array of integer
  271. primary keys.
  272. Refs: #22989
  273. """
  274. components = self.schema['components']['schemas']
  275. for component, field in (
  276. ('SiteRequest', 'asns'),
  277. ('ConfigContextRequest', 'sites'),
  278. ('ASNRequest', 'sites'),
  279. ):
  280. with self.subTest(component=component, field=field):
  281. self.assertEqual(components[component]['properties'][field]['items']['type'], 'integer')
  282. def test_script_run_operation_exists(self):
  283. """
  284. Encodes presence of extras_scripts_run operation in schema as expected.
  285. Refs: #22569
  286. """
  287. paths = self.schema['paths']
  288. resource_path = paths['/api/extras/scripts/{id}/']
  289. self.assertIn('post', resource_path)
  290. run_operation = resource_path['post']
  291. self.assertEqual(run_operation['operationId'], 'extras_scripts_run')
  292. self.assertEqual(len(run_operation['responses']), 1)
  293. self.assertIn('200', run_operation['responses'])
  294. class WritableFieldRebuildTestCase(TestCase):
  295. """
  296. Tests for NetBoxAutoSchema._rebuilds_as_writable(), which decides whether a declared
  297. ChoiceField/WritableNestedSerializer can be nulled out on the generated writable variant and
  298. left for DRF to rebuild from the model. Getting this wrong drops the field from the request
  299. body silently, so the predicate must match DRF's own build_field() behavior rather than merely
  300. testing the model for a field of that name.
  301. Refs: #23083
  302. """
  303. def test_rebuildable_fields(self):
  304. """Fields DRF can rebuild writably should be reported as such."""
  305. serializer = ServiceSerializer()
  306. for field_name in ('name', 'description', 'ipaddresses'):
  307. with self.subTest(field_name=field_name):
  308. self.assertTrue(NetBoxAutoSchema._rebuilds_as_writable(serializer, field_name))
  309. def test_non_rebuildable_fields(self):
  310. """
  311. Fields DRF rebuilds as read-only (or cannot rebuild at all) must be reported as not
  312. rebuildable, so that the declared field is retained instead.
  313. """
  314. serializer = ServiceSerializer()
  315. cases = {
  316. 'protocol': "backed by a read-only model property, not a model field",
  317. 'parent': "a GenericForeignKey, absent from DRF's field info",
  318. 'created': "a non-editable model field",
  319. 'no_such_field': "not present on the model at all",
  320. }
  321. for field_name, reason in cases.items():
  322. with self.subTest(field_name=field_name):
  323. self.assertFalse(
  324. NetBoxAutoSchema._rebuilds_as_writable(serializer, field_name),
  325. f"'{field_name}' should not be considered rebuildable ({reason})"
  326. )
  327. def test_serializer_without_model(self):
  328. """A serializer with no Meta.model has nothing to rebuild from."""
  329. self.assertFalse(NetBoxAutoSchema._rebuilds_as_writable(BulkOperationErrorSerializer(), 'id'))
  330. class SerializedPKRelatedFieldSchemaTestCase(SimpleTestCase):
  331. """Tests for the schema extension which maps SerializedPKRelatedField."""
  332. class DummyComponent:
  333. ref = {'$ref': '#/components/schemas/Dummy'}
  334. class DummyAutoSchema:
  335. """Records the serializer resolved by the extension, in place of generating a component."""
  336. def __init__(self):
  337. self.resolved = []
  338. def resolve_serializer(self, serializer, direction):
  339. self.resolved.append(serializer)
  340. return SerializedPKRelatedFieldSchemaTestCase.DummyComponent
  341. def test_nested_flag_is_passed_to_serializer(self):
  342. """
  343. The field's serializer must be instantiated with the field's nested setting, so that the
  344. component matching the rendered representation is referenced.
  345. Refs: #22989
  346. """
  347. for nested in (True, False):
  348. with self.subTest(nested=nested):
  349. field = SerializedPKRelatedField(
  350. serializer=SiteSerializer,
  351. queryset=Site.objects.all(),
  352. nested=nested
  353. )
  354. auto_schema = self.DummyAutoSchema()
  355. schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'response')
  356. serializer = auto_schema.resolved[0]
  357. self.assertIsInstance(serializer, SiteSerializer)
  358. self.assertEqual(serializer.nested, nested)
  359. self.assertEqual(schema, self.DummyComponent.ref)
  360. def test_request_schema_is_an_integer(self):
  361. """
  362. Request schemas must document an integer primary key, regardless of the nested setting.
  363. Refs: #22989
  364. """
  365. field = SerializedPKRelatedField(serializer=SiteSerializer, queryset=Site.objects.all(), nested=True)
  366. auto_schema = self.DummyAutoSchema()
  367. schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'request')
  368. self.assertEqual(schema['type'], 'integer')
  369. self.assertEqual(auto_schema.resolved, [])