diff --git a/.gitignore b/.gitignore index 0da33f3..65927a9 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ logs/ .cache/ tmp/ *.tmp +validate_live_report.json diff --git a/settings.py b/settings.py index 53f0a50..08d305a 100644 --- a/settings.py +++ b/settings.py @@ -184,9 +184,41 @@ class PrySettings(BaseSettings): rate_limit_per_ip: int = 60 def model_post_init(self, __context: Any) -> None: - """Load runtime overrides from the JSON config file after env parsing.""" + """Post-init: pull sensitive fields from secrets_backend, then config file.""" + self._apply_secrets_backend() self._load_config_file() + def _apply_secrets_backend(self) -> None: + """Override sensitive fields with values from secrets_backend if present. + + Priority per field: + 1. secrets_backend.get_secret(name) # gopass/env/file/auto + 2. PRY_ env var (handled by BaseSettings) + 3. field default + + Sensitive fields: api_key, *_api_key, webhook_secret, jwt_secret, + database_url, x402_wallet, x402_facilitator. We only override if + the field is currently empty (i.e. nothing was provided via env or + the field default is empty). + """ + sensitive = ( + "api_key", "openrouter_api_key", "openai_api_key", + "anthropic_api_key", "cohere_api_key", "webhook_secret", + # x402_wallet/x402_facilitator live in x402.py; jwt_secret/database_url have their own wiring, + ) + try: + from secrets_backend import get_secret + except ImportError: + return + for fname in sensitive: + current = getattr(self, fname, None) + if current: + # Already populated from env or default — respect it + continue + value = get_secret(fname) + if value: + setattr(self, fname, value) + def _load_config_file(self) -> None: path = self.config_file if not path.exists(): diff --git a/tests/conftest.py b/tests/conftest.py index 14f7f8c..47b01f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,3 +112,38 @@ def _make_lenient_logrecord() -> None: # Install the lenient LogRecord at conftest import time _make_lenient_logrecord() + + +# ── Test isolation: disable gopass + clear auth in settings ───── +# PrySettings now pulls api_key from secrets_backend (gopass on Talos has +# a real pry/api_key secret). Tests must not depend on that real secret. +# Solution: set PRY_SECRET_BACKEND=env at session scope (inherited by +# subprocess-based tests like the SSE server test) and zero the +# settings singleton at test scope. +@pytest.fixture(autouse=True, scope="session") +def _pry_disable_secrets_gopass() -> None: + import os + os.environ["PRY_SECRET_BACKEND"] = "env" + os.environ.setdefault("PRY_API_KEY", "") + os.environ.setdefault("PRY_OPENROUTER_API_KEY", "") + os.environ.setdefault("PRY_OPENAI_API_KEY", "") + os.environ.setdefault("PRY_ANTHROPIC_API_KEY", "") + os.environ.setdefault("PRY_COHERE_API_KEY", "") + + +@pytest.fixture(autouse=True) +def pry_test_auth(monkeypatch: pytest.MonkeyPatch) -> None: + import settings as _settings_mod + original = _settings_mod.settings.api_key + _settings_mod.settings.api_key = "" + yield + _settings_mod.settings.api_key = original + + +@pytest.fixture +def client() -> "fastapi.testclient.TestClient": # noqa: F821 + from fastapi.testclient import TestClient + + from api import app + + return TestClient(app) diff --git a/tests/test_secrets_backend_integration.py b/tests/test_secrets_backend_integration.py new file mode 100644 index 0000000..77a3d84 --- /dev/null +++ b/tests/test_secrets_backend_integration.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Rug Munch Media LLC +# +# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper +# Licensed under MIT. See LICENSE. +"""Verify x402 and settings use secrets_backend.get_secret() for sensitive values.""" +import os +from unittest.mock import patch + + +def test_settings_pulls_from_secrets_backend() -> None: + """PrySettings should populate sensitive fields from secrets_backend.get_secret + when env vars are not set.""" + from pydantic import PydanticUserError + + # Force a known value from secrets_backend + fake_secret = "fake-secret-from-gopass" + + def fake_get_secret(name: str, default: str = "") -> str: + if name == "openrouter_api_key": + return fake_secret + return default + + # Clear env so the test is deterministic + env = {k: v for k, v in os.environ.items() if not k.startswith("PRY_")} + env["PRY_OPENROUTER_API_KEY"] = "" # explicitly empty + + with patch.dict(os.environ, env, clear=True): + with patch("secrets_backend.get_secret", side_effect=fake_get_secret): + # Re-import fresh + import importlib + from settings import PrySettings + + s = PrySettings() + # Sensitive field was empty in env, secrets_backend provided it + assert s.openrouter_api_key == fake_secret, ( + f"Expected {fake_secret!r}, got {s.openrouter_api_key!r}" + ) + + +def test_x402_constants_come_from_secrets_backend() -> None: + """x402.py X402_WALLET and X402_FACILITATOR_URL should be settable + via secrets_backend.get_secret().""" + fake_wallet = "0xABCDEF1234567890" + fake_facilitator = "https://my-facilitator.example.com" + + def fake_get_secret(name: str, default: str = "") -> str: + if name == "x402_wallet": + return fake_wallet + if name == "x402_facilitator": + return fake_facilitator + return default + + env = {k: v for k, v in os.environ.items() if not k.startswith("PRY_X402_")} + + with patch.dict(os.environ, env, clear=True): + with patch("secrets_backend.get_secret", side_effect=fake_get_secret): + # Force re-import of x402 constants + import importlib + import x402 + + importlib.reload(x402) + assert x402.X402_WALLET == fake_wallet + assert x402.X402_FACILITATOR_URL == fake_facilitator + + +def test_settings_does_not_override_set_env() -> None: + """If the env var is already set, secrets_backend must NOT override it.""" + fake_secret = "from-gopass" + from_env = "from-env" + + def fake_get_secret(name: str, default: str = "") -> str: + if name == "openrouter_api_key": + return fake_secret + return default + + env = {k: v for k, v in os.environ.items() if k != "PRY_OPENROUTER_API_KEY"} + env["PRY_OPENROUTER_API_KEY"] = from_env + + with patch.dict(os.environ, env, clear=True): + with patch("secrets_backend.get_secret", side_effect=fake_get_secret): + from settings import PrySettings + + s = PrySettings() + # Env wins over secrets_backend + assert s.openrouter_api_key == from_env