mirror of
https://github.com/ansible/awx.git
synced 2026-10-31 06:26:15 -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
|
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):
|
class CustomAutoSchema(AutoSchema):
|
||||||
"""Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints."""
|
"""Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints."""
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ def free_text_pattern_metadata():
|
|||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
'pattern': build_tier2_frontend_pattern(),
|
'pattern': build_tier2_frontend_pattern(),
|
||||||
'pattern_description': TIER2_PATTERN_DESCRIPTION,
|
# camelCase to match DAB OPTIONS / authenticator_plugins (patternDescription).
|
||||||
|
'patternDescription': TIER2_PATTERN_DESCRIPTION,
|
||||||
'flags': 'i',
|
'flags': 'i',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ def test_credential_type_fields_include_patterns_when_toggle_on(get, admin, fake
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
fields = {f['id']: f for f in response.data['inputs']['fields']}
|
fields = {f['id']: f for f in response.data['inputs']['fields']}
|
||||||
assert fields['username']['pattern'] == FAKE_TIER2_PATTERN
|
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['api_token']
|
||||||
assert 'pattern' not in fields['verify_ssl']
|
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
|
assert response.status_code == 200
|
||||||
username = response.data['inputs']['fields'][0]
|
username = response.data['inputs']['fields'][0]
|
||||||
assert 'pattern' not in username
|
assert 'pattern' not in username
|
||||||
assert 'pattern_description' not in username
|
assert 'patternDescription' not in username
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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
|
assert response.status_code == 200
|
||||||
email = response.data['actions']['POST']['notification_configuration']['email']
|
email = response.data['actions']['POST']['notification_configuration']['email']
|
||||||
assert email['host']['pattern'] == FAKE_TIER2_PATTERN
|
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['username']
|
||||||
assert 'pattern' in email['sender']
|
assert 'pattern' in email['sender']
|
||||||
assert 'pattern' not in email['password']
|
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
|
assert response.status_code == 200
|
||||||
email = response.data['actions']['POST']['notification_configuration']['email']
|
email = response.data['actions']['POST']['notification_configuration']['email']
|
||||||
assert 'pattern' not in email['host']
|
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
|
assert response.status_code == 200
|
||||||
post = response.data['actions']['POST']
|
post = response.data['actions']['POST']
|
||||||
assert post['name']['pattern'] == FAKE_SURVEY_TIER2_PATTERN
|
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['description']['pattern'] == FAKE_SURVEY_TIER2_PATTERN
|
||||||
assert post['spec']['question_name']['pattern'] == FAKE_SURVEY_TIER2_PATTERN
|
assert post['spec']['question_name']['pattern'] == FAKE_SURVEY_TIER2_PATTERN
|
||||||
assert post['spec']['variable']['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']}
|
fields = {f['id']: f for f in result['inputs']['fields']}
|
||||||
assert fields['username'].get('pattern') == FAKE_TIER2_PATTERN
|
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']
|
assert 'pattern' not in fields['token']
|
||||||
metadata = {f['id']: f for f in result['inputs']['metadata']}
|
metadata = {f['id']: f for f in result['inputs']['metadata']}
|
||||||
assert metadata['key'].get('pattern') == FAKE_TIER2_PATTERN
|
assert metadata['key'].get('pattern') == FAKE_TIER2_PATTERN
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from awx.api.schema import (
|
|||||||
AuthenticatedSpectacularRedocView,
|
AuthenticatedSpectacularRedocView,
|
||||||
filter_credential_type_schema,
|
filter_credential_type_schema,
|
||||||
inject_ai_descriptions,
|
inject_ai_descriptions,
|
||||||
|
inject_clean_text_pattern_components,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -549,3 +550,167 @@ class TestInjectAiDescriptions:
|
|||||||
inject_ai_descriptions(result, None, None, None)
|
inject_ai_descriptions(result, None, None, None)
|
||||||
|
|
||||||
assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get']
|
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):
|
with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False):
|
||||||
validation_patterns.inject_free_text_pattern(schema)
|
validation_patterns.inject_free_text_pattern(schema)
|
||||||
assert 'pattern' not in 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):
|
def test_injects_tier2_when_toggle_on(self, fake_tier2_pattern):
|
||||||
schema = {'id': 'username', 'label': 'Username', 'type': 'string'}
|
schema = {'id': 'username', 'label': 'Username', 'type': 'string'}
|
||||||
with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True):
|
with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True):
|
||||||
validation_patterns.inject_free_text_pattern(schema)
|
validation_patterns.inject_free_text_pattern(schema)
|
||||||
assert schema['pattern'] == FAKE_PATTERN
|
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'
|
assert schema['flags'] == 'i'
|
||||||
|
|
||||||
def test_skips_secret_fields(self, fake_tier2_pattern):
|
def test_skips_secret_fields(self, fake_tier2_pattern):
|
||||||
@@ -77,7 +77,7 @@ class TestFreeTextPatternMetadata:
|
|||||||
result = validation_patterns.free_text_pattern_metadata()
|
result = validation_patterns.free_text_pattern_metadata()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result['pattern'] == FAKE_PATTERN
|
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'
|
assert result['flags'] == 'i'
|
||||||
|
|
||||||
def test_returns_none_when_helper_missing(self, monkeypatch):
|
def test_returns_none_when_helper_missing(self, monkeypatch):
|
||||||
@@ -376,7 +376,7 @@ class TestBuildSurveySpecOptionsSchema:
|
|||||||
with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True):
|
with override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True):
|
||||||
schema = validation_patterns.build_survey_spec_options_schema()
|
schema = validation_patterns.build_survey_spec_options_schema()
|
||||||
assert schema['name']['pattern'] == FAKE_PATTERN
|
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['description']['pattern'] == FAKE_PATTERN
|
||||||
assert schema['spec']['type'] == 'json'
|
assert schema['spec']['type'] == 'json'
|
||||||
assert schema['spec']['question_name']['pattern'] == FAKE_PATTERN
|
assert schema['spec']['question_name']['pattern'] == FAKE_PATTERN
|
||||||
|
|||||||
@@ -1057,6 +1057,7 @@ SPECTACULAR_SETTINGS = {
|
|||||||
'POSTPROCESSING_HOOKS': [
|
'POSTPROCESSING_HOOKS': [
|
||||||
'awx.api.schema.filter_credential_type_schema',
|
'awx.api.schema.filter_credential_type_schema',
|
||||||
'awx.api.schema.inject_ai_descriptions',
|
'awx.api.schema.inject_ai_descriptions',
|
||||||
|
'awx.api.schema.inject_clean_text_pattern_components',
|
||||||
],
|
],
|
||||||
'SWAGGER_UI_SETTINGS': {
|
'SWAGGER_UI_SETTINGS': {
|
||||||
'deepLinking': True,
|
'deepLinking': True,
|
||||||
|
|||||||
Reference in New Issue
Block a user