-
Notifications
You must be signed in to change notification settings - Fork 556
fix(versioning): block manual commit of a stale change request #8219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |||||||||||||||||||||||||||||||||||||||||||
| from core.helpers import get_current_site_url | ||||||||||||||||||||||||||||||||||||||||||||
| from environments.models import Environment | ||||||||||||||||||||||||||||||||||||||||||||
| from features.models import Feature, FeatureSegment, FeatureState | ||||||||||||||||||||||||||||||||||||||||||||
| from features.value_types import STRING | ||||||||||||||||||||||||||||||||||||||||||||
| from features.versioning.models import ( | ||||||||||||||||||||||||||||||||||||||||||||
| EnvironmentFeatureVersion, | ||||||||||||||||||||||||||||||||||||||||||||
| VersionChangeSet, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -33,6 +34,7 @@ | |||||||||||||||||||||||||||||||||||||||||||
| CannotApproveOwnChangeRequest, | ||||||||||||||||||||||||||||||||||||||||||||
| ChangeRequestDeletionError, | ||||||||||||||||||||||||||||||||||||||||||||
| ChangeRequestNotApprovedError, | ||||||||||||||||||||||||||||||||||||||||||||
| ChangeRequestStaleError, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| from features.workflows.core.models import ( | ||||||||||||||||||||||||||||||||||||||||||||
| ChangeRequest, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -231,6 +233,171 @@ def test_change_request_commit__valid_request__emits_structlog_event( | |||||||||||||||||||||||||||||||||||||||||||
| } in log.events | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| def test_change_request_commit__stale_change_set__raises_exception_and_does_not_revert_conflicting_change( | ||||||||||||||||||||||||||||||||||||||||||||
| environment_v2_versioning: Environment, | ||||||||||||||||||||||||||||||||||||||||||||
| feature: Feature, | ||||||||||||||||||||||||||||||||||||||||||||
| segment: Segment, | ||||||||||||||||||||||||||||||||||||||||||||
| admin_user: FFAdminUser, | ||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||
| # Given | ||||||||||||||||||||||||||||||||||||||||||||
| # An existing, published segment override on the feature. | ||||||||||||||||||||||||||||||||||||||||||||
| current_version = EnvironmentFeatureVersion.objects.get_latest_versions_as_queryset( | ||||||||||||||||||||||||||||||||||||||||||||
| environment_v2_versioning.id | ||||||||||||||||||||||||||||||||||||||||||||
| ).get(feature=feature) | ||||||||||||||||||||||||||||||||||||||||||||
| feature_segment = FeatureSegment.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| segment=segment, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, | ||||||||||||||||||||||||||||||||||||||||||||
| environment_feature_version=current_version, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| FeatureState.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_segment=feature_segment, | ||||||||||||||||||||||||||||||||||||||||||||
| environment_feature_version=current_version, | ||||||||||||||||||||||||||||||||||||||||||||
| enabled=False, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # CR A captures the full state of that override (e.g., as part of | ||||||||||||||||||||||||||||||||||||||||||||
| # reordering overrides on the feature) when it is created. | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_a = ChangeRequest.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, title="CR A", user=admin_user | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| VersionChangeSet.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| change_request=change_request_a, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_states_to_update=json.dumps( | ||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_segment": {"segment": segment.id}, | ||||||||||||||||||||||||||||||||||||||||||||
| "enabled": False, | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_state_value": { | ||||||||||||||||||||||||||||||||||||||||||||
| "type": STRING, | ||||||||||||||||||||||||||||||||||||||||||||
| "string_value": "original value", | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # And CR B changes the value of that same override, and is published | ||||||||||||||||||||||||||||||||||||||||||||
| # first. | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_b = ChangeRequest.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, title="CR B", user=admin_user | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| VersionChangeSet.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| change_request=change_request_b, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_states_to_update=json.dumps( | ||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_segment": {"segment": segment.id}, | ||||||||||||||||||||||||||||||||||||||||||||
| "enabled": True, | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_state_value": { | ||||||||||||||||||||||||||||||||||||||||||||
| "type": STRING, | ||||||||||||||||||||||||||||||||||||||||||||
| "string_value": "concurrent value", | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_b.commit(admin_user) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # When / Then | ||||||||||||||||||||||||||||||||||||||||||||
| # Committing CR A should now be blocked, since it is stale: its | ||||||||||||||||||||||||||||||||||||||||||||
| # captured override state conflicts with CR B's published change. | ||||||||||||||||||||||||||||||||||||||||||||
| with pytest.raises(ChangeRequestStaleError): | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_a.commit(admin_user) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # and CR B's change has not been silently reverted. | ||||||||||||||||||||||||||||||||||||||||||||
| latest_flags = get_environment_flags_list( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, feature_name=feature.name | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| override = next(fs for fs in latest_flags if fs.feature_segment_id is not None) | ||||||||||||||||||||||||||||||||||||||||||||
| assert override.enabled is True | ||||||||||||||||||||||||||||||||||||||||||||
| assert override.get_feature_state_value() == "concurrent value" | ||||||||||||||||||||||||||||||||||||||||||||
| assert change_request_a.committed_at is None | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| def test_change_request_commit__stale_change_set_but_ignore_conflicts__commits_and_reverts_change( | ||||||||||||||||||||||||||||||||||||||||||||
| environment_v2_versioning: Environment, | ||||||||||||||||||||||||||||||||||||||||||||
| feature: Feature, | ||||||||||||||||||||||||||||||||||||||||||||
| segment: Segment, | ||||||||||||||||||||||||||||||||||||||||||||
| admin_user: FFAdminUser, | ||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||
| # Given | ||||||||||||||||||||||||||||||||||||||||||||
| # Same setup as above, but CR A has `ignore_conflicts` set, which is | ||||||||||||||||||||||||||||||||||||||||||||
| # the existing opt-out already respected by scheduled publishes. | ||||||||||||||||||||||||||||||||||||||||||||
| current_version = EnvironmentFeatureVersion.objects.get_latest_versions_as_queryset( | ||||||||||||||||||||||||||||||||||||||||||||
| environment_v2_versioning.id | ||||||||||||||||||||||||||||||||||||||||||||
| ).get(feature=feature) | ||||||||||||||||||||||||||||||||||||||||||||
| feature_segment = FeatureSegment.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| segment=segment, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, | ||||||||||||||||||||||||||||||||||||||||||||
| environment_feature_version=current_version, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| FeatureState.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_segment=feature_segment, | ||||||||||||||||||||||||||||||||||||||||||||
| environment_feature_version=current_version, | ||||||||||||||||||||||||||||||||||||||||||||
| enabled=False, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| change_request_a = ChangeRequest.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, | ||||||||||||||||||||||||||||||||||||||||||||
| title="CR A", | ||||||||||||||||||||||||||||||||||||||||||||
| user=admin_user, | ||||||||||||||||||||||||||||||||||||||||||||
| ignore_conflicts=True, | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| VersionChangeSet.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| change_request=change_request_a, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_states_to_update=json.dumps( | ||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_segment": {"segment": segment.id}, | ||||||||||||||||||||||||||||||||||||||||||||
| "enabled": False, | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_state_value": { | ||||||||||||||||||||||||||||||||||||||||||||
| "type": STRING, | ||||||||||||||||||||||||||||||||||||||||||||
| "string_value": "original value", | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| change_request_b = ChangeRequest.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| environment=environment_v2_versioning, title="CR B", user=admin_user | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| VersionChangeSet.objects.create( | ||||||||||||||||||||||||||||||||||||||||||||
| change_request=change_request_b, | ||||||||||||||||||||||||||||||||||||||||||||
| feature=feature, | ||||||||||||||||||||||||||||||||||||||||||||
| feature_states_to_update=json.dumps( | ||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_segment": {"segment": segment.id}, | ||||||||||||||||||||||||||||||||||||||||||||
| "enabled": True, | ||||||||||||||||||||||||||||||||||||||||||||
| "feature_state_value": { | ||||||||||||||||||||||||||||||||||||||||||||
| "type": STRING, | ||||||||||||||||||||||||||||||||||||||||||||
| "string_value": "concurrent value", | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_b.commit(admin_user) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # When | ||||||||||||||||||||||||||||||||||||||||||||
| change_request_a.commit(admin_user) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| # Then | ||||||||||||||||||||||||||||||||||||||||||||
| # commit succeeds, and (as documented by `ignore_conflicts`) CR A's | ||||||||||||||||||||||||||||||||||||||||||||
| # captured state overwrites CR B's published change. | ||||||||||||||||||||||||||||||||||||||||||||
| assert change_request_a.committed_at is not None | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+392
to
+398
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Assert the overwrite that the test name promises. The test name ends with Add the same state assertions used in the first test, with the opposite expected values. 💚 Proposed fix to assert the overwritten state # When
change_request_a.commit(admin_user)
# Then
# commit succeeds, and (as documented by `ignore_conflicts`) CR A's
# captured state overwrites CR B's published change.
assert change_request_a.committed_at is not None
+
+ latest_flags = get_environment_flags_list(
+ environment=environment_v2_versioning, feature_name=feature.name
+ )
+ override = next(fs for fs in latest_flags if fs.feature_segment_id is not None)
+ assert override.enabled is False
+ assert override.get_feature_state_value() == "original value"📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| def test_change_request_create__valid_environment__creates_audit_log( # type: ignore[no-untyped-def] | ||||||||||||||||||||||||||||||||||||||||||||
| environment, admin_user | ||||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Extract the duplicated setup into a fixture.
Lines 331-390 repeat lines 244-304 almost exactly. The only difference is
ignore_conflicts=Trueat line 352. Extract the published override, CR A, and CR B setup into a fixture or a helper that acceptsignore_conflictsas a parameter. This keeps the two tests aligned when the change-set payload shape changes.🧰 Tools
🪛 ast-grep (0.45.0)
[info] 356-367: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
[
{
"feature_segment": {"segment": segment.id},
"enabled": False,
"feature_state_value": {
"type": STRING,
"string_value": "original value",
},
}
]
)
Note: [CWE-116] Improper Encoding or Escaping of Output.
(use-jsonify)
[info] 376-387: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
[
{
"feature_segment": {"segment": segment.id},
"enabled": True,
"feature_state_value": {
"type": STRING,
"string_value": "concurrent value",
},
}
]
)
Note: [CWE-116] Improper Encoding or Escaping of Output.
(use-jsonify)