mirror of https://github.com/langgenius/dify.git
251 lines
9.0 KiB
Python
251 lines
9.0 KiB
Python
"""
|
|
Unit tests for API Token Cache module.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from services.api_token_service import (
|
|
CACHE_KEY_PREFIX,
|
|
CACHE_NULL_TTL_SECONDS,
|
|
CACHE_TTL_SECONDS,
|
|
ApiTokenCache,
|
|
CachedApiToken,
|
|
)
|
|
|
|
|
|
class TestApiTokenCache:
|
|
"""Test cases for ApiTokenCache class."""
|
|
|
|
def setup_method(self):
|
|
"""Setup test fixtures."""
|
|
self.mock_token = MagicMock()
|
|
self.mock_token.id = "test-token-id-123"
|
|
self.mock_token.app_id = "test-app-id-456"
|
|
self.mock_token.tenant_id = "test-tenant-id-789"
|
|
self.mock_token.type = "app"
|
|
self.mock_token.token = "test-token-value-abc"
|
|
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
|
|
self.mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
|
|
|
def test_make_cache_key(self):
|
|
"""Test cache key generation."""
|
|
# Test with scope
|
|
key = ApiTokenCache._make_cache_key("my-token", "app")
|
|
assert key == f"{CACHE_KEY_PREFIX}:app:my-token"
|
|
|
|
# Test without scope
|
|
key = ApiTokenCache._make_cache_key("my-token", None)
|
|
assert key == f"{CACHE_KEY_PREFIX}:any:my-token"
|
|
|
|
def test_serialize_token(self):
|
|
"""Test token serialization."""
|
|
serialized = ApiTokenCache._serialize_token(self.mock_token)
|
|
data = json.loads(serialized)
|
|
|
|
assert data["id"] == "test-token-id-123"
|
|
assert data["app_id"] == "test-app-id-456"
|
|
assert data["tenant_id"] == "test-tenant-id-789"
|
|
assert data["type"] == "app"
|
|
assert data["token"] == "test-token-value-abc"
|
|
assert data["last_used_at"] == "2026-02-03T10:00:00"
|
|
assert data["created_at"] == "2026-01-01T00:00:00"
|
|
|
|
def test_serialize_token_with_nulls(self):
|
|
"""Test token serialization with None values."""
|
|
mock_token = MagicMock()
|
|
mock_token.id = "test-id"
|
|
mock_token.app_id = None
|
|
mock_token.tenant_id = None
|
|
mock_token.type = "dataset"
|
|
mock_token.token = "test-token"
|
|
mock_token.last_used_at = None
|
|
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
|
|
|
serialized = ApiTokenCache._serialize_token(mock_token)
|
|
data = json.loads(serialized)
|
|
|
|
assert data["app_id"] is None
|
|
assert data["tenant_id"] is None
|
|
assert data["last_used_at"] is None
|
|
|
|
def test_deserialize_token(self):
|
|
"""Test token deserialization."""
|
|
cached_data = json.dumps(
|
|
{
|
|
"id": "test-id",
|
|
"app_id": "test-app",
|
|
"tenant_id": "test-tenant",
|
|
"type": "app",
|
|
"token": "test-token",
|
|
"last_used_at": "2026-02-03T10:00:00",
|
|
"created_at": "2026-01-01T00:00:00",
|
|
}
|
|
)
|
|
|
|
result = ApiTokenCache._deserialize_token(cached_data)
|
|
|
|
assert isinstance(result, CachedApiToken)
|
|
assert result.id == "test-id"
|
|
assert result.app_id == "test-app"
|
|
assert result.tenant_id == "test-tenant"
|
|
assert result.type == "app"
|
|
assert result.token == "test-token"
|
|
assert result.last_used_at == datetime(2026, 2, 3, 10, 0, 0)
|
|
assert result.created_at == datetime(2026, 1, 1, 0, 0, 0)
|
|
|
|
def test_deserialize_null_token(self):
|
|
"""Test deserialization of null token (cached miss)."""
|
|
result = ApiTokenCache._deserialize_token("null")
|
|
assert result is None
|
|
|
|
def test_deserialize_invalid_json(self):
|
|
"""Test deserialization with invalid JSON."""
|
|
result = ApiTokenCache._deserialize_token("invalid-json{")
|
|
assert result is None
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_get_cache_hit(self, mock_redis):
|
|
"""Test cache hit scenario."""
|
|
cached_data = json.dumps(
|
|
{
|
|
"id": "test-id",
|
|
"app_id": "test-app",
|
|
"tenant_id": "test-tenant",
|
|
"type": "app",
|
|
"token": "test-token",
|
|
"last_used_at": "2026-02-03T10:00:00",
|
|
"created_at": "2026-01-01T00:00:00",
|
|
}
|
|
).encode("utf-8")
|
|
mock_redis.get.return_value = cached_data
|
|
|
|
result = ApiTokenCache.get("test-token", "app")
|
|
|
|
assert result is not None
|
|
assert isinstance(result, CachedApiToken)
|
|
assert result.app_id == "test-app"
|
|
mock_redis.get.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_get_cache_miss(self, mock_redis):
|
|
"""Test cache miss scenario."""
|
|
mock_redis.get.return_value = None
|
|
|
|
result = ApiTokenCache.get("test-token", "app")
|
|
|
|
assert result is None
|
|
mock_redis.get.assert_called_once()
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_set_valid_token(self, mock_redis):
|
|
"""Test setting a valid token in cache."""
|
|
result = ApiTokenCache.set("test-token", "app", self.mock_token)
|
|
|
|
assert result is True
|
|
mock_redis.setex.assert_called_once()
|
|
args = mock_redis.setex.call_args[0]
|
|
assert args[0] == f"{CACHE_KEY_PREFIX}:app:test-token"
|
|
assert args[1] == CACHE_TTL_SECONDS
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_set_null_token(self, mock_redis):
|
|
"""Test setting a null token (cache penetration prevention)."""
|
|
result = ApiTokenCache.set("invalid-token", "app", None)
|
|
|
|
assert result is True
|
|
mock_redis.setex.assert_called_once()
|
|
args = mock_redis.setex.call_args[0]
|
|
assert args[0] == f"{CACHE_KEY_PREFIX}:app:invalid-token"
|
|
assert args[1] == CACHE_NULL_TTL_SECONDS
|
|
assert args[2] == b"null"
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_delete_with_scope(self, mock_redis):
|
|
"""Test deleting token cache with specific scope."""
|
|
result = ApiTokenCache.delete("test-token", "app")
|
|
|
|
assert result is True
|
|
mock_redis.delete.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_delete_without_scope(self, mock_redis):
|
|
"""Test deleting token cache without scope (delete all)."""
|
|
# Mock scan_iter to return an iterator of keys
|
|
mock_redis.scan_iter.return_value = iter(
|
|
[
|
|
b"api_token:app:test-token",
|
|
b"api_token:dataset:test-token",
|
|
]
|
|
)
|
|
|
|
result = ApiTokenCache.delete("test-token", None)
|
|
|
|
assert result is True
|
|
# Verify scan_iter was called with the correct pattern
|
|
mock_redis.scan_iter.assert_called_once()
|
|
call_args = mock_redis.scan_iter.call_args
|
|
assert call_args[1]["match"] == f"{CACHE_KEY_PREFIX}:*:test-token"
|
|
|
|
# Verify delete was called with all matched keys
|
|
mock_redis.delete.assert_called_once_with(
|
|
b"api_token:app:test-token",
|
|
b"api_token:dataset:test-token",
|
|
)
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_redis_fallback_on_exception(self, mock_redis):
|
|
"""Test Redis fallback when Redis is unavailable."""
|
|
from redis import RedisError
|
|
|
|
mock_redis.get.side_effect = RedisError("Connection failed")
|
|
|
|
result = ApiTokenCache.get("test-token", "app")
|
|
|
|
# Should return None (fallback) instead of raising exception
|
|
assert result is None
|
|
|
|
|
|
class TestApiTokenCacheIntegration:
|
|
"""Integration test scenarios."""
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_full_cache_lifecycle(self, mock_redis):
|
|
"""Test complete cache lifecycle: set -> get -> delete."""
|
|
# Setup mock token
|
|
mock_token = MagicMock()
|
|
mock_token.id = "id-123"
|
|
mock_token.app_id = "app-456"
|
|
mock_token.tenant_id = "tenant-789"
|
|
mock_token.type = "app"
|
|
mock_token.token = "token-abc"
|
|
mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
|
|
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
|
|
|
# 1. Set token in cache
|
|
ApiTokenCache.set("token-abc", "app", mock_token)
|
|
assert mock_redis.setex.called
|
|
|
|
# 2. Simulate cache hit
|
|
cached_data = ApiTokenCache._serialize_token(mock_token)
|
|
mock_redis.get.return_value = cached_data # bytes from model_dump_json().encode()
|
|
|
|
retrieved = ApiTokenCache.get("token-abc", "app")
|
|
assert retrieved is not None
|
|
assert isinstance(retrieved, CachedApiToken)
|
|
|
|
# 3. Delete from cache
|
|
ApiTokenCache.delete("token-abc", "app")
|
|
assert mock_redis.delete.called
|
|
|
|
@patch("services.api_token_service.redis_client")
|
|
def test_cache_penetration_prevention(self, mock_redis):
|
|
"""Test that non-existent tokens are cached as null."""
|
|
# Set null token (cache miss)
|
|
ApiTokenCache.set("non-existent-token", "app", None)
|
|
|
|
args = mock_redis.setex.call_args[0]
|
|
assert args[2] == b"null"
|
|
assert args[1] == CACHE_NULL_TTL_SECONDS # Shorter TTL for null values
|