diff --git a/awx/api/schema.py b/awx/api/schema.py index 05e9955b74..9be7141961 100644 --- a/awx/api/schema.py +++ b/awx/api/schema.py @@ -9,6 +9,47 @@ from drf_spectacular.views import ( ) +def filter_credential_type_schema(result, _generator, _request, _public): + """ + Postprocessing hook to filter CredentialType kind enum values. + + For CredentialTypeRequest and PatchedCredentialTypeRequest schemas (POST/PUT/PATCH), + filter the 'kind' enum to only show 'cloud' and 'net' values. + + This ensures the OpenAPI schema accurately reflects that only 'cloud' and 'net' + credential types can be created or modified via the API, matching the validation + in CredentialTypeSerializer.validate(). + + Args: + result: The OpenAPI schema dict to be modified + _generator: Schema generator instance (required by drf-spectacular, unused) + _request: Request object (required by drf-spectacular, unused) + _public: Public schema flag (required by drf-spectacular, unused) + + Returns: + The modified OpenAPI schema dict + """ + schemas = result.get('components', {}).get('schemas', {}) + + # Filter CredentialTypeRequest (POST/PUT) - field is required + if 'CredentialTypeRequest' in schemas: + kind_prop = schemas['CredentialTypeRequest'].get('properties', {}).get('kind', {}) + if 'enum' in kind_prop: + # Filter to only cloud and net (no None - field is required) + kind_prop['enum'] = ['cloud', 'net'] + kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network" + + # Filter PatchedCredentialTypeRequest (PATCH) - field is optional + if 'PatchedCredentialTypeRequest' in schemas: + kind_prop = schemas['PatchedCredentialTypeRequest'].get('properties', {}).get('kind', {}) + if 'enum' in kind_prop: + # Filter to only cloud and net (None allowed - field can be omitted in PATCH) + kind_prop['enum'] = ['cloud', 'net', None] + kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network" + + return result + + class CustomAutoSchema(AutoSchema): """Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints.""" diff --git a/awx/main/tests/unit/api/test_schema.py b/awx/main/tests/unit/api/test_schema.py index b6b59c2edc..83ade9e16c 100644 --- a/awx/main/tests/unit/api/test_schema.py +++ b/awx/main/tests/unit/api/test_schema.py @@ -1,3 +1,4 @@ +import copy import warnings from unittest.mock import Mock, patch @@ -8,6 +9,7 @@ from awx.api.schema import ( AuthenticatedSpectacularAPIView, AuthenticatedSpectacularSwaggerView, AuthenticatedSpectacularRedocView, + filter_credential_type_schema, ) @@ -271,3 +273,152 @@ class TestAuthenticatedSchemaViews: def test_authenticated_spectacular_redoc_view_requires_authentication(self): """Test that AuthenticatedSpectacularRedocView requires authentication.""" assert IsAuthenticated in AuthenticatedSpectacularRedocView.permission_classes + + +class TestFilterCredentialTypeSchema: + """Unit tests for filter_credential_type_schema postprocessing hook.""" + + def test_filters_both_schemas_correctly(self): + """Test that both CredentialTypeRequest and PatchedCredentialTypeRequest schemas are filtered.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': { + 'properties': { + 'kind': { + 'enum': [ + 'ssh', + 'vault', + 'net', + 'scm', + 'cloud', + 'registry', + 'token', + 'insights', + 'external', + 'kubernetes', + 'galaxy', + 'cryptography', + None, + ], + 'type': 'string', + } + } + }, + 'PatchedCredentialTypeRequest': { + 'properties': { + 'kind': { + 'enum': [ + 'ssh', + 'vault', + 'net', + 'scm', + 'cloud', + 'registry', + 'token', + 'insights', + 'external', + 'kubernetes', + 'galaxy', + 'cryptography', + None, + ], + 'type': 'string', + } + } + }, + } + } + } + + returned = filter_credential_type_schema(result, None, None, None) + + # POST/PUT schema: no None (required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network" + + # PATCH schema: includes None (optional field) + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None] + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network" + + # Other properties should be preserved + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['type'] == 'string' + + # Function should return the result + assert returned is result + + def test_handles_empty_result(self): + """Test graceful handling when result dict is empty.""" + result = {} + original = copy.deepcopy(result) + + returned = filter_credential_type_schema(result, None, None, None) + + assert result == original + assert returned is result + + def test_handles_missing_enum(self): + """Test that schemas without enum key are not modified.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'type': 'string', 'description': 'Some description'}}}}}} + original = copy.deepcopy(result) + + filter_credential_type_schema(result, None, None, None) + + assert result == original + + def test_filters_only_target_schemas(self): + """Test that only CredentialTypeRequest schemas are modified, not others.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}}, + 'OtherSchema': {'properties': {'kind': {'enum': ['option1', 'option2']}}}, + } + } + } + + other_schema_before = copy.deepcopy(result['components']['schemas']['OtherSchema']) + + filter_credential_type_schema(result, None, None, None) + + # CredentialTypeRequest should be filtered (no None for required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + # OtherSchema should be unchanged + assert result['components']['schemas']['OtherSchema'] == other_schema_before + + def test_handles_only_one_schema_present(self): + """Test that function works when only one target schema is present.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}}}}} + + filter_credential_type_schema(result, None, None, None) + + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + def test_handles_missing_properties(self): + """Test graceful handling when schema has no properties key.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {}}}} + original = copy.deepcopy(result) + + filter_credential_type_schema(result, None, None, None) + + assert result == original + + def test_differentiates_required_vs_optional_fields(self): + """Test that CredentialTypeRequest excludes None but PatchedCredentialTypeRequest includes it.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}}, + 'PatchedCredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}}, + } + } + } + + filter_credential_type_schema(result, None, None, None) + + # POST/PUT schema: no None (required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + # PATCH schema: includes None (optional field) + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None] diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 399c4fe1fa..f52e2ea133 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1032,6 +1032,8 @@ SPECTACULAR_SETTINGS = { # Use our custom schema class that handles swagger_topic and deprecated views 'DEFAULT_SCHEMA_CLASS': 'awx.api.schema.CustomAutoSchema', 'COMPONENT_SPLIT_REQUEST': True, + # Postprocessing hook to filter CredentialType enum values + 'POSTPROCESSING_HOOKS': ['awx.api.schema.filter_credential_type_schema'], 'SWAGGER_UI_SETTINGS': { 'deepLinking': True, 'persistAuthorization': True,