From e4a42b0399a929586060a55e2fac4f8d0ec85f68 Mon Sep 17 00:00:00 2001 From: Pratyush Bhandari <112966709+prat98@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:15:53 -0400 Subject: [PATCH] [AAP-93983] Update schema to add pattern fields for credential types (#16670) * Update schema to add pattern fields for credential types * Update pattern field to be camel case to match pattern field on OPTIONS * Fix lint issues * Add unit tests for clean text pattern hook --- awx/api/schema.py | 69 ++++++++ awx/api/validation_patterns.py | 3 +- .../functional/api/test_credential_type.py | 4 +- .../api/test_notification_templates.py | 4 +- .../tests/functional/api/test_survey_spec.py | 2 +- .../test_credential_type_serializer.py | 2 +- awx/main/tests/unit/api/test_schema.py | 165 ++++++++++++++++++ .../unit/api/test_validation_patterns.py | 8 +- awx/settings/defaults.py | 1 + 9 files changed, 247 insertions(+), 11 deletions(-) diff --git a/awx/api/schema.py b/awx/api/schema.py index efab2a6a47..37fcb201ad 100644 --- a/awx/api/schema.py +++ b/awx/api/schema.py @@ -86,6 +86,75 @@ def inject_ai_descriptions( return result +def inject_clean_text_pattern_components( + result, + generator, # NOSONAR + request, # NOSONAR + public, # NOSONAR +): + """Declare CleanText pattern fields on CredentialType.inputs in OpenAPI. + + Models ``inputs`` to match runtime credential-type catalogs: ``fields[]`` + items are ``CleanTextNestedStringField`` (optional pattern / patternDescription / + flags; no normalize). ``required`` lists field ids; ``metadata`` is optional + and only present on some types. Other keys (e.g. ``dependencies``) remain + allowed via ``additionalProperties``. + """ + try: + from ansible_base.api_documentation.clean_text_schema_hooks import ( + inject_clean_text_pattern_components as _dab_inject, + ) + except ImportError: # pragma: no cover - older DAB without shared schemas + return result + + result = _dab_inject(result, generator, request, public) + schemas = result.get('components', {}).get('schemas', {}) + field_item_ref = {'$ref': '#/components/schemas/CleanTextNestedStringField'} + for schema_name in ( + 'CredentialType', + 'CredentialTypeRequest', + 'PatchedCredentialTypeRequest', + ): + schema = schemas.get(schema_name) + if not isinstance(schema, dict): + continue + props = schema.setdefault('properties', {}) + existing = props.get('inputs') if isinstance(props.get('inputs'), dict) else {} + base_description = existing.get('description') or ('Enter inputs using either JSON or YAML syntax. Refer to the documentation for example syntax.') + note = ( + 'When ENHANCED_INPUT_VALIDATION_ENABLED is on, non-secret string ' + 'entries in fields[] may include optional pattern, patternDescription, ' + 'and flags (Tier 2). Secret and non-string fields omit them. ' + 'Requiredness is expressed via inputs.required, not per-field required.' + ) + description = base_description if note in base_description else f'{base_description} {note}'.strip() + props['inputs'] = { + 'type': 'object', + 'description': description, + 'properties': { + 'fields': { + 'type': 'array', + 'description': 'Input field catalog. Dynamic per credential type.', + 'items': field_item_ref, + }, + 'required': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional list of field ids that are required.', + }, + 'metadata': { + 'type': 'array', + 'description': ('Optional. Present only on some external-secret credential types; same item shape as fields[].'), + 'items': field_item_ref, + }, + }, + 'additionalProperties': True, + } + if 'default' in existing: + props['inputs']['default'] = existing['default'] + return result + + class CustomAutoSchema(AutoSchema): """Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints.""" diff --git a/awx/api/validation_patterns.py b/awx/api/validation_patterns.py index 7d9e724c6a..05ebda88a2 100644 --- a/awx/api/validation_patterns.py +++ b/awx/api/validation_patterns.py @@ -68,7 +68,8 @@ def free_text_pattern_metadata(): return None return { 'pattern': build_tier2_frontend_pattern(), - 'pattern_description': TIER2_PATTERN_DESCRIPTION, + # camelCase to match DAB OPTIONS / authenticator_plugins (patternDescription). + 'patternDescription': TIER2_PATTERN_DESCRIPTION, 'flags': 'i', } diff --git a/awx/main/tests/functional/api/test_credential_type.py b/awx/main/tests/functional/api/test_credential_type.py index 618844089a..739415e40e 100644 --- a/awx/main/tests/functional/api/test_credential_type.py +++ b/awx/main/tests/functional/api/test_credential_type.py @@ -602,7 +602,7 @@ def test_credential_type_fields_include_patterns_when_toggle_on(get, admin, fake assert response.status_code == 200 fields = {f['id']: f for f in response.data['inputs']['fields']} assert fields['username']['pattern'] == FAKE_TIER2_PATTERN - assert 'pattern_description' in fields['username'] + assert 'patternDescription' in fields['username'] assert 'pattern' not in fields['api_token'] assert 'pattern' not in fields['verify_ssl'] @@ -621,7 +621,7 @@ def test_credential_type_fields_omit_patterns_when_toggle_off(get, admin, fake_t assert response.status_code == 200 username = response.data['inputs']['fields'][0] assert 'pattern' not in username - assert 'pattern_description' not in username + assert 'patternDescription' not in username @pytest.mark.django_db diff --git a/awx/main/tests/functional/api/test_notification_templates.py b/awx/main/tests/functional/api/test_notification_templates.py index 0961c59742..b320097a1c 100644 --- a/awx/main/tests/functional/api/test_notification_templates.py +++ b/awx/main/tests/functional/api/test_notification_templates.py @@ -108,7 +108,7 @@ def test_notification_options_include_patterns_when_toggle_on(options, admin, fa assert response.status_code == 200 email = response.data['actions']['POST']['notification_configuration']['email'] assert email['host']['pattern'] == FAKE_TIER2_PATTERN - assert 'pattern_description' in email['host'] + assert 'patternDescription' in email['host'] assert 'pattern' in email['username'] assert 'pattern' in email['sender'] assert 'pattern' not in email['password'] @@ -123,4 +123,4 @@ def test_notification_options_omit_patterns_when_toggle_off(options, admin, fake assert response.status_code == 200 email = response.data['actions']['POST']['notification_configuration']['email'] assert 'pattern' not in email['host'] - assert 'pattern_description' not in email['host'] + assert 'patternDescription' not in email['host'] diff --git a/awx/main/tests/functional/api/test_survey_spec.py b/awx/main/tests/functional/api/test_survey_spec.py index eaa803f33d..fbb80bd2cb 100644 --- a/awx/main/tests/functional/api/test_survey_spec.py +++ b/awx/main/tests/functional/api/test_survey_spec.py @@ -298,7 +298,7 @@ def test_survey_spec_options_include_patterns_when_toggle_on(options, admin, job assert response.status_code == 200 post = response.data['actions']['POST'] assert post['name']['pattern'] == FAKE_SURVEY_TIER2_PATTERN - assert 'pattern_description' in post['name'] + assert 'patternDescription' in post['name'] assert post['description']['pattern'] == FAKE_SURVEY_TIER2_PATTERN assert post['spec']['question_name']['pattern'] == FAKE_SURVEY_TIER2_PATTERN assert post['spec']['variable']['pattern'] == FAKE_SURVEY_TIER2_PATTERN diff --git a/awx/main/tests/unit/api/serializers/test_credential_type_serializer.py b/awx/main/tests/unit/api/serializers/test_credential_type_serializer.py index d812105698..d758d90dfc 100644 --- a/awx/main/tests/unit/api/serializers/test_credential_type_serializer.py +++ b/awx/main/tests/unit/api/serializers/test_credential_type_serializer.py @@ -55,7 +55,7 @@ class TestCredentialTypeSerializerInjectsPatterns: fields = {f['id']: f for f in result['inputs']['fields']} assert fields['username'].get('pattern') == FAKE_TIER2_PATTERN - assert 'pattern_description' in fields['username'] + assert 'patternDescription' in fields['username'] assert 'pattern' not in fields['token'] metadata = {f['id']: f for f in result['inputs']['metadata']} assert metadata['key'].get('pattern') == FAKE_TIER2_PATTERN diff --git a/awx/main/tests/unit/api/test_schema.py b/awx/main/tests/unit/api/test_schema.py index 74c2ad2e98..1b5d4d9901 100644 --- a/awx/main/tests/unit/api/test_schema.py +++ b/awx/main/tests/unit/api/test_schema.py @@ -12,6 +12,7 @@ from awx.api.schema import ( AuthenticatedSpectacularRedocView, filter_credential_type_schema, inject_ai_descriptions, + inject_clean_text_pattern_components, ) @@ -549,3 +550,167 @@ class TestInjectAiDescriptions: inject_ai_descriptions(result, None, None, None) assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get'] + + +class TestInjectCleanTextPatternComponents: + """Unit tests for inject_clean_text_pattern_components postprocessing hook.""" + + FIELD_ITEM_REF = {'$ref': '#/components/schemas/CleanTextNestedStringField'} + TARGET_SCHEMAS = ( + 'CredentialType', + 'CredentialTypeRequest', + 'PatchedCredentialTypeRequest', + ) + ENHANCED_NOTE = ( + 'When ENHANCED_INPUT_VALIDATION_ENABLED is on, non-secret string ' + 'entries in fields[] may include optional pattern, patternDescription, ' + 'and flags (Tier 2). Secret and non-string fields omit them. ' + 'Requiredness is expressed via inputs.required, not per-field required.' + ) + + def _make_result(self, schemas=None): + """Build a minimal OpenAPI result with optional CredentialType schemas.""" + if schemas is None: + schemas = {name: {'properties': {'inputs': {'type': 'object'}}} for name in self.TARGET_SCHEMAS} + return {'components': {'schemas': schemas}} + + def _assert_inputs_shape(self, inputs, *, expect_default=False, expect_note=True): + assert inputs['type'] == 'object' + assert inputs['additionalProperties'] is True + assert inputs['properties']['fields'] == { + 'type': 'array', + 'description': 'Input field catalog. Dynamic per credential type.', + 'items': self.FIELD_ITEM_REF, + } + assert inputs['properties']['required'] == { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional list of field ids that are required.', + } + assert inputs['properties']['metadata']['type'] == 'array' + assert inputs['properties']['metadata']['items'] == self.FIELD_ITEM_REF + if expect_note: + assert self.ENHANCED_NOTE in inputs['description'] + if expect_default: + assert 'default' in inputs + else: + assert 'default' not in inputs + + def test_injects_dab_components_and_shapes_credential_type_inputs(self): + """Hook registers shared CleanText components and shapes CredentialType.inputs.""" + result = self._make_result() + + returned = inject_clean_text_pattern_components(result, None, None, None) + + schemas = result['components']['schemas'] + assert 'CleanTextNestedStringField' in schemas + assert 'CleanTextFieldInfo' in schemas + for name in self.TARGET_SCHEMAS: + self._assert_inputs_shape(schemas[name]['properties']['inputs']) + assert returned is result + + def test_preserves_existing_description_and_appends_note(self): + """Existing inputs.description is kept and the enhanced-validation note is appended once.""" + existing = 'Custom inputs description.' + result = self._make_result( + { + 'CredentialType': { + 'properties': { + 'inputs': { + 'type': 'object', + 'description': existing, + } + } + } + } + ) + + inject_clean_text_pattern_components(result, None, None, None) + + description = result['components']['schemas']['CredentialType']['properties']['inputs']['description'] + assert description.startswith(existing) + assert self.ENHANCED_NOTE in description + assert description.count(self.ENHANCED_NOTE) == 1 + + def test_does_not_duplicate_note_when_already_present(self): + """Re-running the hook must not append the enhanced-validation note twice.""" + base = 'Enter inputs using either JSON or YAML syntax.' + result = self._make_result( + { + 'CredentialTypeRequest': { + 'properties': { + 'inputs': { + 'type': 'object', + 'description': f'{base} {self.ENHANCED_NOTE}', + } + } + } + } + ) + + inject_clean_text_pattern_components(result, None, None, None) + + description = result['components']['schemas']['CredentialTypeRequest']['properties']['inputs']['description'] + assert description.count(self.ENHANCED_NOTE) == 1 + + def test_preserves_existing_default(self): + """Existing inputs.default is preserved on the reshaped schema.""" + result = self._make_result( + { + 'PatchedCredentialTypeRequest': { + 'properties': { + 'inputs': { + 'type': 'object', + 'default': {'fields': []}, + } + } + } + } + ) + + inject_clean_text_pattern_components(result, None, None, None) + + inputs = result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['inputs'] + self._assert_inputs_shape(inputs, expect_default=True) + assert inputs['default'] == {'fields': []} + + def test_skips_missing_and_non_dict_schemas(self): + """Missing or non-dict schemas are left alone; other schemas are untouched.""" + result = { + 'components': { + 'schemas': { + 'CredentialType': 'not-a-dict', + 'OtherSchema': {'properties': {'inputs': {'type': 'string'}}}, + } + } + } + other_before = copy.deepcopy(result['components']['schemas']['OtherSchema']) + + inject_clean_text_pattern_components(result, None, None, None) + + assert result['components']['schemas']['CredentialType'] == 'not-a-dict' + assert result['components']['schemas']['OtherSchema'] == other_before + assert 'CredentialTypeRequest' not in result['components']['schemas'] + + def test_handles_empty_result(self): + """Empty result still gets shared CleanText components from DAB.""" + result = {} + + returned = inject_clean_text_pattern_components(result, None, None, None) + + assert 'CleanTextNestedStringField' in result['components']['schemas'] + assert returned is result + + def test_import_error_returns_result_unchanged(self): + """When DAB shared schemas are unavailable, the hook is a no-op.""" + result = self._make_result() + original = copy.deepcopy(result) + + with patch.dict( + 'sys.modules', + {'ansible_base.api_documentation.clean_text_schema_hooks': None}, + ): + returned = inject_clean_text_pattern_components(result, None, None, None) + + assert result == original + assert returned is result diff --git a/awx/main/tests/unit/api/test_validation_patterns.py b/awx/main/tests/unit/api/test_validation_patterns.py index cf790e2dfb..a420d54ebf 100644 --- a/awx/main/tests/unit/api/test_validation_patterns.py +++ b/awx/main/tests/unit/api/test_validation_patterns.py @@ -32,14 +32,14 @@ class TestInjectFreeTextPattern: with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False): validation_patterns.inject_free_text_pattern(schema) assert 'pattern' not in schema - assert 'pattern_description' not in schema + assert 'patternDescription' not in schema def test_injects_tier2_when_toggle_on(self, fake_tier2_pattern): schema = {'id': 'username', 'label': 'Username', 'type': 'string'} with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True): validation_patterns.inject_free_text_pattern(schema) assert schema['pattern'] == FAKE_PATTERN - assert schema['pattern_description'] == validation_patterns.TIER2_PATTERN_DESCRIPTION + assert schema['patternDescription'] == validation_patterns.TIER2_PATTERN_DESCRIPTION assert schema['flags'] == 'i' def test_skips_secret_fields(self, fake_tier2_pattern): @@ -77,7 +77,7 @@ class TestFreeTextPatternMetadata: result = validation_patterns.free_text_pattern_metadata() assert result is not None assert result['pattern'] == FAKE_PATTERN - assert result['pattern_description'] == validation_patterns.TIER2_PATTERN_DESCRIPTION + assert result['patternDescription'] == validation_patterns.TIER2_PATTERN_DESCRIPTION assert result['flags'] == 'i' def test_returns_none_when_helper_missing(self, monkeypatch): @@ -376,7 +376,7 @@ class TestBuildSurveySpecOptionsSchema: with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True): schema = validation_patterns.build_survey_spec_options_schema() assert schema['name']['pattern'] == FAKE_PATTERN - assert schema['name']['pattern_description'] == validation_patterns.TIER2_PATTERN_DESCRIPTION + assert schema['name']['patternDescription'] == validation_patterns.TIER2_PATTERN_DESCRIPTION assert schema['description']['pattern'] == FAKE_PATTERN assert schema['spec']['type'] == 'json' assert schema['spec']['question_name']['pattern'] == FAKE_PATTERN diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 43d7bac0ca..fc9421229d 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1057,6 +1057,7 @@ SPECTACULAR_SETTINGS = { 'POSTPROCESSING_HOOKS': [ 'awx.api.schema.filter_credential_type_schema', 'awx.api.schema.inject_ai_descriptions', + 'awx.api.schema.inject_clean_text_pattern_components', ], 'SWAGGER_UI_SETTINGS': { 'deepLinking': True,