diff --git a/.env.integration-tests b/.env.integration-tests index 68158d3b5f..2ba4e244ec 100644 --- a/.env.integration-tests +++ b/.env.integration-tests @@ -59,3 +59,4 @@ CIRRUS_FML_PATH=./feature_manifest/sample.yml CIRRUS_SENTRY_DSN= CIRRUS_ENV_NAME=test_instance_stage CIRRUS_GLEAN_MAX_EVENTS_BUFFER=1 +DISABLE_CSRF=True diff --git a/.gitignore b/.gitignore index c06ec47a78..effdbcaef2 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,8 @@ experimenter/experimenter/reporting/reporting-ui/assets/ .tmontmp experimenter/coverage_html_report/ experimenter/tests/integration/test-reports/ +experimenter/tests/integration/nimbus/fixtures/targeting_configs.json +experimenter/tests/integration/nimbus/fixtures/feature_configs.json junit.xml **/coverage_report/ diff --git a/Makefile b/Makefile index 1e3941fd3c..622852a1ac 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,7 @@ LOAD_COUNTRIES = python manage.py loaddata ./experimenter/base/fixtures/countrie LOAD_LOCALES = python manage.py loaddata ./experimenter/base/fixtures/locales.json LOAD_LANGUAGES = python manage.py loaddata ./experimenter/base/fixtures/languages.json LOAD_FEATURES = python manage.py load_feature_configs +GENERATE_TARGETING_CONFIGS = python manage.py generate_targeting_configs LOAD_DUMMY_EXPERIMENTS = [[ -z $$SKIP_DUMMY ]] && python manage.py load_dummy_experiments || python manage.py load_dummy_tags JETSTREAM_CONFIG_URL = https://github.com/mozilla/metric-hub/archive/main.zip @@ -226,7 +227,7 @@ bash: build_dev cirrus_build refresh: kill build_dev cirrus_build compose_build refresh_db ## Rebuild all containers and the database refresh_db: # Rebuild the database - $(COMPOSE_RUN) -e SKIP_DUMMY=$$SKIP_DUMMY experimenter bash -c '$(WAIT_FOR_DB) $(PYTHON_MIGRATE)&&$(LOAD_LOCALES)&&$(LOAD_COUNTRIES)&&$(LOAD_LANGUAGES)&&$(LOAD_FEATURES)&&$(LOAD_DUMMY_EXPERIMENTS)' + $(COMPOSE_RUN) -e SKIP_DUMMY=$$SKIP_DUMMY experimenter bash -c '$(WAIT_FOR_DB) $(PYTHON_MIGRATE)&&$(LOAD_LOCALES)&&$(LOAD_COUNTRIES)&&$(LOAD_LANGUAGES)&&$(LOAD_FEATURES)&&$(GENERATE_TARGETING_CONFIGS)&&$(LOAD_DUMMY_EXPERIMENTS)' dependabot_approve: echo "Install and configure the Github CLI https://github.com/cli/cli" diff --git a/experimenter/experimenter/base/management/commands/generate_targeting_configs.py b/experimenter/experimenter/base/management/commands/generate_targeting_configs.py new file mode 100644 index 0000000000..cdae4ed869 --- /dev/null +++ b/experimenter/experimenter/base/management/commands/generate_targeting_configs.py @@ -0,0 +1,52 @@ +import json +import logging +from pathlib import Path + +from django.core.management.base import BaseCommand + +from experimenter.experiments.models import NimbusFeatureConfig +from experimenter.targeting.constants import NimbusTargetingConfig + +logger = logging.getLogger() + +FIXTURES_DIR = ( + Path(__file__).resolve().parents[4] / "tests" / "integration" / "nimbus" / "fixtures" +) + + +class Command(BaseCommand): + help = "Generate targeting configs and feature configs JSON for integration tests" + + def handle(self, *args, **options): + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + + targeting_configs = [ + { + "label": config.name, + "value": config.slug, + "applicationValues": list(config.application_choice_names), + "description": config.description, + "stickyRequired": config.sticky_required, + "isFirstRunRequired": config.is_first_run_required, + } + for config in NimbusTargetingConfig.targeting_configs + ] + targeting_path = FIXTURES_DIR / "targeting_configs.json" + targeting_path.write_text(json.dumps(targeting_configs, indent=2) + "\n") + logger.info( + f"Generated {len(targeting_configs)} targeting configs to {targeting_path}" + ) + + feature_configs = [ + { + "id": fc.id, + "slug": fc.slug, + "application": fc.application, + } + for fc in NimbusFeatureConfig.objects.filter(enabled=True).order_by( + "application", "slug" + ) + ] + feature_path = FIXTURES_DIR / "feature_configs.json" + feature_path.write_text(json.dumps(feature_configs, indent=2) + "\n") + logger.info(f"Generated {len(feature_configs)} feature configs to {feature_path}") diff --git a/experimenter/experimenter/settings.py b/experimenter/experimenter/settings.py index dfdd7be3f7..4cfa9d25f1 100644 --- a/experimenter/experimenter/settings.py +++ b/experimenter/experimenter/settings.py @@ -132,6 +132,11 @@ "experimenter.glean.middleware.GleanMiddleware", ] +if config("DISABLE_CSRF", default=False, cast=bool): # pragma: no cover + MIDDLEWARE = [ + m for m in MIDDLEWARE if m != "django.middleware.csrf.CsrfViewMiddleware" + ] + ROOT_URLCONF = "experimenter.urls" TEMPLATES = [ diff --git a/experimenter/experimenter/targeting/tests/test_generate_targeting_configs.py b/experimenter/experimenter/targeting/tests/test_generate_targeting_configs.py new file mode 100644 index 0000000000..f50c3a63da --- /dev/null +++ b/experimenter/experimenter/targeting/tests/test_generate_targeting_configs.py @@ -0,0 +1,45 @@ +import json +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase + +from experimenter.experiments.tests.factories import NimbusFeatureConfigFactory +from experimenter.targeting.constants import NimbusTargetingConfig + + +class TestGenerateTargetingConfigsCommand(TestCase): + def test_generates_targeting_and_feature_configs(self): + NimbusFeatureConfigFactory.create(slug="test-feature", enabled=True) + + with patch( + "experimenter.base.management.commands.generate_targeting_configs.FIXTURES_DIR" + ) as mock_dir: + mock_dir.mkdir.return_value = None + targeting_path = mock_dir.__truediv__.return_value + targeting_path.write_text.return_value = None + + call_command("generate_targeting_configs") + + mock_dir.mkdir.assert_called_once_with(parents=True, exist_ok=True) + self.assertEqual(targeting_path.write_text.call_count, 2) + + targeting_json = targeting_path.write_text.call_args_list[0][0][0] + targeting_configs = json.loads(targeting_json) + self.assertEqual( + len(targeting_configs), len(NimbusTargetingConfig.targeting_configs) + ) + for config in targeting_configs: + self.assertIn("label", config) + self.assertIn("value", config) + self.assertIn("applicationValues", config) + self.assertIn("stickyRequired", config) + self.assertIn("isFirstRunRequired", config) + + feature_json = targeting_path.write_text.call_args_list[1][0][0] + feature_configs = json.loads(feature_json) + self.assertTrue(len(feature_configs) > 0) + for fc in feature_configs: + self.assertIn("id", fc) + self.assertIn("slug", fc) + self.assertIn("application", fc) diff --git a/experimenter/tests/integration/nimbus/utils/helpers.py b/experimenter/tests/integration/nimbus/utils/helpers.py index d878a32a78..19ff636b5e 100755 --- a/experimenter/tests/integration/nimbus/utils/helpers.py +++ b/experimenter/tests/integration/nimbus/utils/helpers.py @@ -1,7 +1,8 @@ import json import os +import re import time -from functools import cache +from pathlib import Path import requests @@ -11,141 +12,228 @@ LOAD_DATA_RETRIES = 60 LOAD_DATA_RETRY_DELAY = 1.0 +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" +TARGETING_CONFIGS_PATH = FIXTURES_DIR / "targeting_configs.json" +FEATURE_CONFIGS_PATH = FIXTURES_DIR / "feature_configs.json" +# GraphQL app enum value -> Django model application slug +APP_SLUG_MAP = { + "DESKTOP": "firefox-desktop", + "FENIX": "fenix", + "IOS": "ios", +} -def load_graphql_data(query): - nginx_url = os.getenv("INTEGRATION_TEST_NGINX_URL", "https://nginx") +# GraphQL camelCase -> Django form field name +OVERVIEW_FIELD_MAP = { + "publicDescription": "public_description", + "riskBrand": "risk_brand", + "riskMessage": "risk_message", + "riskRevenue": "risk_revenue", + "riskPartnerRelated": "risk_partner_related", + "riskAi": "risk_ai", +} + +AUDIENCE_FIELD_MAP = { + "targetingConfigSlug": "targeting_config_slug", + "populationPercent": "population_percent", + "totalEnrolledClients": "total_enrolled_clients", + "firefoxMinVersion": "firefox_min_version", + "firefoxMaxVersion": "firefox_max_version", + "proposedEnrollment": "proposed_enrollment", + "proposedDuration": "proposed_duration", + "isSticky": "is_sticky", + "channels": "channels", + "channel": "channel", + "locales": "locales", + "countries": "countries", +} + +_session = None + + +def _get_nginx_url(): + return os.getenv("INTEGRATION_TEST_NGINX_URL", "https://nginx") + + +def _get_session(): + global _session + if _session is None: + _session = requests.Session() + _session.verify = False + return _session + + +def _post_form(path, data=None): + """POST form data to a nimbus view. Retries on connection errors.""" + session = _get_session() + url = f"{_get_nginx_url()}{path}" for retry in range(LOAD_DATA_RETRIES): try: - return requests.post( - f"{nginx_url}/api/v5/graphql", - json=query, - verify=False, - ).json() - except json.JSONDecodeError: + resp = session.post(url, data=data or {}, allow_redirects=False) + if resp.status_code not in (200, 301, 302): + raise RuntimeError( + f"POST {path} failed ({resp.status_code}):\n{resp.text[:1000]}" + ) + return resp + except requests.ConnectionError: if retry + 1 >= LOAD_DATA_RETRIES: raise + time.sleep(LOAD_DATA_RETRY_DELAY) + +def _get_page(path): + """GET a page. Retries on connection errors.""" + session = _get_session() + url = f"{_get_nginx_url()}{path}" + for retry in range(LOAD_DATA_RETRIES): + try: + return session.get(url, allow_redirects=True) + except requests.ConnectionError: + if retry + 1 >= LOAD_DATA_RETRIES: + raise time.sleep(LOAD_DATA_RETRY_DELAY) -@cache -def load_config_data(): - return load_graphql_data( - { - "operationName": "getConfig", - "variables": {}, - "query": """ - query getConfig { - nimbusConfig { - applications { - label - value - } - channels { - label - value - } - conclusionRecommendationsChoices { - label - value - } - applicationConfigs { - application - channels { - label - value - } - } - allFeatureConfigs { - id - name - slug - description - application - ownerEmail - schema - setsPrefs - enabled - } - firefoxVersions { - label - value - } - outcomes { - friendlyName - slug - application - description - isDefault - metrics { - slug - friendlyName - description - } - } - owners { - username - } - targetingConfigs { - label - value - description - applicationValues - stickyRequired - isFirstRunRequired - } - hypothesisDefault - documentationLink { - label - value - } - maxPrimaryOutcomes - locales { - id - code - name - } - countries { - id - code - name - } - languages { - id - code - name - } - projects { - id - name - } - takeaways { - label - value - } - types { - label - value - } - statusUpdateExemptFields { - all - experiments - rollouts - } - populationSizingData - } - } - """, - } - )["data"]["nimbusConfig"] +def _get_api(path): + """GET a REST API endpoint, return parsed JSON.""" + session = _get_session() + url = f"{_get_nginx_url()}{path}" + for retry in range(LOAD_DATA_RETRIES): + try: + resp = session.get(url) + return resp.json() + except (json.JSONDecodeError, requests.ConnectionError): + if retry + 1 >= LOAD_DATA_RETRIES: + raise + time.sleep(LOAD_DATA_RETRY_DELAY) + + +def _extract_slug_from_hx_redirect(resp): + """Extract experiment slug from HX-Redirect header.""" + hx = resp.headers.get("HX-Redirect", "") + parts = [p for p in hx.split("/") if p] + for i, part in enumerate(parts): + if part == "nimbus" and i + 1 < len(parts): + return parts[i + 1] + return None + + +def _parse_branch_ids(html): + """Parse branch form index and DB IDs from hidden inputs.""" + return re.findall(r'name="branches-(\d+)-id"[^>]*value="(\d+)"', html) + + +def _parse_field_value(html, field_name): + """Parse a single hidden/text input value from HTML.""" + match = re.search(rf'name="{re.escape(field_name)}"[^>]*value="([^"]*)"', html) + return match.group(1) if match else "" + + +def _build_branches_form_data( + slug, + feature_config_ids=None, + reference_branch=None, + treatment_branches=None, + is_rollout=False, +): + """Build POST data for the branches update form by parsing the current page.""" + resp = _get_page(f"/nimbus/{slug}/update_branches/") + html = resp.text + branch_ids = _parse_branch_ids(html) + + data = { + "branches-TOTAL_FORMS": str(len(branch_ids)), + "branches-INITIAL_FORMS": str(len(branch_ids)), + "branches-MIN_NUM_FORMS": "0", + "branches-MAX_NUM_FORMS": "1000", + } + + for form_idx_str, branch_id in branch_ids: + prefix = f"branches-{form_idx_str}" + + data[f"{prefix}-id"] = branch_id + for field in ("name", "description", "ratio", "slug"): + data[f"{prefix}-{field}"] = _parse_field_value(html, f"{prefix}-{field}") + + # Override reference branch (form index 0) if data provided + if form_idx_str == "0" and reference_branch: + for key in ("name", "description", "ratio"): + if key in reference_branch: + data[f"{prefix}-{key}"] = str(reference_branch[key]) + + # Mark non-reference branches for deletion if rollout + if is_rollout and form_idx_str != "0": + data[f"{prefix}-DELETE"] = "on" + + # Feature value sub-formset: preserve existing entries + fv_ids = re.findall( + rf'name="({prefix}-feature-value-(\d+)-id)"[^>]*value="(\d+)"', html + ) + data[f"{prefix}-feature-value-TOTAL_FORMS"] = str(len(fv_ids)) + data[f"{prefix}-feature-value-INITIAL_FORMS"] = str(len(fv_ids)) + data[f"{prefix}-feature-value-MIN_NUM_FORMS"] = "0" + data[f"{prefix}-feature-value-MAX_NUM_FORMS"] = "1000" + for fv_name, fv_idx, fv_id in fv_ids: + data[fv_name] = fv_id + data[f"{prefix}-feature-value-{fv_idx}-value"] = _parse_field_value( + html, f"{prefix}-feature-value-{fv_idx}-value" + ) + + # Screenshots sub-formset: preserve existing entries + ss_ids = re.findall( + rf'name="({prefix}-screenshots-(\d+)-id)"[^>]*value="(\d+)"', html + ) + data[f"{prefix}-screenshots-TOTAL_FORMS"] = str(len(ss_ids)) + data[f"{prefix}-screenshots-INITIAL_FORMS"] = str(len(ss_ids)) + data[f"{prefix}-screenshots-MIN_NUM_FORMS"] = "0" + data[f"{prefix}-screenshots-MAX_NUM_FORMS"] = "1000" + + if feature_config_ids: + data["feature_configs"] = [str(fid) for fid in feature_config_ids] + + if is_rollout: + data["is_rollout"] = "on" + + return data + + +def _map_audience_data(gql_data): + """Map GraphQL audience field names to Django form field names.""" + form_data = {} + for gql_key, form_key in AUDIENCE_FIELD_MAP.items(): + if gql_key in gql_data: + val = gql_data[gql_key] + if isinstance(val, bool): + if val: + form_data[form_key] = "on" + elif isinstance(val, list): + form_data[form_key] = val + else: + form_data[form_key] = str(val) + return form_data + + +def _map_overview_data(gql_data): + """Map GraphQL overview field names to Django form field names.""" + form_data = {} + for gql_key, form_key in OVERVIEW_FIELD_MAP.items(): + if gql_key in gql_data: + val = gql_data[gql_key] + if isinstance(val, bool): + form_data[form_key] = "True" if val else "False" + else: + form_data[form_key] = str(val) + return form_data + + +# --- Public API (same signatures as the old GraphQL-based functions) --- def load_targeting_configs(app=BaseExperimentApplications.FIREFOX_DESKTOP.value): - config_data = load_config_data() + targeting_configs = json.loads(TARGETING_CONFIGS_PATH.read_text()) return [ item["value"] - for item in config_data["targetingConfigs"] + for item in targeting_configs if ( BaseExperimentApplications.FIREFOX_DESKTOP.value in app and BaseExperimentApplications.FIREFOX_DESKTOP.value @@ -160,83 +248,96 @@ def load_targeting_configs(app=BaseExperimentApplications.FIREFOX_DESKTOP.value) def get_feature_id_as_string(slug, app): - config_data = load_config_data()["allFeatureConfigs"] - for f in config_data: - if f["slug"] == slug and f["application"] == app: + feature_configs = json.loads(FEATURE_CONFIGS_PATH.read_text()) + for f in feature_configs: + if f["slug"] == slug and f["application"] == APP_SLUG_MAP.get(app, app): return str(f["id"]) def load_experiment_data(slug): - return load_graphql_data( - { - "operationName": "getExperiment", - "variables": {"slug": slug}, - "query": """ - query getExperiment($slug: String!) { - experimentBySlug(slug: $slug) { - id - jexlTargetingExpression - recipeJson - } - } - """, + # Try live experiments first, fall back to drafts + experiment = _get_api(f"/api/v6/experiments/{slug}/") + if not experiment or "detail" in experiment: + experiment = _get_api(f"/api/v6/draft-experiments/{slug}/") + + return { + "data": { + "experimentBySlug": { + "id": experiment.get("id"), + "jexlTargetingExpression": experiment.get("targeting"), + "recipeJson": json.dumps(experiment), + } } - ) + } def create_basic_experiment(name, app, targeting=None, languages=None, is_rollout=False): - config_data = load_config_data() - - if languages is None: - languages = [] - language_ids = [l["id"] for l in config_data["languages"] if l["code"] in languages] - if targeting is None: targeting = load_targeting_configs()[0] - return load_graphql_data( - { - "operationName": "createExperiment", - "variables": { - "input": { - "name": name, - "hypothesis": "Test hypothesis", - "application": app, - "languages": language_ids, - "changelogMessage": "test changelog message", - "targetingConfigSlug": targeting, - "isRollout": is_rollout, - } - }, - "query": """ - mutation createExperiment($input: ExperimentInput!) { - createExperiment(input: $input) { - nimbusExperiment { - slug - } - } - } - """, - } + app_slug = APP_SLUG_MAP.get(app, app) + + # Create the experiment via form POST + resp = _post_form( + "/nimbus/create/", + {"name": name, "hypothesis": "Test hypothesis", "application": app_slug}, ) + slug = _extract_slug_from_hx_redirect(resp) + if not slug: + raise RuntimeError( + f"Failed to extract slug from create response: {resp.text[:500]}" + ) + + # Set targeting on audience form + _post_form( + f"/nimbus/{slug}/update_audience/", + {"targeting_config_slug": targeting, "population_percent": "100"}, + ) + + # Set rollout flag if needed + if is_rollout: + branch_data = _build_branches_form_data(slug, is_rollout=True) + _post_form(f"/nimbus/{slug}/update_branches/", branch_data) + + return {"data": {"createExperiment": {"nimbusExperiment": {"slug": slug}}}} def update_experiment(slug, data): - experiment_id = load_experiment_data(slug)["data"]["experimentBySlug"]["id"] - data.update({"id": experiment_id}) - return load_graphql_data( - { - "operationName": "updateExperiment", - "variables": {"input": data}, - "query": """ - mutation updateExperiment($input: ExperimentInput!) { - updateExperiment(input: $input) { - message - } - } - """, - } - ) + # Overview fields + overview_fields = {k: data[k] for k in OVERVIEW_FIELD_MAP if k in data} + if overview_fields: + form_data = _map_overview_data(overview_fields) + # Name is required on the overview form — read it from the current page + resp = _get_page(f"/nimbus/{slug}/update_overview/") + name_match = re.search(r'name="name"[^>]*value="([^"]*)"', resp.text) + if name_match: + form_data["name"] = name_match.group(1) + _post_form(f"/nimbus/{slug}/update_overview/", form_data) + + # Branch fields + branch_keys = { + "featureConfigIds", + "referenceBranch", + "treatmentBranches", + "isRollout", + } + if branch_keys & data.keys(): + branch_data = _build_branches_form_data( + slug, + feature_config_ids=data.get("featureConfigIds"), + reference_branch=data.get("referenceBranch"), + treatment_branches=data.get("treatmentBranches"), + is_rollout=data.get("isRollout", False), + ) + _post_form(f"/nimbus/{slug}/update_branches/", branch_data) + + # Audience fields + audience_fields = {k: data[k] for k in AUDIENCE_FIELD_MAP if k in data} + if audience_fields: + form_data = _map_audience_data(audience_fields) + _post_form(f"/nimbus/{slug}/update_audience/", form_data) + + return {"data": {"updateExperiment": {"message": "success"}}} def create_experiment(slug, app, data, targeting=None, is_rollout=False): @@ -252,26 +353,5 @@ def create_experiment(slug, app, data, targeting=None, is_rollout=False): def end_experiment(slug): - experiment_id = load_experiment_data(slug)["data"]["experimentBySlug"]["id"] - - data = { - "id": experiment_id, - "changelogMessage": "Update Experiment", - "publishStatus": "APPROVED", - "status": "LIVE", - "statusNext": "COMPLETE", - } - - load_graphql_data( - { - "operationName": "updateExperiment", - "variables": {"input": data}, - "query": """ - mutation updateExperiment($input: ExperimentInput!) { - updateExperiment(input: $input) { - message - } - } - """, - } - ) + _post_form(f"/nimbus/{slug}/live-to-complete/") + _post_form(f"/nimbus/{slug}/approve-end-experiment/")