Skip to content

Commit 91dc078

Browse files
authored
fix: prevent metadata loss due to bad request (#6172)
1 parent f18ab9c commit 91dc078

5 files changed

Lines changed: 294 additions & 0 deletions

File tree

api/environments/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
9191
self._validate_required_metadata(organisation, attrs.get("metadata", []))
9292
return attrs
9393

94+
def create(self, validated_data: dict[str, Any]) -> Environment:
95+
metadata_data = validated_data.pop("metadata", [])
96+
environment = super().create(validated_data) # type: ignore[no-untyped-call]
97+
self._update_metadata(environment, metadata_data)
98+
return environment # type: ignore[no-any-return]
99+
94100
def update(
95101
self, environment: Environment, validated_data: dict[str, Any]
96102
) -> Environment:

api/features/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,12 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
348348
self._validate_required_metadata(organisation, attrs.get("metadata", []))
349349
return attrs
350350

351+
def create(self, validated_data: dict[str, Any]) -> Feature:
352+
metadata_data = validated_data.pop("metadata", [])
353+
feature = super().create(validated_data)
354+
self._update_metadata(feature, metadata_data)
355+
return feature
356+
351357
def update(self, feature: Feature, validated_data: dict[str, Any]) -> Feature:
352358
metadata = validated_data.pop("metadata", [])
353359
feature = super().update(feature, validated_data)

api/tests/unit/environments/test_unit_environments_views.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,100 @@ def test_update_environment_metadata( # type: ignore[no-untyped-def]
914914
]
915915

916916

917+
def test_create_multiple_environments_with_metadata_keeps_metadata_isolated(
918+
project: Project,
919+
admin_client_new: APIClient,
920+
optional_b_environment_metadata_field: MetadataModelField,
921+
) -> None:
922+
"""
923+
Test that creating multiple environments with metadata keeps metadata properly isolated.
924+
This is a regression test for the bug where creating a second environment would remove
925+
metadata from the first environment if the user sent the first environment's metadata ID.
926+
"""
927+
# Given
928+
url = reverse("api-v1:environments:environment-list")
929+
930+
# Create first environment with metadata
931+
first_environment_data = {
932+
"name": "First Environment",
933+
"project": project.id,
934+
"description": "First environment description",
935+
"metadata": [
936+
{
937+
"model_field": optional_b_environment_metadata_field.id,
938+
"field_value": "100",
939+
},
940+
],
941+
}
942+
943+
# When - Create first environment
944+
first_response = admin_client_new.post(
945+
url, data=json.dumps(first_environment_data), content_type="application/json"
946+
)
947+
948+
# Then - First environment should be created successfully
949+
assert first_response.status_code == status.HTTP_201_CREATED
950+
first_environment_api_key = first_response.json()["api_key"]
951+
first_metadata = first_response.json()["metadata"]
952+
assert len(first_metadata) == 1
953+
assert first_metadata[0]["field_value"] == "100"
954+
first_metadata_id = first_metadata[0]["id"]
955+
956+
# Given - Create second environment
957+
second_environment_data = {
958+
"name": "Second Environment",
959+
"project": project.id,
960+
"description": "Second environment description",
961+
"metadata": [
962+
{
963+
"id": first_metadata_id, # user mistakenly sends existing metadata ID
964+
"model_field": optional_b_environment_metadata_field.id,
965+
"field_value": "200",
966+
},
967+
],
968+
}
969+
970+
# When - Create second environment
971+
second_response = admin_client_new.post(
972+
url, data=json.dumps(second_environment_data), content_type="application/json"
973+
)
974+
975+
# Then - Second environment should be created successfully
976+
assert second_response.status_code == status.HTTP_201_CREATED
977+
second_environment_api_key = second_response.json()["api_key"]
978+
second_metadata = second_response.json()["metadata"]
979+
assert len(second_metadata) == 1
980+
assert second_metadata[0]["field_value"] == "200"
981+
# The metadata ID should be different (new metadata instance created)
982+
assert second_metadata[0]["id"] != first_metadata_id
983+
984+
# When - Retrieve first environment to verify metadata is still there
985+
first_environment_url = reverse(
986+
"api-v1:environments:environment-detail", args=[first_environment_api_key]
987+
)
988+
first_environment_check = admin_client_new.get(first_environment_url)
989+
990+
# Then - First environment should still have its metadata
991+
assert first_environment_check.status_code == status.HTTP_200_OK
992+
first_environment_metadata_after = first_environment_check.json()["metadata"]
993+
assert len(first_environment_metadata_after) == 1
994+
assert first_environment_metadata_after[0]["field_value"] == "100"
995+
assert first_environment_metadata_after[0]["id"] == first_metadata_id
996+
997+
# When - Retrieve second environment to verify metadata
998+
second_environment_url = reverse(
999+
"api-v1:environments:environment-detail", args=[second_environment_api_key]
1000+
)
1001+
second_environment_check = admin_client_new.get(second_environment_url)
1002+
1003+
# Then - Second environment should have its own metadata
1004+
assert second_environment_check.status_code == status.HTTP_200_OK
1005+
second_environment_metadata_after = second_environment_check.json()["metadata"]
1006+
assert len(second_environment_metadata_after) == 1
1007+
assert second_environment_metadata_after[0]["field_value"] == "200"
1008+
assert second_environment_metadata_after[0]["id"] != first_metadata_id
1009+
1010+
9171011
def test_audit_log_entry_created_when_environment_updated(
9181012
environment: Environment, project: Project, admin_client_new: APIClient
9191013
) -> None:

api/tests/unit/features/test_unit_features_views.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4075,3 +4075,95 @@ def test_get_multivariate_options_feature_not_found_responds_404(
40754075

40764076
# Then
40774077
assert response.status_code == status.HTTP_404_NOT_FOUND
4078+
4079+
4080+
def test_create_multiple_features_with_metadata_keeps_metadata_isolated(
4081+
admin_client_new: APIClient,
4082+
project: Project,
4083+
optional_b_feature_metadata_field: MetadataModelField,
4084+
) -> None:
4085+
"""
4086+
Test that creating multiple features with metadata keeps metadata properly isolated.
4087+
This is a regression test for the bug where creating a second feature would remove
4088+
metadata from the first feature if the user sent the first feature's metadata ID.
4089+
"""
4090+
# Given
4091+
url = reverse("api-v1:projects:project-features-list", args=[project.id])
4092+
4093+
# Create first feature with metadata
4094+
first_feature_data = {
4095+
"name": "First Feature",
4096+
"description": "First feature description",
4097+
"metadata": [
4098+
{
4099+
"model_field": optional_b_feature_metadata_field.id,
4100+
"field_value": "100",
4101+
},
4102+
],
4103+
}
4104+
4105+
# When - Create first feature
4106+
first_response = admin_client_new.post(
4107+
url, data=json.dumps(first_feature_data), content_type="application/json"
4108+
)
4109+
4110+
# Then - First feature should be created successfully
4111+
assert first_response.status_code == status.HTTP_201_CREATED
4112+
first_feature_id = first_response.json()["id"]
4113+
first_metadata = first_response.json()["metadata"]
4114+
assert len(first_metadata) == 1
4115+
assert first_metadata[0]["field_value"] == "100"
4116+
first_metadata_id = first_metadata[0]["id"]
4117+
4118+
# Given - Create second feature
4119+
second_feature_data = {
4120+
"name": "Second Feature",
4121+
"description": "Second feature description",
4122+
"metadata": [
4123+
{
4124+
"id": first_metadata_id, # user mistakenly sends existing metadata ID
4125+
"model_field": optional_b_feature_metadata_field.id,
4126+
"field_value": "200",
4127+
},
4128+
],
4129+
}
4130+
4131+
# When - Create second feature
4132+
second_response = admin_client_new.post(
4133+
url, data=json.dumps(second_feature_data), content_type="application/json"
4134+
)
4135+
4136+
# Then - Second feature should be created successfully
4137+
assert second_response.status_code == status.HTTP_201_CREATED
4138+
second_feature_id = second_response.json()["id"]
4139+
second_metadata = second_response.json()["metadata"]
4140+
assert len(second_metadata) == 1
4141+
assert second_metadata[0]["field_value"] == "200"
4142+
# The metadata ID should be different (new metadata instance created)
4143+
assert second_metadata[0]["id"] != first_metadata_id
4144+
4145+
# When - Retrieve first feature to verify metadata is still there
4146+
first_feature_url = reverse(
4147+
"api-v1:projects:project-features-detail", args=[project.id, first_feature_id]
4148+
)
4149+
first_feature_check = admin_client_new.get(first_feature_url)
4150+
4151+
# Then - First feature should still have its metadata
4152+
assert first_feature_check.status_code == status.HTTP_200_OK
4153+
first_feature_metadata_after = first_feature_check.json()["metadata"]
4154+
assert len(first_feature_metadata_after) == 1
4155+
assert first_feature_metadata_after[0]["field_value"] == "100"
4156+
assert first_feature_metadata_after[0]["id"] == first_metadata_id
4157+
4158+
# When - Retrieve second feature to verify metadata
4159+
second_feature_url = reverse(
4160+
"api-v1:projects:project-features-detail",
4161+
args=[project.id, second_feature_id],
4162+
)
4163+
second_feature_check = admin_client_new.get(second_feature_url)
4164+
4165+
# Then - Second feature should have its own metadata
4166+
assert second_feature_check.status_code == status.HTTP_200_OK
4167+
second_feature_metadata_after = second_feature_check.json()["metadata"]
4168+
assert len(second_feature_metadata_after) == 1
4169+
assert second_feature_metadata_after[0]["field_value"] == "200"

api/tests/unit/segments/test_unit_segments_views.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,102 @@ def test_create_segment_with_optional_metadata_returns_201(
15231523
assert response.json()["metadata"][0]["field_value"] == str(field_value)
15241524

15251525

1526+
def test_create_multiple_segments_with_metadata_keeps_metadata_isolated(
1527+
project: Project,
1528+
admin_client_new: APIClient,
1529+
optional_b_segment_metadata_field: MetadataModelField,
1530+
) -> None:
1531+
"""
1532+
Test that creating multiple segments with metadata keeps metadata properly isolated.
1533+
This is a regression test for the bug where creating a second segment would remove
1534+
metadata from the first segment if the user sent the first segment's metadata ID.
1535+
"""
1536+
# Given
1537+
url = reverse("api-v1:projects:project-segments-list", args=[project.id])
1538+
1539+
# Create first segment with metadata
1540+
first_segment_data = {
1541+
"name": "First Segment",
1542+
"description": "First segment description",
1543+
"project": project.id,
1544+
"rules": [{"type": "ALL", "rules": [], "conditions": []}],
1545+
"metadata": [
1546+
{
1547+
"model_field": optional_b_segment_metadata_field.id,
1548+
"field_value": "100",
1549+
},
1550+
],
1551+
}
1552+
1553+
# When - Create first segment
1554+
first_response = admin_client_new.post(
1555+
url, data=json.dumps(first_segment_data), content_type="application/json"
1556+
)
1557+
1558+
# Then - First segment should be created successfully
1559+
assert first_response.status_code == status.HTTP_201_CREATED
1560+
first_segment_id = first_response.json()["id"]
1561+
first_metadata = first_response.json()["metadata"]
1562+
assert len(first_metadata) == 1
1563+
assert first_metadata[0]["field_value"] == "100"
1564+
first_metadata_id = first_metadata[0]["id"]
1565+
1566+
# Given - Create second segment
1567+
second_segment_data = {
1568+
"name": "Second Segment",
1569+
"description": "Second segment description",
1570+
"project": project.id,
1571+
"rules": [{"type": "ALL", "rules": [], "conditions": []}],
1572+
"metadata": [
1573+
{
1574+
"id": first_metadata_id, # user mistakenly sends existing metadata ID
1575+
"model_field": optional_b_segment_metadata_field.id,
1576+
"field_value": "200",
1577+
},
1578+
],
1579+
}
1580+
1581+
# When - Create second segment
1582+
second_response = admin_client_new.post(
1583+
url, data=json.dumps(second_segment_data), content_type="application/json"
1584+
)
1585+
1586+
# Then - Second segment should be created successfully
1587+
assert second_response.status_code == status.HTTP_201_CREATED
1588+
second_segment_id = second_response.json()["id"]
1589+
second_metadata = second_response.json()["metadata"]
1590+
assert len(second_metadata) == 1
1591+
assert second_metadata[0]["field_value"] == "200"
1592+
# The metadata ID should be different (new metadata instance created)
1593+
assert second_metadata[0]["id"] != first_metadata_id
1594+
1595+
# When - Retrieve first segment to verify metadata is still there
1596+
first_segment_url = reverse(
1597+
"api-v1:projects:project-segments-detail", args=[project.id, first_segment_id]
1598+
)
1599+
first_segment_check = admin_client_new.get(first_segment_url)
1600+
1601+
# Then - First segment should still have its metadata
1602+
assert first_segment_check.status_code == status.HTTP_200_OK
1603+
first_segment_metadata_after = first_segment_check.json()["metadata"]
1604+
assert len(first_segment_metadata_after) == 1
1605+
assert first_segment_metadata_after[0]["field_value"] == "100"
1606+
assert first_segment_metadata_after[0]["id"] == first_metadata_id
1607+
1608+
# When - Retrieve second segment to verify metadata
1609+
second_segment_url = reverse(
1610+
"api-v1:projects:project-segments-detail", args=[project.id, second_segment_id]
1611+
)
1612+
second_segment_check = admin_client_new.get(second_segment_url)
1613+
1614+
# Then - Second segment should have its own metadata
1615+
assert second_segment_check.status_code == status.HTTP_200_OK
1616+
second_segment_metadata_after = second_segment_check.json()["metadata"]
1617+
assert len(second_segment_metadata_after) == 1
1618+
assert second_segment_metadata_after[0]["field_value"] == "200"
1619+
assert second_segment_metadata_after[0]["id"] != first_metadata_id
1620+
1621+
15261622
def test_update_segment_evades_max_conditions_when_whitelisted(
15271623
project: Project,
15281624
admin_client: APIClient,

0 commit comments

Comments
 (0)