diff --git a/api/features/tests/__init__.py b/api/features/tests/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/api/features/tests/test_fields.py b/api/features/tests/test_fields.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/api/features/tests/test_models.py b/api/features/tests/test_models.py deleted file mode 100644 index 76af46a5f245..000000000000 --- a/api/features/tests/test_models.py +++ /dev/null @@ -1,638 +0,0 @@ -from unittest import mock - -import pytest -from django.core.exceptions import ValidationError -from django.db.models import Q -from django.db.utils import IntegrityError -from django.test import TestCase -from django.utils import timezone - -from environments.identities.models import Identity -from environments.models import Environment -from features.constants import ENVIRONMENT, FEATURE_SEGMENT, IDENTITY -from features.models import Feature, FeatureSegment, FeatureState -from organisations.models import Organisation -from projects.models import Project -from projects.tags.models import Tag -from segments.models import Segment - - -@pytest.mark.django_db -class FeatureTestCase(TestCase): - def setUp(self): - self.organisation = Organisation.objects.create(name="Test Org") - self.project = Project.objects.create( - name="Test Project", organisation=self.organisation - ) - self.environment_one = Environment.objects.create( - name="Test Environment 1", project=self.project - ) - self.environment_two = Environment.objects.create( - name="Test Environment 2", project=self.project - ) - - def test_feature_should_create_feature_states_for_environments(self): - feature = Feature.objects.create(name="Test Feature", project=self.project) - - feature_states = FeatureState.objects.filter(feature=feature) - - self.assertEquals(feature_states.count(), 2) - - def test_save_existing_feature_should_not_change_feature_state_enabled(self): - # Given - default_enabled = True - feature = Feature.objects.create( - name="Test Feature", project=self.project, default_enabled=default_enabled - ) - - # When - # we update the default_enabled state of the feature and save it again - feature.default_enabled = not default_enabled - feature.save() - - # Then - # we expect that the feature state enabled values have not changed - assert all(fs.enabled == default_enabled for fs in feature.feature_states.all()) - - def test_creating_feature_with_initial_value_should_set_value_for_all_feature_states( - self, - ): - feature = Feature.objects.create( - name="Test Feature", - project=self.project, - initial_value="This is a value", - ) - - feature_states = FeatureState.objects.filter(feature=feature) - - for feature_state in feature_states: - self.assertEquals( - feature_state.get_feature_state_value(), "This is a value" - ) - - def test_creating_feature_with_integer_initial_value_should_set_integer_value_for_all_feature_states( - self, - ): - # Given - initial_value = 1 - feature = Feature.objects.create( - name="Test feature", - project=self.project, - initial_value=initial_value, - ) - - # When - feature_states = FeatureState.objects.filter(feature=feature) - - # Then - for feature_state in feature_states: - assert feature_state.get_feature_state_value() == initial_value - - def test_creating_feature_with_boolean_initial_value_should_set_boolean_value_for_all_feature_states( - self, - ): - # Given - initial_value = False - feature = Feature.objects.create( - name="Test feature", - project=self.project, - initial_value=initial_value, - ) - - # When - feature_states = FeatureState.objects.filter(feature=feature) - - # Then - for feature_state in feature_states: - assert feature_state.get_feature_state_value() == initial_value - - def test_updating_feature_state_should_trigger_webhook(self): - Feature.objects.create(name="Test Feature", project=self.project) - # TODO: implement webhook test method - - def test_cannot_create_feature_with_same_case_insensitive_name(self): - # Given - feature_name = "Test Feature" - - feature_one = Feature(project=self.project, name=feature_name) - feature_two = Feature(project=self.project, name=feature_name.lower()) - - # When - feature_one.save() - - # Then - with pytest.raises(IntegrityError): - feature_two.save() - - def test_updating_feature_name_should_update_feature_states(self): - # Given - old_feature_name = "old_feature" - new_feature_name = "new_feature" - - feature = Feature.objects.create(project=self.project, name=old_feature_name) - - # When - feature.name = new_feature_name - feature.save() - - # Then - FeatureState.objects.filter(feature__name=new_feature_name).exists() - - def test_full_clean_fails_when_duplicate_case_insensitive_name(self): - # unit test to validate validate_unique() method - - # Given - feature_name = "Test Feature" - Feature.objects.create( - name=feature_name, initial_value="test", project=self.project - ) - - # When - with self.assertRaises(ValidationError): - feature_two = Feature( - name=feature_name.lower(), - initial_value="test", - project=self.project, - ) - feature_two.full_clean() - - def test_updating_feature_should_allow_case_insensitive_name(self): - # Given - feature_name = "Test Feature" - - feature = Feature.objects.create( - project=self.project, name=feature_name, initial_value="test" - ) - - # When - feature.name = feature_name.lower() - feature.full_clean() # should not raise error as the same Object - - def test_when_create_feature_with_tags_then_success(self): - # Given - tag1 = Tag.objects.create( - label="Test Tag", - color="#fffff", - description="Test Tag description", - project=self.project, - ) - tag2 = Tag.objects.create( - label="Test Tag", - color="#fffff", - description="Test Tag description", - project=self.project, - ) - feature = Feature.objects.create(project=self.project, name="test feature") - - # When - tags_for_feature = Tag.objects.all() - feature.tags.set(tags_for_feature) - feature.save() - - self.assertEqual(feature.tags.count(), 2) - self.assertEqual(list(feature.tags.all()), [tag1, tag2]) - - -@pytest.mark.django_db -class FeatureStateTest(TestCase): - def setUp(self) -> None: - self.organisation = Organisation.objects.create(name="Test org") - self.project = Project.objects.create( - name="Test project", organisation=self.organisation - ) - self.environment = Environment.objects.create( - name="Test environment", project=self.project - ) - self.feature = Feature.objects.create(name="Test feature", project=self.project) - - @mock.patch("features.signals.trigger_feature_state_change_webhooks") - def test_cannot_create_duplicate_feature_state_in_an_environment( - self, mock_trigger_webhooks - ): - """ - Note that although the mock isn't used in this test, it throws an exception on - it's thread so we mock it here anyway. - """ - - # Given - duplicate_feature_state = FeatureState( - feature=self.feature, environment=self.environment, enabled=True - ) - - # When - with pytest.raises(ValidationError): - duplicate_feature_state.save() - - # Then - assert ( - FeatureState.objects.filter( - feature=self.feature, environment=self.environment - ).count() - == 1 - ) - - @mock.patch("features.signals.trigger_feature_state_change_webhooks") - def test_cannot_create_duplicate_feature_state_in_an_environment_for_segment( - self, mock_trigger_webhooks - ): - """ - Note that although the mock isn't used in this test, it throws an exception on - it's thread so we mock it here anyway. - """ - - # Given - segment = Segment.objects.create(project=self.project) - feature_segment = FeatureSegment.objects.create( - feature=self.feature, environment=self.environment, segment=segment - ) - FeatureState.objects.create( - feature=self.feature, - environment=self.environment, - feature_segment=feature_segment, - ) - - duplicate_feature_state = FeatureState( - feature=self.feature, - environment=self.environment, - enabled=True, - feature_segment=feature_segment, - ) - - # When - with pytest.raises(ValidationError): - duplicate_feature_state.save() - - # Then - assert ( - FeatureState.objects.filter( - feature=self.feature, - environment=self.environment, - feature_segment=feature_segment, - ).count() - == 1 - ) - - @mock.patch("features.signals.trigger_feature_state_change_webhooks") - def test_cannot_create_duplicate_feature_state_in_an_environment_for_identity( - self, mock_trigger_webhooks - ): - """ - Note that although the mock isn't used in this test, it throws an exception on - it's thread so we mock it here anyway. - """ - - # Given - identity = Identity.objects.create( - identifier="identifier", environment=self.environment - ) - FeatureState.objects.create( - feature=self.feature, environment=self.environment, identity=identity - ) - - duplicate_feature_state = FeatureState( - feature=self.feature, - environment=self.environment, - enabled=True, - identity=identity, - ) - - # When - with pytest.raises(ValidationError): - duplicate_feature_state.save() - - # Then - assert ( - FeatureState.objects.filter( - feature=self.feature, environment=self.environment, identity=identity - ).count() - == 1 - ) - - def test_feature_state_gt_operator(self): - # Given - identity = Identity.objects.create( - identifier="test_identity", environment=self.environment - ) - segment_1 = Segment.objects.create(name="Test Segment 1", project=self.project) - segment_2 = Segment.objects.create(name="Test Segment 2", project=self.project) - feature_segment_p1 = FeatureSegment.objects.create( - segment=segment_1, - feature=self.feature, - environment=self.environment, - priority=1, - ) - feature_segment_p2 = FeatureSegment.objects.create( - segment=segment_2, - feature=self.feature, - environment=self.environment, - priority=2, - ) - - # When - identity_state = FeatureState.objects.create( - identity=identity, feature=self.feature, environment=self.environment - ) - - segment_1_state = FeatureState.objects.create( - feature_segment=feature_segment_p1, - feature=self.feature, - environment=self.environment, - ) - segment_2_state = FeatureState.objects.create( - feature_segment=feature_segment_p2, - feature=self.feature, - environment=self.environment, - ) - default_env_state = FeatureState.objects.get( - environment=self.environment, identity=None, feature_segment=None - ) - - # Then - identity state is higher priority than all - assert identity_state > segment_1_state - assert identity_state > segment_2_state - assert identity_state > default_env_state - - # and feature state with feature segment with highest priority is greater than feature state with lower - # priority feature segment and default environment state - assert segment_1_state > segment_2_state - assert segment_1_state > default_env_state - - # and feature state with any segment is greater than default environment state - assert segment_2_state > default_env_state - - def test_feature_state_gt_operator_throws_value_error_if_different_environments( - self, - ): - # Given - another_environment = Environment.objects.create( - name="Another environment", project=self.project - ) - feature_state_env_1 = FeatureState.objects.filter( - environment=self.environment - ).first() - feature_state_env_2 = FeatureState.objects.filter( - environment=another_environment - ).first() - - # When - with pytest.raises(ValueError): - feature_state_env_1 > feature_state_env_2 - - # Then - exception raised - - def test_feature_state_gt_operator_throws_value_error_if_different_features(self): - # Given - another_feature = Feature.objects.create( - name="Another feature", project=self.project - ) - feature_state_env_1 = FeatureState.objects.filter(feature=self.feature).first() - feature_state_env_2 = FeatureState.objects.filter( - feature=another_feature - ).first() - - # When - with pytest.raises(ValueError): - feature_state_env_1 > feature_state_env_2 - - # Then - exception raised - - def test_feature_state_gt_operator_throws_value_error_if_different_identities(self): - # Given - identity_1 = Identity.objects.create( - identifier="identity_1", environment=self.environment - ) - identity_2 = Identity.objects.create( - identifier="identity_2", environment=self.environment - ) - - feature_state_identity_1 = FeatureState.objects.create( - feature=self.feature, environment=self.environment, identity=identity_1 - ) - feature_state_identity_2 = FeatureState.objects.create( - feature=self.feature, environment=self.environment, identity=identity_2 - ) - - # When - with pytest.raises(ValueError): - feature_state_identity_1 > feature_state_identity_2 - - # Then - exception raised - - @mock.patch("features.signals.trigger_feature_state_change_webhooks") - def test_save_calls_trigger_webhooks(self, mock_trigger_webhooks): - # Given - feature_state = FeatureState.objects.get( - feature=self.feature, environment=self.environment - ) - - # When - feature_state.save() - - # Then - mock_trigger_webhooks.assert_called_with(feature_state) - - def test_get_environment_flags_returns_latest_live_versions_of_feature_states( - self, - ): - # Given - feature_2 = Feature.objects.create(name="feature_2", project=self.project) - feature_2_v1_feature_state = FeatureState.objects.get(feature=feature_2) - - feature_1_v2_feature_state = FeatureState.objects.create( - feature=self.feature, - enabled=True, - version=2, - environment=self.environment, - live_from=timezone.now(), - ) - FeatureState.objects.create( - feature=self.feature, - enabled=False, - version=None, - environment=self.environment, - ) - - identity = Identity.objects.create( - identifier="identity", environment=self.environment - ) - FeatureState.objects.create( - feature=self.feature, identity=identity, environment=self.environment - ) - - # When - environment_feature_states = FeatureState.get_environment_flags_list( - environment_id=self.environment.id, - additional_filters=Q(feature_segment=None, identity=None), - ) - - # Then - assert set(environment_feature_states) == { - feature_1_v2_feature_state, - feature_2_v1_feature_state, - } - - def test_feature_state_type_environment(self): - # Given - feature_state = FeatureState.objects.get( - environment=self.environment, - feature=self.feature, - identity=None, - feature_segment=None, - ) - - # Then - assert feature_state.type == ENVIRONMENT - - def test_feature_state_type_identity(self): - # Given - identity = Identity.objects.create( - identifier="identity", environment=self.environment - ) - feature_state = FeatureState.objects.create( - environment=self.environment, - feature=self.feature, - identity=identity, - feature_segment=None, - ) - - # Then - assert feature_state.type == IDENTITY - - def test_feature_state_type_feature_segment(self): - # Given - segment = Segment.objects.create(project=self.project) - feature_segment = FeatureSegment.objects.create( - feature=self.feature, segment=segment, environment=self.environment - ) - feature_state = FeatureState.objects.create( - environment=self.environment, - feature=self.feature, - identity=None, - feature_segment=feature_segment, - ) - - # Then - assert feature_state.type == FEATURE_SEGMENT - - def test_feature_state_type_unknown(self): - # Note: this test is a case which should never, ever happen in real life - # as it's not possible to create a feature state that has both an identity - # and a feature segment via the API, however, it's useful to have the logic - # defined in case it ever does happen - - # Given - # a feature state with both identity and feature segment - identity = Identity.objects.create( - identifier="identity", environment=self.environment - ) - segment = Segment.objects.create(project=self.project) - feature_segment = FeatureSegment.objects.create( - feature=self.feature, segment=segment, environment=self.environment - ) - feature_state = FeatureState.objects.create( - environment=self.environment, - feature=self.feature, - identity=identity, - feature_segment=feature_segment, - ) - - # Then - # we default to environment type - with self.assertLogs("features") as caplog: - assert feature_state.type == ENVIRONMENT - - # and an error is logged - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "ERROR" - assert ( - caplog.records[0].message - == f"FeatureState {feature_state.id} does not have a valid type. " - f"Defaulting to environment." - ) - - -@pytest.mark.parametrize("hashed_percentage", (0.0, 0.3, 0.5, 0.8, 0.999999)) -@mock.patch("features.models.get_hashed_percentage_for_object_ids") -def test_get_multivariate_value_returns_correct_value_when_we_pass_identity( - mock_get_hashed_percentage, - hashed_percentage, - multivariate_feature, - environment, - identity, -): - # Given - mock_get_hashed_percentage.return_value = hashed_percentage - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - multivariate_value = feature_state.get_multivariate_feature_state_value( - identity_hash_key=identity.get_hash_key() - ) - - # Then - # we get a multivariate value - assert multivariate_value - - # and that value is not the control (since the fixture includes values that span - # the entire 100%) - assert multivariate_value.value != multivariate_value.initial_value - - -@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") -def test_get_feature_state_value_for_multivariate_features( - mock_get_mv_feature_state_value, environment, multivariate_feature, identity -): - # Given - value = "value" - mock_mv_feature_state_value = mock.MagicMock(value=value) - mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value - - environment.use_identity_composite_key_for_hashing = False - environment.save() - - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - feature_state_value = feature_state.get_feature_state_value(identity=identity) - - # Then - # the correct value is returned - assert feature_state_value == value - # and the correct call is made to get the multivariate feature state value - mock_get_mv_feature_state_value.assert_called_once_with(str(identity.id)) - - -@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") -def test_get_feature_state_value_for_multivariate_features_mv_v2_evaluation( - mock_get_mv_feature_state_value, environment, multivariate_feature, identity -): - # Given - value = "value" - mock_mv_feature_state_value = mock.MagicMock(value=value) - mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value - - feature_state = FeatureState.objects.get( - environment=environment, - feature=multivariate_feature, - identity=None, - feature_segment=None, - ) - - # When - feature_state_value = feature_state.get_feature_state_value(identity=identity) - - # Then - # the correct value is returned - assert feature_state_value == value - # and the correct call is made to get the multivariate feature state value - mock_get_mv_feature_state_value.assert_called_once_with(identity.composite_key) diff --git a/api/features/tests/test_permissions.py b/api/features/tests/test_permissions.py deleted file mode 100644 index cdeb54c359f4..000000000000 --- a/api/features/tests/test_permissions.py +++ /dev/null @@ -1,383 +0,0 @@ -from unittest import TestCase, mock - -import pytest - -from features.models import Feature -from features.permissions import FeaturePermissions -from organisations.models import Organisation, OrganisationRole -from projects.models import ( - Project, - UserPermissionGroupProjectPermission, - UserProjectPermission, -) -from projects.permissions import CREATE_FEATURE, DELETE_FEATURE, VIEW_PROJECT -from users.models import FFAdminUser, UserPermissionGroup - -mock_view = mock.MagicMock() -mock_request = mock.MagicMock() - -feature_permissions = FeaturePermissions() - - -@pytest.mark.django_db -class FeaturePermissionsTestCase(TestCase): - def setUp(self) -> None: - self.organisation = Organisation.objects.create(name="Test") - self.project = Project.objects.create( - name="Test project", organisation=self.organisation - ) - - self.feature = Feature.objects.create(name="Test feature", project=self.project) - - self.user = FFAdminUser.objects.create(email="user@test.com") - self.user.add_organisation(self.organisation, OrganisationRole.USER) - - self.org_admin = FFAdminUser.objects.create(email="admin@test.com") - self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) - - self.group = UserPermissionGroup.objects.create( - name="Test group", organisation=self.organisation - ) - self.group.users.add(self.user) - - mock_view.kwargs = {} - mock_request.data = {} - - def test_organisation_admin_can_list_features(self): - # Given - mock_view.action = "list" - mock_view.detail = False - mock_view.kwargs["project_pk"] = self.project.id - mock_request.user = self.org_admin - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_project_admin_can_list_features(self): - # Given - UserProjectPermission.objects.create( - user=self.user, admin=True, project=self.project - ) - - mock_view.action = "list" - mock_view.detail = False - mock_view.kwargs["project_pk"] = self.project.id - mock_request.user = self.user - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_project_user_with_read_access_can_list_features(self): - # Given - user_project_permission = UserProjectPermission.objects.create( - user=self.user, admin=False, project=self.project - ) - user_project_permission.set_permissions([VIEW_PROJECT]) - - mock_view.action = "list" - mock_view.detail = False - mock_view.kwargs["project_pk"] = self.project.id - mock_request.user = self.user - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_user_with_no_project_permissions_cannot_list_features(self): - # Given - mock_view.action = "list" - mock_view.detail = False - mock_view.kwargs["project_pk"] = self.project.id - mock_request.user = self.user - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert not result - - def test_organisation_admin_can_create_feature(self): - # Given - mock_view.action = "create" - mock_view.detail = False - mock_request.user = self.org_admin - mock_request.data = {"project": self.project.id, "name": "new feature"} - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_project_admin_can_create_feature(self): - # Given - # use a group to test groups work too - UserPermissionGroupProjectPermission.objects.create( - group=self.group, project=self.project, admin=True - ) - mock_view.action = "create" - mock_view.detail = False - mock_request.user = self.user - mock_request.data = {"project": self.project.id, "name": "new feature"} - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_project_user_with_create_feature_permission_can_create_feature(self): - # Given - # use a group to test groups work too - user_group_permission = UserPermissionGroupProjectPermission.objects.create( - group=self.group, project=self.project, admin=False - ) - user_group_permission.add_permission(CREATE_FEATURE) - mock_view.action = "create" - mock_view.detail = False - mock_request.user = self.user - mock_request.data = {"project": self.project.id, "name": "new feature"} - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert result - - def test_project_user_without_create_feature_permission_cannot_create_feature(self): - # Given - mock_view.action = "create" - mock_view.detail = False - mock_request.user = self.user - mock_request.data = {"project": self.project.id, "name": "new feature"} - - # When - result = feature_permissions.has_permission(mock_request, mock_view) - - # Then - assert not result - - def test_organisation_admin_can_view_feature(self): - # Given - mock_view.action = "retrieve" - mock_view.detail = True - mock_request.user = self.org_admin - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_admin_can_view_feature(self): - # Given - UserProjectPermission.objects.create( - user=self.user, project=self.project, admin=True - ) - mock_request.user = self.user - mock_view.action = "retrieve" - mock_view.detail = True - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_with_view_project_permission_can_view_feature(self): - # Given - user_permission = UserProjectPermission.objects.create( - user=self.user, project=self.project, admin=False - ) - user_permission.set_permissions([VIEW_PROJECT]) - mock_request.user = self.user - mock_view.action = "retrieve" - mock_view.detail = True - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_without_view_project_permission_cannot_view_feature(self): - # Given - mock_request.user = self.user - mock_view.action = "retrieve" - mock_view.detail = True - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert not result - - def test_organisation_admin_can_edit_feature(self): - # Given - mock_view.action = "update" - mock_view.detail = True - mock_request.user = self.org_admin - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_admin_can_edit_feature(self): - # Given - UserProjectPermission.objects.create( - user=self.user, project=self.project, admin=True - ) - mock_view.action = "update" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_cannot_edit_feature(self): - # Given - mock_view.action = "update" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert not result - - def test_organisation_admin_can_delete_feature(self): - # Given - mock_view.action = "destroy" - mock_view.detail = True - mock_request.user = self.org_admin - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_admin_can_delete_feature(self): - # Given - UserProjectPermission.objects.create( - user=self.user, project=self.project, admin=True - ) - mock_view.action = "destroy" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_with_delete_feature_permission_can_delete_feature(self): - # Given - user_project_permission = UserProjectPermission.objects.create( - user=self.user, project=self.project - ) - user_project_permission.add_permission(DELETE_FEATURE) - - mock_view.action = "destroy" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_without_delete_feature_permission_cannot_delete_feature(self): - # Given - mock_view.action = "destroy" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert not result - - def test_organisation_admin_can_update_feature_segments(self): - # Given - mock_view.action = "segments" - mock_view.detail = True - mock_request.user = self.org_admin - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_admin_can_update_feature_segments(self): - # Given - UserProjectPermission.objects.create( - user=self.user, project=self.project, admin=True - ) - mock_view.action = "segments" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert result - - def test_project_user_cannot_update_feature_segments(self): - # Given - mock_view.action = "segments" - mock_view.detail = True - mock_request.user = self.user - - # When - result = feature_permissions.has_object_permission( - mock_request, mock_view, self.feature - ) - - # Then - assert not result diff --git a/api/features/tests/test_views.py b/api/features/tests/test_views.py deleted file mode 100644 index 5b66a2ff93eb..000000000000 --- a/api/features/tests/test_views.py +++ /dev/null @@ -1,905 +0,0 @@ -import json -from datetime import date, datetime, timedelta -from unittest import TestCase, mock - -import pytest -import pytz -from app_analytics.dataclasses import FeatureEvaluationData -from core.constants import FLAGSMITH_UPDATED_AT_HEADER -from django.forms import model_to_dict -from django.urls import reverse -from pytest_lazyfixture import lazy_fixture -from rest_framework import status -from rest_framework.test import APIClient, APITestCase - -from audit.constants import ( - IDENTITY_FEATURE_STATE_DELETED_MESSAGE, - IDENTITY_FEATURE_STATE_UPDATED_MESSAGE, -) -from audit.models import AuditLog, RelatedObjectType -from environments.identities.models import Identity -from environments.models import Environment, EnvironmentAPIKey -from environments.permissions.constants import MANAGE_SEGMENT_OVERRIDES -from environments.permissions.models import UserEnvironmentPermission -from features.models import ( - Feature, - FeatureSegment, - FeatureState, - FeatureStateValue, -) -from organisations.models import Organisation, OrganisationRole -from permissions.models import PermissionModel -from projects.models import Project, UserProjectPermission -from projects.permissions import CREATE_FEATURE, VIEW_PROJECT -from projects.tags.models import Tag -from segments.models import Segment -from users.models import FFAdminUser -from util.tests import Helper -from webhooks.webhooks import WebhookEventType - -# patch this function as it's triggering extra threads and causing errors -mock.patch("features.signals.trigger_feature_state_change_webhooks").start() - - -@pytest.mark.django_db -class ProjectFeatureTestCase(TestCase): - project_features_url = "/api/v1/projects/%s/features/" - project_feature_detail_url = "/api/v1/projects/%s/features/%d/" - post_template = '{ "name": "%s", "project": %d, "initial_value": "%s" }' - - def setUp(self): - self.client = APIClient() - self.user = Helper.create_ffadminuser() - self.client.force_authenticate(user=self.user) - - self.organisation = Organisation.objects.create(name="Test Org") - - self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) - - self.project = Project.objects.create( - name="Test project", organisation=self.organisation - ) - self.project2 = Project.objects.create( - name="Test project2", organisation=self.organisation - ) - self.environment_1 = Environment.objects.create( - name="Test environment 1", project=self.project - ) - self.environment_2 = Environment.objects.create( - name="Test environment 2", project=self.project - ) - - self.tag_one = Tag.objects.create( - label="Test Tag", - color="#fffff", - description="Test Tag description", - project=self.project, - ) - self.tag_two = Tag.objects.create( - label="Test Tag2", - color="#fffff", - description="Test Tag2 description", - project=self.project, - ) - self.tag_other_project = Tag.objects.create( - label="Wrong Tag", - color="#fffff", - description="Test Tag description", - project=self.project2, - ) - - def test_owners_is_read_only_for_feature_create(self): - # Given - set up data - default_value = "This is a value" - data = { - "name": "test feature", - "initial_value": default_value, - "project": self.project.id, - "owners": [ - { - "id": 2, - "email": "fake_user@mail.com", - "first_name": "fake", - "last_name": "user", - } - ], - } - url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) - - # When - response = self.client.post( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_201_CREATED - assert len(response.json()["owners"]) == 1 - assert response.json()["owners"][0]["id"] == self.user.id - assert response.json()["owners"][0]["email"] == self.user.email - - @mock.patch("features.views.trigger_feature_state_change_webhooks") - def test_feature_state_webhook_triggered_when_feature_deleted( - self, mocked_trigger_fs_change_webhook - ): - # Given - feature = Feature.objects.create(name="test feature", project=self.project) - feature_states = list(feature.feature_states.all()) - # When - self.client.delete( - self.project_feature_detail_url % (self.project.id, feature.id) - ) - # Then - mock_calls = [ - mock.call(fs, WebhookEventType.FLAG_DELETED) for fs in feature_states - ] - mocked_trigger_fs_change_webhook.has_calls(mock_calls) - - def test_remove_owners_only_remove_specified_owners(self): - # Given - user_2 = FFAdminUser.objects.create_user(email="user2@mail.com") - user_3 = FFAdminUser.objects.create_user(email="user3@mail.com") - feature = Feature.objects.create(name="Test Feature", project=self.project) - feature.owners.add(user_2, user_3) - - url = reverse( - "api-v1:projects:project-features-remove-owners", - args=[self.project.id, feature.id], - ) - data = {"user_ids": [user_2.id]} - # When - json_response = self.client.post( - url, data=json.dumps(data), content_type="application/json" - ).json() - assert len(json_response["owners"]) == 1 - assert json_response["owners"][0] == { - "id": user_3.id, - "email": user_3.email, - "first_name": user_3.first_name, - "last_name": user_3.last_name, - "last_login": None, - } - - def test_audit_log_created_when_feature_state_created_for_identity(self): - # Given - feature = Feature.objects.create(name="Test feature", project=self.project) - identity = Identity.objects.create( - identifier="test-identifier", environment=self.environment_1 - ) - url = reverse( - "api-v1:environments:identity-featurestates-list", - args=[self.environment_1.api_key, identity.id], - ) - data = {"feature": feature.id, "enabled": True} - - # When - self.client.post(url, data=json.dumps(data), content_type="application/json") - - # Then - assert ( - AuditLog.objects.filter( - related_object_type=RelatedObjectType.FEATURE_STATE.name - ).count() - == 1 - ) - - # and - expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( - feature.name, - identity.identifier, - ) - audit_log = AuditLog.objects.get( - related_object_type=RelatedObjectType.FEATURE_STATE.name - ) - assert audit_log.log == expected_log_message - - def test_audit_log_created_when_feature_state_updated_for_identity(self): - # Given - feature = Feature.objects.create(name="Test feature", project=self.project) - identity = Identity.objects.create( - identifier="test-identifier", environment=self.environment_1 - ) - feature_state = FeatureState.objects.create( - feature=feature, - environment=self.environment_1, - identity=identity, - enabled=True, - ) - url = reverse( - "api-v1:environments:identity-featurestates-detail", - args=[self.environment_1.api_key, identity.id, feature_state.id], - ) - data = {"feature": feature.id, "enabled": False} - - # When - self.client.put(url, data=json.dumps(data), content_type="application/json") - - # Then - assert ( - AuditLog.objects.filter( - related_object_type=RelatedObjectType.FEATURE_STATE.name - ).count() - == 1 - ) - - # and - expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( - feature.name, - identity.identifier, - ) - audit_log = AuditLog.objects.get( - related_object_type=RelatedObjectType.FEATURE_STATE.name - ) - assert audit_log.log == expected_log_message - - def test_audit_log_created_when_feature_state_deleted_for_identity(self): - # Given - feature = Feature.objects.create(name="Test feature", project=self.project) - identity = Identity.objects.create( - identifier="test-identifier", environment=self.environment_1 - ) - feature_state = FeatureState.objects.create( - feature=feature, - environment=self.environment_1, - identity=identity, - enabled=True, - ) - url = reverse( - "api-v1:environments:identity-featurestates-detail", - args=[self.environment_1.api_key, identity.id, feature_state.id], - ) - - # When - self.client.delete(url) - - # Then - assert ( - AuditLog.objects.filter( - log=IDENTITY_FEATURE_STATE_DELETED_MESSAGE - % ( - feature.name, - identity.identifier, - ) - ).count() - == 1 - ) - - def test_when_add_tags_from_different_project_on_feature_create_then_failed(self): - # Given - set up data - feature_name = "test feature" - data = { - "name": feature_name, - "project": self.project.id, - "initial_value": "test", - "tags": [self.tag_other_project.id], - } - - # When - response = self.client.post( - self.project_features_url % self.project.id, - data=json.dumps(data), - content_type="application/json", - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - - # check no feature was created successfully - assert ( - Feature.objects.filter(name=feature_name, project=self.project.id).count() - == 0 - ) - - def test_when_add_tags_on_feature_update_then_success(self): - # Given - set up data - feature = Feature.objects.create(project=self.project, name="test feature") - data = { - "name": feature.name, - "project": self.project.id, - "tags": [self.tag_one.id], - } - - # When - response = self.client.put( - self.project_feature_detail_url % (self.project.id, feature.id), - data=json.dumps(data), - content_type="application/json", - ) - - # Then - assert response.status_code == status.HTTP_200_OK - - # check feature was created successfully - check_feature = Feature.objects.filter( - name=feature.name, project=self.project.id - ).first() - - # check tags added - assert check_feature.tags.count() == 1 - - def test_when_add_tags_from_different_project_on_feature_update_then_failed(self): - # Given - set up data - feature = Feature.objects.create(project=self.project, name="test feature") - data = { - "name": feature.name, - "project": self.project.id, - "tags": [self.tag_other_project.id], - } - - # When - response = self.client.put( - self.project_feature_detail_url % (self.project.id, feature.id), - data=json.dumps(data), - content_type="application/json", - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - - # check feature was created successfully - check_feature = Feature.objects.filter( - name=feature.name, project=self.project.id - ).first() - - # check tags not added - assert check_feature.tags.count() == 0 - - def test_list_features_is_archived_filter(self): - # Firstly, let's setup the initial data - feature = Feature.objects.create(name="test_feature", project=self.project) - archived_feature = Feature.objects.create( - name="archived_feature", project=self.project, is_archived=True - ) - base_url = reverse( - "api-v1:projects:project-features-list", args=[self.project.id] - ) - # Next, let's test true filter - url = f"{base_url}?is_archived=true" - response = self.client.get(url) - assert len(response.json()["results"]) == 1 - assert response.json()["results"][0]["id"] == archived_feature.id - - # Finally, let's test false filter - url = f"{base_url}?is_archived=false" - response = self.client.get(url) - assert len(response.json()["results"]) == 1 - assert response.json()["results"][0]["id"] == feature.id - - def test_put_feature_does_not_update_feature_states(self): - # Given - feature = Feature.objects.create( - name="test_feature", project=self.project, default_enabled=False - ) - url = reverse( - "api-v1:projects:project-features-detail", - args=[self.project.id, feature.id], - ) - data = model_to_dict(feature) - data["default_enabled"] = True - - # When - response = self.client.put( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_200_OK - - assert all(fs.enabled is False for fs in feature.feature_states.all()) - - @mock.patch("features.views.get_multiple_event_list_for_feature") - def test_get_influx_data(self, mock_get_event_list): - # Given - feature = Feature.objects.create(name="test_feature", project=self.project) - base_url = reverse( - "api-v1:projects:project-features-get-influx-data", - args=[self.project.id, feature.id], - ) - url = f"{base_url}?environment_id={self.environment_1.id}" - - mock_get_event_list.return_value = [ - { - feature.name: 1, - "datetime": datetime(2021, 2, 26, 12, 0, 0, tzinfo=pytz.UTC), - } - ] - - # When - response = self.client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - - mock_get_event_list.assert_called_once_with( - feature_name=feature.name, - environment_id=str(self.environment_1.id), # provided as a GET param - period="24h", # this is the default but can be provided as a GET param - aggregate_every="24h", # this is the default but can be provided as a GET param - ) - - def test_regular_user_cannot_create_mv_options_when_creating_feature(self): - # Given - user = FFAdminUser.objects.create(email="regularuser@project.com") - user.add_organisation(self.organisation) - user_project_permission = UserProjectPermission.objects.create( - user=user, project=self.project - ) - permissions = PermissionModel.objects.filter( - key__in=[VIEW_PROJECT, CREATE_FEATURE] - ) - user_project_permission.permissions.add(*permissions) - client = APIClient() - client.force_authenticate(user) - - data = { - "name": "test_feature", - "default_enabled": True, - "multivariate_options": [{"type": "unicode", "string_value": "test-value"}], - } - url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) - - # When - response = client.post( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_regular_user_cannot_create_mv_options_when_updating_feature(self): - # Given - user = FFAdminUser.objects.create(email="regularuser@project.com") - user.add_organisation(self.organisation) - user_project_permission = UserProjectPermission.objects.create( - user=user, project=self.project - ) - permissions = PermissionModel.objects.filter( - key__in=[VIEW_PROJECT, CREATE_FEATURE] - ) - user_project_permission.permissions.add(*permissions) - client = APIClient() - client.force_authenticate(user) - - feature = Feature.objects.create( - project=self.project, - name="a_feature", - default_enabled=True, - ) - - data = { - "name": feature.name, - "default_enabled": feature.default_enabled, - "multivariate_options": [{"type": "unicode", "string_value": "test-value"}], - } - url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) - - # When - response = client.post( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_regular_user_can_update_feature_description(self): - # Given - user = FFAdminUser.objects.create(email="regularuser@project.com") - user.add_organisation(self.organisation) - user_project_permission = UserProjectPermission.objects.create( - user=user, project=self.project - ) - permissions = PermissionModel.objects.filter( - key__in=[VIEW_PROJECT, CREATE_FEATURE] - ) - user_project_permission.permissions.add(*permissions) - client = APIClient() - client.force_authenticate(user) - - feature = Feature.objects.create( - project=self.project, - name="a_feature", - default_enabled=True, - ) - - data = { - "name": feature.name, - "default_enabled": feature.default_enabled, - "description": "a description", - } - - url = reverse( - "api-v1:projects:project-features-detail", - args=[self.project.id, feature.id], - ) - - # When - response = client.put( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_200_OK - - feature.refresh_from_db() - assert feature.description == data["description"] - - @mock.patch("environments.models.environment_wrapper") - def test_create_feature_only_triggers_write_to_dynamodb_once_per_environment( - self, mock_dynamo_environment_wrapper - ): - # Given - url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) - data = {"name": "Test feature flag", "type": "FLAG", "project": self.project.id} - - self.project.enable_dynamo_db = True - self.project.save() - - mock_dynamo_environment_wrapper.is_enabled = True - mock_dynamo_environment_wrapper.reset_mock() - - # When - self.client.post(url, data=data) - - # Then - mock_dynamo_environment_wrapper.write_environments.assert_called_once() - - -@pytest.mark.django_db -class SDKFeatureStatesTestCase(APITestCase): - def setUp(self) -> None: - self.environment_fs_value = "environment" - self.identity_fs_value = "identity" - self.segment_fs_value = "segment" - - self.organisation = Organisation.objects.create(name="Test organisation") - self.project = Project.objects.create( - name="Test project", organisation=self.organisation - ) - self.environment = Environment.objects.create( - name="Test environment", project=self.project - ) - self.feature = Feature.objects.create( - name="Test feature", - project=self.project, - initial_value=self.environment_fs_value, - ) - segment = Segment.objects.create(name="Test segment", project=self.project) - feature_segment = FeatureSegment.objects.create( - segment=segment, - feature=self.feature, - environment=self.environment, - ) - segment_feature_state = FeatureState.objects.create( - feature=self.feature, - feature_segment=feature_segment, - environment=self.environment, - ) - FeatureStateValue.objects.filter(feature_state=segment_feature_state).update( - string_value=self.segment_fs_value - ) - identity = Identity.objects.create( - identifier="test", environment=self.environment - ) - identity_feature_state = FeatureState.objects.create( - identity=identity, environment=self.environment, feature=self.feature - ) - FeatureStateValue.objects.filter(feature_state=identity_feature_state).update( - string_value=self.identity_fs_value - ) - - self.url = reverse("api-v1:flags") - - self.client.credentials(HTTP_X_ENVIRONMENT_KEY=self.environment.api_key) - - def test_get_flags(self): - # Given - setup data which includes a single feature overridden by a segment and an identity - - # When - we get flags - response = self.client.get(self.url) - - # Then - we only get a single flag back and that is the environment default - assert response.status_code == status.HTTP_200_OK - response_json = response.json() - assert len(response_json) == 1 - assert response_json[0]["feature"]["id"] == self.feature.id - assert response_json[0]["feature_state_value"] == self.environment_fs_value - # refresh the last_updated_at - self.environment.refresh_from_db() - assert response.headers[FLAGSMITH_UPDATED_AT_HEADER] == str( - self.environment.updated_at.timestamp() - ) - - -@pytest.mark.parametrize( - "environment_value, project_value, disabled_flag_returned", - ( - (True, True, False), - (True, False, False), - (False, True, True), - (False, False, True), - (None, True, False), - (None, False, True), - ), -) -def test_get_flags_hide_disabled_flags( - environment_value, - project_value, - disabled_flag_returned, - project, - environment, - api_client, -): - # Given - project.hide_disabled_flags = project_value - project.save() - - environment.hide_disabled_flags = environment_value - environment.save() - - Feature.objects.create(name="disabled_flag", project=project, default_enabled=False) - Feature.objects.create(name="enabled_flag", project=project, default_enabled=True) - - url = reverse("api-v1:flags") - - # When - api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) - response = api_client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - assert len(response.json()) == (2 if disabled_flag_returned else 1) - - -def test_get_flags_hide_sensitive_data(api_client, environment, feature): - # Given - environment.hide_sensitive_data = True - environment.save() - - url = reverse("api-v1:flags") - - # When - api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) - response = api_client.get(url) - feature_sensitive_fields = [ - "created_date", - "description", - "initial_value", - "default_enabled", - ] - fs_sensitive_fields = ["id", "environment", "identity", "feature_segment"] - - # Then - assert response.status_code == status.HTTP_200_OK - # Check that the sensitive fields are None - for flag in response.json(): - for field in fs_sensitive_fields: - assert flag[field] is None - - for field in feature_sensitive_fields: - assert flag["feature"][field] is None - - -def test_get_flags__server_key_only_feature__return_expected( - api_client: APIClient, - environment: Environment, - feature: Feature, -) -> None: - # Given - feature.is_server_key_only = True - feature.save() - - url = reverse("api-v1:flags") - - # When - api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) - response = api_client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - assert not response.json() - - -def test_get_flags__server_key_only_feature__server_key_auth__return_expected( - api_client: APIClient, - environment_api_key: EnvironmentAPIKey, - feature: Feature, -) -> None: - # Given - feature.is_server_key_only = True - feature.save() - - url = reverse("api-v1:flags") - - # When - api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment_api_key.key) - response = api_client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - assert response.json() - - -@pytest.mark.parametrize( - "client", - [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], -) -def test_get_feature_states_by_uuid(client, environment, feature, feature_state): - # Given - url = reverse( - "api-v1:features:get-feature-state-by-uuid", args=[feature_state.uuid] - ) - - # When - response = client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - - response_json = response.json() - assert response_json["uuid"] == str(feature_state.uuid) - - -@pytest.mark.parametrize( - "client", - [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], -) -def test_deleted_features_are_not_listed(client, project, environment, feature): - # Given - url = reverse("api-v1:projects:project-features-list", args=[project.id]) - feature.delete() - - # When - response = client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - assert response.json()["count"] == 0 - - -@pytest.mark.parametrize( - "client", - [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], -) -def test_get_feature_evaluation_data(project, feature, environment, mocker, client): - # Given - base_url = reverse( - "api-v1:projects:project-features-get-evaluation-data", - args=[project.id, feature.id], - ) - url = f"{base_url}?environment_id={environment.id}" - mocked_get_feature_evaluation_data = mocker.patch( - "features.views.get_feature_evaluation_data", autospec=True - ) - mocked_get_feature_evaluation_data.return_value = [ - FeatureEvaluationData(count=10, day=date.today()), - FeatureEvaluationData(count=10, day=date.today() - timedelta(days=1)), - ] - # When - response = client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK - assert len(response.json()) == 2 - assert response.json()[0] == {"day": str(date.today()), "count": 10} - assert response.json()[1] == { - "day": str(date.today() - timedelta(days=1)), - "count": 10, - } - mocked_get_feature_evaluation_data.assert_called_with( - feature=feature, period=30, environment_id=environment.id - ) - - -def test_create_segment_override_forbidden( - feature: Feature, - segment: Segment, - environment: Environment, - staff_user: FFAdminUser, - staff_client: APIClient, -) -> None: - # Given - url = reverse( - "api-v1:environments:create-segment-override", - args=[environment.api_key, feature.id], - ) - - # When - enabled = True - string_value = "foo" - data = { - "feature_state_value": {"string_value": string_value}, - "enabled": enabled, - "feature_segment": {"segment": segment.id}, - } - - # Staff client lacks permission to create segment. - response = staff_client.post( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == 403 - assert response.data == { - "detail": "You do not have permission to perform this action." - } - - -def test_create_segment_override_staff( - feature: Feature, - segment: Segment, - environment: Environment, - staff_user: FFAdminUser, - staff_client: APIClient, -) -> None: - # Given - url = reverse( - "api-v1:environments:create-segment-override", - args=[environment.api_key, feature.id], - ) - - # When - enabled = True - string_value = "foo" - data = { - "feature_state_value": {"string_value": string_value}, - "enabled": enabled, - "feature_segment": {"segment": segment.id}, - } - user_environment_permission = UserEnvironmentPermission.objects.create( - user=staff_user, admin=False, environment=environment - ) - user_environment_permission.permissions.add(MANAGE_SEGMENT_OVERRIDES) - - response = staff_client.post( - url, data=json.dumps(data), content_type="application/json" - ) - # Then - assert response.status_code == 201 - assert response.data["feature_segment"]["segment"] == segment.id - - -def test_create_segment_override(admin_client, feature, segment, environment): - # Given - url = reverse( - "api-v1:environments:create-segment-override", - args=[environment.api_key, feature.id], - ) - - enabled = True - string_value = "foo" - data = { - "feature_state_value": {"string_value": string_value}, - "enabled": enabled, - "feature_segment": {"segment": segment.id}, - } - - # When - response = admin_client.post( - url, data=json.dumps(data), content_type="application/json" - ) - - # Then - assert response.status_code == status.HTTP_201_CREATED - - created_override = FeatureState.objects.filter( - feature=feature, environment=environment, feature_segment__segment=segment - ).first() - assert created_override is not None - assert created_override.enabled is enabled - assert created_override.get_feature_state_value() == string_value - - -def test_get_flags_is_not_throttled_by_user_throttle( - api_client, environment, feature, settings -): - # Given - settings.REST_FRAMEWORK = {"DEFAULT_THROTTLE_RATES": {"user": "1/minute"}} - api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) - - url = reverse("api-v1:flags") - - # When - for _ in range(10): - response = api_client.get(url) - - # Then - assert response.status_code == status.HTTP_200_OK diff --git a/api/features/tests/test_helpers.py b/api/tests/unit/features/test_unit_features_helpers.py similarity index 100% rename from api/features/tests/test_helpers.py rename to api/tests/unit/features/test_unit_features_helpers.py diff --git a/api/tests/unit/features/test_unit_features_models.py b/api/tests/unit/features/test_unit_features_models.py index 000575a2351d..5969bb971910 100644 --- a/api/tests/unit/features/test_unit_features_models.py +++ b/api/tests/unit/features/test_unit_features_models.py @@ -1,12 +1,21 @@ from datetime import timedelta +from unittest import mock import pytest +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.db.utils import IntegrityError +from django.test import TestCase from django.utils import timezone from environments.identities.models import Identity from environments.models import Environment +from features.constants import ENVIRONMENT, FEATURE_SEGMENT, IDENTITY from features.models import Feature, FeatureSegment, FeatureState from features.workflows.core.models import ChangeRequest +from organisations.models import Organisation +from projects.models import Project +from projects.tags.models import Tag from segments.models import Segment now = timezone.now() @@ -14,6 +23,627 @@ tomorrow = now + timedelta(days=1) +@pytest.mark.django_db +class FeatureTestCase(TestCase): + def setUp(self): + self.organisation = Organisation.objects.create(name="Test Org") + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) + self.environment_one = Environment.objects.create( + name="Test Environment 1", project=self.project + ) + self.environment_two = Environment.objects.create( + name="Test Environment 2", project=self.project + ) + + def test_feature_should_create_feature_states_for_environments(self): + feature = Feature.objects.create(name="Test Feature", project=self.project) + + feature_states = FeatureState.objects.filter(feature=feature) + + self.assertEquals(feature_states.count(), 2) + + def test_save_existing_feature_should_not_change_feature_state_enabled(self): + # Given + default_enabled = True + feature = Feature.objects.create( + name="Test Feature", project=self.project, default_enabled=default_enabled + ) + + # When + # we update the default_enabled state of the feature and save it again + feature.default_enabled = not default_enabled + feature.save() + + # Then + # we expect that the feature state enabled values have not changed + assert all(fs.enabled == default_enabled for fs in feature.feature_states.all()) + + def test_creating_feature_with_initial_value_should_set_value_for_all_feature_states( + self, + ): + feature = Feature.objects.create( + name="Test Feature", + project=self.project, + initial_value="This is a value", + ) + + feature_states = FeatureState.objects.filter(feature=feature) + + for feature_state in feature_states: + self.assertEquals( + feature_state.get_feature_state_value(), "This is a value" + ) + + def test_creating_feature_with_integer_initial_value_should_set_integer_value_for_all_feature_states( + self, + ): + # Given + initial_value = 1 + feature = Feature.objects.create( + name="Test feature", + project=self.project, + initial_value=initial_value, + ) + + # When + feature_states = FeatureState.objects.filter(feature=feature) + + # Then + for feature_state in feature_states: + assert feature_state.get_feature_state_value() == initial_value + + def test_creating_feature_with_boolean_initial_value_should_set_boolean_value_for_all_feature_states( + self, + ): + # Given + initial_value = False + feature = Feature.objects.create( + name="Test feature", + project=self.project, + initial_value=initial_value, + ) + + # When + feature_states = FeatureState.objects.filter(feature=feature) + + # Then + for feature_state in feature_states: + assert feature_state.get_feature_state_value() == initial_value + + def test_updating_feature_state_should_trigger_webhook(self): + Feature.objects.create(name="Test Feature", project=self.project) + # TODO: implement webhook test method + + def test_cannot_create_feature_with_same_case_insensitive_name(self): + # Given + feature_name = "Test Feature" + + feature_one = Feature(project=self.project, name=feature_name) + feature_two = Feature(project=self.project, name=feature_name.lower()) + + # When + feature_one.save() + + # Then + with pytest.raises(IntegrityError): + feature_two.save() + + def test_updating_feature_name_should_update_feature_states(self): + # Given + old_feature_name = "old_feature" + new_feature_name = "new_feature" + + feature = Feature.objects.create(project=self.project, name=old_feature_name) + + # When + feature.name = new_feature_name + feature.save() + + # Then + FeatureState.objects.filter(feature__name=new_feature_name).exists() + + def test_full_clean_fails_when_duplicate_case_insensitive_name(self): + # unit test to validate validate_unique() method + + # Given + feature_name = "Test Feature" + Feature.objects.create( + name=feature_name, initial_value="test", project=self.project + ) + + # When + with self.assertRaises(ValidationError): + feature_two = Feature( + name=feature_name.lower(), + initial_value="test", + project=self.project, + ) + feature_two.full_clean() + + def test_updating_feature_should_allow_case_insensitive_name(self): + # Given + feature_name = "Test Feature" + + feature = Feature.objects.create( + project=self.project, name=feature_name, initial_value="test" + ) + + # When + feature.name = feature_name.lower() + feature.full_clean() # should not raise error as the same Object + + def test_when_create_feature_with_tags_then_success(self): + # Given + tag1 = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) + tag2 = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) + feature = Feature.objects.create(project=self.project, name="test feature") + + # When + tags_for_feature = Tag.objects.all() + feature.tags.set(tags_for_feature) + feature.save() + + self.assertEqual(feature.tags.count(), 2) + self.assertEqual(list(feature.tags.all()), [tag1, tag2]) + + +@pytest.mark.django_db +class FeatureStateTest(TestCase): + def setUp(self) -> None: + self.organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) + self.feature = Feature.objects.create(name="Test feature", project=self.project) + + @mock.patch("features.signals.trigger_feature_state_change_webhooks") + def test_cannot_create_duplicate_feature_state_in_an_environment( + self, mock_trigger_webhooks + ): + """ + Note that although the mock isn't used in this test, it throws an exception on + it's thread so we mock it here anyway. + """ + + # Given + duplicate_feature_state = FeatureState( + feature=self.feature, environment=self.environment, enabled=True + ) + + # When + with pytest.raises(ValidationError): + duplicate_feature_state.save() + + # Then + assert ( + FeatureState.objects.filter( + feature=self.feature, environment=self.environment + ).count() + == 1 + ) + + @mock.patch("features.signals.trigger_feature_state_change_webhooks") + def test_cannot_create_duplicate_feature_state_in_an_environment_for_segment( + self, mock_trigger_webhooks + ): + """ + Note that although the mock isn't used in this test, it throws an exception on + it's thread so we mock it here anyway. + """ + + # Given + segment = Segment.objects.create(project=self.project) + feature_segment = FeatureSegment.objects.create( + feature=self.feature, environment=self.environment, segment=segment + ) + FeatureState.objects.create( + feature=self.feature, + environment=self.environment, + feature_segment=feature_segment, + ) + + duplicate_feature_state = FeatureState( + feature=self.feature, + environment=self.environment, + enabled=True, + feature_segment=feature_segment, + ) + + # When + with pytest.raises(ValidationError): + duplicate_feature_state.save() + + # Then + assert ( + FeatureState.objects.filter( + feature=self.feature, + environment=self.environment, + feature_segment=feature_segment, + ).count() + == 1 + ) + + @mock.patch("features.signals.trigger_feature_state_change_webhooks") + def test_cannot_create_duplicate_feature_state_in_an_environment_for_identity( + self, mock_trigger_webhooks + ): + """ + Note that although the mock isn't used in this test, it throws an exception on + it's thread so we mock it here anyway. + """ + + # Given + identity = Identity.objects.create( + identifier="identifier", environment=self.environment + ) + FeatureState.objects.create( + feature=self.feature, environment=self.environment, identity=identity + ) + + duplicate_feature_state = FeatureState( + feature=self.feature, + environment=self.environment, + enabled=True, + identity=identity, + ) + + # When + with pytest.raises(ValidationError): + duplicate_feature_state.save() + + # Then + assert ( + FeatureState.objects.filter( + feature=self.feature, environment=self.environment, identity=identity + ).count() + == 1 + ) + + def test_feature_state_gt_operator(self): + # Given + identity = Identity.objects.create( + identifier="test_identity", environment=self.environment + ) + segment_1 = Segment.objects.create(name="Test Segment 1", project=self.project) + segment_2 = Segment.objects.create(name="Test Segment 2", project=self.project) + feature_segment_p1 = FeatureSegment.objects.create( + segment=segment_1, + feature=self.feature, + environment=self.environment, + priority=1, + ) + feature_segment_p2 = FeatureSegment.objects.create( + segment=segment_2, + feature=self.feature, + environment=self.environment, + priority=2, + ) + + # When + identity_state = FeatureState.objects.create( + identity=identity, feature=self.feature, environment=self.environment + ) + + segment_1_state = FeatureState.objects.create( + feature_segment=feature_segment_p1, + feature=self.feature, + environment=self.environment, + ) + segment_2_state = FeatureState.objects.create( + feature_segment=feature_segment_p2, + feature=self.feature, + environment=self.environment, + ) + default_env_state = FeatureState.objects.get( + environment=self.environment, identity=None, feature_segment=None + ) + + # Then - identity state is higher priority than all + assert identity_state > segment_1_state + assert identity_state > segment_2_state + assert identity_state > default_env_state + + # and feature state with feature segment with highest priority is greater than feature state with lower + # priority feature segment and default environment state + assert segment_1_state > segment_2_state + assert segment_1_state > default_env_state + + # and feature state with any segment is greater than default environment state + assert segment_2_state > default_env_state + + def test_feature_state_gt_operator_throws_value_error_if_different_environments( + self, + ): + # Given + another_environment = Environment.objects.create( + name="Another environment", project=self.project + ) + feature_state_env_1 = FeatureState.objects.filter( + environment=self.environment + ).first() + feature_state_env_2 = FeatureState.objects.filter( + environment=another_environment + ).first() + + # When + with pytest.raises(ValueError): + feature_state_env_1 > feature_state_env_2 + + # Then - exception raised + + def test_feature_state_gt_operator_throws_value_error_if_different_features(self): + # Given + another_feature = Feature.objects.create( + name="Another feature", project=self.project + ) + feature_state_env_1 = FeatureState.objects.filter(feature=self.feature).first() + feature_state_env_2 = FeatureState.objects.filter( + feature=another_feature + ).first() + + # When + with pytest.raises(ValueError): + feature_state_env_1 > feature_state_env_2 + + # Then - exception raised + + def test_feature_state_gt_operator_throws_value_error_if_different_identities(self): + # Given + identity_1 = Identity.objects.create( + identifier="identity_1", environment=self.environment + ) + identity_2 = Identity.objects.create( + identifier="identity_2", environment=self.environment + ) + + feature_state_identity_1 = FeatureState.objects.create( + feature=self.feature, environment=self.environment, identity=identity_1 + ) + feature_state_identity_2 = FeatureState.objects.create( + feature=self.feature, environment=self.environment, identity=identity_2 + ) + + # When + with pytest.raises(ValueError): + feature_state_identity_1 > feature_state_identity_2 + + # Then - exception raised + + @mock.patch("features.signals.trigger_feature_state_change_webhooks") + def test_save_calls_trigger_webhooks(self, mock_trigger_webhooks): + # Given + feature_state = FeatureState.objects.get( + feature=self.feature, environment=self.environment + ) + + # When + feature_state.save() + + # Then + mock_trigger_webhooks.assert_called_with(feature_state) + + def test_get_environment_flags_returns_latest_live_versions_of_feature_states( + self, + ): + # Given + feature_2 = Feature.objects.create(name="feature_2", project=self.project) + feature_2_v1_feature_state = FeatureState.objects.get(feature=feature_2) + + feature_1_v2_feature_state = FeatureState.objects.create( + feature=self.feature, + enabled=True, + version=2, + environment=self.environment, + live_from=timezone.now(), + ) + FeatureState.objects.create( + feature=self.feature, + enabled=False, + version=None, + environment=self.environment, + ) + + identity = Identity.objects.create( + identifier="identity", environment=self.environment + ) + FeatureState.objects.create( + feature=self.feature, identity=identity, environment=self.environment + ) + + # When + environment_feature_states = FeatureState.get_environment_flags_list( + environment_id=self.environment.id, + additional_filters=Q(feature_segment=None, identity=None), + ) + + # Then + assert set(environment_feature_states) == { + feature_1_v2_feature_state, + feature_2_v1_feature_state, + } + + def test_feature_state_type_environment(self): + # Given + feature_state = FeatureState.objects.get( + environment=self.environment, + feature=self.feature, + identity=None, + feature_segment=None, + ) + + # Then + assert feature_state.type == ENVIRONMENT + + def test_feature_state_type_identity(self): + # Given + identity = Identity.objects.create( + identifier="identity", environment=self.environment + ) + feature_state = FeatureState.objects.create( + environment=self.environment, + feature=self.feature, + identity=identity, + feature_segment=None, + ) + + # Then + assert feature_state.type == IDENTITY + + def test_feature_state_type_feature_segment(self): + # Given + segment = Segment.objects.create(project=self.project) + feature_segment = FeatureSegment.objects.create( + feature=self.feature, segment=segment, environment=self.environment + ) + feature_state = FeatureState.objects.create( + environment=self.environment, + feature=self.feature, + identity=None, + feature_segment=feature_segment, + ) + + # Then + assert feature_state.type == FEATURE_SEGMENT + + def test_feature_state_type_unknown(self): + # Note: this test is a case which should never, ever happen in real life + # as it's not possible to create a feature state that has both an identity + # and a feature segment via the API, however, it's useful to have the logic + # defined in case it ever does happen + + # Given + # a feature state with both identity and feature segment + identity = Identity.objects.create( + identifier="identity", environment=self.environment + ) + segment = Segment.objects.create(project=self.project) + feature_segment = FeatureSegment.objects.create( + feature=self.feature, segment=segment, environment=self.environment + ) + feature_state = FeatureState.objects.create( + environment=self.environment, + feature=self.feature, + identity=identity, + feature_segment=feature_segment, + ) + + # Then + # we default to environment type + with self.assertLogs("features") as caplog: + assert feature_state.type == ENVIRONMENT + + # and an error is logged + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "ERROR" + assert ( + caplog.records[0].message + == f"FeatureState {feature_state.id} does not have a valid type. " + f"Defaulting to environment." + ) + + +@pytest.mark.parametrize("hashed_percentage", (0.0, 0.3, 0.5, 0.8, 0.999999)) +@mock.patch("features.models.get_hashed_percentage_for_object_ids") +def test_get_multivariate_value_returns_correct_value_when_we_pass_identity( + mock_get_hashed_percentage, + hashed_percentage, + multivariate_feature, + environment, + identity, +): + # Given + mock_get_hashed_percentage.return_value = hashed_percentage + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + + # When + multivariate_value = feature_state.get_multivariate_feature_state_value( + identity_hash_key=identity.get_hash_key() + ) + + # Then + # we get a multivariate value + assert multivariate_value + + # and that value is not the control (since the fixture includes values that span + # the entire 100%) + assert multivariate_value.value != multivariate_value.initial_value + + +@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") +def test_get_feature_state_value_for_multivariate_features( + mock_get_mv_feature_state_value, environment, multivariate_feature, identity +): + # Given + value = "value" + mock_mv_feature_state_value = mock.MagicMock(value=value) + mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value + + environment.use_identity_composite_key_for_hashing = False + environment.save() + + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + + # When + feature_state_value = feature_state.get_feature_state_value(identity=identity) + + # Then + # the correct value is returned + assert feature_state_value == value + # and the correct call is made to get the multivariate feature state value + mock_get_mv_feature_state_value.assert_called_once_with(str(identity.id)) + + +@mock.patch.object(FeatureState, "get_multivariate_feature_state_value") +def test_get_feature_state_value_for_multivariate_features_mv_v2_evaluation( + mock_get_mv_feature_state_value, environment, multivariate_feature, identity +): + # Given + value = "value" + mock_mv_feature_state_value = mock.MagicMock(value=value) + mock_get_mv_feature_state_value.return_value = mock_mv_feature_state_value + + feature_state = FeatureState.objects.get( + environment=environment, + feature=multivariate_feature, + identity=None, + feature_segment=None, + ) + + # When + feature_state_value = feature_state.get_feature_state_value(identity=identity) + + # Then + # the correct value is returned + assert feature_state_value == value + # and the correct call is made to get the multivariate feature state value + mock_get_mv_feature_state_value.assert_called_once_with(identity.composite_key) + + def test_feature_state_get_environment_flags_queryset_returns_only_latest_versions( feature, environment ): diff --git a/api/tests/unit/features/test_unit_features_permissions.py b/api/tests/unit/features/test_unit_features_permissions.py index 3157f583ecad..78371b367378 100644 --- a/api/tests/unit/features/test_unit_features_permissions.py +++ b/api/tests/unit/features/test_unit_features_permissions.py @@ -1,14 +1,393 @@ +from unittest import TestCase, mock from unittest.mock import MagicMock import pytest +from features.models import Feature +from features.permissions import FeaturePermissions +from organisations.models import Organisation, OrganisationRole from permissions.models import PermissionModel -from projects.models import UserProjectPermission +from projects.models import ( + Project, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) from projects.permissions import ( CREATE_FEATURE, + DELETE_FEATURE, VIEW_PROJECT, NestedProjectPermissions, ) +from users.models import FFAdminUser, UserPermissionGroup + +mock_view = mock.MagicMock() +mock_request = mock.MagicMock() + +feature_permissions = FeaturePermissions() + + +@pytest.mark.django_db +class FeaturePermissionsTestCase(TestCase): + def setUp(self) -> None: + self.organisation = Organisation.objects.create(name="Test") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + + self.feature = Feature.objects.create(name="Test feature", project=self.project) + + self.user = FFAdminUser.objects.create(email="user@test.com") + self.user.add_organisation(self.organisation, OrganisationRole.USER) + + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") + self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) + + self.group = UserPermissionGroup.objects.create( + name="Test group", organisation=self.organisation + ) + self.group.users.add(self.user) + + mock_view.kwargs = {} + mock_request.data = {} + + def test_organisation_admin_can_list_features(self): + # Given + mock_view.action = "list" + mock_view.detail = False + mock_view.kwargs["project_pk"] = self.project.id + mock_request.user = self.org_admin + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_project_admin_can_list_features(self): + # Given + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) + + mock_view.action = "list" + mock_view.detail = False + mock_view.kwargs["project_pk"] = self.project.id + mock_request.user = self.user + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_project_user_with_read_access_can_list_features(self): + # Given + user_project_permission = UserProjectPermission.objects.create( + user=self.user, admin=False, project=self.project + ) + user_project_permission.set_permissions([VIEW_PROJECT]) + + mock_view.action = "list" + mock_view.detail = False + mock_view.kwargs["project_pk"] = self.project.id + mock_request.user = self.user + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_user_with_no_project_permissions_cannot_list_features(self): + # Given + mock_view.action = "list" + mock_view.detail = False + mock_view.kwargs["project_pk"] = self.project.id + mock_request.user = self.user + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert not result + + def test_organisation_admin_can_create_feature(self): + # Given + mock_view.action = "create" + mock_view.detail = False + mock_request.user = self.org_admin + mock_request.data = {"project": self.project.id, "name": "new feature"} + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_project_admin_can_create_feature(self): + # Given + # use a group to test groups work too + UserPermissionGroupProjectPermission.objects.create( + group=self.group, project=self.project, admin=True + ) + mock_view.action = "create" + mock_view.detail = False + mock_request.user = self.user + mock_request.data = {"project": self.project.id, "name": "new feature"} + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_project_user_with_create_feature_permission_can_create_feature(self): + # Given + # use a group to test groups work too + user_group_permission = UserPermissionGroupProjectPermission.objects.create( + group=self.group, project=self.project, admin=False + ) + user_group_permission.add_permission(CREATE_FEATURE) + mock_view.action = "create" + mock_view.detail = False + mock_request.user = self.user + mock_request.data = {"project": self.project.id, "name": "new feature"} + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert result + + def test_project_user_without_create_feature_permission_cannot_create_feature(self): + # Given + mock_view.action = "create" + mock_view.detail = False + mock_request.user = self.user + mock_request.data = {"project": self.project.id, "name": "new feature"} + + # When + result = feature_permissions.has_permission(mock_request, mock_view) + + # Then + assert not result + + def test_organisation_admin_can_view_feature(self): + # Given + mock_view.action = "retrieve" + mock_view.detail = True + mock_request.user = self.org_admin + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_admin_can_view_feature(self): + # Given + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_request.user = self.user + mock_view.action = "retrieve" + mock_view.detail = True + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_with_view_project_permission_can_view_feature(self): + # Given + user_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=False + ) + user_permission.set_permissions([VIEW_PROJECT]) + mock_request.user = self.user + mock_view.action = "retrieve" + mock_view.detail = True + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_without_view_project_permission_cannot_view_feature(self): + # Given + mock_request.user = self.user + mock_view.action = "retrieve" + mock_view.detail = True + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert not result + + def test_organisation_admin_can_edit_feature(self): + # Given + mock_view.action = "update" + mock_view.detail = True + mock_request.user = self.org_admin + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_admin_can_edit_feature(self): + # Given + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "update" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_cannot_edit_feature(self): + # Given + mock_view.action = "update" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert not result + + def test_organisation_admin_can_delete_feature(self): + # Given + mock_view.action = "destroy" + mock_view.detail = True + mock_request.user = self.org_admin + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_admin_can_delete_feature(self): + # Given + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "destroy" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_with_delete_feature_permission_can_delete_feature(self): + # Given + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) + user_project_permission.add_permission(DELETE_FEATURE) + + mock_view.action = "destroy" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_without_delete_feature_permission_cannot_delete_feature(self): + # Given + mock_view.action = "destroy" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert not result + + def test_organisation_admin_can_update_feature_segments(self): + # Given + mock_view.action = "segments" + mock_view.detail = True + mock_request.user = self.org_admin + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_admin_can_update_feature_segments(self): + # Given + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "segments" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert result + + def test_project_user_cannot_update_feature_segments(self): + # Given + mock_view.action = "segments" + mock_view.detail = True + mock_request.user = self.user + + # When + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) + + # Then + assert not result @pytest.mark.parametrize( diff --git a/api/features/tests/test_tasks.py b/api/tests/unit/features/test_unit_features_tasks.py similarity index 100% rename from api/features/tests/test_tasks.py rename to api/tests/unit/features/test_unit_features_tasks.py diff --git a/api/features/tests/test_utils.py b/api/tests/unit/features/test_unit_features_utils.py similarity index 100% rename from api/features/tests/test_utils.py rename to api/tests/unit/features/test_unit_features_utils.py diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 7c69ecbf1005..61215e847e10 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -1,31 +1,917 @@ import json import uuid +from datetime import date, datetime, timedelta from typing import Callable +from unittest import TestCase, mock import pytest +import pytz +from app_analytics.dataclasses import FeatureEvaluationData +from core.constants import FLAGSMITH_UPDATED_AT_HEADER +from django.forms import model_to_dict from django.urls import reverse from django.utils import timezone from pytest_lazyfixture import lazy_fixture from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APITestCase -from audit.constants import FEATURE_DELETED_MESSAGE +from audit.constants import ( + FEATURE_DELETED_MESSAGE, + IDENTITY_FEATURE_STATE_DELETED_MESSAGE, + IDENTITY_FEATURE_STATE_UPDATED_MESSAGE, +) from audit.models import AuditLog, RelatedObjectType from environments.identities.models import Identity -from environments.models import Environment +from environments.models import Environment, EnvironmentAPIKey from environments.permissions.constants import ( MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE, ) from environments.permissions.models import UserEnvironmentPermission from features.feature_types import MULTIVARIATE -from features.models import Feature, FeatureSegment, FeatureState +from features.models import ( + Feature, + FeatureSegment, + FeatureState, + FeatureStateValue, +) from features.multivariate.models import MultivariateFeatureOption from organisations.models import Organisation, OrganisationRole +from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission -from projects.permissions import VIEW_PROJECT +from projects.permissions import CREATE_FEATURE, VIEW_PROJECT +from projects.tags.models import Tag from segments.models import Segment from users.models import FFAdminUser, UserPermissionGroup +from util.tests import Helper +from webhooks.webhooks import WebhookEventType + +# patch this function as it's triggering extra threads and causing errors +mock.patch("features.signals.trigger_feature_state_change_webhooks").start() + + +@pytest.mark.django_db +class ProjectFeatureTestCase(TestCase): + project_features_url = "/api/v1/projects/%s/features/" + project_feature_detail_url = "/api/v1/projects/%s/features/%d/" + post_template = '{ "name": "%s", "project": %d, "initial_value": "%s" }' + + def setUp(self): + self.client = APIClient() + self.user = Helper.create_ffadminuser() + self.client.force_authenticate(user=self.user) + + self.organisation = Organisation.objects.create(name="Test Org") + + self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) + + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.project2 = Project.objects.create( + name="Test project2", organisation=self.organisation + ) + self.environment_1 = Environment.objects.create( + name="Test environment 1", project=self.project + ) + self.environment_2 = Environment.objects.create( + name="Test environment 2", project=self.project + ) + + self.tag_one = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) + self.tag_two = Tag.objects.create( + label="Test Tag2", + color="#fffff", + description="Test Tag2 description", + project=self.project, + ) + self.tag_other_project = Tag.objects.create( + label="Wrong Tag", + color="#fffff", + description="Test Tag description", + project=self.project2, + ) + + def test_owners_is_read_only_for_feature_create(self): + # Given - set up data + default_value = "This is a value" + data = { + "name": "test feature", + "initial_value": default_value, + "project": self.project.id, + "owners": [ + { + "id": 2, + "email": "fake_user@mail.com", + "first_name": "fake", + "last_name": "user", + } + ], + } + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) + + # When + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert len(response.json()["owners"]) == 1 + assert response.json()["owners"][0]["id"] == self.user.id + assert response.json()["owners"][0]["email"] == self.user.email + + @mock.patch("features.views.trigger_feature_state_change_webhooks") + def test_feature_state_webhook_triggered_when_feature_deleted( + self, mocked_trigger_fs_change_webhook + ): + # Given + feature = Feature.objects.create(name="test feature", project=self.project) + feature_states = list(feature.feature_states.all()) + # When + self.client.delete( + self.project_feature_detail_url % (self.project.id, feature.id) + ) + # Then + mock_calls = [ + mock.call(fs, WebhookEventType.FLAG_DELETED) for fs in feature_states + ] + mocked_trigger_fs_change_webhook.has_calls(mock_calls) + + def test_remove_owners_only_remove_specified_owners(self): + # Given + user_2 = FFAdminUser.objects.create_user(email="user2@mail.com") + user_3 = FFAdminUser.objects.create_user(email="user3@mail.com") + feature = Feature.objects.create(name="Test Feature", project=self.project) + feature.owners.add(user_2, user_3) + + url = reverse( + "api-v1:projects:project-features-remove-owners", + args=[self.project.id, feature.id], + ) + data = {"user_ids": [user_2.id]} + # When + json_response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ).json() + assert len(json_response["owners"]) == 1 + assert json_response["owners"][0] == { + "id": user_3.id, + "email": user_3.email, + "first_name": user_3.first_name, + "last_name": user_3.last_name, + "last_login": None, + } + + def test_audit_log_created_when_feature_state_created_for_identity(self): + # Given + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[self.environment_1.api_key, identity.id], + ) + data = {"feature": feature.id, "enabled": True} + + # When + self.client.post(url, data=json.dumps(data), content_type="application/json") + + # Then + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) + + # and + expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( + feature.name, + identity.identifier, + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ) + assert audit_log.log == expected_log_message + + def test_audit_log_created_when_feature_state_updated_for_identity(self): + # Given + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=self.environment_1, + identity=identity, + enabled=True, + ) + url = reverse( + "api-v1:environments:identity-featurestates-detail", + args=[self.environment_1.api_key, identity.id, feature_state.id], + ) + data = {"feature": feature.id, "enabled": False} + + # When + self.client.put(url, data=json.dumps(data), content_type="application/json") + + # Then + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) + + # and + expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( + feature.name, + identity.identifier, + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ) + assert audit_log.log == expected_log_message + + def test_audit_log_created_when_feature_state_deleted_for_identity(self): + # Given + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=self.environment_1, + identity=identity, + enabled=True, + ) + url = reverse( + "api-v1:environments:identity-featurestates-detail", + args=[self.environment_1.api_key, identity.id, feature_state.id], + ) + + # When + self.client.delete(url) + + # Then + assert ( + AuditLog.objects.filter( + log=IDENTITY_FEATURE_STATE_DELETED_MESSAGE + % ( + feature.name, + identity.identifier, + ) + ).count() + == 1 + ) + + def test_when_add_tags_from_different_project_on_feature_create_then_failed(self): + # Given - set up data + feature_name = "test feature" + data = { + "name": feature_name, + "project": self.project.id, + "initial_value": "test", + "tags": [self.tag_other_project.id], + } + + # When + response = self.client.post( + self.project_features_url % self.project.id, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # check no feature was created successfully + assert ( + Feature.objects.filter(name=feature_name, project=self.project.id).count() + == 0 + ) + + def test_when_add_tags_on_feature_update_then_success(self): + # Given - set up data + feature = Feature.objects.create(project=self.project, name="test feature") + data = { + "name": feature.name, + "project": self.project.id, + "tags": [self.tag_one.id], + } + + # When + response = self.client.put( + self.project_feature_detail_url % (self.project.id, feature.id), + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + + # check feature was created successfully + check_feature = Feature.objects.filter( + name=feature.name, project=self.project.id + ).first() + + # check tags added + assert check_feature.tags.count() == 1 + + def test_when_add_tags_from_different_project_on_feature_update_then_failed(self): + # Given - set up data + feature = Feature.objects.create(project=self.project, name="test feature") + data = { + "name": feature.name, + "project": self.project.id, + "tags": [self.tag_other_project.id], + } + + # When + response = self.client.put( + self.project_feature_detail_url % (self.project.id, feature.id), + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # check feature was created successfully + check_feature = Feature.objects.filter( + name=feature.name, project=self.project.id + ).first() + + # check tags not added + assert check_feature.tags.count() == 0 + + def test_list_features_is_archived_filter(self): + # Firstly, let's setup the initial data + feature = Feature.objects.create(name="test_feature", project=self.project) + archived_feature = Feature.objects.create( + name="archived_feature", project=self.project, is_archived=True + ) + base_url = reverse( + "api-v1:projects:project-features-list", args=[self.project.id] + ) + # Next, let's test true filter + url = f"{base_url}?is_archived=true" + response = self.client.get(url) + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["id"] == archived_feature.id + + # Finally, let's test false filter + url = f"{base_url}?is_archived=false" + response = self.client.get(url) + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["id"] == feature.id + + def test_put_feature_does_not_update_feature_states(self): + # Given + feature = Feature.objects.create( + name="test_feature", project=self.project, default_enabled=False + ) + url = reverse( + "api-v1:projects:project-features-detail", + args=[self.project.id, feature.id], + ) + data = model_to_dict(feature) + data["default_enabled"] = True + + # When + response = self.client.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert all(fs.enabled is False for fs in feature.feature_states.all()) + + @mock.patch("features.views.get_multiple_event_list_for_feature") + def test_get_influx_data(self, mock_get_event_list): + # Given + feature = Feature.objects.create(name="test_feature", project=self.project) + base_url = reverse( + "api-v1:projects:project-features-get-influx-data", + args=[self.project.id, feature.id], + ) + url = f"{base_url}?environment_id={self.environment_1.id}" + + mock_get_event_list.return_value = [ + { + feature.name: 1, + "datetime": datetime(2021, 2, 26, 12, 0, 0, tzinfo=pytz.UTC), + } + ] + + # When + response = self.client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + mock_get_event_list.assert_called_once_with( + feature_name=feature.name, + environment_id=str(self.environment_1.id), # provided as a GET param + period="24h", # this is the default but can be provided as a GET param + aggregate_every="24h", # this is the default but can be provided as a GET param + ) + + def test_regular_user_cannot_create_mv_options_when_creating_feature(self): + # Given + user = FFAdminUser.objects.create(email="regularuser@project.com") + user.add_organisation(self.organisation) + user_project_permission = UserProjectPermission.objects.create( + user=user, project=self.project + ) + permissions = PermissionModel.objects.filter( + key__in=[VIEW_PROJECT, CREATE_FEATURE] + ) + user_project_permission.permissions.add(*permissions) + client = APIClient() + client.force_authenticate(user) + + data = { + "name": "test_feature", + "default_enabled": True, + "multivariate_options": [{"type": "unicode", "string_value": "test-value"}], + } + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) + + # When + response = client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_regular_user_cannot_create_mv_options_when_updating_feature(self): + # Given + user = FFAdminUser.objects.create(email="regularuser@project.com") + user.add_organisation(self.organisation) + user_project_permission = UserProjectPermission.objects.create( + user=user, project=self.project + ) + permissions = PermissionModel.objects.filter( + key__in=[VIEW_PROJECT, CREATE_FEATURE] + ) + user_project_permission.permissions.add(*permissions) + client = APIClient() + client.force_authenticate(user) + + feature = Feature.objects.create( + project=self.project, + name="a_feature", + default_enabled=True, + ) + + data = { + "name": feature.name, + "default_enabled": feature.default_enabled, + "multivariate_options": [{"type": "unicode", "string_value": "test-value"}], + } + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) + + # When + response = client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_regular_user_can_update_feature_description(self): + # Given + user = FFAdminUser.objects.create(email="regularuser@project.com") + user.add_organisation(self.organisation) + user_project_permission = UserProjectPermission.objects.create( + user=user, project=self.project + ) + permissions = PermissionModel.objects.filter( + key__in=[VIEW_PROJECT, CREATE_FEATURE] + ) + user_project_permission.permissions.add(*permissions) + client = APIClient() + client.force_authenticate(user) + + feature = Feature.objects.create( + project=self.project, + name="a_feature", + default_enabled=True, + ) + + data = { + "name": feature.name, + "default_enabled": feature.default_enabled, + "description": "a description", + } + + url = reverse( + "api-v1:projects:project-features-detail", + args=[self.project.id, feature.id], + ) + + # When + response = client.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + + feature.refresh_from_db() + assert feature.description == data["description"] + + @mock.patch("environments.models.environment_wrapper") + def test_create_feature_only_triggers_write_to_dynamodb_once_per_environment( + self, mock_dynamo_environment_wrapper + ): + # Given + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) + data = {"name": "Test feature flag", "type": "FLAG", "project": self.project.id} + + self.project.enable_dynamo_db = True + self.project.save() + + mock_dynamo_environment_wrapper.is_enabled = True + mock_dynamo_environment_wrapper.reset_mock() + + # When + self.client.post(url, data=data) + + # Then + mock_dynamo_environment_wrapper.write_environments.assert_called_once() + + +@pytest.mark.django_db +class SDKFeatureStatesTestCase(APITestCase): + def setUp(self) -> None: + self.environment_fs_value = "environment" + self.identity_fs_value = "identity" + self.segment_fs_value = "segment" + + self.organisation = Organisation.objects.create(name="Test organisation") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) + self.feature = Feature.objects.create( + name="Test feature", + project=self.project, + initial_value=self.environment_fs_value, + ) + segment = Segment.objects.create(name="Test segment", project=self.project) + feature_segment = FeatureSegment.objects.create( + segment=segment, + feature=self.feature, + environment=self.environment, + ) + segment_feature_state = FeatureState.objects.create( + feature=self.feature, + feature_segment=feature_segment, + environment=self.environment, + ) + FeatureStateValue.objects.filter(feature_state=segment_feature_state).update( + string_value=self.segment_fs_value + ) + identity = Identity.objects.create( + identifier="test", environment=self.environment + ) + identity_feature_state = FeatureState.objects.create( + identity=identity, environment=self.environment, feature=self.feature + ) + FeatureStateValue.objects.filter(feature_state=identity_feature_state).update( + string_value=self.identity_fs_value + ) + + self.url = reverse("api-v1:flags") + + self.client.credentials(HTTP_X_ENVIRONMENT_KEY=self.environment.api_key) + + def test_get_flags(self): + # Given - setup data which includes a single feature overridden by a segment and an identity + + # When - we get flags + response = self.client.get(self.url) + + # Then - we only get a single flag back and that is the environment default + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert len(response_json) == 1 + assert response_json[0]["feature"]["id"] == self.feature.id + assert response_json[0]["feature_state_value"] == self.environment_fs_value + # refresh the last_updated_at + self.environment.refresh_from_db() + assert response.headers[FLAGSMITH_UPDATED_AT_HEADER] == str( + self.environment.updated_at.timestamp() + ) + + +@pytest.mark.parametrize( + "environment_value, project_value, disabled_flag_returned", + ( + (True, True, False), + (True, False, False), + (False, True, True), + (False, False, True), + (None, True, False), + (None, False, True), + ), +) +def test_get_flags_hide_disabled_flags( + environment_value, + project_value, + disabled_flag_returned, + project, + environment, + api_client, +): + # Given + project.hide_disabled_flags = project_value + project.save() + + environment.hide_disabled_flags = environment_value + environment.save() + + Feature.objects.create(name="disabled_flag", project=project, default_enabled=False) + Feature.objects.create(name="enabled_flag", project=project, default_enabled=True) + + url = reverse("api-v1:flags") + + # When + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == (2 if disabled_flag_returned else 1) + + +def test_get_flags_hide_sensitive_data(api_client, environment, feature): + # Given + environment.hide_sensitive_data = True + environment.save() + + url = reverse("api-v1:flags") + + # When + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + response = api_client.get(url) + feature_sensitive_fields = [ + "created_date", + "description", + "initial_value", + "default_enabled", + ] + fs_sensitive_fields = ["id", "environment", "identity", "feature_segment"] + + # Then + assert response.status_code == status.HTTP_200_OK + # Check that the sensitive fields are None + for flag in response.json(): + for field in fs_sensitive_fields: + assert flag[field] is None + + for field in feature_sensitive_fields: + assert flag["feature"][field] is None + + +def test_get_flags__server_key_only_feature__return_expected( + api_client: APIClient, + environment: Environment, + feature: Feature, +) -> None: + # Given + feature.is_server_key_only = True + feature.save() + + url = reverse("api-v1:flags") + + # When + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert not response.json() + + +def test_get_flags__server_key_only_feature__server_key_auth__return_expected( + api_client: APIClient, + environment_api_key: EnvironmentAPIKey, + feature: Feature, +) -> None: + # Given + feature.is_server_key_only = True + feature.save() + + url = reverse("api-v1:flags") + + # When + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment_api_key.key) + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json() + + +@pytest.mark.parametrize( + "client", + [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], +) +def test_get_feature_states_by_uuid(client, environment, feature, feature_state): + # Given + url = reverse( + "api-v1:features:get-feature-state-by-uuid", args=[feature_state.uuid] + ) + + # When + response = client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["uuid"] == str(feature_state.uuid) + + +@pytest.mark.parametrize( + "client", + [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], +) +def test_deleted_features_are_not_listed(client, project, environment, feature): + # Given + url = reverse("api-v1:projects:project-features-list", args=[project.id]) + feature.delete() + + # When + response = client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 0 + + +@pytest.mark.parametrize( + "client", + [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], +) +def test_get_feature_evaluation_data(project, feature, environment, mocker, client): + # Given + base_url = reverse( + "api-v1:projects:project-features-get-evaluation-data", + args=[project.id, feature.id], + ) + url = f"{base_url}?environment_id={environment.id}" + mocked_get_feature_evaluation_data = mocker.patch( + "features.views.get_feature_evaluation_data", autospec=True + ) + mocked_get_feature_evaluation_data.return_value = [ + FeatureEvaluationData(count=10, day=date.today()), + FeatureEvaluationData(count=10, day=date.today() - timedelta(days=1)), + ] + # When + response = client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 2 + assert response.json()[0] == {"day": str(date.today()), "count": 10} + assert response.json()[1] == { + "day": str(date.today() - timedelta(days=1)), + "count": 10, + } + mocked_get_feature_evaluation_data.assert_called_with( + feature=feature, period=30, environment_id=environment.id + ) + + +def test_create_segment_override_forbidden( + feature: Feature, + segment: Segment, + environment: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, +) -> None: + # Given + url = reverse( + "api-v1:environments:create-segment-override", + args=[environment.api_key, feature.id], + ) + + # When + enabled = True + string_value = "foo" + data = { + "feature_state_value": {"string_value": string_value}, + "enabled": enabled, + "feature_segment": {"segment": segment.id}, + } + + # Staff client lacks permission to create segment. + response = staff_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == 403 + assert response.data == { + "detail": "You do not have permission to perform this action." + } + + +def test_create_segment_override_staff( + feature: Feature, + segment: Segment, + environment: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, +) -> None: + # Given + url = reverse( + "api-v1:environments:create-segment-override", + args=[environment.api_key, feature.id], + ) + + # When + enabled = True + string_value = "foo" + data = { + "feature_state_value": {"string_value": string_value}, + "enabled": enabled, + "feature_segment": {"segment": segment.id}, + } + user_environment_permission = UserEnvironmentPermission.objects.create( + user=staff_user, admin=False, environment=environment + ) + user_environment_permission.permissions.add(MANAGE_SEGMENT_OVERRIDES) + + response = staff_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + # Then + assert response.status_code == 201 + assert response.data["feature_segment"]["segment"] == segment.id + + +def test_create_segment_override(admin_client, feature, segment, environment): + # Given + url = reverse( + "api-v1:environments:create-segment-override", + args=[environment.api_key, feature.id], + ) + + enabled = True + string_value = "foo" + data = { + "feature_state_value": {"string_value": string_value}, + "enabled": enabled, + "feature_segment": {"segment": segment.id}, + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + created_override = FeatureState.objects.filter( + feature=feature, environment=environment, feature_segment__segment=segment + ).first() + assert created_override is not None + assert created_override.enabled is enabled + assert created_override.get_feature_state_value() == string_value + + +def test_get_flags_is_not_throttled_by_user_throttle( + api_client, environment, feature, settings +): + # Given + settings.REST_FRAMEWORK = {"DEFAULT_THROTTLE_RATES": {"user": "1/minute"}} + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + url = reverse("api-v1:flags") + + # When + for _ in range(10): + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK def test_list_feature_states_from_simple_view_set(