diff --git a/api/.env-local b/api/.env-local index ff290fe34c85..fe53d02b4521 100644 --- a/api/.env-local +++ b/api/.env-local @@ -1,3 +1,5 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/flagsmith DJANGO_SETTINGS_MODULE=app.settings.local PYTEST_ADDOPTS=--cov . --cov-report html -n auto +USE_SECURE_COOKIES=False +COOKIE_SAME_SITE=lax diff --git a/api/Makefile b/api/Makefile index 5e550dae2421..cb74c8c96888 100644 --- a/api/Makefile +++ b/api/Makefile @@ -156,3 +156,8 @@ generate-docs: .PHONY: add-known-sdk-version add-known-sdk-version: poetry run python scripts/add-known-sdk-version.py $(opts) + +.PHONY: reset +reset: docker-up + poetry run python manage.py waitfordb + poetry run python manage.py reset_local_database diff --git a/api/app/settings/common.py b/api/app/settings/common.py index e194a7a36b34..9dca9103f9a5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1471,3 +1471,7 @@ PYLON_IDENTITY_VERIFICATION_SECRET = env.str("PYLON_IDENTITY_VERIFICATION_SECRET", None) OSIC_UPDATE_BATCH_SIZE = env.int("OSIC_UPDATE_BATCH_SIZE", default=500) + +# Allow the reset_local_database management command to run. +# This command flushes and seeds the database with test data. +ENABLE_LOCAL_DATABASE_RESET = False diff --git a/api/app/settings/local.py b/api/app/settings/local.py index becc3a0cea18..c2a1ec0d8c48 100644 --- a/api/app/settings/local.py +++ b/api/app/settings/local.py @@ -18,3 +18,5 @@ ENABLE_ADMIN_ACCESS_USER_PASS = True SKIP_MIGRATION_TESTS = True + +ENABLE_LOCAL_DATABASE_RESET = True diff --git a/api/app/settings/test.py b/api/app/settings/test.py index 605d54aba53b..0185c7b3bb4e 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -1,5 +1,7 @@ from app.settings.common import * # noqa -from app.settings.common import REST_FRAMEWORK +from app.settings.common import ALLOWED_HOSTS, REST_FRAMEWORK + +ALLOWED_HOSTS = [*ALLOWED_HOSTS, "testserver"] # We dont want to track tests ENABLE_TELEMETRY = False diff --git a/api/core/management/commands/reset_local_database.py b/api/core/management/commands/reset_local_database.py new file mode 100644 index 000000000000..763e960eeff5 --- /dev/null +++ b/api/core/management/commands/reset_local_database.py @@ -0,0 +1,396 @@ +import json +from typing import Any + +from django.conf import settings +from django.core.management import BaseCommand, CommandError, call_command +from django.urls import reverse +from rest_framework.test import APIClient + + +class Command(BaseCommand): + help = "Resets and seeds the database with test data for local development" + + def handle(self, *args: Any, **kwargs: Any) -> None: + if not settings.ENABLE_LOCAL_DATABASE_RESET: + raise CommandError( + "This command is disabled. " + "Set ENABLE_LOCAL_DATABASE_RESET to True in Django settings to enable it." + ) + self.stdout.write("Flushing database...") + call_command("flush", "--noinput", verbosity=0) + + self.stdout.write("Running migrations...") + call_command("migrate", verbosity=0) + + self.stdout.write("Creating cache table...") + call_command("createcachetable", verbosity=0) + + self.stdout.write("Seeding database with test data...") + self._seed_database() + + def _seed_database(self) -> None: + email = "local@flagsmith.com" + password = "testpass1" + + client = APIClient() + + # Create user via signup API + signup_url = reverse("api-v1:custom_auth:ffadminuser-list") + signup_response = client.post( + signup_url, + data={ + "email": email, + "password": password, + "re_password": password, + "first_name": "Local", + "last_name": "Developer", + }, + ) + auth_token = signup_response.json()["key"] + client.credentials(HTTP_AUTHORIZATION=f"Token {auth_token}") + + # Create organisation via API (user becomes admin automatically) + org_name = "Acme, Inc." + org_url = reverse("api-v1:organisations:organisation-list") + org_response = client.post(org_url, data={"name": org_name}) + organisation_id = org_response.json()["id"] + + # Create project via API + project_name = "AI Booster" + project_url = reverse("api-v1:projects:project-list") + project_response = client.post( + project_url, + data={"name": project_name, "organisation": organisation_id}, + ) + project_id = project_response.json()["id"] + + # Create environments via API + env_url = reverse("api-v1:environments:environment-list") + dev_env_response = client.post( + env_url, + data={"name": "Development", "project": project_id}, + ) + dev_environment = dev_env_response.json() + dev_env_api_key = dev_environment["api_key"] + + client.post( + env_url, + data={"name": "Staging", "project": project_id}, + ) + + client.post( + env_url, + data={"name": "Production", "project": project_id}, + ) + + # Create features via API + feature_url = reverse( + "api-v1:projects:project-features-list", args=[project_id] + ) + + dark_mode_response = client.post( + feature_url, + data={ + "name": "dark_mode", + "description": "Enable dark mode theme for the application", + "default_enabled": True, + "type": "FLAG", + }, + ) + dark_mode_id = dark_mode_response.json()["id"] + + client.post( + feature_url, + data={ + "name": "ai_assistant", + "description": "Enable AI-powered assistant features", + "default_enabled": False, + "type": "FLAG", + }, + ) + + api_rate_limit_response = client.post( + feature_url, + data={ + "name": "api_rate_limit", + "description": "Maximum API requests per minute", + "default_enabled": True, + "type": "CONFIG", + "initial_value": "100", + }, + ) + api_rate_limit_id = api_rate_limit_response.json()["id"] + + welcome_message_response = client.post( + feature_url, + data={ + "name": "welcome_message", + "description": "Welcome message displayed to users", + "default_enabled": True, + "type": "CONFIG", + "initial_value": "Welcome to AI Booster!", + }, + ) + welcome_message_id = welcome_message_response.json()["id"] + + client.post( + feature_url, + data={ + "name": "feature_config", + "description": "JSON configuration for feature behavior", + "default_enabled": True, + "type": "CONFIG", + "initial_value": '{"theme": "modern", "animations": true}', + }, + ) + + beta_features_response = client.post( + feature_url, + data={ + "name": "beta_features", + "description": "Enable access to beta features", + "default_enabled": True, + "type": "FLAG", + }, + ) + beta_features_id = beta_features_response.json()["id"] + + # Create segments via API + segment_url = reverse( + "api-v1:projects:project-segments-list", args=[project_id] + ) + + premium_segment_response = client.post( + segment_url, + data=json.dumps( + { + "name": "Premium Users", + "description": "Users with premium subscription and active status", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "property": "subscription_tier", + "operator": "EQUAL", + "value": "premium", + }, + { + "property": "account_age", + "operator": "GREATER_THAN_INCLUSIVE", + "value": "30", + }, + ], + } + ], + "conditions": [], + } + ], + } + ), + content_type="application/json", + ) + premium_segment_id = premium_segment_response.json()["id"] + + beta_segment_response = client.post( + segment_url, + data=json.dumps( + { + "name": "Beta Testers", + "description": "Users enrolled in beta testing program", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "property": "beta_tester", + "operator": "EQUAL", + "value": "true", + }, + ], + } + ], + } + ), + content_type="application/json", + ) + beta_segment_id = beta_segment_response.json()["id"] + + client.post( + segment_url, + data=json.dumps( + { + "name": "50% Rollout", + "description": "50% of users for gradual feature rollout", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "property": "id", + "operator": "PERCENTAGE_SPLIT", + "value": "50", + }, + ], + } + ], + } + ), + content_type="application/json", + ) + + # Create segment overrides via API + # dark_mode enabled for Premium Users + dark_mode_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, dark_mode_id], + ) + client.post( + dark_mode_override_url, + data=json.dumps( + { + "feature_segment": {"segment": premium_segment_id}, + "enabled": True, + "feature_state_value": {}, + } + ), + content_type="application/json", + ) + + # beta_features enabled for Beta Testers + beta_features_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, beta_features_id], + ) + client.post( + beta_features_override_url, + data=json.dumps( + { + "feature_segment": {"segment": beta_segment_id}, + "enabled": True, + "feature_state_value": {}, + } + ), + content_type="application/json", + ) + + # api_rate_limit with custom value for Premium Users + api_rate_limit_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, api_rate_limit_id], + ) + client.post( + api_rate_limit_override_url, + data=json.dumps( + { + "feature_segment": {"segment": premium_segment_id}, + "enabled": True, + "feature_state_value": {"type": "int", "integer_value": 500}, + } + ), + content_type="application/json", + ) + + # welcome_message with custom value for Beta Testers + welcome_message_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, welcome_message_id], + ) + client.post( + welcome_message_override_url, + data=json.dumps( + { + "feature_segment": {"segment": beta_segment_id}, + "enabled": True, + "feature_state_value": {"string_value": "Welcome, Beta Tester!"}, + } + ), + content_type="application/json", + ) + + # Create identities via API + identity_url = reverse( + "api-v1:environments:environment-identities-list", args=[dev_env_api_key] + ) + + alice_response = client.post( + identity_url, data={"identifier": "alice@example.com"} + ) + alice_id = alice_response.json()["id"] + + bob_response = client.post(identity_url, data={"identifier": "bob@example.com"}) + bob_id = bob_response.json()["id"] + + # Create identity overrides via API + identity_featurestates_url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[dev_env_api_key, alice_id], + ) + + # Override dark_mode to false for alice + client.post( + identity_featurestates_url, + data=json.dumps( + { + "feature": dark_mode_id, + "enabled": False, + } + ), + content_type="application/json", + ) + + # Override welcome_message for bob + bob_featurestates_url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[dev_env_api_key, bob_id], + ) + + client.post( + bob_featurestates_url, + data=json.dumps( + { + "feature": welcome_message_id, + "enabled": True, + "feature_state_value": "Hello, Bob!", + } + ), + content_type="application/json", + ) + + # Print summary + self.stdout.write(self.style.SUCCESS("\nDatabase seeded successfully\n")) + + self.stdout.write("Created entities:\n") + self.stdout.write(f" Organisation: {org_name}\n") + self.stdout.write(f" Project: {project_name}\n") + self.stdout.write(" Environments (3):\n") + self.stdout.write(" Development\n") + self.stdout.write(" Staging\n") + self.stdout.write(" Production\n") + self.stdout.write(" Features (6):\n") + self.stdout.write(" dark_mode (FLAG, enabled)\n") + self.stdout.write(" ai_assistant (FLAG, disabled)\n") + self.stdout.write(" api_rate_limit (CONFIG)\n") + self.stdout.write(" welcome_message (CONFIG)\n") + self.stdout.write(" feature_config (CONFIG)\n") + self.stdout.write(" beta_features (FLAG, enabled)\n") + self.stdout.write(" Segments (3):\n") + self.stdout.write(" Premium Users (with overrides)\n") + self.stdout.write(" Beta Testers (with overrides)\n") + self.stdout.write(" 50% Rollout\n") + self.stdout.write(" Identities (2):\n") + self.stdout.write(" alice@example.com (with overrides)\n") + self.stdout.write(" bob@example.com (with overrides)\n") + + self.stdout.write("\nLogin credentials:\n") + self.stdout.write(f" Email: {email}\n") + self.stdout.write(f" Password: {password}\n") diff --git a/api/pyproject.toml b/api/pyproject.toml index 077a39a6d35a..90d919fb6cfb 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -63,6 +63,7 @@ exclude_also = [ [tool.coverage.run] omit = [ "app/settings/common.py", + "app/settings/local.py", "manage.py", "e2etests/*", "scripts/*", diff --git a/api/tests/integration/core/test_reset_local_database.py b/api/tests/integration/core/test_reset_local_database.py new file mode 100644 index 000000000000..39c997d0b1a2 --- /dev/null +++ b/api/tests/integration/core/test_reset_local_database.py @@ -0,0 +1,219 @@ +from unittest.mock import MagicMock + +import pytest +from django.core.management import CommandError, call_command +from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture + +from environments.identities.models import Identity +from environments.models import Environment +from environments.permissions.models import UserEnvironmentPermission +from features.models import Feature, FeatureSegment, FeatureState +from organisations.models import ( + Organisation, + OrganisationRole, + Subscription, + UserOrganisation, +) +from projects.models import Project, UserProjectPermission +from segments.models import Segment +from users.models import FFAdminUser + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def enable_local_database_reset(settings: SettingsWrapper) -> None: + """Enable the reset_local_database command for tests.""" + settings.ENABLE_LOCAL_DATABASE_RESET = True + + +@pytest.fixture(autouse=True) +def mock_reset_commands(mocker: MockerFixture) -> MagicMock: + """Mock flush/migrate/createcachetable to avoid resetting the test database.""" + return mocker.patch( + "core.management.commands.reset_local_database.call_command", + ) + + +def test_reset_local_database__calls_reset_commands( + mock_reset_commands: MagicMock, + mocker: MockerFixture, +) -> None: + # When + call_command("reset_local_database") + + # Then + assert mock_reset_commands.call_args_list == [ + mocker.call("flush", "--noinput", verbosity=0), + mocker.call("migrate", verbosity=0), + mocker.call("createcachetable", verbosity=0), + ] + + +# Multiple assertions are grouped in this test to avoid running the slow +# reset_local_database command multiple times. +def test_reset_local_database__creates_expected_data() -> None: + # When + call_command("reset_local_database") + + # Then: user is created with expected attributes + user = FFAdminUser.objects.get() + assert user.email == "local@flagsmith.com" + assert user.first_name == "Local" + assert user.last_name == "Developer" + assert user.check_password("testpass1") + + # Then: organisation is created with expected name + organisation = Organisation.objects.get() + assert organisation.name == "Acme, Inc." + + # Then: user has admin role in organisation + user_organisation = UserOrganisation.objects.get() + assert user_organisation.user == user + assert user_organisation.organisation == organisation + assert user_organisation.role == OrganisationRole.ADMIN.name + + # Then: subscription exists for the organisation + assert Subscription.objects.filter(organisation=organisation).exists() + + # Then: project is created with expected name + project = Project.objects.get() + assert project.name == "AI Booster" + assert project.organisation == organisation + + # Then: user has project admin access + user_project_permission = UserProjectPermission.objects.get(user=user) + assert user_project_permission.project == project + assert user_project_permission.admin is True + + # Then: environments are created correctly + environments = Environment.objects.all() + assert environments.count() == 3 + environment_names = set(environments.values_list("name", flat=True)) + assert environment_names == {"Development", "Staging", "Production"} + for env in environments: + assert env.project == project + + # Then: user has admin access to all three environments + assert UserEnvironmentPermission.objects.filter(user=user, admin=True).count() == 3 + + dev_environment = Environment.objects.get(name="Development") + + # Then: features are created with correct attributes + features = Feature.objects.all() + assert features.count() == 6 + + dark_mode = Feature.objects.get(name="dark_mode") + assert dark_mode.description == "Enable dark mode theme for the application" + assert dark_mode.default_enabled is True + assert dark_mode.type == "FLAG" + + ai_assistant = Feature.objects.get(name="ai_assistant") + assert ai_assistant.description == "Enable AI-powered assistant features" + assert ai_assistant.default_enabled is False + assert ai_assistant.type == "FLAG" + + api_rate_limit = Feature.objects.get(name="api_rate_limit") + assert api_rate_limit.description == "Maximum API requests per minute" + assert api_rate_limit.default_enabled is True + assert api_rate_limit.type == "CONFIG" + assert api_rate_limit.initial_value == "100" + + welcome_message = Feature.objects.get(name="welcome_message") + assert welcome_message.description == "Welcome message displayed to users" + assert welcome_message.default_enabled is True + assert welcome_message.type == "CONFIG" + assert welcome_message.initial_value == "Welcome to AI Booster!" + + feature_config = Feature.objects.get(name="feature_config") + assert feature_config.description == "JSON configuration for feature behavior" + assert feature_config.default_enabled is True + assert feature_config.type == "CONFIG" + assert feature_config.initial_value == '{"theme": "modern", "animations": true}' + + beta_features = Feature.objects.get(name="beta_features") + assert beta_features.description == "Enable access to beta features" + assert beta_features.default_enabled is True + assert beta_features.type == "FLAG" + + # Then: segments are created with correct attributes + segments = Segment.objects.all() + assert segments.count() == 3 + + premium_segment = Segment.objects.get(name="Premium Users") + assert ( + premium_segment.description + == "Users with premium subscription and active status" + ) + + beta_segment = Segment.objects.get(name="Beta Testers") + assert beta_segment.description == "Users enrolled in beta testing program" + + rollout_segment = Segment.objects.get(name="50% Rollout") + assert rollout_segment.description == "50% of users for gradual feature rollout" + + # Then: segment overrides are created with correct values + feature_segments = FeatureSegment.objects.all() + assert feature_segments.count() == 4 + for feature_segment in feature_segments: + assert feature_segment.environment == dev_environment + + # dark_mode -> Premium Users + dark_mode_fs = FeatureSegment.objects.get( + feature=dark_mode, segment=premium_segment + ) + dark_mode_override = FeatureState.objects.get(feature_segment=dark_mode_fs) + assert dark_mode_override.enabled is True + + # beta_features -> Beta Testers + beta_fs = FeatureSegment.objects.get(feature=beta_features, segment=beta_segment) + beta_override = FeatureState.objects.get(feature_segment=beta_fs) + assert beta_override.enabled is True + + # api_rate_limit -> Premium Users with value "500" + rate_limit_fs = FeatureSegment.objects.get( + feature=api_rate_limit, segment=premium_segment + ) + rate_limit_override = FeatureState.objects.get(feature_segment=rate_limit_fs) + assert rate_limit_override.enabled is True + assert rate_limit_override.get_feature_state_value() == 500 + + # welcome_message -> Beta Testers with value "Welcome, Beta Tester!" + welcome_fs = FeatureSegment.objects.get( + feature=welcome_message, segment=beta_segment + ) + welcome_override = FeatureState.objects.get(feature_segment=welcome_fs) + assert welcome_override.enabled is True + assert welcome_override.get_feature_state_value() == "Welcome, Beta Tester!" + + # Then: identities are created + identities = Identity.objects.all() + assert identities.count() == 2 + alice = Identity.objects.get(identifier="alice@example.com") + bob = Identity.objects.get(identifier="bob@example.com") + assert alice.environment == dev_environment + assert bob.environment == dev_environment + + # Then: identity overrides are created with correct values + # alice: dark_mode disabled + alice_dark_mode = FeatureState.objects.get(identity=alice, feature=dark_mode) + assert alice_dark_mode.enabled is False + + # bob: welcome_message with custom value + bob_welcome = FeatureState.objects.get(identity=bob, feature=welcome_message) + assert bob_welcome.enabled is True + assert bob_welcome.get_feature_state_value() == "Hello, Bob!" + + +def test_reset_local_database__raises_error_when_disabled( + settings: SettingsWrapper, +) -> None: + # Given + settings.ENABLE_LOCAL_DATABASE_RESET = False + + # When / Then + with pytest.raises(CommandError) as exc_info: + call_command("reset_local_database") + + assert "ENABLE_LOCAL_DATABASE_RESET" in str(exc_info.value) diff --git a/docs/docs/project-and-community/contributing.md b/docs/docs/project-and-community/contributing.md index 39bed9a895e5..e669a5a3a3b3 100644 --- a/docs/docs/project-and-community/contributing.md +++ b/docs/docs/project-and-community/contributing.md @@ -49,6 +49,25 @@ You can also manually run all the checks across the entire codebase with: pre-commit run --all-files ``` +## Local Development + +To run the API locally with Docker services: + +```bash +cd api +make serve +``` + +To reset your local database and populate it with test data: + +```bash +cd api +make reset +``` + +The command creates a development environment with sample organisations, projects, +features, and segments for testing. + ## Running Tests The application uses pytest for writing (appropriate use of fixtures) and running tests. Before running tests please make sure that `DJANGO_SETTINGS_MODULE` env var is pointing to the right module, e.g. `app.settings.test`. diff --git a/frontend/.env-local b/frontend/.env-local new file mode 100644 index 000000000000..3c01e1fdd0d0 --- /dev/null +++ b/frontend/.env-local @@ -0,0 +1,5 @@ +# Use local environment configuration (frontend/env/project_local.js) +ENV=local + +USE_SECURE_COOKIES=false +COOKIE_SAME_SITE=lax diff --git a/frontend/api/index.js b/frontend/api/index.js index 1027f557943c..1dd331ac8c1a 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -107,7 +107,7 @@ app.get('/config/project-overrides', (req, res) => { }, { name: 'albacross', value: process.env.ALBACROSS_CLIENT_ID }, { name: 'useSecureCookies', value: envToBool('USE_SECURE_COOKIES', true) }, - { name: 'cookieSameSite', value: process.env.USE_SECURE_COOKIES }, + { name: 'cookieSameSite', value: process.env.COOKIE_SAME_SITE }, { name: 'cookieAuthEnabled', value: process.env.COOKIE_AUTH_ENABLED }, { name: 'githubAppURL',