| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- """
- Unit tests for OpenAPI schema generation.
- Refs: #20638
- """
- import json
- from django.test import SimpleTestCase, TestCase, override_settings
- from core.api.schema import FixSerializedPKRelatedField, NetBoxAutoSchema
- from dcim.api.serializers import SiteSerializer
- from dcim.models import Site
- from ipam.api.serializers import ServiceSerializer
- from netbox.api.fields import SerializedPKRelatedField
- from netbox.api.serializers import BulkOperationErrorSerializer
- @override_settings(CACHES={
- 'default': {
- 'BACKEND': 'django.core.cache.backends.dummy.DummyCache'
- }
- })
- class OpenAPISchemaTestCase(TestCase):
- """Tests for OpenAPI schema generation."""
- @classmethod
- def setUpClass(cls):
- """
- Fetch the schema via the API endpoint. Schema generation is expensive and its output is
- immutable across these tests, so do this once for the class rather than per test method.
- """
- super().setUpClass()
- response = cls.client_class().get('/api/schema/', {'format': 'json'})
- assert response.status_code == 200, f'Failed to generate OpenAPI schema (HTTP {response.status_code})'
- cls.schema = json.loads(response.content)
- def test_post_operation_documents_single_or_array(self):
- """
- POST operations on NetBoxModelViewSet endpoints should document
- support for both single objects and arrays via oneOf.
- Refs: #20638
- """
- # Test representative endpoints across different apps
- test_paths = [
- '/api/core/data-sources/',
- '/api/dcim/sites/',
- '/api/users/users/',
- '/api/ipam/ip-addresses/',
- ]
- for path in test_paths:
- with self.subTest(path=path):
- operation = self.schema['paths'][path]['post']
- # Get the request body schema
- request_schema = operation['requestBody']['content']['application/json']['schema']
- # Should have oneOf with two options
- self.assertIn('oneOf', request_schema, f"POST {path} should have oneOf schema")
- self.assertEqual(
- len(request_schema['oneOf']), 2,
- f"POST {path} oneOf should have exactly 2 options"
- )
- # First option: single object (has $ref or properties)
- single_schema = request_schema['oneOf'][0]
- self.assertTrue(
- '$ref' in single_schema or 'properties' in single_schema,
- f"POST {path} first oneOf option should be single object"
- )
- # Second option: array of objects
- array_schema = request_schema['oneOf'][1]
- self.assertEqual(
- array_schema['type'], 'array',
- f"POST {path} second oneOf option should be array"
- )
- self.assertIn('items', array_schema, f"POST {path} array should have items")
- def test_bulk_update_operations_require_array_only(self):
- """
- Bulk update/patch operations should require arrays only, not oneOf.
- They don't support single object input.
- Refs: #20638
- """
- test_paths = [
- '/api/dcim/sites/',
- '/api/users/users/',
- ]
- for path in test_paths:
- for method in ['put', 'patch']:
- with self.subTest(path=path, method=method):
- operation = self.schema['paths'][path][method]
- request_schema = operation['requestBody']['content']['application/json']['schema']
- # Should be array-only, not oneOf
- self.assertNotIn(
- 'oneOf', request_schema,
- f"{method.upper()} {path} should NOT have oneOf (array-only)"
- )
- self.assertEqual(
- request_schema['type'], 'array',
- f"{method.upper()} {path} should require array"
- )
- self.assertIn(
- 'items', request_schema,
- f"{method.upper()} {path} array should have items"
- )
- def test_bulk_delete_requires_array(self):
- """
- Bulk delete operations should require arrays.
- Refs: #20638
- """
- path = '/api/dcim/sites/'
- operation = self.schema['paths'][path]['delete']
- request_schema = operation['requestBody']['content']['application/json']['schema']
- # Should be array-only
- self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
- self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
- self.assertIn('items', request_schema, "DELETE array should have items")
- def _get_response_schema(self, path, method, code):
- """Return the JSON response schema documented for the given operation and status code."""
- responses = self.schema['paths'][path][method]['responses']
- self.assertIn(code, responses, f"{method.upper()} {path} should document a {code} response")
- return responses[code]['content']['application/json']['schema']
- def test_bulk_error_component_is_defined(self):
- """
- The structured error body returned by a failed bulk operation should be a named component,
- so that generated clients have a type for it.
- Refs: #20054
- """
- components = self.schema['components']['schemas']
- self.assertIn('BulkOperationError', components)
- envelope = components['BulkOperationError']
- self.assertEqual(sorted(envelope['properties']), ['detail', 'errors'])
- # `errors` is absent where the request could not be attributed to individual entries
- self.assertEqual(envelope['required'], ['detail'])
- self.assertEqual(
- envelope['properties']['errors']['items']['$ref'],
- '#/components/schemas/BulkOperationEntryError',
- )
- self.assertIn('BulkOperationEntryError', components)
- entry = components['BulkOperationEntryError']
- # An entry is correlated by `id` or by `index`, so neither is required; `errors` always is
- self.assertEqual(sorted(entry['properties']), ['errors', 'id', 'index'])
- self.assertEqual(entry['required'], ['errors'])
- def test_bulk_update_documents_error_response(self):
- """
- Bulk update operations should document the structured 400 response.
- Refs: #20054
- """
- ref = {'$ref': '#/components/schemas/BulkOperationError'}
- for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
- for method in ('put', 'patch'):
- with self.subTest(path=path, method=method):
- self.assertEqual(self._get_response_schema(path, method, '400'), ref)
- def test_bulk_delete_documents_error_responses(self):
- """
- Bulk delete operations should document the 400 (unresolvable request or protection rule), the
- 403 (not permitted) and the 409 (dependent object) responses.
- Refs: #20054
- """
- ref = {'$ref': '#/components/schemas/BulkOperationError'}
- for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
- with self.subTest(path=path):
- self.assertEqual(self._get_response_schema(path, 'delete', '400'), ref)
- self.assertEqual(self._get_response_schema(path, 'delete', '403'), ref)
- self.assertEqual(self._get_response_schema(path, 'delete', '409'), ref)
- def test_bulk_write_operations_document_forbidden_response(self):
- """
- Every bulk write should document the 403 returned when an object-level permission refuses one
- of the objects specified.
- Refs: #20054
- """
- ref = {'$ref': '#/components/schemas/BulkOperationError'}
- for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
- for method in ('post', 'put', 'patch', 'delete'):
- with self.subTest(path=path, method=method):
- self.assertEqual(self._get_response_schema(path, method, '403'), ref)
- def test_create_documents_error_response_for_either_shape(self):
- """
- A POST to a list endpoint accepts either a single object or a list, so its 400 response
- should document both the field-keyed and the bulk error shapes.
- Refs: #20054
- """
- for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
- with self.subTest(path=path):
- schema = self._get_response_schema(path, 'post', '400')
- self.assertEqual(
- schema['oneOf'],
- [
- {'type': 'object', 'additionalProperties': {}},
- {'$ref': '#/components/schemas/BulkOperationError'},
- ],
- )
- def test_detail_operations_omit_bulk_error_response(self):
- """
- The bulk error body applies only to list endpoints; detail endpoints must not advertise it.
- Refs: #20054
- """
- path = '/api/dcim/sites/{id}/'
- for method in ('get', 'put', 'patch', 'delete'):
- with self.subTest(method=method):
- responses = self.schema['paths'][path][method]['responses']
- self.assertNotIn('409', responses)
- self.assertNotIn('403', responses)
- for code, response in responses.items():
- schema = response.get('content', {}).get('application/json', {}).get('schema', {})
- self.assertNotEqual(
- schema.get('$ref'), '#/components/schemas/BulkOperationError',
- f"{method.upper()} {path} ({code}) should not reference the bulk error body"
- )
- def test_service_request_documents_legacy_protocol_and_ports(self):
- """
- The deprecated protocol/ports pair remains writable on application services (the serializer
- translates it into port_mappings), so both must appear in the request body alongside
- port_mappings. protocol is backed by a read-only model property rather than a model field,
- which previously caused it to be dropped from the generated writable variant.
- Refs: #20285
- """
- for path in ('/api/ipam/services/', '/api/ipam/service-templates/'):
- with self.subTest(path=path):
- schema = self.schema['paths'][path]['post']['requestBody']['content']['application/json']['schema']
- ref = schema['oneOf'][0]['$ref'].split('/')[-1]
- properties = self.schema['components']['schemas'][ref]['properties']
- for field in ('port_mappings', 'protocol', 'ports'):
- self.assertIn(field, properties, f"{ref} should document the '{field}' field")
- def test_nested_related_fields_reference_brief_components(self):
- """
- A SerializedPKRelatedField declared with nested=True must reference the brief component in
- response schemas, as that is what the API returns.
- Refs: #22989
- """
- components = self.schema['components']['schemas']
- for component, field, ref in (
- ('Site', 'asns', 'BriefASN'),
- ('ConfigContext', 'sites', 'BriefSite'),
- ('Interface', 'tagged_vlans', 'BriefVLAN'),
- ):
- with self.subTest(component=component, field=field):
- self.assertEqual(
- components[component]['properties'][field]['items']['$ref'],
- f'#/components/schemas/{ref}'
- )
- # The brief component must advertise only the serializer's brief fields
- self.assertEqual(
- set(components['BriefASN']['properties']),
- {'id', 'url', 'display', 'asn', 'description'}
- )
- def test_ref_name_exempts_serializer_from_brief_prefix(self):
- """
- A serializer which declares an explicit Meta.ref_name keeps that name when nested, rather than
- acquiring a Brief prefix. These serializers are brief by design and have no complete form in the
- schema, so prefixing them would rename an existing component to no purpose.
- Refs: #22989
- """
- components = self.schema['components']['schemas']
- for component, field, ref in (
- ('ASN', 'sites', 'ASNSite'),
- ('ObjectPermission', 'groups', 'NestedGroup'),
- ('ObjectPermission', 'users', 'NestedUser'),
- ):
- with self.subTest(component=component, field=field):
- self.assertEqual(
- components[component]['properties'][field]['items']['$ref'],
- f'#/components/schemas/{ref}'
- )
- self.assertNotIn(f'Brief{ref}', components)
- def test_non_nested_related_fields_reference_full_components(self):
- """
- A SerializedPKRelatedField declared without nested=True must continue to reference the
- complete component.
- Refs: #22989
- """
- components = self.schema['components']['schemas']
- for field in ('import_targets', 'export_targets'):
- with self.subTest(field=field):
- self.assertEqual(
- components['VRF']['properties'][field]['items']['$ref'],
- '#/components/schemas/RouteTarget'
- )
- def test_nested_related_fields_accept_pks_on_write(self):
- """
- Request schemas for a SerializedPKRelatedField must continue to accept an array of integer
- primary keys.
- Refs: #22989
- """
- components = self.schema['components']['schemas']
- for component, field in (
- ('SiteRequest', 'asns'),
- ('ConfigContextRequest', 'sites'),
- ('ASNRequest', 'sites'),
- ):
- with self.subTest(component=component, field=field):
- self.assertEqual(components[component]['properties'][field]['items']['type'], 'integer')
- def test_script_run_operation_exists(self):
- """
- Encodes presence of extras_scripts_run operation in schema as expected.
- Refs: #22569
- """
- paths = self.schema['paths']
- resource_path = paths['/api/extras/scripts/{id}/']
- self.assertIn('post', resource_path)
- run_operation = resource_path['post']
- self.assertEqual(run_operation['operationId'], 'extras_scripts_run')
- self.assertEqual(len(run_operation['responses']), 1)
- self.assertIn('200', run_operation['responses'])
- class WritableFieldRebuildTestCase(TestCase):
- """
- Tests for NetBoxAutoSchema._rebuilds_as_writable(), which decides whether a declared
- ChoiceField/WritableNestedSerializer can be nulled out on the generated writable variant and
- left for DRF to rebuild from the model. Getting this wrong drops the field from the request
- body silently, so the predicate must match DRF's own build_field() behavior rather than merely
- testing the model for a field of that name.
- Refs: #23083
- """
- def test_rebuildable_fields(self):
- """Fields DRF can rebuild writably should be reported as such."""
- serializer = ServiceSerializer()
- for field_name in ('name', 'description', 'ipaddresses'):
- with self.subTest(field_name=field_name):
- self.assertTrue(NetBoxAutoSchema._rebuilds_as_writable(serializer, field_name))
- def test_non_rebuildable_fields(self):
- """
- Fields DRF rebuilds as read-only (or cannot rebuild at all) must be reported as not
- rebuildable, so that the declared field is retained instead.
- """
- serializer = ServiceSerializer()
- cases = {
- 'protocol': "backed by a read-only model property, not a model field",
- 'parent': "a GenericForeignKey, absent from DRF's field info",
- 'created': "a non-editable model field",
- 'no_such_field': "not present on the model at all",
- }
- for field_name, reason in cases.items():
- with self.subTest(field_name=field_name):
- self.assertFalse(
- NetBoxAutoSchema._rebuilds_as_writable(serializer, field_name),
- f"'{field_name}' should not be considered rebuildable ({reason})"
- )
- def test_serializer_without_model(self):
- """A serializer with no Meta.model has nothing to rebuild from."""
- self.assertFalse(NetBoxAutoSchema._rebuilds_as_writable(BulkOperationErrorSerializer(), 'id'))
- class SerializedPKRelatedFieldSchemaTestCase(SimpleTestCase):
- """Tests for the schema extension which maps SerializedPKRelatedField."""
- class DummyComponent:
- ref = {'$ref': '#/components/schemas/Dummy'}
- class DummyAutoSchema:
- """Records the serializer resolved by the extension, in place of generating a component."""
- def __init__(self):
- self.resolved = []
- def resolve_serializer(self, serializer, direction):
- self.resolved.append(serializer)
- return SerializedPKRelatedFieldSchemaTestCase.DummyComponent
- def test_nested_flag_is_passed_to_serializer(self):
- """
- The field's serializer must be instantiated with the field's nested setting, so that the
- component matching the rendered representation is referenced.
- Refs: #22989
- """
- for nested in (True, False):
- with self.subTest(nested=nested):
- field = SerializedPKRelatedField(
- serializer=SiteSerializer,
- queryset=Site.objects.all(),
- nested=nested
- )
- auto_schema = self.DummyAutoSchema()
- schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'response')
- serializer = auto_schema.resolved[0]
- self.assertIsInstance(serializer, SiteSerializer)
- self.assertEqual(serializer.nested, nested)
- self.assertEqual(schema, self.DummyComponent.ref)
- def test_request_schema_is_an_integer(self):
- """
- Request schemas must document an integer primary key, regardless of the nested setting.
- Refs: #22989
- """
- field = SerializedPKRelatedField(serializer=SiteSerializer, queryset=Site.objects.all(), nested=True)
- auto_schema = self.DummyAutoSchema()
- schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'request')
- self.assertEqual(schema['type'], 'integer')
- self.assertEqual(auto_schema.resolved, [])
|