From 300ad998d9db128ac73471472e0f7dbf48837fb6 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Tue, 25 Aug 2026 09:05:17 -0700 Subject: [PATCH 01/19] add CleanTextMixin to eda serializers --- src/aap_eda/api/serializers/activation.py | 23 ++++++++++++--- .../serializers/credential_input_source.py | 29 +++++++++++++++---- .../api/serializers/credential_type.py | 17 +++++++++-- .../api/serializers/decision_environment.py | 9 ++++-- src/aap_eda/api/serializers/eda_credential.py | 29 ++++++++++++++----- src/aap_eda/api/serializers/event_stream.py | 7 +++-- src/aap_eda/api/serializers/organization.py | 8 +++-- src/aap_eda/api/serializers/project.py | 11 ++++--- src/aap_eda/api/serializers/team.py | 9 ++++-- src/aap_eda/api/serializers/user.py | 11 +++++-- 10 files changed, 116 insertions(+), 37 deletions(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index 44998b3ca..7862384d7 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -19,6 +19,7 @@ from typing import Optional import yaml +from ansible_base.lib.serializers.mixins import CleanTextMixin from django.conf import settings from django.utils import timezone from rest_framework import serializers @@ -366,9 +367,13 @@ def replace_vault_data(extra_var): return yaml.safe_dump(data) -class ActivationSerializer(serializers.ModelSerializer): +class ActivationSerializer(CleanTextMixin, serializers.ModelSerializer): """Serializer for the Activation model.""" + # extra_var may legitimately contain Jinja2 template syntax + # (e.g. credential injectors), so it is excluded from free-text checks. + excluded_fields = frozenset({"extra_var"}) + eda_credentials = serializers.ListField( required=False, allow_null=True, @@ -577,9 +582,14 @@ class ActivationCreateSerializer( _K8sPodMetadataWriteFields, OrganizationIdFieldMixin, serializers.ModelSerializer, + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): """Serializer for creating the Activation.""" + # extra_var may legitimately contain Jinja2 template syntax + # (e.g. credential injectors), so it is excluded from free-text checks. + excluded_fields = frozenset({"extra_var"}) + class Meta: model = models.Activation fields = [ @@ -665,7 +675,7 @@ def validate(self, data): _validate_sources_with_event_streams(data=data) _validate_persistence_credential(data=data) _normalize_activation_k8s_pod_fields(data) - return data + return super().validate(data) def create(self, validated_data): rulebook_id = validated_data["rulebook_id"] @@ -708,7 +718,7 @@ def create(self, validated_data): return super().create(validated_data) -class ActivationCopySerializer(serializers.ModelSerializer): +class ActivationCopySerializer(CleanTextMixin, serializers.ModelSerializer): name = serializers.CharField( required=True, validators=[validators.check_if_activation_name_used] ) @@ -785,9 +795,14 @@ class ActivationUpdateSerializer( _K8sPodMetadataWriteFields, OrganizationIdFieldMixin, serializers.ModelSerializer, + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): """Serializer for updating the Activation.""" + # extra_var may legitimately contain Jinja2 template syntax + # (e.g. credential injectors), so it is excluded from free-text checks. + excluded_fields = frozenset({"extra_var"}) + class Meta: model = models.Activation fields = [ @@ -897,7 +912,7 @@ def validate(self, data): _validate_sources_with_event_streams(data=data) _validate_persistence_credential(data=data) _normalize_activation_k8s_pod_fields(data) - return data + return super().validate(data) def prepare_update(self, activation: models.Activation): rulebook_id = self.validated_data.get("rulebook_id") diff --git a/src/aap_eda/api/serializers/credential_input_source.py b/src/aap_eda/api/serializers/credential_input_source.py index 260108529..e79ac461b 100644 --- a/src/aap_eda/api/serializers/credential_input_source.py +++ b/src/aap_eda/api/serializers/credential_input_source.py @@ -12,6 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.serializers.mixins import CleanTextMixin from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -46,9 +47,15 @@ class CredentialInputSourceReferenceField(serializers.JSONField): pass -class CredentialInputSourceSerializer(serializers.ModelSerializer): +class CredentialInputSourceSerializer( + CleanTextMixin, serializers.ModelSerializer +): """Serializer used during a GET.""" + # metadata holds credential input values and is populated + # programmatically, so it is excluded from free-text checks. + excluded_fields = frozenset({"metadata"}) + organization = OrganizationRefSerializer() references = EdaCredentialReferenceField(required=False, allow_null=True) created_by = BasicUserFieldSerializer() @@ -99,9 +106,15 @@ def to_representation(self, instance): } -class CredentialInputSourceCreateSerializer(serializers.ModelSerializer): +class CredentialInputSourceCreateSerializer( + CleanTextMixin, serializers.ModelSerializer +): """Serializer used during the Create process of the instance.""" + # metadata holds credential input values and is populated + # programmatically, so it is excluded from free-text checks. + excluded_fields = frozenset({"metadata"}) + target_credential = serializers.PrimaryKeyRelatedField( queryset=models.EdaCredential.objects.all(), required=True, @@ -149,7 +162,7 @@ def validate(self, attrs): attrs.get("input_field_name"), ) - return attrs + return super().validate(attrs) class Meta: model = models.CredentialInputSource @@ -163,9 +176,15 @@ class Meta: ] -class CredentialInputSourceUpdateSerializer(serializers.ModelSerializer): +class CredentialInputSourceUpdateSerializer( + CleanTextMixin, serializers.ModelSerializer +): """Serializer used during update of the instance.""" + # metadata holds credential input values and is populated + # programmatically, so it is excluded from free-text checks. + excluded_fields = frozenset({"metadata"}) + organization_id = serializers.IntegerField( required=True, allow_null=False, @@ -185,7 +204,7 @@ def validate(self, attrs): if bool(errors): raise serializers.ValidationError(errors) - return attrs + return super().validate(attrs) class Meta: model = models.CredentialInputSource diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 73d3219a8..9a15300e5 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -12,13 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.serializers.mixins import CleanTextMixin from rest_framework import serializers from aap_eda.core import models, validators from aap_eda.core.utils.credentials import validate_injectors -class CredentialTypeSerializer(serializers.ModelSerializer): +class CredentialTypeSerializer(CleanTextMixin, serializers.ModelSerializer): + # injectors commonly contain Jinja2 template syntax, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"injectors"}) + class Meta: model = models.CredentialType read_only_fields = [ @@ -38,7 +43,13 @@ class Meta: ] -class CredentialTypeCreateSerializer(serializers.ModelSerializer): +class CredentialTypeCreateSerializer( + CleanTextMixin, serializers.ModelSerializer +): + # injectors commonly contain Jinja2 template syntax, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"injectors"}) + inputs = serializers.JSONField( required=False, default=dict, @@ -63,7 +74,7 @@ def validate(self, data): if bool(errors): raise serializers.ValidationError(errors) - return data + return super().validate(data) class Meta: model = models.CredentialType diff --git a/src/aap_eda/api/serializers/decision_environment.py b/src/aap_eda/api/serializers/decision_environment.py index 2ed335218..baed29abb 100644 --- a/src/aap_eda/api/serializers/decision_environment.py +++ b/src/aap_eda/api/serializers/decision_environment.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.serializers.mixins import CleanTextMixin from rest_framework import serializers from aap_eda.api.serializers.eda_credential import EdaCredentialRefSerializer @@ -22,7 +23,9 @@ from aap_eda.core import models, validators -class DecisionEnvironmentSerializer(serializers.ModelSerializer): +class DecisionEnvironmentSerializer( + CleanTextMixin, serializers.ModelSerializer +): created_by = BasicUserFieldSerializer() modified_by = BasicUserFieldSerializer() @@ -58,7 +61,7 @@ def to_representation(self, decision_environment): class DecisionEnvironmentCreateSerializer( - OrganizationIdFieldMixin, serializers.ModelSerializer + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): """Serializer for creating the DecisionEnvironment.""" @@ -75,7 +78,7 @@ def validate(self, data): image_url = data.get("image_url") or self.instance.image_url validators.check_if_de_valid(image_url, eda_credential_id) - return data + return super().validate(data) class Meta: model = models.DecisionEnvironment diff --git a/src/aap_eda/api/serializers/eda_credential.py b/src/aap_eda/api/serializers/eda_credential.py index c929614f2..44e08fe5a 100644 --- a/src/aap_eda/api/serializers/eda_credential.py +++ b/src/aap_eda/api/serializers/eda_credential.py @@ -13,6 +13,7 @@ # limitations under the License. import logging +from ansible_base.lib.serializers.mixins import CleanTextMixin from django.db.models import Q from django.urls import reverse from drf_spectacular.utils import extend_schema_field @@ -54,7 +55,11 @@ class EdaCredentialReferenceField(serializers.JSONField): pass -class EdaCredentialSerializer(serializers.ModelSerializer): +class EdaCredentialSerializer(CleanTextMixin, serializers.ModelSerializer): + # inputs holds arbitrary secret credential values, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"inputs"}) + inputs = serializers.SerializerMethodField() credential_type = CredentialTypeRefSerializer( required=False, allow_null=True @@ -120,7 +125,9 @@ def to_representation(self, eda_credential): } -class EdaCredentialCopySerializer(serializers.ModelSerializer): +class EdaCredentialCopySerializer( + CleanTextMixin, serializers.ModelSerializer +): name = serializers.CharField( required=True, validators=[validators.check_if_credential_name_used], @@ -135,8 +142,12 @@ class Meta: class EdaCredentialCreateSerializer( - OrganizationIdFieldMixin, serializers.ModelSerializer + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): + # inputs holds arbitrary secret credential values, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"inputs"}) + credential_type_id = serializers.IntegerField( required=True, allow_null=False, @@ -181,7 +192,7 @@ def validate(self, data): if bool(errors): raise serializers.ValidationError(errors) - return data + return super().validate(data) class Meta: model = models.EdaCredential @@ -195,8 +206,12 @@ class Meta: class EdaCredentialUpdateSerializer( - OrganizationIdFieldMixin, serializers.ModelSerializer + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): + # inputs holds arbitrary secret credential values, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"inputs"}) + inputs = serializers.JSONField() def validate(self, data): @@ -212,7 +227,7 @@ def validate(self, data): inputs = data.get("inputs", {}) # allow empty inputs during updating if self.partial and not bool(inputs): - return data + return super().validate(data) errors = validate_inputs( credential_type, @@ -224,7 +239,7 @@ def validate(self, data): if bool(errors): raise serializers.ValidationError(errors) - return data + return super().validate(data) class Meta: model = models.EdaCredential diff --git a/src/aap_eda/api/serializers/event_stream.py b/src/aap_eda/api/serializers/event_stream.py index 1f8080b14..932c2626a 100644 --- a/src/aap_eda/api/serializers/event_stream.py +++ b/src/aap_eda/api/serializers/event_stream.py @@ -16,6 +16,7 @@ from urllib.parse import urljoin import yaml +from ansible_base.lib.serializers.mixins import CleanTextMixin from django.conf import settings from django.urls import reverse from rest_framework import serializers @@ -27,7 +28,7 @@ from aap_eda.core import enums, models, validators -class EventStreamInSerializer(serializers.ModelSerializer): +class EventStreamInSerializer(CleanTextMixin, serializers.ModelSerializer): organization_id = serializers.IntegerField( required=True, allow_null=False, @@ -80,7 +81,7 @@ def validate(self, data): if not event_stream_type: data["event_stream_type"] = kind - return data + return super().validate(data) class Meta: model = models.EventStream @@ -95,7 +96,7 @@ class Meta: ] -class EventStreamOutSerializer(serializers.ModelSerializer): +class EventStreamOutSerializer(CleanTextMixin, serializers.ModelSerializer): owner = serializers.SerializerMethodField() organization = serializers.SerializerMethodField() eda_credential = EdaCredentialRefSerializer( diff --git a/src/aap_eda/api/serializers/organization.py b/src/aap_eda/api/serializers/organization.py index b9f4199b3..72466effe 100644 --- a/src/aap_eda/api/serializers/organization.py +++ b/src/aap_eda/api/serializers/organization.py @@ -13,6 +13,7 @@ # limitations under the License. from ansible_base.lib.serializers.common import NamedCommonModelSerializer +from ansible_base.lib.serializers.mixins import CleanTextMixin from django.conf import settings from aap_eda.api import exceptions as api_exc @@ -23,6 +24,7 @@ class OrganizationSerializer( + CleanTextMixin, NamedCommonModelSerializer, SharedResourceSerializerMixin, ): @@ -53,7 +55,7 @@ def validate(self, data): raise api_exc.Conflict( "The default organization cannot be modified." ) - return data + return super().validate(data) class OrganizationRefSerializer(NamedCommonModelSerializer): @@ -66,7 +68,9 @@ class Meta: ] -class OrganizationCreateSerializer(NamedCommonModelSerializer): +class OrganizationCreateSerializer( + CleanTextMixin, NamedCommonModelSerializer +): class Meta: model = Organization fields = [ diff --git a/src/aap_eda/api/serializers/project.py b/src/aap_eda/api/serializers/project.py index a1242db3d..097a8a9ed 100644 --- a/src/aap_eda/api/serializers/project.py +++ b/src/aap_eda/api/serializers/project.py @@ -14,6 +14,7 @@ from urllib.parse import urlparse, urlunparse +from ansible_base.lib.serializers.mixins import CleanTextMixin from rest_framework import serializers from rest_framework.validators import UniqueValidator @@ -39,7 +40,9 @@ def get_proxy(self, obj: models.Project) -> str: return get_proxy_for_display(url) -class ProjectSerializer(serializers.ModelSerializer, ProxyFieldMixin): +class ProjectSerializer( + CleanTextMixin, serializers.ModelSerializer, ProxyFieldMixin +): eda_credential_id = serializers.IntegerField( required=False, allow_null=True ) @@ -86,7 +89,7 @@ def to_representation(self, instance): class ProjectCreateRequestSerializer( - OrganizationIdFieldMixin, serializers.ModelSerializer + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): eda_credential_id = serializers.IntegerField( required=False, @@ -158,7 +161,7 @@ class Meta: class ProjectUpdateRequestSerializer( - OrganizationIdFieldMixin, serializers.ModelSerializer + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): name = serializers.CharField( required=False, @@ -279,7 +282,7 @@ def validate(self, data): raise serializers.ValidationError( "The password in the proxy field should be unencrypted" ) - return data + return super().validate(data) def to_representation(self, instance): result = super().to_representation(instance) diff --git a/src/aap_eda/api/serializers/team.py b/src/aap_eda/api/serializers/team.py index fa7e90104..76c202911 100644 --- a/src/aap_eda/api/serializers/team.py +++ b/src/aap_eda/api/serializers/team.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.serializers.mixins import CleanTextMixin from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator @@ -22,7 +23,7 @@ from .mixins import SharedResourceSerializerMixin -class TeamSerializer(serializers.ModelSerializer): +class TeamSerializer(CleanTextMixin, serializers.ModelSerializer): resource = AnsibleResourceFieldSerializer(read_only=True) class Meta: @@ -41,6 +42,7 @@ class Meta: class TeamCreateSerializer( + CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer, SharedResourceSerializerMixin, @@ -63,7 +65,7 @@ class Meta: def validate(self, data): self.validate_shared_resource() - return data + return super().validate(data) class TeamDetailSerializer(serializers.ModelSerializer): @@ -86,6 +88,7 @@ class Meta: class TeamUpdateSerializer( + CleanTextMixin, serializers.ModelSerializer, SharedResourceSerializerMixin, ): @@ -98,4 +101,4 @@ class Meta: def validate(self, data): self.validate_shared_resource() - return data + return super().validate(data) diff --git a/src/aap_eda/api/serializers/user.py b/src/aap_eda/api/serializers/user.py index fcf2831f8..c77e65782 100644 --- a/src/aap_eda/api/serializers/user.py +++ b/src/aap_eda/api/serializers/user.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.serializers.mixins import CleanTextMixin from django.contrib.auth.hashers import make_password from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -25,7 +26,7 @@ USERNAME_HELP = "The user's log in name." -class UserSerializer(serializers.ModelSerializer): +class UserSerializer(CleanTextMixin, serializers.ModelSerializer): resource = AnsibleResourceFieldSerializer(read_only=True) class Meta: @@ -183,7 +184,11 @@ class Meta: ] -class AwxTokenCreateSerializer(serializers.ModelSerializer): +class AwxTokenCreateSerializer(CleanTextMixin, serializers.ModelSerializer): + # token holds the secret AWX token value, so it is excluded from + # free-text checks. + excluded_fields = frozenset({"token"}) + class Meta: model = models.AwxToken fields = [ @@ -206,4 +211,4 @@ def validate(self, data): if existing_token.exists(): raise Conflict("Token with this name already exists.") - return data + return super().validate(data) From fb9bdbe1f304845cf3bf4babce85fa3646d5b30a Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 26 Aug 2026 15:26:27 -0700 Subject: [PATCH 02/19] fix duplicated base class --- src/aap_eda/api/serializers/activation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index 7862384d7..ada06bd0b 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -579,10 +579,10 @@ def to_representation(self, activation): class ActivationCreateSerializer( + CleanTextMixin, _K8sPodMetadataWriteFields, OrganizationIdFieldMixin, serializers.ModelSerializer, - CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): """Serializer for creating the Activation.""" @@ -792,10 +792,10 @@ def copy(self) -> dict: class ActivationUpdateSerializer( + CleanTextMixin, _K8sPodMetadataWriteFields, OrganizationIdFieldMixin, serializers.ModelSerializer, - CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): """Serializer for updating the Activation.""" From 742fa9a7cc353b32b5c404de55121ceb9420b428 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 26 Aug 2026 15:33:59 -0700 Subject: [PATCH 03/19] add mixin to UserUpdateSerializerBase --- src/aap_eda/api/serializers/user.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aap_eda/api/serializers/user.py b/src/aap_eda/api/serializers/user.py index c77e65782..6dc8b206f 100644 --- a/src/aap_eda/api/serializers/user.py +++ b/src/aap_eda/api/serializers/user.py @@ -103,6 +103,7 @@ class UserListSerializer(serializers.Serializer): class UserUpdateSerializerBase( + CleanTextMixin, serializers.ModelSerializer, SharedResourceSerializerMixin, ): @@ -128,6 +129,7 @@ def validate_is_superuser(self, value): return value def validate(self, data): + data = super().validate(data) self.validate_shared_resource() return data From c1f8b55a650621222843160a1676b8ceb606b75e Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 27 Aug 2026 13:22:31 -0700 Subject: [PATCH 04/19] add unit tests --- .../integration/api/test_clean_text_mixin.py | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 tests/integration/api/test_clean_text_mixin.py diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py new file mode 100644 index 000000000..fa697f4a9 --- /dev/null +++ b/tests/integration/api/test_clean_text_mixin.py @@ -0,0 +1,764 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Integration tests verifying CleanTextMixin is correctly wired to EDA +serializers. + +These tests verify that the CleanTextMixin validation (two-tier text +validation with grandfathering) works correctly on all EDA serializers +that were updated in AAP-78702. + +The validation is gated behind ENHANCED_INPUT_VALIDATION_ENABLED, so all +test classes use @override_settings to enable it. +""" +from types import SimpleNamespace +from unittest import mock +from unittest.mock import patch + +import pytest +from django.conf import settings +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from aap_eda.api.serializers.credential_input_source import ( + CredentialInputSourceUpdateSerializer, +) +from aap_eda.api.serializers.credential_type import ( + CredentialTypeCreateSerializer, +) +from aap_eda.api.serializers.decision_environment import ( + DecisionEnvironmentCreateSerializer, +) +from aap_eda.api.serializers.eda_credential import ( + EdaCredentialCreateSerializer, + EdaCredentialUpdateSerializer, +) +from aap_eda.api.serializers.event_stream import EventStreamInSerializer +from aap_eda.api.serializers.project import ( + ProjectCreateRequestSerializer, + ProjectUpdateRequestSerializer, +) +from aap_eda.api.serializers.user import AwxTokenCreateSerializer +from aap_eda.core import enums, models +from tests.integration.constants import api_url_v1 + +DANGEROUS_NAME = "" +DANGEROUS_TEXT = "$(rm -rf /)" +VALID_NAME = "Valid Resource Name" +VALID_USERNAME = "valid.user123" + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestOrganizationCleanText: + """Test CleanTextMixin integration with OrganizationSerializer.""" + + def test_rejects_invalid_name_on_create( + self, use_local_resource_setting, superuser_client: APIClient + ): + response = superuser_client.post( + f"{api_url_v1}/organizations/", data={"name": DANGEROUS_NAME} + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + def test_accepts_valid_name_on_create( + self, use_local_resource_setting, superuser_client: APIClient + ): + response = superuser_client.post( + f"{api_url_v1}/organizations/", data={"name": VALID_NAME} + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == VALID_NAME + + def test_rejects_invalid_description_on_create( + self, use_local_resource_setting, superuser_client: APIClient + ): + response = superuser_client.post( + f"{api_url_v1}/organizations/", + data={"name": VALID_NAME, "description": DANGEROUS_TEXT}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "description" in response.data + + def test_grandfather_unchanged_name_on_update( + self, + use_local_resource_setting, + new_organization: models.Organization, + superuser_client: APIClient, + ): + models.Organization.objects.filter(pk=new_organization.pk).update( + name="name;semicolon" + ) + response = superuser_client.patch( + f"{api_url_v1}/organizations/{new_organization.id}/", + data={ + "name": "name;semicolon", + "description": "Updated description", + }, + ) + assert response.status_code == status.HTTP_200_OK + + def test_rejects_changed_invalid_name_on_update( + self, + use_local_resource_setting, + new_organization: models.Organization, + superuser_client: APIClient, + ): + response = superuser_client.patch( + f"{api_url_v1}/organizations/{new_organization.id}/", + data={"name": DANGEROUS_NAME}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestTeamCleanText: + """Test CleanTextMixin integration with TeamSerializer.""" + + def test_rejects_invalid_name_on_create( + self, + use_local_resource_setting, + default_organization: models.Organization, + admin_client: APIClient, + ): + data_in = { + "name": DANGEROUS_NAME, + "organization_id": default_organization.id, + } + response = admin_client.post(f"{api_url_v1}/teams/", data=data_in) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + def test_accepts_valid_name_on_create( + self, + use_local_resource_setting, + default_organization: models.Organization, + admin_client: APIClient, + ): + data_in = { + "name": VALID_NAME, + "organization_id": default_organization.id, + } + response = admin_client.post(f"{api_url_v1}/teams/", data=data_in) + assert response.status_code == status.HTTP_201_CREATED + + def test_rejects_invalid_description_on_create( + self, + use_local_resource_setting, + default_organization: models.Organization, + admin_client: APIClient, + ): + data_in = { + "name": VALID_NAME, + "description": DANGEROUS_TEXT, + "organization_id": default_organization.id, + } + response = admin_client.post(f"{api_url_v1}/teams/", data=data_in) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "description" in response.data + + def test_grandfather_unchanged_name_on_update( + self, + use_local_resource_setting, + default_team: models.Team, + admin_client: APIClient, + ): + models.Team.objects.filter(pk=default_team.pk).update( + name="team" + ) + response = admin_client.patch( + f"{api_url_v1}/teams/{default_team.id}/", + data={ + "name": "team", + "description": "Updated", + }, + ) + assert response.status_code == status.HTTP_200_OK + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestUserCleanText: + """Test CleanTextMixin integration with UserSerializer / + UserUpdateSerializerBase. + """ + + def test_rejects_invalid_username_on_create( + self, use_local_resource_setting, admin_client: APIClient + ): + data_in = {"username": DANGEROUS_NAME, "password": "secret"} + response = admin_client.post(f"{api_url_v1}/users/", data=data_in) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "username" in response.data + + def test_accepts_valid_username_on_create( + self, use_local_resource_setting, admin_client: APIClient + ): + data_in = {"username": VALID_USERNAME, "password": "secret"} + response = admin_client.post(f"{api_url_v1}/users/", data=data_in) + assert response.status_code == status.HTTP_201_CREATED + + def test_rejects_invalid_first_name_on_create( + self, use_local_resource_setting, admin_client: APIClient + ): + data_in = { + "username": VALID_USERNAME, + "password": "secret", + "first_name": DANGEROUS_TEXT, + } + response = admin_client.post(f"{api_url_v1}/users/", data=data_in) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "first_name" in response.data + + def test_rejects_invalid_last_name_on_create( + self, use_local_resource_setting, admin_client: APIClient + ): + data_in = { + "username": VALID_USERNAME, + "password": "secret", + "last_name": DANGEROUS_TEXT, + } + response = admin_client.post(f"{api_url_v1}/users/", data=data_in) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "last_name" in response.data + + def test_grandfather_unchanged_username_on_update( + self, use_local_resource_setting, admin_client: APIClient + ): + data_in = {"username": "temp.user", "password": "secret"} + response = admin_client.post(f"{api_url_v1}/users/", data=data_in) + assert response.status_code == status.HTTP_201_CREATED + user_id = response.data["id"] + + models.User.objects.filter(pk=user_id).update( + username="user;invalid" + ) + response = admin_client.patch( + f"{api_url_v1}/users/{user_id}/", + data={"username": "user;invalid", "first_name": "Updated"}, + ) + assert response.status_code == status.HTTP_200_OK + + def test_rejects_changed_invalid_username_on_update( + self, + use_local_resource_setting, + admin_client: APIClient, + admin_user: models.User, + ): + response = admin_client.patch( + f"{api_url_v1}/users/{admin_user.id}/", + data={"username": DANGEROUS_NAME}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "username" in response.data + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestActivationCleanText: + """Test CleanTextMixin integration with ActivationCreateSerializer / + ActivationUpdateSerializer. + """ + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_rejects_invalid_name_on_create( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + activation_payload["name"] = DANGEROUS_NAME + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_accepts_valid_name_on_create( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_grandfather_unchanged_description_on_update( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + activation_payload["is_enabled"] = False + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + activation_id = response.data["id"] + + models.Activation.objects.filter(pk=activation_id).update( + description="description;semicolon" + ) + response = admin_client.patch( + f"{api_url_v1}/activations/{activation_id}/", + data={"description": "description;semicolon"}, + ) + assert response.status_code == status.HTTP_200_OK + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_rejects_changed_invalid_description_on_update( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + activation_payload["is_enabled"] = False + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + activation_id = response.data["id"] + + response = admin_client.patch( + f"{api_url_v1}/activations/{activation_id}/", + data={"description": DANGEROUS_TEXT}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "description" in response.data + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestProjectCleanText: + """Test CleanTextMixin integration with ProjectCreateRequestSerializer / + ProjectUpdateRequestSerializer. + """ + + def test_rejects_invalid_name_on_create( + self, default_organization: models.Organization + ): + serializer = ProjectCreateRequestSerializer( + data={ + "name": DANGEROUS_NAME, + "url": "https://git.example.com/acme/project-01", + "organization_id": default_organization.id, + } + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create( + self, default_organization: models.Organization + ): + serializer = ProjectCreateRequestSerializer( + data={ + "name": VALID_NAME, + "url": "https://git.example.com/acme/project-01", + "organization_id": default_organization.id, + } + ) + assert serializer.is_valid(), serializer.errors + assert "name" not in serializer.errors + + def test_rejects_invalid_description_on_create( + self, default_organization: models.Organization + ): + serializer = ProjectCreateRequestSerializer( + data={ + "name": VALID_NAME, + "description": DANGEROUS_TEXT, + "url": "https://git.example.com/acme/project-01", + "organization_id": default_organization.id, + } + ) + assert not serializer.is_valid() + assert "description" in serializer.errors + + def test_grandfather_unchanged_name_on_update( + self, default_project: models.Project + ): + models.Project.objects.filter(pk=default_project.pk).update( + name="project;invalid" + ) + default_project.refresh_from_db() + + serializer = ProjectUpdateRequestSerializer( + instance=default_project, + data={"name": "project;invalid"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + def test_rejects_changed_invalid_name_on_update( + self, default_project: models.Project + ): + serializer = ProjectUpdateRequestSerializer( + instance=default_project, + data={"name": DANGEROUS_NAME}, + partial=True, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestDecisionEnvironmentCleanText: + """Test CleanTextMixin integration with + DecisionEnvironmentCreateSerializer. + """ + + def test_rejects_invalid_name_on_create( + self, default_organization: models.Organization + ): + serializer = DecisionEnvironmentCreateSerializer( + data={ + "name": DANGEROUS_NAME, + "image_url": "quay.io/ansible/ansible-rulebook:latest", + "organization_id": default_organization.id, + } + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create( + self, default_organization: models.Organization + ): + serializer = DecisionEnvironmentCreateSerializer( + data={ + "name": VALID_NAME, + "image_url": "quay.io/ansible/ansible-rulebook:latest", + "organization_id": default_organization.id, + } + ) + assert serializer.is_valid(), serializer.errors + + def test_grandfather_unchanged_name_on_update( + self, default_decision_environment: models.DecisionEnvironment + ): + models.DecisionEnvironment.objects.filter( + pk=default_decision_environment.pk + ).update(name="de`invalid") + default_decision_environment.refresh_from_db() + + serializer = DecisionEnvironmentCreateSerializer( + instance=default_decision_environment, + data={"name": "de`invalid"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + def test_rejects_changed_invalid_name_on_update( + self, default_decision_environment: models.DecisionEnvironment + ): + serializer = DecisionEnvironmentCreateSerializer( + instance=default_decision_environment, + data={"name": DANGEROUS_NAME}, + partial=True, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestCredentialTypeCleanText: + """Test CleanTextMixin integration with CredentialTypeCreateSerializer.""" + + def test_rejects_invalid_name_on_create(self): + serializer = CredentialTypeCreateSerializer( + data={"name": DANGEROUS_NAME} + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create(self): + serializer = CredentialTypeCreateSerializer(data={"name": VALID_NAME}) + assert serializer.is_valid(), serializer.errors + + def test_rejects_invalid_description_on_create(self): + serializer = CredentialTypeCreateSerializer( + data={"name": VALID_NAME, "description": DANGEROUS_TEXT} + ) + assert not serializer.is_valid() + assert "description" in serializer.errors + + def test_grandfather_unchanged_name_on_update( + self, credential_type: models.CredentialType + ): + models.CredentialType.objects.filter(pk=credential_type.pk).update( + name="type;invalid" + ) + credential_type.refresh_from_db() + + serializer = CredentialTypeCreateSerializer( + instance=credential_type, + data={"name": "type;invalid"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestEdaCredentialCleanText: + """Test CleanTextMixin integration with EdaCredentialCreateSerializer / + EdaCredentialUpdateSerializer. + """ + + def test_rejects_invalid_name_on_create( + self, + default_organization: models.Organization, + preseed_credential_types, + ): + registry_type = models.CredentialType.objects.get( + name=enums.DefaultCredentialType.REGISTRY + ) + serializer = EdaCredentialCreateSerializer( + data={ + "name": DANGEROUS_NAME, + "credential_type_id": registry_type.id, + "inputs": { + "username": "dummy-user", + "password": "dummy-password", + }, + "organization_id": default_organization.id, + } + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create( + self, + default_organization: models.Organization, + preseed_credential_types, + ): + registry_type = models.CredentialType.objects.get( + name=enums.DefaultCredentialType.REGISTRY + ) + serializer = EdaCredentialCreateSerializer( + data={ + "name": VALID_NAME, + "credential_type_id": registry_type.id, + "inputs": { + "username": "dummy-user", + "password": "dummy-password", + }, + "organization_id": default_organization.id, + } + ) + assert serializer.is_valid(), serializer.errors + + def test_grandfather_unchanged_name_on_update( + self, default_registry_credential: models.EdaCredential + ): + models.EdaCredential.objects.filter( + pk=default_registry_credential.pk + ).update(name="cred;invalid") + default_registry_credential.refresh_from_db() + + serializer = EdaCredentialUpdateSerializer( + instance=default_registry_credential, + data={"name": "cred;invalid"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + def test_rejects_changed_invalid_name_on_update( + self, default_registry_credential: models.EdaCredential + ): + serializer = EdaCredentialUpdateSerializer( + instance=default_registry_credential, + data={"name": DANGEROUS_NAME}, + partial=True, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestEventStreamCleanText: + """Test CleanTextMixin integration with EventStreamInSerializer.""" + + def test_rejects_invalid_name_on_create( + self, + default_organization: models.Organization, + default_user: models.User, + default_hmac_credential: models.EdaCredential, + ): + serializer = EventStreamInSerializer( + data={ + "name": DANGEROUS_NAME, + "eda_credential_id": default_hmac_credential.id, + "organization_id": default_organization.id, + }, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create( + self, + default_organization: models.Organization, + default_user: models.User, + default_hmac_credential: models.EdaCredential, + ): + serializer = EventStreamInSerializer( + data={ + "name": VALID_NAME, + "eda_credential_id": default_hmac_credential.id, + "organization_id": default_organization.id, + }, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert serializer.is_valid(), serializer.errors + + def test_grandfather_unchanged_name_on_update( + self, default_event_stream: models.EventStream + ): + models.EventStream.objects.filter( + pk=default_event_stream.pk + ).update(name="es") + default_event_stream.refresh_from_db() + + serializer = EventStreamInSerializer( + instance=default_event_stream, + data={"name": "es"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + def test_rejects_changed_invalid_name_on_update( + self, default_event_stream: models.EventStream + ): + serializer = EventStreamInSerializer( + instance=default_event_stream, + data={"name": DANGEROUS_NAME}, + partial=True, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestCredentialInputSourceCleanText: + """Test CleanTextMixin integration with + CredentialInputSourceUpdateSerializer. + + Note: CredentialInputSource has no name field, so only the Tier 2 + (free-text) description field is exercised here. + """ + + def test_rejects_invalid_description_on_update( + self, + default_credential_input_source: models.CredentialInputSource, + ): + serializer = CredentialInputSourceUpdateSerializer( + instance=default_credential_input_source, + data={"description": DANGEROUS_TEXT}, + partial=True, + ) + assert not serializer.is_valid() + assert "description" in serializer.errors + + def test_accepts_valid_description_on_update( + self, + default_credential_input_source: models.CredentialInputSource, + ): + serializer = CredentialInputSourceUpdateSerializer( + instance=default_credential_input_source, + data={"description": "A perfectly reasonable description"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + def test_grandfather_unchanged_description_on_update( + self, + default_credential_input_source: models.CredentialInputSource, + ): + models.CredentialInputSource.objects.filter( + pk=default_credential_input_source.pk + ).update(description="description;semicolon") + default_credential_input_source.refresh_from_db() + + serializer = CredentialInputSourceUpdateSerializer( + instance=default_credential_input_source, + data={"description": "description;semicolon"}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestAwxTokenCleanText: + """Test CleanTextMixin integration with AwxTokenCreateSerializer.""" + + def test_rejects_invalid_name_on_create(self, default_user: models.User): + serializer = AwxTokenCreateSerializer( + data={"name": DANGEROUS_NAME, "token": "sometoken"}, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert not serializer.is_valid() + assert "name" in serializer.errors + + def test_accepts_valid_name_on_create(self, default_user: models.User): + serializer = AwxTokenCreateSerializer( + data={"name": VALID_NAME, "token": "sometoken"}, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert serializer.is_valid(), serializer.errors + + def test_rejects_invalid_description_on_create( + self, default_user: models.User + ): + serializer = AwxTokenCreateSerializer( + data={ + "name": VALID_NAME, + "token": "sometoken", + "description": DANGEROUS_TEXT, + }, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert not serializer.is_valid() + assert "description" in serializer.errors From 4ceec6861b89f6b01869e6411edaf6be38a9241e Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 27 Aug 2026 13:30:07 -0700 Subject: [PATCH 05/19] linting fixes --- src/aap_eda/api/serializers/eda_credential.py | 4 +--- src/aap_eda/api/serializers/organization.py | 4 +--- tests/integration/api/test_clean_text_mixin.py | 10 ++++------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/aap_eda/api/serializers/eda_credential.py b/src/aap_eda/api/serializers/eda_credential.py index 44e08fe5a..0faf4ce2c 100644 --- a/src/aap_eda/api/serializers/eda_credential.py +++ b/src/aap_eda/api/serializers/eda_credential.py @@ -125,9 +125,7 @@ def to_representation(self, eda_credential): } -class EdaCredentialCopySerializer( - CleanTextMixin, serializers.ModelSerializer -): +class EdaCredentialCopySerializer(CleanTextMixin, serializers.ModelSerializer): name = serializers.CharField( required=True, validators=[validators.check_if_credential_name_used], diff --git a/src/aap_eda/api/serializers/organization.py b/src/aap_eda/api/serializers/organization.py index 72466effe..5c01821b9 100644 --- a/src/aap_eda/api/serializers/organization.py +++ b/src/aap_eda/api/serializers/organization.py @@ -68,9 +68,7 @@ class Meta: ] -class OrganizationCreateSerializer( - CleanTextMixin, NamedCommonModelSerializer -): +class OrganizationCreateSerializer(CleanTextMixin, NamedCommonModelSerializer): class Meta: model = Organization fields = [ diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index fa697f4a9..5791e450b 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -244,9 +244,7 @@ def test_grandfather_unchanged_username_on_update( assert response.status_code == status.HTTP_201_CREATED user_id = response.data["id"] - models.User.objects.filter(pk=user_id).update( - username="user;invalid" - ) + models.User.objects.filter(pk=user_id).update(username="user;invalid") response = admin_client.patch( f"{api_url_v1}/users/{user_id}/", data={"username": "user;invalid", "first_name": "Updated"}, @@ -655,9 +653,9 @@ def test_accepts_valid_name_on_create( def test_grandfather_unchanged_name_on_update( self, default_event_stream: models.EventStream ): - models.EventStream.objects.filter( - pk=default_event_stream.pk - ).update(name="es") + models.EventStream.objects.filter(pk=default_event_stream.pk).update( + name="es" + ) default_event_stream.refresh_from_db() serializer = EventStreamInSerializer( From d637e88bbeb06546243457a0885c67ffb673d926 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Tue, 1 Sep 2026 14:52:54 -0700 Subject: [PATCH 06/19] address review feedback: excluded_fields tests, validate ordering, drop mixin from read-only serializers - add test coverage proving excluded_fields (extra_var, injectors, inputs, token, metadata) bypass CleanTextMixin validation - standardize UserUpdateSerializerBase.validate() to run validate_shared_resource() before super().validate(), matching organization/team serializers - remove CleanTextMixin from EdaCredentialSerializer, EventStreamOutSerializer, and CredentialInputSourceSerializer, which are only ever used for GET representation --- .../serializers/credential_input_source.py | 8 +- src/aap_eda/api/serializers/eda_credential.py | 6 +- src/aap_eda/api/serializers/event_stream.py | 4 +- src/aap_eda/api/serializers/user.py | 3 +- .../integration/api/test_clean_text_mixin.py | 88 +++++++++++++++++++ 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/aap_eda/api/serializers/credential_input_source.py b/src/aap_eda/api/serializers/credential_input_source.py index e79ac461b..ed654e9ae 100644 --- a/src/aap_eda/api/serializers/credential_input_source.py +++ b/src/aap_eda/api/serializers/credential_input_source.py @@ -47,15 +47,9 @@ class CredentialInputSourceReferenceField(serializers.JSONField): pass -class CredentialInputSourceSerializer( - CleanTextMixin, serializers.ModelSerializer -): +class CredentialInputSourceSerializer(serializers.ModelSerializer): """Serializer used during a GET.""" - # metadata holds credential input values and is populated - # programmatically, so it is excluded from free-text checks. - excluded_fields = frozenset({"metadata"}) - organization = OrganizationRefSerializer() references = EdaCredentialReferenceField(required=False, allow_null=True) created_by = BasicUserFieldSerializer() diff --git a/src/aap_eda/api/serializers/eda_credential.py b/src/aap_eda/api/serializers/eda_credential.py index 0faf4ce2c..0b29d6b66 100644 --- a/src/aap_eda/api/serializers/eda_credential.py +++ b/src/aap_eda/api/serializers/eda_credential.py @@ -55,10 +55,8 @@ class EdaCredentialReferenceField(serializers.JSONField): pass -class EdaCredentialSerializer(CleanTextMixin, serializers.ModelSerializer): - # inputs holds arbitrary secret credential values, so it is excluded - # from free-text checks. - excluded_fields = frozenset({"inputs"}) +class EdaCredentialSerializer(serializers.ModelSerializer): + """Serializer used during a GET.""" inputs = serializers.SerializerMethodField() credential_type = CredentialTypeRefSerializer( diff --git a/src/aap_eda/api/serializers/event_stream.py b/src/aap_eda/api/serializers/event_stream.py index 932c2626a..868a016e1 100644 --- a/src/aap_eda/api/serializers/event_stream.py +++ b/src/aap_eda/api/serializers/event_stream.py @@ -96,7 +96,9 @@ class Meta: ] -class EventStreamOutSerializer(CleanTextMixin, serializers.ModelSerializer): +class EventStreamOutSerializer(serializers.ModelSerializer): + """Serializer used during a GET.""" + owner = serializers.SerializerMethodField() organization = serializers.SerializerMethodField() eda_credential = EdaCredentialRefSerializer( diff --git a/src/aap_eda/api/serializers/user.py b/src/aap_eda/api/serializers/user.py index 6dc8b206f..36002d91d 100644 --- a/src/aap_eda/api/serializers/user.py +++ b/src/aap_eda/api/serializers/user.py @@ -129,9 +129,8 @@ def validate_is_superuser(self, value): return value def validate(self, data): - data = super().validate(data) self.validate_shared_resource() - return data + return super().validate(data) class UserCreateUpdateSerializer(UserUpdateSerializerBase): diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 5791e450b..4a5acf7d6 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -760,3 +760,91 @@ def test_rejects_invalid_description_on_create( ) assert not serializer.is_valid() assert "description" in serializer.errors + + +@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.mark.django_db +class TestExcludedFieldsCleanText: + """Test that fields listed in a serializer's excluded_fields bypass + CleanTextMixin validation. + + Each field below legitimately carries content (Jinja templates, + secrets, opaque tokens) that would otherwise trip the free-text + checks, so it must be accepted even when it contains characters + that are rejected on non-excluded fields like name/description. + """ + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_activation_extra_var_excluded( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + activation_payload["extra_var"] = f"dangerous: '{DANGEROUS_TEXT}'" + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED, response.data + + def test_credential_type_injectors_excluded(self): + serializer = CredentialTypeCreateSerializer( + data={ + "name": VALID_NAME, + "inputs": { + "fields": [ + { + "id": "password", + "label": "Password", + "type": "string", + }, + ] + }, + "injectors": {"extra_vars": {"password": DANGEROUS_TEXT}}, + } + ) + assert serializer.is_valid(), serializer.errors + + def test_eda_credential_inputs_excluded( + self, + default_organization: models.Organization, + preseed_credential_types, + ): + registry_type = models.CredentialType.objects.get( + name=enums.DefaultCredentialType.REGISTRY + ) + serializer = EdaCredentialCreateSerializer( + data={ + "name": VALID_NAME, + "credential_type_id": registry_type.id, + "inputs": { + "username": "dummy-user", + "password": DANGEROUS_TEXT, + }, + "organization_id": default_organization.id, + } + ) + assert serializer.is_valid(), serializer.errors + + def test_awx_token_excluded(self, default_user: models.User): + serializer = AwxTokenCreateSerializer( + data={"name": VALID_NAME, "token": DANGEROUS_TEXT}, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert serializer.is_valid(), serializer.errors + + def test_credential_input_source_metadata_excluded( + self, + default_credential_input_source: models.CredentialInputSource, + ): + serializer = CredentialInputSourceUpdateSerializer( + instance=default_credential_input_source, + data={"metadata": {"secret_path": DANGEROUS_TEXT}}, + partial=True, + ) + assert serializer.is_valid(), serializer.errors From 908ef787e45a70dd8b4d6a77db0378f2c4f72092 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Tue, 1 Sep 2026 15:42:40 -0700 Subject: [PATCH 07/19] fix: prevent ActivationCopySerializer from bypassing CleanTextMixin on description ActivationCopySerializer.Meta.fields only lists "name", so copy() persisted activation.description via super().create() without ever running it through CleanTextMixin. Because the serializer is instantiated with instance=, simply adding "description" to Meta.fields would have let grandfathering treat the copied (unchanged) value as already-validated, silently propagating any blocklisted text stuck in the source row (e.g. pre-dating ENHANCED_INPUT_VALIDATION_ENABLED) into new database rows indefinitely. Add a private, instance-less _ActivationCopyTextCheckSerializer to re-validate the copied description as genuinely new content before it's persisted, mirroring how EdaCredentialCreateSerializer already re-validates a copied credential's description on /copy/. --- src/aap_eda/api/serializers/activation.py | 28 ++++++++- .../integration/api/test_clean_text_mixin.py | 61 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index ada06bd0b..05682af11 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -718,6 +718,25 @@ def create(self, validated_data): return super().create(validated_data) +class _ActivationCopyTextCheckSerializer( + CleanTextMixin, serializers.ModelSerializer +): + """Internal-only: re-validate copied free-text fields as new content. + + ActivationCopySerializer.is_valid() only ever validates "name", + since it's constructed with instance=, which + would make CleanTextMixin grandfather "description" as unchanged. + This serializer is never exposed to clients; it's only used from + copy() to re-check the copied description with no instance to + grandfather against, the same way EdaCredentialCreateSerializer + re-validates a copied credential's description. + """ + + class Meta: + model = models.Activation + fields = ["description"] + + class ActivationCopySerializer(CleanTextMixin, serializers.ModelSerializer): name = serializers.CharField( required=True, validators=[validators.check_if_activation_name_used] @@ -729,6 +748,13 @@ class Meta: def copy(self) -> dict: activation: models.Activation = self.instance + + text_check = _ActivationCopyTextCheckSerializer( + data={"description": activation.description} + ) + text_check.is_valid(raise_exception=True) + description = text_check.validated_data["description"] + pod_metadata = _activation_k8s_pod_metadata_payload(activation) _normalize_activation_k8s_pod_fields(pod_metadata) validators.check_if_k8s_pod_service_account_name_valid( @@ -750,7 +776,7 @@ def copy(self) -> dict: copied_data = { "name": self.validated_data["name"], - "description": activation.description, + "description": description, "is_enabled": False, "decision_environment": activation.decision_environment, "rulebook": activation.rulebook, diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 4a5acf7d6..98b23b956 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -362,6 +362,67 @@ def test_rejects_changed_invalid_description_on_update( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "description" in response.data + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_copy_propagates_valid_description( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + activation_id = response.data["id"] + + response = admin_client.post( + f"{api_url_v1}/activations/{activation_id}/copy/", + data={"name": "copied-activation"}, + ) + assert response.status_code == status.HTTP_201_CREATED + copied = models.Activation.objects.get(name="copied-activation") + assert copied.description == activation_payload["description"] + + @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) + @patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, + ) + def test_rejects_copy_of_grandfathered_invalid_description( + self, + mock_health_check, + admin_awx_token: models.AwxToken, + activation_payload: dict, + admin_client: APIClient, + ): + """A /copy/ must not propagate blocklisted text into a new row, + even when the source activation's description was grandfathered + in (e.g. it predates ENHANCED_INPUT_VALIDATION_ENABLED). + """ + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + activation_id = response.data["id"] + + models.Activation.objects.filter(pk=activation_id).update( + description=DANGEROUS_TEXT + ) + + response = admin_client.post( + f"{api_url_v1}/activations/{activation_id}/copy/", + data={"name": "copied-activation"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not models.Activation.objects.filter( + name="copied-activation" + ).exists() + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db From 106778a06b2e95b144b718e8ccff28378828af70 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 09:34:42 -0700 Subject: [PATCH 08/19] Fix review feedback: extend copy validation, exclude sensitive fields Co-Authored-By: Claude Opus 4.6 --- src/aap_eda/api/serializers/activation.py | 14 ++++++++++---- src/aap_eda/api/serializers/project.py | 4 ++++ src/aap_eda/api/serializers/user.py | 2 ++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index 05682af11..aac5e9361 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -734,7 +734,7 @@ class _ActivationCopyTextCheckSerializer( class Meta: model = models.Activation - fields = ["description"] + fields = ["description", "k8s_service_name", "source_mappings"] class ActivationCopySerializer(CleanTextMixin, serializers.ModelSerializer): @@ -750,10 +750,16 @@ def copy(self) -> dict: activation: models.Activation = self.instance text_check = _ActivationCopyTextCheckSerializer( - data={"description": activation.description} + data={ + "description": activation.description, + "k8s_service_name": activation.k8s_service_name, + "source_mappings": activation.source_mappings, + } ) text_check.is_valid(raise_exception=True) description = text_check.validated_data["description"] + k8s_service_name = text_check.validated_data["k8s_service_name"] + source_mappings = text_check.validated_data["source_mappings"] pod_metadata = _activation_k8s_pod_metadata_payload(activation) _normalize_activation_k8s_pod_fields(pod_metadata) @@ -787,8 +793,8 @@ def copy(self) -> dict: "awx_token_id": activation.awx_token, "log_level": activation.log_level, "eda_credentials": activation.eda_credentials.all(), - "k8s_service_name": activation.k8s_service_name, - "source_mappings": activation.source_mappings, + "k8s_service_name": k8s_service_name, + "source_mappings": source_mappings, "event_streams": activation.event_streams.all(), "skip_audit_events": activation.skip_audit_events, "rulebook_name": activation.rulebook.name, diff --git a/src/aap_eda/api/serializers/project.py b/src/aap_eda/api/serializers/project.py index 097a8a9ed..57d0533bd 100644 --- a/src/aap_eda/api/serializers/project.py +++ b/src/aap_eda/api/serializers/project.py @@ -91,6 +91,8 @@ def to_representation(self, instance): class ProjectCreateRequestSerializer( CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): + excluded_fields = frozenset({"proxy"}) + eda_credential_id = serializers.IntegerField( required=False, allow_null=True, @@ -163,6 +165,8 @@ class Meta: class ProjectUpdateRequestSerializer( CleanTextMixin, OrganizationIdFieldMixin, serializers.ModelSerializer ): + excluded_fields = frozenset({"proxy"}) + name = serializers.CharField( required=False, allow_blank=False, diff --git a/src/aap_eda/api/serializers/user.py b/src/aap_eda/api/serializers/user.py index 36002d91d..f5c7e5d39 100644 --- a/src/aap_eda/api/serializers/user.py +++ b/src/aap_eda/api/serializers/user.py @@ -107,6 +107,8 @@ class UserUpdateSerializerBase( serializers.ModelSerializer, SharedResourceSerializerMixin, ): + excluded_fields = frozenset({"password"}) + username = serializers.CharField( help_text=USERNAME_HELP, ) From 319f3d22ea9dd5820431658ce6771b828b66a3bc Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 10:05:38 -0700 Subject: [PATCH 09/19] test: add missing excluded_fields coverage for proxy and password Co-Authored-By: Claude Opus 4.6 --- .../integration/api/test_clean_text_mixin.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 98b23b956..c1fce58ae 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -49,7 +49,10 @@ ProjectCreateRequestSerializer, ProjectUpdateRequestSerializer, ) -from aap_eda.api.serializers.user import AwxTokenCreateSerializer +from aap_eda.api.serializers.user import ( + AwxTokenCreateSerializer, + UserCreateUpdateSerializer, +) from aap_eda.core import enums, models from tests.integration.constants import api_url_v1 @@ -909,3 +912,26 @@ def test_credential_input_source_metadata_excluded( partial=True, ) assert serializer.is_valid(), serializer.errors + + def test_project_proxy_excluded( + self, default_organization: models.Organization + ): + serializer = ProjectCreateRequestSerializer( + data={ + "name": VALID_NAME, + "url": "https://git.example.com/acme/project-01", + "proxy": DANGEROUS_TEXT, + "organization_id": default_organization.id, + } + ) + assert serializer.is_valid(), serializer.errors + + def test_user_password_excluded(self, default_user: models.User): + serializer = UserCreateUpdateSerializer( + data={ + "username": VALID_USERNAME, + "password": DANGEROUS_TEXT, + }, + context={"request": SimpleNamespace(user=default_user)}, + ) + assert serializer.is_valid(), serializer.errors From 7c08c35e4ceb7c184e4e11286a206ccb2d4c91e9 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 10:17:49 -0700 Subject: [PATCH 10/19] refactor: remove CleanTextMixin from read-only serializers These 6 serializers are only used for GET/response rendering and never process user input, so CleanTextMixin validation is unnecessary: - ActivationSerializer - CredentialTypeSerializer - ProjectSerializer - DecisionEnvironmentSerializer - UserSerializer - TeamSerializer All write-path serializers retain CleanTextMixin. Co-Authored-By: Claude Opus 4.6 --- src/aap_eda/api/serializers/activation.py | 6 +----- src/aap_eda/api/serializers/credential_type.py | 6 +----- src/aap_eda/api/serializers/decision_environment.py | 4 +--- src/aap_eda/api/serializers/project.py | 4 +--- src/aap_eda/api/serializers/team.py | 2 +- src/aap_eda/api/serializers/user.py | 2 +- 6 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index aac5e9361..5c1876fe0 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -367,13 +367,9 @@ def replace_vault_data(extra_var): return yaml.safe_dump(data) -class ActivationSerializer(CleanTextMixin, serializers.ModelSerializer): +class ActivationSerializer(serializers.ModelSerializer): """Serializer for the Activation model.""" - # extra_var may legitimately contain Jinja2 template syntax - # (e.g. credential injectors), so it is excluded from free-text checks. - excluded_fields = frozenset({"extra_var"}) - eda_credentials = serializers.ListField( required=False, allow_null=True, diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 9a15300e5..3b750f71e 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -19,11 +19,7 @@ from aap_eda.core.utils.credentials import validate_injectors -class CredentialTypeSerializer(CleanTextMixin, serializers.ModelSerializer): - # injectors commonly contain Jinja2 template syntax, so it is excluded - # from free-text checks. - excluded_fields = frozenset({"injectors"}) - +class CredentialTypeSerializer(serializers.ModelSerializer): class Meta: model = models.CredentialType read_only_fields = [ diff --git a/src/aap_eda/api/serializers/decision_environment.py b/src/aap_eda/api/serializers/decision_environment.py index baed29abb..7bc3b33fd 100644 --- a/src/aap_eda/api/serializers/decision_environment.py +++ b/src/aap_eda/api/serializers/decision_environment.py @@ -23,9 +23,7 @@ from aap_eda.core import models, validators -class DecisionEnvironmentSerializer( - CleanTextMixin, serializers.ModelSerializer -): +class DecisionEnvironmentSerializer(serializers.ModelSerializer): created_by = BasicUserFieldSerializer() modified_by = BasicUserFieldSerializer() diff --git a/src/aap_eda/api/serializers/project.py b/src/aap_eda/api/serializers/project.py index 57d0533bd..3b5dd3e9d 100644 --- a/src/aap_eda/api/serializers/project.py +++ b/src/aap_eda/api/serializers/project.py @@ -40,9 +40,7 @@ def get_proxy(self, obj: models.Project) -> str: return get_proxy_for_display(url) -class ProjectSerializer( - CleanTextMixin, serializers.ModelSerializer, ProxyFieldMixin -): +class ProjectSerializer(serializers.ModelSerializer, ProxyFieldMixin): eda_credential_id = serializers.IntegerField( required=False, allow_null=True ) diff --git a/src/aap_eda/api/serializers/team.py b/src/aap_eda/api/serializers/team.py index 76c202911..13fea6a34 100644 --- a/src/aap_eda/api/serializers/team.py +++ b/src/aap_eda/api/serializers/team.py @@ -23,7 +23,7 @@ from .mixins import SharedResourceSerializerMixin -class TeamSerializer(CleanTextMixin, serializers.ModelSerializer): +class TeamSerializer(serializers.ModelSerializer): resource = AnsibleResourceFieldSerializer(read_only=True) class Meta: diff --git a/src/aap_eda/api/serializers/user.py b/src/aap_eda/api/serializers/user.py index f5c7e5d39..b33795597 100644 --- a/src/aap_eda/api/serializers/user.py +++ b/src/aap_eda/api/serializers/user.py @@ -26,7 +26,7 @@ USERNAME_HELP = "The user's log in name." -class UserSerializer(CleanTextMixin, serializers.ModelSerializer): +class UserSerializer(serializers.ModelSerializer): resource = AnsibleResourceFieldSerializer(read_only=True) class Meta: From 5c81099e0413b230429a46fc78274ce0e476b98a Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 12:39:11 -0700 Subject: [PATCH 11/19] update dependencies --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index f4409f179..c2a081306 100644 --- a/poetry.lock +++ b/poetry.lock @@ -934,7 +934,7 @@ name = "django-ansible-base" version = "2026.8.31.0.dev62+ge5a492d23" description = "A Django app used by ansible services" optional = false -python-versions = ">=3.12" +python-versions = ">=3.11" groups = ["main"] files = [] develop = false @@ -943,7 +943,7 @@ develop = false asgiref = {version = "*", optional = true, markers = "extra == \"resource-registry\""} channels = {version = "*", optional = true, markers = "extra == \"channel-auth\""} cryptography = "*" -Django = ">=5,<6.0" +Django = ">=4.2.26,<6.0" django-crum = "*" django-flags = {version = "*", optional = true, markers = "extra == \"feature-flags\""} djangorestframework = "<3.16" @@ -957,7 +957,7 @@ sqlparse = ">=0.5.2" urllib3 = {version = "*", optional = true, markers = "extra == \"resource-registry\""} [package.extras] -all = ["asgiref", "channels", "cryptography", "django-auth-ldap", "django-flags", "django-oauth-toolkit (<2.4.0)", "django-redis", "drf-spectacular (<1.0)", "jwcrypto (>=1.5.7)", "ldap-filter", "lxml (==5.3.0)", "opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk", "pyjwt", "pyjwt", "pyrad", "pytest", "pytest-django", "python-ldap", "python3-saml", "redis (>=7.4.0)", "referencing (<0.37.0)", "requests", "requests", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "urllib3", "xmlsec (==1.3.13)"] +all = ["asgiref", "channels", "cryptography", "django-auth-ldap", "django-flags", "django-oauth-toolkit (<2.4.0)", "django-redis", "drf-spectacular (<1.0)", "jwcrypto (>=1.5.7)", "ldap-filter", "lxml (==5.3.0)", "opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk", "pyjwt", "pyjwt", "pyrad", "pytest", "pytest-django", "python-ldap", "python3-saml", "redis (>=7.4.0,<8.0.0)", "referencing (<0.37.0)", "requests", "requests", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "urllib3", "xmlsec (==1.3.13)"] api-documentation = ["drf-spectacular (<1.0)", "referencing (<0.37.0)"] authentication = ["django-auth-ldap", "ldap-filter", "lxml (==5.3.0)", "pyrad", "python-ldap", "python3-saml", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "xmlsec (==1.3.13)"] channel-auth = ["channels"] @@ -965,7 +965,7 @@ feature-flags = ["django-flags"] jwt-consumer = ["pyjwt", "requests"] oauth2-provider = ["django-oauth-toolkit (<2.4.0)", "jwcrypto (>=1.5.7)"] observability = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk"] -redis-client = ["django-redis", "redis (>=7.4.0)"] +redis-client = ["django-redis", "redis (>=7.4.0,<8.0.0)"] resource-registry = ["asgiref", "pyjwt", "requests", "urllib3"] testing = ["cryptography", "pytest", "pytest-django"] From e2104d63ee49869a49712f2040e20dca1962e063 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 13:04:33 -0700 Subject: [PATCH 12/19] update again to ensure new dab dependencies nh3 and regex are installed --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index c2a081306..f4409f179 100644 --- a/poetry.lock +++ b/poetry.lock @@ -934,7 +934,7 @@ name = "django-ansible-base" version = "2026.8.31.0.dev62+ge5a492d23" description = "A Django app used by ansible services" optional = false -python-versions = ">=3.11" +python-versions = ">=3.12" groups = ["main"] files = [] develop = false @@ -943,7 +943,7 @@ develop = false asgiref = {version = "*", optional = true, markers = "extra == \"resource-registry\""} channels = {version = "*", optional = true, markers = "extra == \"channel-auth\""} cryptography = "*" -Django = ">=4.2.26,<6.0" +Django = ">=5,<6.0" django-crum = "*" django-flags = {version = "*", optional = true, markers = "extra == \"feature-flags\""} djangorestframework = "<3.16" @@ -957,7 +957,7 @@ sqlparse = ">=0.5.2" urllib3 = {version = "*", optional = true, markers = "extra == \"resource-registry\""} [package.extras] -all = ["asgiref", "channels", "cryptography", "django-auth-ldap", "django-flags", "django-oauth-toolkit (<2.4.0)", "django-redis", "drf-spectacular (<1.0)", "jwcrypto (>=1.5.7)", "ldap-filter", "lxml (==5.3.0)", "opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk", "pyjwt", "pyjwt", "pyrad", "pytest", "pytest-django", "python-ldap", "python3-saml", "redis (>=7.4.0,<8.0.0)", "referencing (<0.37.0)", "requests", "requests", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "urllib3", "xmlsec (==1.3.13)"] +all = ["asgiref", "channels", "cryptography", "django-auth-ldap", "django-flags", "django-oauth-toolkit (<2.4.0)", "django-redis", "drf-spectacular (<1.0)", "jwcrypto (>=1.5.7)", "ldap-filter", "lxml (==5.3.0)", "opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk", "pyjwt", "pyjwt", "pyrad", "pytest", "pytest-django", "python-ldap", "python3-saml", "redis (>=7.4.0)", "referencing (<0.37.0)", "requests", "requests", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "urllib3", "xmlsec (==1.3.13)"] api-documentation = ["drf-spectacular (<1.0)", "referencing (<0.37.0)"] authentication = ["django-auth-ldap", "ldap-filter", "lxml (==5.3.0)", "pyrad", "python-ldap", "python3-saml", "social-auth-app-django (==5.4.1)", "social-auth-core (<=4.5.4)", "tabulate", "tacacs_plus", "xmlsec (==1.3.13)"] channel-auth = ["channels"] @@ -965,7 +965,7 @@ feature-flags = ["django-flags"] jwt-consumer = ["pyjwt", "requests"] oauth2-provider = ["django-oauth-toolkit (<2.4.0)", "jwcrypto (>=1.5.7)"] observability = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-grpc", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-psycopg", "opentelemetry-instrumentation-psycopg2", "opentelemetry-instrumentation-requests", "opentelemetry-sdk"] -redis-client = ["django-redis", "redis (>=7.4.0,<8.0.0)"] +redis-client = ["django-redis", "redis (>=7.4.0)"] resource-registry = ["asgiref", "pyjwt", "requests", "urllib3"] testing = ["cryptography", "pytest", "pytest-django"] From 81212d704b3d6338e4e93b80ddcd72df11b2c15a Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 2 Sep 2026 14:49:49 -0700 Subject: [PATCH 13/19] [AAP-78702] Fix test collection error: replace @override_settings with pytest fixture Django's @override_settings as a class decorator only works on subclasses of django.test.SimpleTestCase. These are plain pytest classes, causing ValueError at collection time (exit code 2). Replace the 12 class-level @override_settings decorators and the 7 @mock.patch.object(settings, ...) decorators with a single module-level autouse pytest fixture that sets both ENHANCED_INPUT_VALIDATION_ENABLED and RULEBOOK_WORKER_QUEUES for every test in the module. Co-Authored-By: Claude Opus 4.6 --- .../integration/api/test_clean_text_mixin.py | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index c1fce58ae..3f3c90f71 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -18,16 +18,14 @@ validation with grandfathering) works correctly on all EDA serializers that were updated in AAP-78702. -The validation is gated behind ENHANCED_INPUT_VALIDATION_ENABLED, so all -test classes use @override_settings to enable it. +The validation is gated behind ENHANCED_INPUT_VALIDATION_ENABLED, so a +module-level autouse fixture (enable_input_validation) enables it for +every test in this module. """ from types import SimpleNamespace -from unittest import mock from unittest.mock import patch import pytest -from django.conf import settings -from django.test import override_settings from rest_framework import status from rest_framework.test import APIClient @@ -62,7 +60,12 @@ VALID_USERNAME = "valid.user123" -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) +@pytest.fixture(autouse=True) +def enable_input_validation(settings): + settings.ENHANCED_INPUT_VALIDATION_ENABLED = True + settings.RULEBOOK_WORKER_QUEUES = [] + + @pytest.mark.django_db class TestOrganizationCleanText: """Test CleanTextMixin integration with OrganizationSerializer.""" @@ -127,7 +130,6 @@ def test_rejects_changed_invalid_name_on_update( assert "name" in response.data -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestTeamCleanText: """Test CleanTextMixin integration with TeamSerializer.""" @@ -193,7 +195,6 @@ def test_grandfather_unchanged_name_on_update( assert response.status_code == status.HTTP_200_OK -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestUserCleanText: """Test CleanTextMixin integration with UserSerializer / @@ -268,14 +269,12 @@ def test_rejects_changed_invalid_username_on_update( assert "username" in response.data -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestActivationCleanText: """Test CleanTextMixin integration with ActivationCreateSerializer / ActivationUpdateSerializer. """ - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -294,7 +293,6 @@ def test_rejects_invalid_name_on_create( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "name" in response.data - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -311,7 +309,6 @@ def test_accepts_valid_name_on_create( ) assert response.status_code == status.HTTP_201_CREATED - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -339,7 +336,6 @@ def test_grandfather_unchanged_description_on_update( ) assert response.status_code == status.HTTP_200_OK - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -365,7 +361,6 @@ def test_rejects_changed_invalid_description_on_update( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "description" in response.data - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -391,7 +386,6 @@ def test_copy_propagates_valid_description( copied = models.Activation.objects.get(name="copied-activation") assert copied.description == activation_payload["description"] - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, @@ -427,7 +421,6 @@ def test_rejects_copy_of_grandfathered_invalid_description( ).exists() -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestProjectCleanText: """Test CleanTextMixin integration with ProjectCreateRequestSerializer / @@ -501,7 +494,6 @@ def test_rejects_changed_invalid_name_on_update( assert "name" in serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestDecisionEnvironmentCleanText: """Test CleanTextMixin integration with @@ -560,7 +552,6 @@ def test_rejects_changed_invalid_name_on_update( assert "name" in serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestCredentialTypeCleanText: """Test CleanTextMixin integration with CredentialTypeCreateSerializer.""" @@ -599,7 +590,6 @@ def test_grandfather_unchanged_name_on_update( assert serializer.is_valid(), serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestEdaCredentialCleanText: """Test CleanTextMixin integration with EdaCredentialCreateSerializer / @@ -676,7 +666,6 @@ def test_rejects_changed_invalid_name_on_update( assert "name" in serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestEventStreamCleanText: """Test CleanTextMixin integration with EventStreamInSerializer.""" @@ -741,7 +730,6 @@ def test_rejects_changed_invalid_name_on_update( assert "name" in serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestCredentialInputSourceCleanText: """Test CleanTextMixin integration with @@ -791,7 +779,6 @@ def test_grandfather_unchanged_description_on_update( assert serializer.is_valid(), serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestAwxTokenCleanText: """Test CleanTextMixin integration with AwxTokenCreateSerializer.""" @@ -826,7 +813,6 @@ def test_rejects_invalid_description_on_create( assert "description" in serializer.errors -@override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) @pytest.mark.django_db class TestExcludedFieldsCleanText: """Test that fields listed in a serializer's excluded_fields bypass @@ -838,7 +824,6 @@ class TestExcludedFieldsCleanText: that are rejected on non-excluded fields like name/description. """ - @mock.patch.object(settings, "RULEBOOK_WORKER_QUEUES", []) @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", return_value=True, From 6ebbe370ff1edee9beb0672c54b13726abbe198e Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 3 Sep 2026 10:39:43 -0700 Subject: [PATCH 14/19] Fix 6 failing CleanTextMixin tests Co-Authored-By: Claude Opus 4.6 --- .../integration/api/test_clean_text_mixin.py | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 3f3c90f71..4064b6f93 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -26,6 +26,7 @@ from unittest.mock import patch import pytest +from django.test import override_settings from rest_framework import status from rest_framework.test import APIClient @@ -241,16 +242,16 @@ def test_rejects_invalid_last_name_on_create( assert "last_name" in response.data def test_grandfather_unchanged_username_on_update( - self, use_local_resource_setting, admin_client: APIClient + self, + use_local_resource_setting, + admin_client: APIClient, + admin_user: models.User, ): - data_in = {"username": "temp.user", "password": "secret"} - response = admin_client.post(f"{api_url_v1}/users/", data=data_in) - assert response.status_code == status.HTTP_201_CREATED - user_id = response.data["id"] - - models.User.objects.filter(pk=user_id).update(username="user;invalid") + models.User.objects.filter(pk=admin_user.pk).update( + username="user;invalid" + ) response = admin_client.patch( - f"{api_url_v1}/users/{user_id}/", + f"{api_url_v1}/users/{admin_user.id}/", data={"username": "user;invalid", "first_name": "Updated"}, ) assert response.status_code == status.HTTP_200_OK @@ -670,6 +671,7 @@ def test_rejects_changed_invalid_name_on_update( class TestEventStreamCleanText: """Test CleanTextMixin integration with EventStreamInSerializer.""" + @override_settings(EVENT_STREAM_BASE_URL="https://www.example.com/") def test_rejects_invalid_name_on_create( self, default_organization: models.Organization, @@ -682,11 +684,14 @@ def test_rejects_invalid_name_on_create( "eda_credential_id": default_hmac_credential.id, "organization_id": default_organization.id, }, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert not serializer.is_valid() assert "name" in serializer.errors + @override_settings(EVENT_STREAM_BASE_URL="https://www.example.com/") def test_accepts_valid_name_on_create( self, default_organization: models.Organization, @@ -699,7 +704,9 @@ def test_accepts_valid_name_on_create( "eda_credential_id": default_hmac_credential.id, "organization_id": default_organization.id, }, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert serializer.is_valid(), serializer.errors @@ -786,7 +793,9 @@ class TestAwxTokenCleanText: def test_rejects_invalid_name_on_create(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": DANGEROUS_NAME, "token": "sometoken"}, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert not serializer.is_valid() assert "name" in serializer.errors @@ -794,7 +803,9 @@ def test_rejects_invalid_name_on_create(self, default_user: models.User): def test_accepts_valid_name_on_create(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": VALID_NAME, "token": "sometoken"}, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert serializer.is_valid(), serializer.errors @@ -807,7 +818,9 @@ def test_rejects_invalid_description_on_create( "token": "sometoken", "description": DANGEROUS_TEXT, }, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert not serializer.is_valid() assert "description" in serializer.errors @@ -883,7 +896,9 @@ def test_eda_credential_inputs_excluded( def test_awx_token_excluded(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": VALID_NAME, "token": DANGEROUS_TEXT}, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert serializer.is_valid(), serializer.errors @@ -893,7 +908,12 @@ def test_credential_input_source_metadata_excluded( ): serializer = CredentialInputSourceUpdateSerializer( instance=default_credential_input_source, - data={"metadata": {"secret_path": DANGEROUS_TEXT}}, + data={ + "metadata": { + "secret_path": DANGEROUS_TEXT, + "secret_key": "bar", + }, + }, partial=True, ) assert serializer.is_valid(), serializer.errors @@ -917,6 +937,8 @@ def test_user_password_excluded(self, default_user: models.User): "username": VALID_USERNAME, "password": DANGEROUS_TEXT, }, - context={"request": SimpleNamespace(user=default_user)}, + context={ + "request": SimpleNamespace(user=default_user, META={}) + }, ) assert serializer.is_valid(), serializer.errors From 2f3e92011dc0465c664e48cf20cebd959b909ab0 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 3 Sep 2026 12:56:31 -0700 Subject: [PATCH 15/19] linting fix --- .../integration/api/test_clean_text_mixin.py | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 4064b6f93..35a16db9e 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -684,9 +684,7 @@ def test_rejects_invalid_name_on_create( "eda_credential_id": default_hmac_credential.id, "organization_id": default_organization.id, }, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert not serializer.is_valid() assert "name" in serializer.errors @@ -704,9 +702,7 @@ def test_accepts_valid_name_on_create( "eda_credential_id": default_hmac_credential.id, "organization_id": default_organization.id, }, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert serializer.is_valid(), serializer.errors @@ -793,9 +789,7 @@ class TestAwxTokenCleanText: def test_rejects_invalid_name_on_create(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": DANGEROUS_NAME, "token": "sometoken"}, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert not serializer.is_valid() assert "name" in serializer.errors @@ -803,9 +797,7 @@ def test_rejects_invalid_name_on_create(self, default_user: models.User): def test_accepts_valid_name_on_create(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": VALID_NAME, "token": "sometoken"}, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert serializer.is_valid(), serializer.errors @@ -818,9 +810,7 @@ def test_rejects_invalid_description_on_create( "token": "sometoken", "description": DANGEROUS_TEXT, }, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert not serializer.is_valid() assert "description" in serializer.errors @@ -896,9 +886,7 @@ def test_eda_credential_inputs_excluded( def test_awx_token_excluded(self, default_user: models.User): serializer = AwxTokenCreateSerializer( data={"name": VALID_NAME, "token": DANGEROUS_TEXT}, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert serializer.is_valid(), serializer.errors @@ -937,8 +925,6 @@ def test_user_password_excluded(self, default_user: models.User): "username": VALID_USERNAME, "password": DANGEROUS_TEXT, }, - context={ - "request": SimpleNamespace(user=default_user, META={}) - }, + context={"request": SimpleNamespace(user=default_user, META={})}, ) assert serializer.is_valid(), serializer.errors From 3f793309b4c1997b4c4f5d4864c7fa0cbe83a72c Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 9 Sep 2026 15:09:08 -0700 Subject: [PATCH 16/19] test: add CREATE-path test for CredentialInputSourceCreateSerializer Add test_rejects_invalid_description_on_create to verify that CredentialInputSourceCreateSerializer rejects a dangerous description on the create path, complementing the existing update-path tests. Co-Authored-By: Claude Opus 4.6 --- .../integration/api/test_clean_text_mixin.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 35a16db9e..0b0611e5b 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -22,6 +22,7 @@ module-level autouse fixture (enable_input_validation) enables it for every test in this module. """ + from types import SimpleNamespace from unittest.mock import patch @@ -31,6 +32,7 @@ from rest_framework.test import APIClient from aap_eda.api.serializers.credential_input_source import ( + CredentialInputSourceCreateSerializer, CredentialInputSourceUpdateSerializer, ) from aap_eda.api.serializers.credential_type import ( @@ -736,12 +738,31 @@ def test_rejects_changed_invalid_name_on_update( @pytest.mark.django_db class TestCredentialInputSourceCleanText: """Test CleanTextMixin integration with + CredentialInputSourceCreateSerializer / CredentialInputSourceUpdateSerializer. Note: CredentialInputSource has no name field, so only the Tier 2 (free-text) description field is exercised here. """ + def test_rejects_invalid_description_on_create( + self, + default_credential_input_source: models.CredentialInputSource, + ): + source = default_credential_input_source + serializer = CredentialInputSourceCreateSerializer( + data={ + "description": DANGEROUS_TEXT, + "source_credential": source.source_credential.id, + "target_credential": source.target_credential.id, + "input_field_name": source.input_field_name, + "metadata": source.metadata, + "organization_id": source.organization.id, + } + ) + assert not serializer.is_valid() + assert "description" in serializer.errors + def test_rejects_invalid_description_on_update( self, default_credential_input_source: models.CredentialInputSource, From 8e45fdfad3d8f296f0a4549bb19b3fa9a9d9aa19 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Wed, 9 Sep 2026 15:36:08 -0700 Subject: [PATCH 17/19] fix: avoid UniqueTogetherValidator collision in create-path test Use "username" instead of source.input_field_name ("password") to avoid colliding with the existing fixture record on the same (target_credential, input_field_name) pair. Co-Authored-By: Claude Opus 4.6 --- tests/integration/api/test_clean_text_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api/test_clean_text_mixin.py b/tests/integration/api/test_clean_text_mixin.py index 0b0611e5b..933eacc06 100644 --- a/tests/integration/api/test_clean_text_mixin.py +++ b/tests/integration/api/test_clean_text_mixin.py @@ -755,7 +755,7 @@ def test_rejects_invalid_description_on_create( "description": DANGEROUS_TEXT, "source_credential": source.source_credential.id, "target_credential": source.target_credential.id, - "input_field_name": source.input_field_name, + "input_field_name": "username", "metadata": source.metadata, "organization_id": source.organization.id, } From e4f2335fb3f4151cd8219ca71e20862308a7833e Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 10 Sep 2026 15:59:40 -0700 Subject: [PATCH 18/19] update poetry lock --- poetry.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index f4409f179..1d44086cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "adal" @@ -932,6 +932,7 @@ bcrypt = ["bcrypt"] [[package]] name = "django-ansible-base" version = "2026.8.31.0.dev62+ge5a492d23" +version = "2026.8.31.0.dev62+ge5a492d23" description = "A Django app used by ansible services" optional = false python-versions = ">=3.12" From 3118982a88923d76de682164784e68a7eb72a8c9 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Mon, 14 Sep 2026 11:57:23 -0700 Subject: [PATCH 19/19] fix duplicate line and regenerate poetry lock --- poetry.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1d44086cc..b7684aaf8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -932,7 +932,6 @@ bcrypt = ["bcrypt"] [[package]] name = "django-ansible-base" version = "2026.8.31.0.dev62+ge5a492d23" -version = "2026.8.31.0.dev62+ge5a492d23" description = "A Django app used by ansible services" optional = false python-versions = ">=3.12" @@ -974,7 +973,7 @@ testing = ["cryptography", "pytest", "pytest-django"] type = "git" url = "https://github.com/ansible/django-ansible-base.git" reference = "devel" -resolved_reference = "405ef5827400f4b10f101cfb0b70f8d607ea828e" +resolved_reference = "7701dd4f239fce2761631a306b7e3a5a48211340" [[package]] name = "django-crum"