diff --git a/.github/workflows/fuzz-smoke.yml b/.github/workflows/fuzz-smoke.yml index 7151be2..523b2c2 100644 --- a/.github/workflows/fuzz-smoke.yml +++ b/.github/workflows/fuzz-smoke.yml @@ -29,9 +29,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Rust nightly - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # nightly - with: - toolchain: nightly + run: | + rustup toolchain install nightly + rustup default nightly - name: Cache Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 15ec3a4..ad70039 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -21,11 +21,15 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Install Kani verifier + run: | + cargo install --locked kani-verifier + cargo kani setup + - name: Run Kani verification - uses: model-checking/kani-github-action@f838096619a707b0f6b2118cf435eaccfa33e51f # v1 - with: - working-directory: rust - args: --tests --no-default-features --features compression,checksum,messagepack,encryption + run: | + cd rust + cargo kani --tests --no-default-features --features compression,checksum,messagepack,encryption fuzzing: name: Extended Fuzzing (3 targets × 1h) @@ -100,9 +104,9 @@ jobs: run: uv python install 3.11 - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable + run: | + rustup toolchain install stable + rustup default stable - name: Cache Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index 23fade4..5ab9df3 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -50,10 +50,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable - components: clippy + run: | + rustup toolchain install stable --component clippy + rustup default stable - name: Cache Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -74,9 +73,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable + run: | + rustup toolchain install stable + rustup default stable - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main diff --git a/.github/workflows/security-medium.yml b/.github/workflows/security-medium.yml index 692d216..ff86601 100644 --- a/.github/workflows/security-medium.yml +++ b/.github/workflows/security-medium.yml @@ -26,9 +26,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable + run: | + rustup toolchain install stable + rustup default stable - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main diff --git a/.secrets.baseline b/.secrets.baseline index 5e4e340..c5d845c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -90,6 +90,10 @@ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 @@ -203,6 +207,15 @@ "line_number": 32 } ], + "src/cachekit/backends/provider.py": [ + { + "type": "Secret Keyword", + "filename": "src/cachekit/backends/provider.py", + "hashed_secret": "e85f23776076483714cb45e8acf32298fcc42a15", + "is_verified": false, + "line_number": 139 + } + ], "src/cachekit/cache_handler.py": [ { "type": "Secret Keyword", @@ -421,5 +434,5 @@ } ] }, - "generated_at": "2026-04-06T07:15:57Z" + "generated_at": "2026-04-06T10:31:50Z" } diff --git a/src/cachekit/backends/provider.py b/src/cachekit/backends/provider.py index 4ef8e26..85aad8a 100644 --- a/src/cachekit/backends/provider.py +++ b/src/cachekit/backends/provider.py @@ -5,6 +5,15 @@ for maximum flexibility and testing capability. """ +from __future__ import annotations + +import logging +import os + +from cachekit.config.validation import ConfigurationError + +logger = logging.getLogger(__name__) + class CacheClientProvider: """Abstract interface for Redis client providers.""" @@ -102,33 +111,158 @@ async def get_async_client(self): class DefaultBackendProvider(BackendProviderInterface): - """Default backend provider using Redis backend. - - Creates RedisBackendProvider singleton with connection pooling. - Delegates to RedisBackendProvider.get_backend() for per-request wrappers. - - For single-tenant deployments (default), sets tenant_context to "default". - For multi-tenant deployments, tenant_context must be set externally. + """Default backend provider with environment-based auto-detection. + + Resolves the cache backend from CACHEKIT_-prefixed environment variables. + Raises ConfigurationError if multiple CACHEKIT_-prefixed backend variables + are set simultaneously (ambiguous configuration). + + Priority (first match wins): + 1. CACHEKIT_API_KEY → CachekitIOBackend + 2. CACHEKIT_REDIS_URL → RedisBackend + 3. CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend + 4. CACHEKIT_FILE_CACHE_DIR → FileBackend + 5. REDIS_URL (no prefix) → RedisBackend (12-factor fallback) + 6. Nothing set → None (L1-only) + + Conflict rules: + - Multiple CACHEKIT_-prefixed backend vars → ConfigurationError + - Non-prefixed REDIS_URL never conflicts (different intent signal) + + Unchanged behavior: + - Explicit backend= parameter always takes precedence + - set_default_backend() always takes precedence """ + # Environment variables that signal a specific CACHEKIT backend + _CACHEKIT_BACKEND_VARS: dict[str, str] = { + "CACHEKIT_API_KEY": "CachekitIO", + "CACHEKIT_REDIS_URL": "Redis", + "CACHEKIT_MEMCACHED_SERVERS": "Memcached", + "CACHEKIT_FILE_CACHE_DIR": "File", + } + def __init__(self): self._provider = None + self._resolved = False def get_backend(self): - """Get per-request backend instance from singleton provider.""" - if self._provider is None: - from cachekit.backends.redis.config import RedisBackendConfig - from cachekit.backends.redis.provider import RedisBackendProvider, tenant_context + """Get backend instance via environment auto-detection. - redis_config = RedisBackendConfig.from_env() - self._provider = RedisBackendProvider(redis_url=redis_config.redis_url) + Returns: + Backend instance, or None if no backend is configured (L1-only). - # Set default tenant for single-tenant mode (if not already set) - if tenant_context.get() is None: - tenant_context.set("default") + Raises: + ConfigurationError: If multiple CACHEKIT_-prefixed backend variables are set. + """ + if not self._resolved: + self._provider = self._resolve_provider() + self._resolved = True + if self._provider is None: + return None return self._provider.get_backend() + def _resolve_provider(self): + """Auto-detect backend provider from environment variables. + + Returns: + Backend provider instance, or None for L1-only. + + Raises: + ConfigurationError: If multiple CACHEKIT_-prefixed backend variables are set. + """ + # Detect all CACHEKIT_-prefixed backend signals + detected = {var: label for var, label in self._CACHEKIT_BACKEND_VARS.items() if os.environ.get(var)} + + # Conflict: 2+ CACHEKIT_-prefixed backend vars is ambiguous + if len(detected) > 1: + vars_str = ", ".join(f"{var} ({label})" for var, label in sorted(detected.items())) + raise ConfigurationError( + f"Ambiguous backend configuration: multiple CACHEKIT_ backend variables set: {vars_str}\n\n" + "Set exactly one CACHEKIT_ backend variable, or use explicit backend= parameter." + ) + + # Single CACHEKIT_-prefixed var detected + if detected: + var = next(iter(detected)) + return self._create_provider(var) + + # Fallback: non-prefixed REDIS_URL (12-factor convention, never conflicts) + if os.environ.get("REDIS_URL"): + return self._create_redis_provider() + + # Nothing configured → L1-only + logger.debug("No backend environment variables detected — L1-only mode") + return None + + def _create_provider(self, env_var: str): + """Create the appropriate backend provider for the given env var.""" + if env_var == "CACHEKIT_API_KEY": + return self._create_cachekitio_provider() + if env_var == "CACHEKIT_REDIS_URL": + return self._create_redis_provider() + if env_var == "CACHEKIT_MEMCACHED_SERVERS": + return self._create_memcached_provider() + if env_var == "CACHEKIT_FILE_CACHE_DIR": + return self._create_file_provider() + raise ConfigurationError(f"Unknown backend env var: {env_var}") # pragma: no cover + + def _create_cachekitio_provider(self): + """Create CachekitIO backend (wraps in a simple provider).""" + from cachekit.backends.cachekitio import CachekitIOBackend + + backend = CachekitIOBackend() + return _StaticBackendProvider(backend) + + def _create_redis_provider(self): + """Create Redis backend provider with tenant context.""" + from cachekit.backends.redis.config import RedisBackendConfig + from cachekit.backends.redis.provider import RedisBackendProvider, tenant_context + + redis_config = RedisBackendConfig.from_env() + provider = RedisBackendProvider(redis_url=redis_config.redis_url) + + # Set default tenant for single-tenant mode (if not already set) + if tenant_context.get() is None: + tenant_context.set("default") + + return provider + + def _create_memcached_provider(self): + """Create Memcached backend.""" + from cachekit.backends.memcached import MemcachedBackend + from cachekit.backends.memcached.config import MemcachedBackendConfig + + config = MemcachedBackendConfig.from_env() + backend = MemcachedBackend(config) + return _StaticBackendProvider(backend) + + def _create_file_provider(self): + """Create File backend.""" + from cachekit.backends.file import FileBackend + from cachekit.backends.file.config import FileBackendConfig + + config = FileBackendConfig.from_env() + backend = FileBackend(config) + return _StaticBackendProvider(backend) + + +class _StaticBackendProvider: + """Wraps a pre-created backend instance as a provider. + + Used for backends that don't have their own provider class + (CachekitIO, Memcached, File). Unlike RedisBackendProvider which + creates per-request wrappers, these backends are stateless enough + to share a single instance. + """ + + def __init__(self, backend): + self._backend = backend + + def get_backend(self): + return self._backend + __all__ = [ "CacheClientProvider", diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index b9dd7d8..0a2eddf 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -625,6 +625,14 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 if _backend is None: _backend = get_backend_provider().get_backend() + # No backend configured → degrade to L1-only (function executes with L1 cache) + if _backend is None: + raise BackendError( + "No backend configured — running with L1 cache only. " + "Set CACHEKIT_API_KEY, CACHEKIT_REDIS_URL, or other backend env vars to enable L2.", + error_type=BackendErrorType.PERMANENT, + ) + # Setup cache handler strategy on first use with adaptive timeout handler = StandardCacheHandler( _backend, @@ -990,6 +998,10 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: ) return await func(*args, **kwargs) + # No backend configured → degrade gracefully (function runs without L2 caching) + if _backend is None: + return await func(*args, **kwargs) + # Update operation handler with the backend (sync or async) handler = StandardCacheHandler( _backend, diff --git a/tests/unit/backends/test_provider.py b/tests/unit/backends/test_provider.py index 8d7d4c7..9381754 100644 --- a/tests/unit/backends/test_provider.py +++ b/tests/unit/backends/test_provider.py @@ -5,7 +5,7 @@ - SimpleLogger all logging methods - DefaultLoggerProvider - DefaultCacheClientProvider (sync and async) -- DefaultBackendProvider with lazy initialization and tenant context +- DefaultBackendProvider with environment-based auto-detection """ from __future__ import annotations @@ -24,6 +24,7 @@ LoggerProvider, SimpleLogger, ) +from cachekit.config.validation import ConfigurationError @pytest.mark.unit @@ -252,139 +253,224 @@ async def test_get_async_client(self) -> None: @pytest.mark.unit class TestDefaultBackendProvider: - """Test DefaultBackendProvider lazy initialization and tenant context.""" + """Test DefaultBackendProvider initialization state.""" def test_init_provider_is_none(self) -> None: - """Test __init__ sets _provider to None.""" + """Test __init__ sets _provider to None and _resolved to False.""" provider = DefaultBackendProvider() assert provider._provider is None + assert provider._resolved is False + + +@pytest.mark.unit +class TestDefaultBackendProviderAutoDetect: + """Test DefaultBackendProvider environment-based auto-detection.""" + + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Remove all backend env vars before each test.""" + for var in ( + "CACHEKIT_API_KEY", + "CACHEKIT_REDIS_URL", + "CACHEKIT_MEMCACHED_SERVERS", + "CACHEKIT_FILE_CACHE_DIR", + "REDIS_URL", + ): + monkeypatch.delenv(var, raising=False) + + def test_auto_detect_cachekitio_from_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_API_KEY → CachekitIOBackend.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") - def test_get_backend_lazy_initialization(self) -> None: - """Test get_backend creates provider on first call.""" provider = DefaultBackendProvider() + with mock.patch("cachekit.backends.cachekitio.CachekitIOBackend") as mock_backend_class: + mock_backend = mock.MagicMock() + mock_backend_class.return_value = mock_backend + + result = provider.get_backend() + assert result is mock_backend + mock_backend_class.assert_called_once() + + def test_auto_detect_redis_from_cachekit_redis_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_REDIS_URL → RedisBackend via RedisBackendProvider.""" + monkeypatch.setenv("CACHEKIT_REDIS_URL", "redis://myhost:6379") + + provider = DefaultBackendProvider() with mock.patch("cachekit.backends.redis.config.RedisBackendConfig") as mock_config_class: with mock.patch("cachekit.backends.redis.provider.RedisBackendProvider") as mock_provider_class: with mock.patch("cachekit.backends.redis.provider.tenant_context") as mock_context: - mock_config_instance = mock.MagicMock() - mock_config_class.from_env.return_value = mock_config_instance - mock_config_instance.redis_url = "redis://localhost:6379" + mock_config = mock.MagicMock() + mock_config.redis_url = "redis://myhost:6379" + mock_config_class.from_env.return_value = mock_config - mock_provider_instance = mock.MagicMock() - mock_backend_instance = mock.MagicMock() - mock_provider_class.return_value = mock_provider_instance - mock_provider_instance.get_backend.return_value = mock_backend_instance + mock_backend = mock.MagicMock() + mock_provider = mock.MagicMock() + mock_provider.get_backend.return_value = mock_backend + mock_provider_class.return_value = mock_provider mock_context.get.return_value = None - backend = provider.get_backend() + result = provider.get_backend() + + assert result is mock_backend + + def test_auto_detect_memcached_from_servers(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend.""" + monkeypatch.setenv("CACHEKIT_MEMCACHED_SERVERS", '["mc1:11211"]') + + provider = DefaultBackendProvider() + with mock.patch("cachekit.backends.memcached.MemcachedBackend") as mock_backend_class: + with mock.patch("cachekit.backends.memcached.config.MemcachedBackendConfig") as mock_config_class: + mock_config = mock.MagicMock() + mock_config_class.from_env.return_value = mock_config + mock_backend = mock.MagicMock() + mock_backend_class.return_value = mock_backend + + result = provider.get_backend() + + assert result is mock_backend - assert backend is mock_backend_instance - mock_config_class.from_env.assert_called_once() - mock_provider_class.assert_called_once_with(redis_url="redis://localhost:6379") + def test_auto_detect_file_from_cache_dir(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_FILE_CACHE_DIR → FileBackend.""" + monkeypatch.setenv("CACHEKIT_FILE_CACHE_DIR", "/var/cache/test-cache") - def test_get_backend_uses_cached_provider(self) -> None: - """Test get_backend reuses provider on subsequent calls.""" provider = DefaultBackendProvider() + with mock.patch("cachekit.backends.file.FileBackend") as mock_backend_class: + with mock.patch("cachekit.backends.file.config.FileBackendConfig") as mock_config_class: + mock_config = mock.MagicMock() + mock_config_class.from_env.return_value = mock_config + mock_backend = mock.MagicMock() + mock_backend_class.return_value = mock_backend + result = provider.get_backend() + + assert result is mock_backend + + def test_fallback_redis_url_no_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """REDIS_URL (no CACHEKIT_ prefix) → RedisBackend as 12-factor fallback.""" + monkeypatch.setenv("REDIS_URL", "redis://fallback:6379") + + provider = DefaultBackendProvider() with mock.patch("cachekit.backends.redis.config.RedisBackendConfig") as mock_config_class: with mock.patch("cachekit.backends.redis.provider.RedisBackendProvider") as mock_provider_class: with mock.patch("cachekit.backends.redis.provider.tenant_context") as mock_context: - mock_config_instance = mock.MagicMock() - mock_config_class.from_env.return_value = mock_config_instance - mock_config_instance.redis_url = "redis://localhost:6379" + mock_config = mock.MagicMock() + mock_config.redis_url = "redis://fallback:6379" + mock_config_class.from_env.return_value = mock_config - mock_provider_instance = mock.MagicMock() - mock_backend_instance = mock.MagicMock() - mock_provider_class.return_value = mock_provider_instance - mock_provider_instance.get_backend.return_value = mock_backend_instance + mock_backend = mock.MagicMock() + mock_provider = mock.MagicMock() + mock_provider.get_backend.return_value = mock_backend + mock_provider_class.return_value = mock_provider mock_context.get.return_value = None - # First call - backend1 = provider.get_backend() + result = provider.get_backend() + + assert result is mock_backend + + def test_no_env_vars_returns_none(self) -> None: + """No backend env vars set → None (L1-only mode).""" + provider = DefaultBackendProvider() + + result = provider.get_backend() - # Second call - backend2 = provider.get_backend() + assert result is None - # Should use cached provider - assert backend1 is mock_backend_instance - assert backend2 is mock_backend_instance - # RedisBackendProvider should only be instantiated once - mock_provider_class.assert_called_once() + def test_conflict_api_key_and_cachekit_redis_url_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_API_KEY + CACHEKIT_REDIS_URL → ConfigurationError.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") + monkeypatch.setenv("CACHEKIT_REDIS_URL", "redis://localhost:6379") - def test_get_backend_sets_default_tenant_on_init(self) -> None: - """Test get_backend sets default tenant context if not already set.""" provider = DefaultBackendProvider() - with mock.patch("cachekit.backends.redis.config.RedisBackendConfig") as mock_config_class: - with mock.patch("cachekit.backends.redis.provider.RedisBackendProvider") as mock_provider_class: - with mock.patch("cachekit.backends.redis.provider.tenant_context") as mock_context: - mock_config_instance = mock.MagicMock() - mock_config_class.from_env.return_value = mock_config_instance - mock_config_instance.redis_url = "redis://localhost:6379" + with pytest.raises(ConfigurationError, match="Ambiguous backend configuration"): + provider.get_backend() - mock_provider_instance = mock.MagicMock() - mock_backend_instance = mock.MagicMock() - mock_provider_class.return_value = mock_provider_instance - mock_provider_instance.get_backend.return_value = mock_backend_instance + def test_conflict_api_key_and_memcached_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_API_KEY + CACHEKIT_MEMCACHED_SERVERS → ConfigurationError.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") + monkeypatch.setenv("CACHEKIT_MEMCACHED_SERVERS", '["mc1:11211"]') - # Tenant context is None initially - mock_context.get.return_value = None + provider = DefaultBackendProvider() - provider.get_backend() + with pytest.raises(ConfigurationError, match="Ambiguous backend configuration"): + provider.get_backend() - # Should set default tenant - mock_context.set.assert_called_once_with("default") + def test_conflict_redis_and_file_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_REDIS_URL + CACHEKIT_FILE_CACHE_DIR → ConfigurationError.""" + monkeypatch.setenv("CACHEKIT_REDIS_URL", "redis://localhost:6379") + monkeypatch.setenv("CACHEKIT_FILE_CACHE_DIR", "/var/cache/test-cache") - def test_get_backend_skips_tenant_setup_if_already_set(self) -> None: - """Test get_backend doesn't override existing tenant context.""" provider = DefaultBackendProvider() - with mock.patch("cachekit.backends.redis.config.RedisBackendConfig") as mock_config_class: - with mock.patch("cachekit.backends.redis.provider.RedisBackendProvider") as mock_provider_class: - with mock.patch("cachekit.backends.redis.provider.tenant_context") as mock_context: - mock_config_instance = mock.MagicMock() - mock_config_class.from_env.return_value = mock_config_instance - mock_config_instance.redis_url = "redis://localhost:6379" + with pytest.raises(ConfigurationError, match="Ambiguous backend configuration"): + provider.get_backend() - mock_provider_instance = mock.MagicMock() - mock_backend_instance = mock.MagicMock() - mock_provider_class.return_value = mock_provider_instance - mock_provider_instance.get_backend.return_value = mock_backend_instance + def test_conflict_three_backends_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Three CACHEKIT_ backend vars → ConfigurationError listing all.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") + monkeypatch.setenv("CACHEKIT_REDIS_URL", "redis://localhost:6379") + monkeypatch.setenv("CACHEKIT_MEMCACHED_SERVERS", '["mc1:11211"]') - # Tenant context is already set - mock_context.get.return_value = "existing-tenant" + provider = DefaultBackendProvider() - provider.get_backend() + with pytest.raises(ConfigurationError, match="multiple CACHEKIT_ backend variables set"): + provider.get_backend() - # Should NOT call set - mock_context.set.assert_not_called() + def test_no_conflict_api_key_with_plain_redis_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CACHEKIT_API_KEY + REDIS_URL (no prefix) → CachekitIO wins, no error.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") + monkeypatch.setenv("REDIS_URL", "redis://fallback:6379") - def test_get_backend_multiple_calls_no_tenant_override(self) -> None: - """Test that subsequent calls don't reset tenant context.""" provider = DefaultBackendProvider() + with mock.patch("cachekit.backends.cachekitio.CachekitIOBackend") as mock_backend_class: + mock_backend = mock.MagicMock() + mock_backend_class.return_value = mock_backend - with mock.patch("cachekit.backends.redis.config.RedisBackendConfig") as mock_config_class: - with mock.patch("cachekit.backends.redis.provider.RedisBackendProvider") as mock_provider_class: - with mock.patch("cachekit.backends.redis.provider.tenant_context") as mock_context: - mock_config_instance = mock.MagicMock() - mock_config_class.from_env.return_value = mock_config_instance - mock_config_instance.redis_url = "redis://localhost:6379" + # Should NOT raise — REDIS_URL (no prefix) doesn't conflict + result = provider.get_backend() - mock_provider_instance = mock.MagicMock() - mock_backend_instance = mock.MagicMock() - mock_provider_class.return_value = mock_provider_instance - mock_provider_instance.get_backend.return_value = mock_backend_instance + assert result is mock_backend - # First call - tenant is None - mock_context.get.return_value = None - provider.get_backend() + def test_provider_caches_after_first_call(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Provider resolution runs once, subsequent calls use cached result.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") - # Second call - tenant was set by first call - mock_context.get.return_value = "default" - provider.get_backend() + provider = DefaultBackendProvider() + with mock.patch("cachekit.backends.cachekitio.CachekitIOBackend") as mock_backend_class: + mock_backend = mock.MagicMock() + mock_backend_class.return_value = mock_backend + + result1 = provider.get_backend() + result2 = provider.get_backend() + + assert result1 is result2 + # CachekitIOBackend constructor called only once + mock_backend_class.assert_called_once() + + def test_none_result_cached(self) -> None: + """None result (L1-only) is cached — don't re-detect on every call.""" + provider = DefaultBackendProvider() + + result1 = provider.get_backend() + result2 = provider.get_backend() + + assert result1 is None + assert result2 is None + assert provider._resolved is True + + def test_error_message_includes_conflicting_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ConfigurationError message lists the conflicting variables and their backends.""" + monkeypatch.setenv("CACHEKIT_API_KEY", "ck_test_abc123") + monkeypatch.setenv("CACHEKIT_FILE_CACHE_DIR", "/var/cache/test") + + provider = DefaultBackendProvider() + + with pytest.raises(ConfigurationError, match="CACHEKIT_API_KEY.*CachekitIO") as exc_info: + provider.get_backend() - # Should only set once (on first call) - mock_context.set.assert_called_once_with("default") + assert "CACHEKIT_FILE_CACHE_DIR" in str(exc_info.value) + assert "File" in str(exc_info.value) diff --git a/tests/unit/test_l1_only_mode.py b/tests/unit/test_l1_only_mode.py index 08db76f..e681796 100644 --- a/tests/unit/test_l1_only_mode.py +++ b/tests/unit/test_l1_only_mode.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os import time from unittest.mock import MagicMock, patch @@ -229,17 +230,27 @@ def production_func() -> str: assert production_call_count == 1, f"@cache.production L1 miss - called {production_call_count} times" # Test @cache.secure(master_key="...", backend=None) + # validate_encryption_config() checks CACHEKIT_MASTER_KEY env var + # independently of the inline master_key param, so we must set it. secure_call_count = 0 - - @cache.secure(master_key="a" * 64, backend=None) - def secure_func() -> str: - nonlocal secure_call_count - secure_call_count += 1 - return "secure" - - assert secure_func() == "secure" - assert secure_func() == "secure" - assert secure_call_count == 1, f"@cache.secure L1 miss - called {secure_call_count} times" + old_key = os.environ.get("CACHEKIT_MASTER_KEY") + os.environ["CACHEKIT_MASTER_KEY"] = "a" * 64 + try: + + @cache.secure(master_key="a" * 64, backend=None) + def secure_func() -> str: + nonlocal secure_call_count + secure_call_count += 1 + return "secure" + + assert secure_func() == "secure" + assert secure_func() == "secure" + assert secure_call_count == 1, f"@cache.secure L1 miss - called {secure_call_count} times" + finally: + if old_key is None: + os.environ.pop("CACHEKIT_MASTER_KEY", None) + else: + os.environ["CACHEKIT_MASTER_KEY"] = old_key # Backend provider should NEVER have been called for any preset mock_provider.return_value.get_backend.assert_not_called()