"""Section 7.1 — Environments: isolation, config, and secrets tests. Covers: Local dev : docker-compose.override.yml injects DEBUG=true; bind mounts keep the image separate from staging/prod. Staging : separate instance name in aws-provision-staging.sh; distinct domain in setup-ec2-staging.sh; same Docker image as prod. Production : SSH never open to 0.0.0.0/0; startup guard rejects weak DB_PASSWORD; isolated named volume for object store. Config/secrets: all secrets injected via env vars; .env excluded from snapshots; .env.example documents every required var; no hardcoded secrets in config.py defaults. """ from __future__ import annotations import ast import re import subprocess from pathlib import Path import pytest _ROOT = Path(__file__).resolve().parents[1] # ── file paths ───────────────────────────────────────────────────────────────── _MUSEIGNORE = _ROOT / ".museignore" _ENV_EXAMPLE = _ROOT / ".env.example" _CONFIG_PY = _ROOT / "musehub" / "config.py" _MAIN_PY = _ROOT / "musehub" / "main.py" _COMPOSE = _ROOT / "docker-compose.yml" _COMPOSE_OVER = _ROOT / "docker-compose.override.yml" _PROVISION = _ROOT / "deploy" / "aws-provision.sh" _PROVISION_STG = _ROOT / "deploy" / "aws-provision-staging.sh" _SETUP_EC2 = _ROOT / "deploy" / "setup-ec2.sh" _SETUP_STG = _ROOT / "deploy" / "setup-ec2-staging.sh" # ═══════════════════════════════════════════════════════════════════════════════ # Secrets never committed # ═══════════════════════════════════════════════════════════════════════════════ from musehub.config import Settings from pydantic_settings import SettingsConfigDict class _TestSettings(Settings): """Settings subclass for tests — does not load from any .env file.""" model_config = SettingsConfigDict( env_file=None, env_file_encoding="utf-8", extra="ignore", ) class TestSecretsNotCommitted: def test_museignore_excludes_env(self) -> None: """.env must be listed in .museignore so it is never snapshotted.""" src = _MUSEIGNORE.read_text() # Accept both bare ".env" and glob patterns like ".env.*" assert re.search(r'["\']\s*\.env\s*["\']', src), ( ".env is not in .museignore — it would be snapshotted and committed" ) def test_museignore_excludes_env_star(self) -> None: """Explicit environment-specific .env variants must be in .museignore. We use explicit entries instead of a .env.* glob so that .env.example (a committed template convention) is not accidentally excluded. """ src = _MUSEIGNORE.read_text() for variant in (".env.staging", ".env.production", ".env.prod", ".env.local"): assert variant in src, ( f"{variant} is not in .museignore — environment-specific files could be committed" ) def test_env_example_exists(self) -> None: """.env.example must exist as a safe template without real secrets.""" assert _ENV_EXAMPLE.exists(), ".env.example missing — engineers have no config template" def test_no_hardcoded_secrets_in_config_defaults(self) -> None: """config.py defaults must not contain real credentials. Checks that string defaults for secret fields are None (not literal passwords). Parses the AST to be precise — no false positives from comments. """ tree = ast.parse(_CONFIG_PY.read_text()) _SECRET_FIELDS = { "db_password", "blob_storage_access_key_id", "blob_storage_secret_access_key", "webhook_secret_key", "mcp_token", } for node in ast.walk(tree): if not isinstance(node, ast.AnnAssign): continue if not isinstance(node.target, ast.Name): continue fname = node.target.id if fname not in _SECRET_FIELDS: continue # Default must be None, not a literal string if node.value is not None and isinstance(node.value, ast.Constant): val = node.value.value if val is not None: pytest.fail( f"config.py: {fname!r} has a hardcoded default {val!r} — " "secrets must default to None" ) def test_env_example_has_no_real_secrets(self) -> None: """.env.example must not contain any value that looks like a generated secret. A generated secret has ≥ 32 random hex chars or a Fernet key pattern. Real values like 'musehub' or 'changeme' are expected (they're documented weak values, not actual secrets). """ src = _ENV_EXAMPLE.read_text() # Fernet key pattern: URL-safe base64, exactly 44 chars ending in '=' fernet_pattern = re.compile(r'[A-Za-z0-9_\-]{43}=') assert not fernet_pattern.search(src), ( ".env.example appears to contain a real Fernet key" ) # Generated hex secret: 32+ hex chars on a value line (not a comment) for line in src.splitlines(): line = line.strip() if line.startswith("#") or "=" not in line: continue _, _, value = line.partition("=") value = value.strip().strip('"\'') if re.fullmatch(r"[0-9a-f]{32,}", value): pytest.fail( f".env.example contains what looks like a real secret: {line!r}" ) # ═══════════════════════════════════════════════════════════════════════════════ # .env.example completeness # ═══════════════════════════════════════════════════════════════════════════════ class TestEnvExampleCompleteness: _src = _ENV_EXAMPLE.read_text() def test_documents_muse_env(self) -> None: assert "MUSE_ENV" in self._src def test_documents_db_password(self) -> None: assert "DB_PASSWORD" in self._src def test_documents_debug(self) -> None: assert "DEBUG" in self._src def test_documents_cors_origins(self) -> None: assert "CORS_ORIGINS" in self._src def test_documents_webhook_secret_key(self) -> None: assert "WEBHOOK_SECRET_KEY" in self._src def test_documents_runner_token(self) -> None: assert "RUNNER_TOKEN" in self._src def test_warns_against_debug_in_prod(self) -> None: assert re.search(r"NEVER.*DEBUG.*prod|prod.*NEVER.*DEBUG|staging.*production", self._src, re.IGNORECASE), ( ".env.example should warn against DEBUG=true in staging/production" ) def test_documents_staging_and_production_values(self) -> None: """Must have guidance for at least staging and production.""" assert "staging" in self._src.lower() assert "production" in self._src.lower() def test_documents_secret_generation_commands(self) -> None: """Must show how to generate secrets — engineers should never guess.""" assert "openssl rand" in self._src or "Fernet.generate_key" in self._src # ═══════════════════════════════════════════════════════════════════════════════ # Local dev: docker compose isolation # ═══════════════════════════════════════════════════════════════════════════════ class TestLocalDevIsolation: def test_override_sets_debug_true(self) -> None: """docker-compose.override.yml must set DEBUG=true for local dev.""" src = _COMPOSE_OVER.read_text() assert re.search(r'DEBUG.*true', src, re.IGNORECASE), ( "override.yml does not set DEBUG=true — local dev won't have debug mode" ) def test_main_compose_does_not_set_debug_true(self) -> None: """docker-compose.yml must NOT set DEBUG=true — would be a prod footgun.""" src = _COMPOSE.read_text() # Allow DEBUG references in comments but not as a live env value for line in src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue # Pattern: DEBUG: "true" or DEBUG=true if re.search(r'DEBUG[:\s]*["\']?true["\']?', stripped, re.IGNORECASE): pytest.fail( f"docker-compose.yml sets DEBUG=true on line: {line!r} — " "this would enable debug mode in production" ) def test_override_has_bind_mounts(self) -> None: """override.yml must bind-mount source dirs for live reload.""" src = _COMPOSE_OVER.read_text() assert "./musehub:/app/musehub" in src, ( "override.yml should bind-mount ./musehub for live reload" ) def test_object_store_uses_named_volume(self) -> None: """musehub_data volume in docker-compose.yml isolates object store from host.""" src = _COMPOSE.read_text() assert "musehub_data:/data" in src, ( "Object store must be mounted via a named volume — not a bind mount — " "so it survives container restarts and can be backed up independently" ) def test_postgres_uses_named_volume(self) -> None: """postgres_data volume isolates DB from host filesystem.""" src = _COMPOSE.read_text() assert "postgres_data:/var/lib/postgresql/data" in src # ═══════════════════════════════════════════════════════════════════════════════ # Staging: separate instance and domain # ═══════════════════════════════════════════════════════════════════════════════ class TestStagingIsolation: def test_staging_instance_name_differs_from_prod(self) -> None: """Staging and prod must use different EC2 instance names.""" prod_src = _PROVISION.read_text() stg_src = _PROVISION_STG.read_text() prod_name = re.search(r'INSTANCE_NAME=["\']?(\S+)["\']?', prod_src) stg_name = re.search(r'INSTANCE_NAME=["\']?(\S+)["\']?', stg_src) assert prod_name and stg_name, "INSTANCE_NAME not found in provision scripts" assert prod_name.group(1) != stg_name.group(1), ( f"Staging and prod use the same INSTANCE_NAME ({prod_name.group(1)!r}) — " "they would overwrite each other" ) def test_staging_uses_staging_domain(self) -> None: """setup-ec2-staging.sh must reference a staging domain, not the prod domain.""" src = _SETUP_STG.read_text() assert "staging" in src.lower(), ( "setup-ec2-staging.sh does not reference a staging domain" ) # Must not set DOMAIN to the production domain without qualification domain_match = re.search(r'^DOMAIN=["\']?(\S+)["\']?', src, re.MULTILINE) if domain_match: domain = domain_match.group(1) assert "staging" in domain.lower(), ( f"setup-ec2-staging.sh DOMAIN={domain!r} does not look like a staging domain" ) def test_same_ami_for_staging_and_prod(self) -> None: """Staging and prod must use the same AMI (same Docker image = full mirror).""" prod_src = _PROVISION.read_text() stg_src = _PROVISION_STG.read_text() prod_ami = re.search(r'AMI_ID=["\']?(\S+)["\']?', prod_src) stg_ami = re.search(r'AMI_ID=["\']?(\S+)["\']?', stg_src) if prod_ami and stg_ami: assert prod_ami.group(1) == stg_ami.group(1), ( "Staging and prod use different AMIs — staging is no longer a full mirror" ) def test_staging_env_documented_in_env_example(self) -> None: src = _ENV_EXAMPLE.read_text() assert re.search(r"staging", src, re.IGNORECASE), ( ".env.example has no staging guidance" ) # ═══════════════════════════════════════════════════════════════════════════════ # Production: network hardening # ═══════════════════════════════════════════════════════════════════════════════ class TestProductionNetworkHardening: def test_ssh_not_open_to_all_in_provision(self) -> None: """Port 22 must NOT be open to 0.0.0.0/0 in aws-provision.sh.""" src = _PROVISION.read_text() # Pattern: add_rule 22 "0.0.0.0/0" or similar for line in src.splitlines(): if re.search(r'22.*0\.0\.0\.0/0|0\.0\.0\.0/0.*22', line): # Allow if the line is a comment stripped = line.strip() if not stripped.startswith("#"): pytest.fail( f"Port 22 is open to 0.0.0.0/0 in aws-provision.sh: {line!r}\n" "SSH should be restricted to a specific IP or bastion." ) def test_http_redirect_in_nginx_config(self) -> None: """nginx config must redirect HTTP to HTTPS (80 → 443).""" nginx_conf = _ROOT / "deploy" / "nginx-cf.conf" src = nginx_conf.read_text() assert re.search(r'listen\s+80', src), "nginx config has no port-80 listener" assert "return 301" in src or "return 308" in src, ( "nginx config does not redirect port 80 to HTTPS" ) def test_ssl_configured_in_nginx(self) -> None: """nginx must be configured with SSL (port 443).""" nginx_conf = _ROOT / "deploy" / "nginx-cf.conf" src = nginx_conf.read_text() assert re.search(r'listen\s+443\s+ssl', src) assert "ssl_certificate" in src def test_entrypoint_uses_proxy_headers(self) -> None: """uvicorn must run with --proxy-headers so Cloudflare IPs are trusted.""" src = (_ROOT / "entrypoint.sh").read_text() assert "--proxy-headers" in src # ═══════════════════════════════════════════════════════════════════════════════ # Settings: env-var injection and startup guards # ═══════════════════════════════════════════════════════════════════════════════ class TestSettingsEnvInjection: def test_settings_reads_from_env_file(self) -> None: """Settings must be configured to read .env (pydantic-settings env_file).""" src = _CONFIG_PY.read_text() assert "env_file" in src and ".env" in src def test_muse_env_defaults_to_production(self) -> None: """Default must be 'production' — fail-closed (not development by accident).""" from musehub.config import Settings # Instantiate without any env vars — only pydantic defaults import os saved = os.environ.pop("MUSE_ENV", None) try: s = _TestSettings() assert s.muse_env == "production" finally: if saved is not None: os.environ["MUSE_ENV"] = saved def test_debug_defaults_to_false(self) -> None: """DEBUG must default to False — never accidentally expose Swagger.""" import os saved = os.environ.pop("DEBUG", None) try: from musehub.config import Settings s = _TestSettings() assert s.debug is False finally: if saved is not None: os.environ["DEBUG"] = saved def test_weak_db_password_rejected_in_production(self) -> None: """Production startup must refuse to start with a weak DB password.""" src = _MAIN_PY.read_text() # The guard checks for known weak passwords assert "changeme" in src or "weak" in src.lower(), ( "lifespan() does not appear to guard against weak DB passwords" ) assert "RuntimeError" in src or "raise" in src def test_production_secrets_validator_warns_on_missing_webhook_key(self, caplog: pytest.LogCaptureFixture) -> None: """In production mode, missing WEBHOOK_SECRET_KEY must produce a log warning.""" import logging from musehub.config import Settings with caplog.at_level(logging.WARNING, logger="musehub.config"): _TestSettings( debug=False, muse_env="production", db_password="strong-password-that-passes-guard", webhook_secret_key=None, ) warning_text = " ".join(caplog.messages) assert "WEBHOOK_SECRET_KEY" in warning_text or "webhook" in warning_text.lower() def test_production_secrets_validator_silent_in_dev(self, caplog: pytest.LogCaptureFixture) -> None: """In development mode, missing secrets must NOT produce warnings.""" import logging from musehub.config import Settings caplog.clear() with caplog.at_level(logging.WARNING, logger="musehub.config"): _TestSettings( debug=True, muse_env="development", webhook_secret_key=None, ) assert not any("WEBHOOK_SECRET_KEY" in m for m in caplog.messages) def test_cors_wildcard_warns_in_non_debug(self, caplog: pytest.LogCaptureFixture) -> None: """CORS=* in non-debug mode must trigger a security warning.""" import logging from musehub.config import Settings with caplog.at_level(logging.WARNING, logger="musehub.config"): _TestSettings( debug=False, cors_origins=["*"], muse_env="test", ) assert any("CORS" in m or "origin" in m.lower() for m in caplog.messages) def test_settings_extra_env_vars_ignored(self) -> None: """Unknown env vars must not cause validation errors (extra='ignore').""" import os os.environ["COMPLETELY_UNKNOWN_VAR"] = "value" try: from musehub.config import Settings s = _TestSettings() # Should not raise finally: del os.environ["COMPLETELY_UNKNOWN_VAR"] # ═══════════════════════════════════════════════════════════════════════════════ # OpenAPI exposure: disabled in production # ═══════════════════════════════════════════════════════════════════════════════ class TestOpenAPIExposure: def test_openapi_disabled_in_production(self) -> None: """OpenAPI URL must be None in production (DEBUG=false, MUSE_ENV != test).""" src = _MAIN_PY.read_text() # The app init must conditional on debug or test env assert re.search(r'openapi_url.*debug.*test|openapi_url.*None', src), ( "OpenAPI endpoint does not appear to be gated by debug/test mode" ) def test_openapi_enabled_in_test_env(self) -> None: """OpenAPI must be reachable in the test environment (tests use it).""" from musehub.main import app from musehub.config import settings if settings.muse_env == "test": assert app.openapi_url is not None, ( "OpenAPI URL is None in test env — tests that hit /api/openapi.json will fail" )