Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/cachekit/decorators/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,17 @@ def decorator(f: F) -> F:
elif _intent == "production": # Renamed from "safe"
resolved_config = DecoratorConfig.production(backend=backend, **manual_overrides)
elif _intent == "secure":
# Extract master_key from manual_overrides (required for secure preset)
# Extract master_key from manual_overrides, fall back to env var via settings
master_key = manual_overrides.pop("master_key", None)
tenant_extractor = manual_overrides.pop("tenant_extractor", None) or None
if not master_key:
raise ValueError("cache.secure requires master_key parameter")
from cachekit.config.singleton import get_settings

settings_key = get_settings().master_key
if settings_key:
master_key = settings_key.get_secret_value()
if not master_key:
raise ValueError("cache.secure requires master_key parameter or CACHEKIT_MASTER_KEY environment variable")
resolved_config = DecoratorConfig.secure(
master_key=master_key, tenant_extractor=tenant_extractor, backend=backend, **manual_overrides
)
Expand Down
12 changes: 9 additions & 3 deletions tests/critical/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

def pytest_runtest_setup(item):
"""Skip redis setup for file backend and cachekitio metrics tests."""
if "file_backend" in item.nodeid or "cachekitio_metrics" in item.nodeid or "memcached_backend" in item.nodeid:
# Remove the autouse redis isolation fixture for this test
item.fixturenames = [f for f in item.fixturenames if f != "setup_di_for_redis_isolation"]
skip_redis = (
"file_backend" in item.nodeid
or "cachekitio_metrics" in item.nodeid
or "memcached_backend" in item.nodeid
or "secure_env_fallback" in item.nodeid
)
if skip_redis:
# Remove autouse redis fixtures for tests that don't need Redis
item.fixturenames = [f for f in item.fixturenames if f not in ("setup_di_for_redis_isolation", "setup_redis_env")]
66 changes: 66 additions & 0 deletions tests/critical/test_secure_env_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
CRITICAL PATH TEST: cache.secure env var fallback (issue #69)

Verifies cache.secure resolves CACHEKIT_MASTER_KEY from the environment
when master_key is not passed explicitly. No Redis required.
"""

from __future__ import annotations

import pytest

from cachekit.config.singleton import reset_settings
from cachekit.decorators import cache

pytestmark = [pytest.mark.critical]


class TestSecureEnvFallback:
"""Critical: cache.secure must resolve master_key from env var."""

def test_secure_reads_master_key_from_env(self, monkeypatch):
"""@cache.secure(ttl=300) works when CACHEKIT_MASTER_KEY is set."""
test_key = "ab" * 32 # 64 hex chars = 32 bytes
monkeypatch.setenv("CACHEKIT_MASTER_KEY", test_key)
reset_settings()

try:

@cache.secure(backend=None, ttl=300)
def secure_from_env(x: int) -> int:
return x * 2

assert secure_from_env is not None
finally:
reset_settings()

def test_secure_raises_without_key_or_env(self, monkeypatch):
"""@cache.secure raises ValueError when no key available anywhere."""
monkeypatch.delenv("CACHEKIT_MASTER_KEY", raising=False)
reset_settings()

try:
with pytest.raises(ValueError, match="CACHEKIT_MASTER_KEY"):

@cache.secure(backend=None)
def secure_no_key():
pass
finally:
reset_settings()

def test_explicit_master_key_takes_precedence(self, monkeypatch):
"""Explicit master_key param is used even when env var is set."""
monkeypatch.setenv("CACHEKIT_MASTER_KEY", "ff" * 32)
reset_settings()

explicit_key = "aa" * 32

try:

@cache.secure(master_key=explicit_key, backend=None, ttl=60)
def secure_explicit(x: int) -> int:
return x

assert secure_explicit is not None
finally:
reset_settings()
24 changes: 16 additions & 8 deletions tests/docs/test_decorator_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,19 @@ def test_func():
result = test_func()
assert result == "test", f"Preset {preset} broke function execution"

def test_secure_preset_requires_master_key(self):
"""@cache.secure requires master_key parameter."""
# Verify secure preset exists but requires master_key
with pytest.raises(ValueError, match="master_key"):

@cache.secure
def test_func():
return "test"
def test_secure_preset_requires_master_key(self, monkeypatch):
"""@cache.secure requires master_key parameter or env var."""
from cachekit.config.singleton import reset_settings

monkeypatch.delenv("CACHEKIT_MASTER_KEY", raising=False)
reset_settings()

try:
# Verify secure preset exists but requires master_key
with pytest.raises(ValueError, match="master_key"):

@cache.secure
def test_func():
return "test"
finally:
reset_settings()
39 changes: 33 additions & 6 deletions tests/test_decorator_intent_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,40 @@ def test_secure_config_monitoring_enabled(self):
assert config.monitoring.collect_stats is True
assert config.monitoring.enable_tracing is True

def test_secure_decorator_requires_master_key(self):
"""@cache.secure decorator requires master_key parameter."""
with pytest.raises(ValueError, match="master_key"):
def test_secure_decorator_requires_master_key(self, monkeypatch):
"""@cache.secure raises when no master_key param AND no env var."""
from cachekit.config.singleton import reset_settings

@cache.secure(backend=None) # Missing master_key in secure context
def secure_func():
pass
monkeypatch.delenv("CACHEKIT_MASTER_KEY", raising=False)
reset_settings()

try:
with pytest.raises(ValueError, match="master_key"):

@cache.secure(backend=None) # No master_key param, no env var
def secure_func():
pass
finally:
reset_settings()

def test_secure_decorator_reads_master_key_from_env(self, monkeypatch):
"""@cache.secure falls back to CACHEKIT_MASTER_KEY env var (issue #69)."""
from cachekit.config.singleton import reset_settings

test_key = "ab" * 32 # 64 hex chars = 32 bytes
monkeypatch.setenv("CACHEKIT_MASTER_KEY", test_key)
reset_settings() # Force re-read from env

try:

@cache.secure(backend=None, ttl=300)
def secure_from_env(x: int) -> int:
return x * 2

# Should NOT raise - master_key resolved from env
assert secure_from_env is not None
finally:
reset_settings()

def test_secure_decorator_with_tenant_extractor(self):
"""Secure preset supports multi-tenant extraction."""
Expand Down
Loading