[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
This commit is contained in:
Pratyush Bhandari
2026-10-02 15:15:53 +02:00
committed by GitHub
parent 3c76e236c2
commit e4a42b0399
9 changed files with 247 additions and 11 deletions
+69
View File
@@ -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."""
+2 -1
View File
@@ -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',
}
@@ -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
@@ -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']
@@ -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
@@ -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
+165
View File
@@ -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
@@ -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
+1
View File
@@ -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,