From 35cf9bf3bdbbfa03ec8416ff7c26d638fee0f271 Mon Sep 17 00:00:00 2001 From: wavebyrd Date: Thu, 12 Mar 2026 15:49:44 -0400 Subject: [PATCH 1/5] fix(api): handle null environment in validate_environment (#6597) When a request passes a null environment value to the featurestates endpoint, `validate_environment` raises an `AttributeError` because it tries to access `.id` on `None`. Guard against `None` and return early, letting the broader `validate` method resolve the environment from the serialiser context. --- api/features/serializers.py | 2 ++ .../test_unit_features_serializers.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/api/features/serializers.py b/api/features/serializers.py index c27ee5aa78e5..be2f634b3e9f 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -597,6 +597,8 @@ def validate_feature(self, feature): # type: ignore[no-untyped-def] return feature def validate_environment(self, environment): # type: ignore[no-untyped-def] + if environment is None: + return environment if self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] raise serializers.ValidationError( "Cannot change the environment of a feature state" diff --git a/api/tests/unit/features/test_unit_features_serializers.py b/api/tests/unit/features/test_unit_features_serializers.py index 36e3eff19e79..013e4cf20a95 100644 --- a/api/tests/unit/features/test_unit_features_serializers.py +++ b/api/tests/unit/features/test_unit_features_serializers.py @@ -14,6 +14,29 @@ from features.serializers import FeatureStateSerializerBasic +def test_feature_state_serializer_basic__null_environment_with_context__is_valid( # type: ignore[no-untyped-def] + feature, environment +): + # Given + feature_state = FeatureState.objects.get(feature=feature, environment=environment) + data = { + "id": feature_state.id, + "feature": feature.id, + "environment": None, + } + serializer = FeatureStateSerializerBasic( + instance=feature_state, + data=data, + context={"environment": environment}, + ) + + # When + is_valid = serializer.is_valid() + + # Then - should not raise AttributeError on environment.id + assert is_valid + + @pytest.mark.parametrize( "percentage_value, expected_is_valid", ((90, True), (100, True), (110, False)) ) From 556c32709741eb29c0ac340faec93deb9b2a8176 Mon Sep 17 00:00:00 2001 From: wavebyrd Date: Thu, 12 Mar 2026 16:49:01 -0400 Subject: [PATCH 2/5] Reject null environment with a validation error Address review feedback: raise ValidationError instead of passing null through, since the nullable environment column is a historical data layer artefact and not intended API behaviour. --- api/features/serializers.py | 4 +++- api/tests/unit/features/test_unit_features_serializers.py | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index be2f634b3e9f..f228ef4ec299 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -598,7 +598,9 @@ def validate_feature(self, feature): # type: ignore[no-untyped-def] def validate_environment(self, environment): # type: ignore[no-untyped-def] if environment is None: - return environment + raise serializers.ValidationError( + "This field may not be null." + ) if self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] raise serializers.ValidationError( "Cannot change the environment of a feature state" diff --git a/api/tests/unit/features/test_unit_features_serializers.py b/api/tests/unit/features/test_unit_features_serializers.py index 013e4cf20a95..d804bf2b102d 100644 --- a/api/tests/unit/features/test_unit_features_serializers.py +++ b/api/tests/unit/features/test_unit_features_serializers.py @@ -14,7 +14,7 @@ from features.serializers import FeatureStateSerializerBasic -def test_feature_state_serializer_basic__null_environment_with_context__is_valid( # type: ignore[no-untyped-def] +def test_feature_state_serializer_basic__null_environment__returns_validation_error( # type: ignore[no-untyped-def] feature, environment ): # Given @@ -33,8 +33,9 @@ def test_feature_state_serializer_basic__null_environment_with_context__is_valid # When is_valid = serializer.is_valid() - # Then - should not raise AttributeError on environment.id - assert is_valid + # Then - should reject null environment, not raise AttributeError + assert not is_valid + assert "environment" in serializer.errors @pytest.mark.parametrize( From 246d558af5fa163529ce4efe75397a95444bb1e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:05:58 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/features/serializers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index f228ef4ec299..9a8fd9d4de69 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -598,9 +598,7 @@ def validate_feature(self, feature): # type: ignore[no-untyped-def] def validate_environment(self, environment): # type: ignore[no-untyped-def] if environment is None: - raise serializers.ValidationError( - "This field may not be null." - ) + raise serializers.ValidationError("This field may not be null.") if self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] raise serializers.ValidationError( "Cannot change the environment of a feature state" From 02253cf2464cca735288a65d3b2232cf32166e94 Mon Sep 17 00:00:00 2001 From: wavebyrd Date: Thu, 12 Mar 2026 18:36:48 -0400 Subject: [PATCH 4/5] Move null environment check to object-level validate() Field-level validate_environment was blocking the context fallback in validate() from ever running. Now validate_environment just guards the .id access, and validate() resolves environment from context before rejecting null. --- api/features/serializers.py | 10 ++++--- .../test_unit_features_serializers.py | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 9a8fd9d4de69..583cbf969f9c 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -597,16 +597,18 @@ def validate_feature(self, feature): # type: ignore[no-untyped-def] return feature def validate_environment(self, environment): # type: ignore[no-untyped-def] - if environment is None: - raise serializers.ValidationError("This field may not be null.") - if self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] + if environment is not None and self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] raise serializers.ValidationError( "Cannot change the environment of a feature state" ) return environment def validate(self, attrs): # type: ignore[no-untyped-def] - environment = attrs.get("environment") or self.context["environment"] + environment = attrs.get("environment") or self.context.get("environment") + if environment is None: + raise serializers.ValidationError( + {"environment": ["This field may not be null."]} + ) identity = attrs.get("identity") feature_segment = attrs.get("feature_segment") identifier = attrs.pop("identifier", None) diff --git a/api/tests/unit/features/test_unit_features_serializers.py b/api/tests/unit/features/test_unit_features_serializers.py index d804bf2b102d..ce76783b9d2c 100644 --- a/api/tests/unit/features/test_unit_features_serializers.py +++ b/api/tests/unit/features/test_unit_features_serializers.py @@ -14,10 +14,10 @@ from features.serializers import FeatureStateSerializerBasic -def test_feature_state_serializer_basic__null_environment__returns_validation_error( # type: ignore[no-untyped-def] +def test_feature_state_serializer_basic__null_environment_no_context__returns_validation_error( # type: ignore[no-untyped-def] feature, environment ): - # Given + # Given - null environment in payload and no environment in context feature_state = FeatureState.objects.get(feature=feature, environment=environment) data = { "id": feature_state.id, @@ -27,7 +27,7 @@ def test_feature_state_serializer_basic__null_environment__returns_validation_er serializer = FeatureStateSerializerBasic( instance=feature_state, data=data, - context={"environment": environment}, + context={}, ) # When @@ -38,6 +38,29 @@ def test_feature_state_serializer_basic__null_environment__returns_validation_er assert "environment" in serializer.errors +def test_feature_state_serializer_basic__null_environment_with_context__falls_back_to_context( # type: ignore[no-untyped-def] + feature, environment +): + # Given - null environment in payload but valid environment in context + feature_state = FeatureState.objects.get(feature=feature, environment=environment) + data = { + "id": feature_state.id, + "feature": feature.id, + "environment": None, + } + serializer = FeatureStateSerializerBasic( + instance=feature_state, + data=data, + context={"environment": environment}, + ) + + # When + is_valid = serializer.is_valid() + + # Then - should fall back to context environment + assert is_valid + + @pytest.mark.parametrize( "percentage_value, expected_is_valid", ((90, True), (100, True), (110, False)) ) From 5de3d486166e052a18157f9ca75da8881fb96ab0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:15:41 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/features/serializers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/features/serializers.py b/api/features/serializers.py index 583cbf969f9c..4b331ec2c757 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -597,7 +597,11 @@ def validate_feature(self, feature): # type: ignore[no-untyped-def] return feature def validate_environment(self, environment): # type: ignore[no-untyped-def] - if environment is not None and self.instance and self.instance.environment_id != environment.id: # type: ignore[union-attr] + if ( + environment is not None + and self.instance + and self.instance.environment_id != environment.id + ): # type: ignore[union-attr] raise serializers.ValidationError( "Cannot change the environment of a feature state" )