"""Tests for the shared TokenCache utility.""" import time import pytest from fastmcp.server.auth.auth import AccessToken from fastmcp.utilities.token_cache import TokenCache def _make_token( *, token: str = "tok", client_id: str = "client-1", scopes: list[str] | None = None, expires_at: int | None = None, ) -> AccessToken: return AccessToken( token=token, client_id=client_id, scopes=scopes or [], expires_at=expires_at, ) class TestTokenCacheDisabled: """Verify behaviour when caching is turned off.""" @pytest.mark.parametrize( "ttl, max_size", [ (None, None), (0, 100), (300, 0), ], ) def test_disabled_configurations(self, ttl: int | None, max_size: int | None): cache = TokenCache(ttl_seconds=ttl, max_size=max_size) assert not cache.enabled def test_negative_ttl_raises(self): with pytest.raises(ValueError, match="cache_ttl_seconds must be non-negative"): TokenCache(ttl_seconds=-1) def test_negative_max_size_raises(self): with pytest.raises(ValueError, match="max_cache_size must be non-negative"): TokenCache(max_size=-1) def test_get_returns_miss_when_disabled(self): cache = TokenCache(ttl_seconds=0) cache.set("tok", _make_token()) hit, result = cache.get("tok") assert not hit assert result is None def test_set_is_noop_when_disabled(self): cache = TokenCache(ttl_seconds=0) cache.set("tok", _make_token()) assert len(cache._entries) == 0 class TestTokenCacheEnabled: """Core get/set behaviour with caching on.""" @pytest.fixture def cache(self) -> TokenCache: return TokenCache(ttl_seconds=300, max_size=100) def test_enabled(self, cache: TokenCache): assert cache.enabled def test_set_and_get(self, cache: TokenCache): access = _make_token(client_id="user-1") cache.set("tok-1", access) hit, result = cache.get("tok-1") assert hit assert result is not None assert result.client_id == "user-1" def test_miss_for_unknown_token(self, cache: TokenCache): hit, result = cache.get("unknown") assert not hit assert result is None def test_different_tokens_cached_separately(self, cache: TokenCache): cache.set("tok-a", _make_token(client_id="a")) cache.set("tok-b", _make_token(client_id="b")) _, a = cache.get("tok-a") _, b = cache.get("tok-b") assert a is not None and a.client_id == "a" assert b is not None and b.client_id == "b" class TestTokenCacheDefensiveCopy: """Mutating a returned token must not affect the cached value.""" def test_get_returns_deep_copy(self): cache = TokenCache(ttl_seconds=300, max_size=100) access = _make_token(client_id="orig") access.claims = {"key": "original"} cache.set("tok", access) _, first = cache.get("tok") assert first is not None first.claims["key"] = "mutated" first.scopes.append("admin") _, second = cache.get("tok") assert second is not None assert second.claims["key"] == "original" assert "admin" not in second.scopes def test_mutating_source_does_not_affect_cache(self): cache = TokenCache(ttl_seconds=300, max_size=100) access = _make_token(client_id="orig") access.claims = {"key": "original"} cache.set("tok", access) access.claims["key"] = "mutated" _, cached = cache.get("tok") assert cached is not None assert cached.claims["key"] == "original" class TestTokenCacheTTL: """Expiration and TTL behaviour.""" def test_expired_entry_is_evicted_on_get(self): cache = TokenCache(ttl_seconds=300, max_size=100) cache.set("tok", _make_token()) key = cache._hash_token("tok") cache._entries[key].expires_at = time.time() - 1 hit, result = cache.get("tok") assert not hit assert result is None assert key not in cache._entries def test_token_expires_at_caps_ttl(self): cache = TokenCache(ttl_seconds=300, max_size=100) short_exp = int(time.time()) + 30 cache.set("tok", _make_token(expires_at=short_exp)) key = cache._hash_token("tok") assert cache._entries[key].expires_at <= short_exp def test_ttl_used_when_no_token_expiry(self): cache = TokenCache(ttl_seconds=60, max_size=100) before = time.time() cache.set("tok", _make_token(expires_at=None)) after = time.time() key = cache._hash_token("tok") entry = cache._entries[key] assert before + 60 <= entry.expires_at <= after + 60 class TestTokenCacheSizeLimit: """Eviction and size-limit behaviour.""" def test_evicts_oldest_when_full(self): cache = TokenCache(ttl_seconds=300, max_size=2) cache.set("tok-0", _make_token(client_id="0")) cache.set("tok-1", _make_token(client_id="1")) cache.set("tok-2", _make_token(client_id="2")) assert len(cache._entries) == 2 hit_0, _ = cache.get("tok-0") assert not hit_0 hit_1, _ = cache.get("tok-1") hit_2, _ = cache.get("tok-2") assert hit_1 assert hit_2 def test_cleanup_expired_before_eviction(self): cache = TokenCache(ttl_seconds=300, max_size=2) cache.set("tok-0", _make_token(client_id="0")) cache.set("tok-1", _make_token(client_id="1")) key_0 = cache._hash_token("tok-0") cache._entries[key_0].expires_at = time.time() - 1 cache.set("tok-2", _make_token(client_id="2")) assert len(cache._entries) == 2 hit_1, _ = cache.get("tok-1") hit_2, _ = cache.get("tok-2") assert hit_1 assert hit_2 def test_overwrite_does_not_evict(self): """Overwriting an existing key should not evict another entry.""" cache = TokenCache(ttl_seconds=300, max_size=2) cache.set("tok-0", _make_token(client_id="0")) cache.set("tok-1", _make_token(client_id="1")) # Overwrite tok-0 — should NOT evict tok-1 cache.set("tok-0", _make_token(client_id="0-updated")) assert len(cache._entries) == 2 hit_0, result_0 = cache.get("tok-0") hit_1, _ = cache.get("tok-1") assert hit_0 assert hit_1 assert result_0 is not None assert result_0.client_id == "0-updated" class TestTokenCacheHashing: """SHA-256 key hashing.""" def test_consistent_hashing(self): assert TokenCache._hash_token("abc") == TokenCache._hash_token("abc") def test_different_tokens_different_hashes(self): assert TokenCache._hash_token("abc") != TokenCache._hash_token("xyz") def test_hash_is_64_hex_chars(self): h = TokenCache._hash_token("anything") assert len(h) == 64 int(h, 16) # must be valid hex