"""U3 — SecretsStore Redis 后端单元测试。 覆盖场景: - Redis 后端 CRUD(set/get/delete/list_keys) - Redis=None 降级到内存字典 - 加密-存储-读取-解密往返(Redis 后端) - Redis 连接异常 fail-closed(get_secret 返回 None) - 多实例共享语义(两个 store 共用同一 Redis mock) 不依赖真实 Redis 实例 — 使用 AsyncMock 模拟 redis.asyncio.Redis。 """ from __future__ import annotations import base64 from unittest.mock import AsyncMock, MagicMock import pytest from agentkit.channels.secrets import KEY_SIZE, SecretsStore # ── 辅助函数 ────────────────────────────────────────────── def _make_master_key() -> bytes: """确定性测试 master key(32 字节)。""" return b"\x01" * KEY_SIZE class _FakeRedis: """极简 Redis mock — 仅模拟 SecretsStore 用到的方法。 内部用 dict 存储,支持 set/get/delete/scan_iter。 scan_iter 返回 async generator(匹配真实 Redis 接口)。 """ def __init__(self): self._data: dict[str, str] = {} async def set(self, key: str, value: str, **kwargs) -> bool: self._data[key] = value return True async def get(self, key: str) -> str | None: return self._data.get(key) async def delete(self, key: str) -> int: if key in self._data: del self._data[key] return 1 return 0 async def scan_iter(self, match: str = "*"): import fnmatch for k in self._data: if fnmatch.fnmatch(k, match): yield k def _make_failing_redis() -> AsyncMock: """构造一个所有方法都抛 ConnectionError 的 Redis mock。""" redis = AsyncMock() redis.get = AsyncMock(side_effect=ConnectionError("redis down")) redis.set = AsyncMock(side_effect=ConnectionError("redis down")) redis.delete = AsyncMock(side_effect=ConnectionError("redis down")) redis.scan_iter = MagicMock(side_effect=ConnectionError("redis down")) return redis @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """确保测试不在生产模式下运行。""" monkeypatch.delenv("AGENTKIT_ENV", raising=False) monkeypatch.delenv("AGENTKIT_MASTER_KEY", raising=False) # ── Redis 后端 CRUD ────────────────────────────────────── class TestRedisBackendCrud: """Redis 后端的 set/get/delete/list_keys 操作。""" async def test_set_and_get_secret_with_redis(self): """Redis 后端:写入后读取返回明文。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) await store.set_secret("feishu:app_id", "cli_xxx") assert await store.get_secret("feishu:app_id") == "cli_xxx" async def test_get_nonexistent_returns_none_with_redis(self): """Redis 后端:读取不存在的 key 返回 None。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) assert await store.get_secret("missing") is None async def test_set_overwrites_existing_with_redis(self): """Redis 后端:同名 key 写入覆盖旧值。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) await store.set_secret("k", "old") await store.set_secret("k", "new") assert await store.get_secret("k") == "new" async def test_delete_secret_with_redis(self): """Redis 后端:删除凭证后不可读取。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) await store.set_secret("k", "v") assert await store.delete_secret("k") is True assert await store.get_secret("k") is None async def test_delete_nonexistent_returns_false_with_redis(self): """Redis 后端:删除不存在的 key 返回 False。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) assert await store.delete_secret("missing") is False async def test_list_keys_with_redis(self): """Redis 后端:列出全部凭证键。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) await store.set_secret("feishu:a", "1") await store.set_secret("feishu:b", "2") await store.set_secret("dingtalk:c", "3") keys = await store.list_keys() assert set(keys) == {"feishu:a", "feishu:b", "dingtalk:c"} async def test_list_keys_with_prefix_with_redis(self): """Redis 后端:按前缀过滤凭证键。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) await store.set_secret("feishu:a", "1") await store.set_secret("feishu:b", "2") await store.set_secret("dingtalk:c", "3") keys = await store.list_keys(prefix="feishu:") assert set(keys) == {"feishu:a", "feishu:b"} # ── Redis=None 降级模式 ───────────────────────────────── class TestFallbackMode: """redis=None 时降级到内存字典。""" async def test_fallback_set_and_get(self): """降级模式:写入后读取返回明文。""" store = SecretsStore(master_key=_make_master_key(), redis=None) await store.set_secret("k", "v") assert await store.get_secret("k") == "v" async def test_fallback_delete(self): """降级模式:删除凭证后不可读取。""" store = SecretsStore(master_key=_make_master_key(), redis=None) await store.set_secret("k", "v") assert await store.delete_secret("k") is True assert await store.get_secret("k") is None async def test_fallback_list_keys_with_prefix(self): """降级模式:按前缀过滤。""" store = SecretsStore(master_key=_make_master_key(), redis=None) await store.set_secret("feishu:a", "1") await store.set_secret("dingtalk:b", "2") keys = await store.list_keys(prefix="feishu:") assert keys == ["feishu:a"] # ── 加密往返 ───────────────────────────────────────────── class TestRedisEncryptionRoundtrip: """Redis 后端的加密-存储-读取-解密往返。""" async def test_stored_value_is_encrypted_in_redis(self): """Redis 中存储的 value 字段为密文,非明文。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) plaintext = "plaintext-token" await store.set_secret("k", plaintext) # 直接检查 Redis 中的原始值 raw = redis._data["agentkit:secrets:k"] assert plaintext not in raw # base64 解码后的密文也不含明文 import json entry = json.loads(raw) ciphertext_bytes = base64.b64decode(entry["value"]) assert plaintext.encode() not in ciphertext_bytes async def test_roundtrip_preserves_plaintext(self): """加密-存储-读取-解密往返保持明文一致。""" redis = _FakeRedis() store = SecretsStore(master_key=_make_master_key(), redis=redis) plaintext = "feishu-app-secret-12345" await store.set_secret("key1", plaintext) assert await store.get_secret("key1") == plaintext # ── 多实例共享语义 ─────────────────────────────────────── class TestMultiInstanceSharing: """两个 store 共用同一 Redis → 多 worker 共享语义。""" async def test_two_stores_share_state_via_redis(self): """worker A set_secret → worker B get_secret 能读到(共享 Redis)。""" redis = _FakeRedis() store_a = SecretsStore(master_key=_make_master_key(), redis=redis) store_b = SecretsStore(master_key=_make_master_key(), redis=redis) await store_a.set_secret("shared:key", "value-from-a") # worker B 通过同一 Redis 能读到 worker A 写入的凭证 assert await store_b.get_secret("shared:key") == "value-from-a" async def test_delete_from_one_store_visible_to_other(self): """worker A delete → worker B get 返回 None。""" redis = _FakeRedis() store_a = SecretsStore(master_key=_make_master_key(), redis=redis) store_b = SecretsStore(master_key=_make_master_key(), redis=redis) await store_a.set_secret("k", "v") assert await store_b.delete_secret("k") is True assert await store_a.get_secret("k") is None # ── Redis 连接异常 ─────────────────────────────────────── class TestRedisConnectionFailure: """Redis 连接失败时的 fail-closed 行为。""" async def test_get_secret_raises_on_redis_failure(self): """Redis 异常时 get_secret 抛异常(fail-closed,不静默返回 None)。""" redis = _make_failing_redis() store = SecretsStore(master_key=_make_master_key(), redis=redis) with pytest.raises(ConnectionError): await store.get_secret("k") async def test_set_secret_raises_on_redis_failure(self): """Redis 异常时 set_secret 抛异常(写入失败不静默降级)。""" redis = _make_failing_redis() store = SecretsStore(master_key=_make_master_key(), redis=redis) with pytest.raises(ConnectionError): await store.set_secret("k", "v")