"""Startup policy and the environment contract shared by blue/green services.""" import base64 import json import os import shutil import subprocess import sys from pathlib import Path import pytest from config.validation import ConfigurationError, validate_configuration ROOT = Path(__file__).resolve().parents[2] @pytest.fixture def production_env(): return { "DATABASE_URL": "postgres://raggr:a-random-test-password@postgres:5432/raggr", "JWT_SECRET_KEY": "a-long-test-signing-secret-with-32-characters", "FERNET_KEY": base64.urlsafe_b64encode(bytes(range(32))).decode(), "OPENAI_API_KEY": "synthetic-test-api-key", } def test_optional_integrations_are_not_required(production_env): validate_configuration(production_env) validate_configuration({}, production=False) @pytest.mark.parametrize( "name", ["DATABASE_URL", "JWT_SECRET_KEY", "FERNET_KEY", "OPENAI_API_KEY"] ) def test_missing_required_settings(production_env, name): del production_env[name] with pytest.raises(ConfigurationError, match=name): validate_configuration(production_env) @pytest.mark.parametrize( "name,value", [ ("JWT_SECRET_KEY", "SECRET_KEY"), ("JWT_SECRET_KEY", "your-" + "x" * 40), ("JWT_SECRET_KEY", "short"), ("OPENAI_API_KEY", "your-openai-api-key"), ("FERNET_KEY", "not-base64"), ("FERNET_KEY", base64.urlsafe_b64encode(b"short").decode()), ("FERNET_KEY", base64.b64encode(b"\xff" * 32).decode()), ("DATABASE_URL", "sqlite://test.db"), ("DATABASE_URL", "postgres://raggr:change%6de@postgres/raggr"), ("DATABASE_URL", "postgres://raggr:password@postgres/raggr"), ("DATABASE_URL", "postgres://raggr:strong@postgres:70000/raggr"), ("DATABASE_URL", "postgres://raggr@postgres/raggr"), ("LLAMA_SERVER_URL", "localhost:8080/v1"), ("EMBEDDING_SERVER_URL", "http://server:invalid/v1"), ("LLAMA_SERVER_URL", "http://user:private@server/v1"), ("LLAMA_MODEL_NAME", ""), ("METRICS_TOKEN", "short"), ("CHECKPOINT_DB_PATH", ":memory:"), ("CHECKPOINT_DB_PATH", "/data/invalid\x00.db"), ("CONVERSATION_SUMMARY_DB_PATH", ""), ("OBSIDIAN_CONTINUOUS_SYNC", "yes"), ], ) def test_malformed_and_unsafe_settings(production_env, name, value): production_env[name] = value with pytest.raises(ConfigurationError, match=name) as error: validate_configuration(production_env) if value and value not in name: assert value not in str(error.value) @pytest.mark.parametrize("value", ["0", "-1", "abc", "", "nan", "inf", "-inf"]) @pytest.mark.parametrize( "name", [ "TTFT_TIMEOUT_SECONDS", "LLAMA_TIMEOUT_SECONDS", "PROMPT_BUDGET_TOKENS", "WHATSAPP_RATE_LIMIT_MAX", ], ) def test_invalid_numbers(production_env, name, value): production_env[name] = value with pytest.raises(ConfigurationError, match=name): validate_configuration(production_env) def test_context_budget_boundary(production_env): production_env.update( CONTEXT_WINDOW_TOKENS="100", PROMPT_BUDGET_TOKENS="80", RESPONSE_RESERVE_TOKENS="20", ) validate_configuration(production_env) production_env["RESPONSE_RESERVE_TOKENS"] = "21" with pytest.raises(ConfigurationError, match="exceeds context window"): validate_configuration(production_env) def test_oidc_partial_insecure_and_manual_endpoints(production_env): production_env["OIDC_ISSUER"] = "https://auth.example.test" with pytest.raises(ConfigurationError, match="OIDC_CLIENT_ID"): validate_configuration(production_env) production_env.update( OIDC_CLIENT_ID="raggr", OIDC_CLIENT_SECRET="synthetic-client-secret", OIDC_REDIRECT_URI="http://localhost:8080/api/user/oidc/callback", ) with pytest.raises(ConfigurationError, match="OIDC_REDIRECT_URI"): validate_configuration(production_env) production_env["OIDC_REDIRECT_URI"] = ( "https://app.example.test/api/user/oidc/callback" ) validate_configuration(production_env) production_env["OIDC_USE_DISCOVERY"] = "false" with pytest.raises(ConfigurationError, match="OIDC_TOKEN_ENDPOINT"): validate_configuration(production_env) for name in ( "OIDC_AUTHORIZATION_ENDPOINT", "OIDC_TOKEN_ENDPOINT", "OIDC_USERINFO_ENDPOINT", "OIDC_JWKS_URI", ): production_env[name] = "https://auth.example.test/endpoint" validate_configuration(production_env) @pytest.mark.parametrize( "trigger,key,flag", [ ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_SIGNATURE_VALIDATION"), ( "MAILGUN_DOMAIN", "MAILGUN_WEBHOOK_SIGNING_KEY", "MAILGUN_SIGNATURE_VALIDATION", ), ( "SENDBLUE_API_KEY", "SENDBLUE_WEBHOOK_SECRET", "SENDBLUE_SIGNATURE_VALIDATION", ), ], ) def test_enabled_webhooks_require_signatures(production_env, trigger, key, flag): production_env[trigger] = "synthetic-test-value" with pytest.raises(ConfigurationError, match=key): validate_configuration(production_env) production_env[key] = "synthetic-signing-key" validate_configuration(production_env) production_env[flag] = "false" with pytest.raises(ConfigurationError, match=flag): validate_configuration(production_env) @pytest.mark.parametrize( "flag", [ "TWILIO_SIGNATURE_VALIDATION", "MAILGUN_SIGNATURE_VALIDATION", "SENDBLUE_SIGNATURE_VALIDATION", ], ) def test_production_never_disables_webhook_verification(production_env, flag): production_env[flag] = "false" with pytest.raises(ConfigurationError, match=flag): validate_configuration(production_env) validate_configuration(production_env, production=False) def test_sync_and_summary_requirements(production_env): production_env["OBSIDIAN_CONTINUOUS_SYNC"] = "true" with pytest.raises(ConfigurationError, match="OBSIDIAN_VAULT_ID"): validate_configuration(production_env) production_env.update( OBSIDIAN_EMAIL="test@example.test", OBSIDIAN_PASSWORD="test-password", OBSIDIAN_VAULT_ID="test-vault", ) validate_configuration(production_env) production_env.update( CHECKPOINT_DB_PATH="/data/store.db", CONVERSATION_SUMMARY_DB_PATH="/data/../data/store.db", ) with pytest.raises(ConfigurationError, match="must differ"): validate_configuration(production_env) def test_compose_forwards_runtime_settings(): text = (ROOT / "docker-compose.yml").read_text() common = text.split("services:")[0] for name in ( "METRICS_TOKEN", "TTFT_TIMEOUT_SECONDS", "LLAMA_TIMEOUT_SECONDS", "CONTEXT_WINDOW_TOKENS", "PROMPT_BUDGET_TOKENS", "RESPONSE_RESERVE_TOKENS", "CHECKPOINT_DB_PATH", "CONVERSATION_SUMMARY_DB_PATH", "OBSIDIAN_SYNC_INTERVAL", "OIDC_AUTHORIZATION_ENDPOINT", "OIDC_TOKEN_ENDPOINT", "OIDC_USERINFO_ENDPOINT", "OIDC_JWKS_URI", "EMAIL_HMAC_SECRET", "WHATSAPP_RATE_LIMIT_MAX", "WHATSAPP_RATE_LIMIT_WINDOW", "IMESSAGE_RATE_LIMIT_MAX", "IMESSAGE_RATE_LIMIT_WINDOW", ): assert f"- {name}=${{{name}" in common assert "TAVILY_API_KEY=${TAVILY_API_KEY:-${TAVILIY_API_KEY:-}}" in common assert text.count("<<: *raggr-common") == 2 @pytest.mark.parametrize( "canonical,legacy,expected", [("new-key", "old-key", "new-key"), ("", "old-key", "old-key")], ) def test_compose_interpolation_without_secrets(canonical, legacy, expected): if not shutil.which("docker"): pytest.skip("Docker Compose is unavailable") env = { "PATH": os.environ["PATH"], "HOME": os.environ.get("HOME", "/tmp"), "TAVILY_API_KEY": canonical, "TAVILIY_API_KEY": legacy, "METRICS_TOKEN": "test-metrics", "TTFT_TIMEOUT_SECONDS": "42", "CHECKPOINT_DB_PATH": "/data/custom.db", } result = subprocess.run( [ "docker", "compose", "--env-file", "/dev/null", "--profile", "blue", "--profile", "green", "config", "--format", "json", ], cwd=ROOT, env=env, capture_output=True, text=True, ) if result.returncode and "not a docker command" in result.stderr: pytest.skip("Docker Compose is unavailable") assert result.returncode == 0, result.stderr services = json.loads(result.stdout)["services"] for color in ("blue", "green"): actual = services[f"raggr-{color}"]["environment"] assert actual["TAVILY_API_KEY"] == expected assert actual["METRICS_TOKEN"] == "test-metrics" assert actual["TTFT_TIMEOUT_SECONDS"] == "42" assert actual["CONVERSATION_SUMMARY_DB_PATH"] == "/data/custom.db.summaries.db" @pytest.mark.parametrize( "validation_status,migration_status,expected", [(0, 19, 19), (23, 0, 23)] ) def test_startup_stops_before_app_on_failure( tmp_path, validation_status, migration_status, expected ): log = tmp_path / "commands" for name, body in { "python": f'echo "python $*" >> "$COMMAND_LOG"\nexit {validation_status}\n', "aerich": f'echo "aerich $*" >> "$COMMAND_LOG"\nexit {migration_status}\n', }.items(): executable = tmp_path / name executable.write_text("#!/bin/sh\n" + body) executable.chmod(0o755) result = subprocess.run( ["/bin/bash", str(ROOT / "startup.sh")], cwd=ROOT, env={"PATH": str(tmp_path), "COMMAND_LOG": str(log)}, capture_output=True, text=True, ) assert result.returncode == expected commands = log.read_text() assert "python scripts/validate_config.py" in commands assert "python app.py" not in commands assert ("aerich upgrade" in commands) == (validation_status == 0) def test_validation_cli_reports_errors_without_values(tmp_path, production_env): # A separate project tree avoids loading any real .env in the shared checkout. (tmp_path / "scripts").mkdir() (tmp_path / "config").mkdir() (tmp_path / "config" / "__init__.py").touch() shutil.copy(ROOT / "scripts" / "validate_config.py", tmp_path / "scripts") shutil.copy(ROOT / "config" / "validation.py", tmp_path / "config") command = [sys.executable, str(tmp_path / "scripts" / "validate_config.py")] env = {"PATH": os.environ["PATH"], **production_env} result = subprocess.run(command, env=env, capture_output=True, text=True) assert result.returncode == 0 assert result.stdout == "Configuration valid.\n" env["JWT_SECRET_KEY"] = "private-invalid-value" result = subprocess.run(command, env=env, capture_output=True, text=True) assert result.returncode == 1 assert "JWT_SECRET_KEY" in result.stderr assert env["JWT_SECRET_KEY"] not in result.stderr assert env["OPENAI_API_KEY"] not in result.stderr # dotenv values are validated too, but do not override exported settings. (tmp_path / ".env").write_text("JWT_SECRET_KEY=invalid-dotenv-value\n") env["JWT_SECRET_KEY"] = production_env["JWT_SECRET_KEY"] result = subprocess.run(command, env=env, capture_output=True, text=True) assert result.returncode == 0