diff --git a/src/aap_eda/api/metadata.py b/src/aap_eda/api/metadata.py index 249c039c8..c8b23afb6 100644 --- a/src/aap_eda/api/metadata.py +++ b/src/aap_eda/api/metadata.py @@ -1,3 +1,7 @@ +try: + from ansible_base.lib.metadata import inject_clean_text_patterns +except ImportError: # pragma: no cover - DAB without AAP-85987 + inject_clean_text_patterns = None from django.core.exceptions import PermissionDenied from django.http import Http404 from django.utils.encoding import force_str @@ -23,6 +27,8 @@ class EDAMetadata(metadata.SimpleMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) + if inject_clean_text_patterns is not None: + field_info = inject_clean_text_patterns(field, field_info) for attr in ADDITIONAL_ATTRS: value = getattr(field, attr, None) diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 73d3219a8..db8282140 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -12,6 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +try: + from ansible_base.lib.metadata import get_tier2_pattern, validation_enabled +except ImportError: # pragma: no cover - DAB without AAP-85987 + get_tier2_pattern = None + validation_enabled = None + +try: + from ansible_base.lib.serializers.mixins import CleanTextMixin +except ImportError: # pragma: no cover - DAB without AAP-85987 + # Provide a no-op stand-in so the class definition is valid + class CleanTextMixin: + pass from rest_framework import serializers from aap_eda.core import models, validators @@ -37,8 +49,51 @@ class Meta: *read_only_fields, ] + def to_representation(self, instance): + data = super().to_representation(instance) + inputs = data.get("inputs") + if validation_enabled is not None and validation_enabled() and isinstance(inputs, dict): + data["inputs"] = _with_field_patterns(inputs) + return data + + +def _with_field_patterns(inputs: dict) -> dict: + """Return a copy of the inputs schema with patterns for string fields. + + CleanTextMixin (from DAB) enforces free-text validation rules on + serializer string fields at write time (when the ENHANCED_INPUT_VALIDATION_ENABLED setting is turned on). Only non-secret "string" + sub-fields get a pattern here, since those are the only ones its + JSON sub-key validation applies to; secret and boolean fields are + left untouched. + """ + fields = inputs.get("fields") + if not isinstance(fields, list): + return inputs + + if get_tier2_pattern is None: + return inputs + pattern = get_tier2_pattern() + new_fields = [ + { + **field, + "pattern": pattern["pattern"], + "pattern_description": pattern["description"], + } + if isinstance(field, dict) + and field.get("type") == "string" + and not field.get("secret") + else field + for field in fields + ] + return {**inputs, "fields": new_fields} + +class CredentialTypeCreateSerializer( + CleanTextMixin, serializers.ModelSerializer +): + # injectors commonly contain Jinja2 template syntax, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"injectors"}) -class CredentialTypeCreateSerializer(serializers.ModelSerializer): inputs = serializers.JSONField( required=False, default=dict, diff --git a/tests/integration/api/test_credential_type.py b/tests/integration/api/test_credential_type.py index 797517f42..523191228 100644 --- a/tests/integration/api/test_credential_type.py +++ b/tests/integration/api/test_credential_type.py @@ -15,6 +15,7 @@ from unittest.mock import patch import pytest +from django.test import override_settings from pytest_lazyfixture import lazy_fixture from rest_framework import status from rest_framework.test import APIClient @@ -23,6 +24,13 @@ from aap_eda.core.utils.credentials import SUPPORTED_KEYS_IN_INJECTORS from tests.integration.constants import api_url_v1 +try: + from ansible_base.lib.metadata import get_tier2_pattern as _get_tier2_pattern + _has_dab_validation_metadata = True +except ImportError: + _get_tier2_pattern = None + _has_dab_validation_metadata = False + INPUT = { "fields": [ { @@ -1267,3 +1275,85 @@ def test_eda_rule_engine_credential_validates_required_fields( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "postgres_db_name" in str(response.data) + + +@pytest.mark.django_db +class TestCredentialTypeValidationPatterns: + """AAP-87587: pattern/pattern_description injection for JSON sub-keys.""" + + @pytest.mark.skipif(not _has_dab_validation_metadata, reason="DAB validation metadata not available (AAP-85987)") + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) + def test_patterns_present_when_toggle_on( + self, + superuser_client: APIClient, + credential_type: models.CredentialType, + ): + get_tier2_pattern = _get_tier2_pattern + + response = superuser_client.get( + f"{api_url_v1}/credential-types/{credential_type.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + pattern = get_tier2_pattern() + fields_by_id = { + field["id"]: field for field in response.data["inputs"]["fields"] + } + + username_field = fields_by_id["username"] + assert username_field["pattern"] == pattern["pattern"] + assert username_field["pattern_description"] == pattern["description"] + + # secret fields are excluded even though they are also type "string" + password_field = fields_by_id["password"] + assert "pattern" not in password_field + assert "pattern_description" not in password_field + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False) + def test_patterns_absent_when_toggle_off( + self, + superuser_client: APIClient, + credential_type: models.CredentialType, + ): + response = superuser_client.get( + f"{api_url_v1}/credential-types/{credential_type.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + for field in response.data["inputs"]["fields"]: + assert "pattern" not in field + assert "pattern_description" not in field + + +@pytest.mark.django_db +class TestCredentialTypeOptionsValidationPatterns: + """EDAMetadata wires DAB's top-level OPTIONS pattern injection. + + EDA overrides DEFAULT_METADATA_CLASS with its own EDAMetadata, so DAB's + CleanTextMetadata never runs; EDAMetadata.get_field_info() must call + inject_clean_text_patterns() itself for CleanTextMixin serializers to + advertise a pattern on OPTIONS, same as any other DAB consumer. + """ + + @pytest.mark.skipif(not _has_dab_validation_metadata, reason="DAB validation metadata not available (AAP-85987)") + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) + def test_options_includes_pattern_when_toggle_on( + self, superuser_client: APIClient + ): + response = superuser_client.options(f"{api_url_v1}/credential-types/") + assert response.status_code == status.HTTP_200_OK + + description_field = response.data["actions"]["POST"]["description"] + assert "pattern" in description_field + assert "patternDescription" in description_field + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False) + def test_options_excludes_pattern_when_toggle_off( + self, superuser_client: APIClient + ): + response = superuser_client.options(f"{api_url_v1}/credential-types/") + assert response.status_code == status.HTTP_200_OK + + description_field = response.data["actions"]["POST"]["description"] + assert "pattern" not in description_field + assert "patternDescription" not in description_field