mirror of
https://github.com/ansible/awx.git
synced 2026-10-29 22:03:20 -02:30
[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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user