Coverage for agentos/tests/test_secrets.py: 0%
322 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 08:04 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 08:04 +0800
1"""Tests for agentos.core.secrets — SecretsManager, backends, caching, fail-open."""
3import json
4import os
5import tempfile
7import pytest
9from agentos.core.secrets import (
10 BackendUnavailableError,
11 CompositeSecretsBackend,
12 EncryptedFileBackend,
13 EnvSecretsBackend,
14 SecretNotFoundError,
15 SecretsConfig,
16 SecretsManager,
17 VaultSecretsBackend,
18 create_secrets_manager,
19)
21# ============================================================================
22# SecretsConfig
23# ============================================================================
26class TestSecretsConfig:
27 def test_defaults(self):
28 cfg = SecretsConfig()
29 assert cfg.cache_ttl == 300.0
30 assert cfg.max_cache_size == 1000
31 assert cfg.fail_open is False
32 assert cfg.allow_environment_fallback is True
34 def test_custom(self):
35 cfg = SecretsConfig(cache_ttl=60, max_cache_size=100, fail_open=True)
36 assert cfg.cache_ttl == 60
37 assert cfg.fail_open is True
40# ============================================================================
41# EnvSecretsBackend
42# ============================================================================
45class TestEnvSecretsBackend:
46 @pytest.mark.asyncio
47 async def test_get(self):
48 os.environ["TEST_SECRET_X"] = "hello"
49 be = EnvSecretsBackend(prefix="TEST_SECRET_")
50 assert await be.get("X") == "hello"
51 del os.environ["TEST_SECRET_X"]
53 @pytest.mark.asyncio
54 async def test_get_missing(self):
55 be = EnvSecretsBackend()
56 assert await be.get("NONEXISTENT") is None
58 @pytest.mark.asyncio
59 async def test_get_all(self):
60 os.environ["TS_A"] = "1"
61 os.environ["TS_B"] = "2"
62 os.environ["OTHER"] = "3"
63 be = EnvSecretsBackend(prefix="TS_")
64 result = await be.get_all("A")
65 assert result == {"A": "1"}
66 del os.environ["TS_A"]
67 del os.environ["TS_B"]
68 del os.environ["OTHER"]
70 @pytest.mark.asyncio
71 async def test_get_all_no_prefix(self):
72 os.environ["TS_X"] = "v"
73 be = EnvSecretsBackend(prefix="TS_")
74 result = await be.get_all()
75 assert "X" in result
76 assert result["X"] == "v"
77 del os.environ["TS_X"]
79 @pytest.mark.asyncio
80 async def test_health_check(self):
81 be = EnvSecretsBackend()
82 assert await be.health_check() is True
85# ============================================================================
86# EncryptedFileBackend
87# ============================================================================
90class TestEncryptedFileBackend:
91 def _make_encrypted_file(self, data: dict) -> str:
92 from cryptography.fernet import Fernet
94 key = Fernet.generate_key()
95 fernet = Fernet(key)
96 encrypted = fernet.encrypt(json.dumps(data).encode())
98 with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f:
99 f.write(encrypted)
100 self._cleanup_files = getattr(self, "_cleanup_files", [])
101 self._cleanup_files.append(f.name)
103 self._test_key = key.decode()
104 return f.name
106 def teardown_method(self):
107 for fp in getattr(self, "_cleanup_files", []):
108 try:
109 os.unlink(fp)
110 except OSError:
111 pass
113 @pytest.mark.asyncio
114 async def test_get(self):
115 path = self._make_encrypted_file({"API_KEY": "abc123"})
116 be = EncryptedFileBackend(file_path=path, encryption_key=self._test_key)
117 assert await be.get("API_KEY") == "abc123"
119 @pytest.mark.asyncio
120 async def test_get_missing(self):
121 path = self._make_encrypted_file({"API_KEY": "abc123"})
122 be = EncryptedFileBackend(file_path=path, encryption_key=self._test_key)
123 assert await be.get("MISSING") is None
125 @pytest.mark.asyncio
126 async def test_get_all(self):
127 path = self._make_encrypted_file({"A": "1", "B": "2"})
128 be = EncryptedFileBackend(file_path=path, encryption_key=self._test_key)
129 result = await be.get_all()
130 assert result == {"A": "1", "B": "2"}
132 @pytest.mark.asyncio
133 async def test_get_all_prefix(self):
134 path = self._make_encrypted_file({"DB_HOST": "x", "DB_PORT": "y", "API_KEY": "z"})
135 be = EncryptedFileBackend(file_path=path, encryption_key=self._test_key)
136 result = await be.get_all(prefix="DB_")
137 assert result == {"DB_HOST": "x", "DB_PORT": "y"}
139 @pytest.mark.asyncio
140 async def test_file_not_found(self):
141 be = EncryptedFileBackend(file_path="/nonexistent/path.enc", encryption_key="dummy")
142 assert await be.get("X") is None
144 @pytest.mark.asyncio
145 async def test_health_check_ok(self):
146 path = self._make_encrypted_file({"X": "1"})
147 be = EncryptedFileBackend(file_path=path, encryption_key=self._test_key)
148 assert await be.health_check() is True
150 @pytest.mark.asyncio
151 async def test_health_check_bad_key(self):
152 path = self._make_encrypted_file({"X": "1"})
153 from cryptography.fernet import Fernet
154 bad_key = Fernet.generate_key().decode()
155 be = EncryptedFileBackend(file_path=path, encryption_key=bad_key)
156 assert await be.health_check() is False
159# ============================================================================
160# CompositeSecretsBackend
161# ============================================================================
164class TestCompositeSecretsBackend:
165 @pytest.mark.asyncio
166 async def test_priority_order(self):
167 be1 = EnvSecretsBackend()
168 os.environ["KEY"] = "from_env"
169 be2 = EnvSecretsBackend()
170 os.environ["KEY2"] = "also_env"
172 comp = CompositeSecretsBackend([be1, be2])
173 # env backends both read from same os.environ, so this is degenerate
174 # but verifies first hit wins
175 assert await comp.get("NONEXISTENT") is None
176 del os.environ["KEY"]
177 del os.environ["KEY2"]
179 @pytest.mark.asyncio
180 async def test_get_all_merged(self):
181 # Create a mock backend for deterministic testing
182 class MockBackend(EnvSecretsBackend):
183 def __init__(self, data: dict):
184 self._data = data
186 async def get(self, key: str) -> str | None:
187 return self._data.get(key)
189 async def get_all(self, prefix: str = "") -> dict[str, str]:
190 if prefix:
191 return {k: v for k, v in self._data.items() if k.startswith(prefix)}
192 return dict(self._data)
194 be1 = MockBackend({"A": "1"})
195 be2 = MockBackend({"B": "2"})
196 comp = CompositeSecretsBackend([be1, be2])
197 result = await comp.get_all()
198 assert "A" in result
199 assert "B" in result
201 @pytest.mark.asyncio
202 async def test_health_check_any(self):
203 class DeadBackend:
204 async def health_check(self):
205 return False
207 class AliveBackend:
208 async def health_check(self):
209 return True
211 comp = CompositeSecretsBackend([DeadBackend(), AliveBackend()])
212 assert await comp.health_check() is True
214 @pytest.mark.asyncio
215 async def test_health_check_all_dead(self):
216 class DeadBackend:
217 async def health_check(self):
218 return False
220 comp = CompositeSecretsBackend([DeadBackend(), DeadBackend()])
221 assert await comp.health_check() is False
224# ============================================================================
225# SecretsManager
226# ============================================================================
229class TestSecretsManager:
230 @pytest.mark.asyncio
231 async def test_get_from_backend(self):
232 class MockBackend:
233 async def get(self, key):
234 return "val" if key == "KEY" else None
236 async def get_all(self, prefix=""):
237 return {"KEY": "val"}
239 async def health_check(self):
240 return True
242 sm = SecretsManager(MockBackend())
243 assert await sm.get("KEY") == "val"
244 assert await sm.get("MISSING") is None
246 @pytest.mark.asyncio
247 async def test_require(self):
248 class MockBackend:
249 async def get(self, key):
250 return "val" if key == "KEY" else None
252 async def get_all(self, prefix=""):
253 return {}
255 async def health_check(self):
256 return True
258 sm = SecretsManager(MockBackend())
259 assert await sm.require("KEY") == "val"
261 @pytest.mark.asyncio
262 async def test_require_raises(self):
263 class MockBackend:
264 async def get(self, key):
265 return None
267 async def get_all(self, prefix=""):
268 return {}
270 async def health_check(self):
271 return True
273 sm = SecretsManager(MockBackend())
274 with pytest.raises(SecretNotFoundError):
275 await sm.require("MISSING")
277 @pytest.mark.asyncio
278 async def test_cache_reuse(self):
279 call_count = 0
281 class CountingBackend:
282 async def get(self, key):
283 nonlocal call_count
284 call_count += 1
285 return "cached"
287 async def get_all(self, prefix=""):
288 return {}
290 async def health_check(self):
291 return True
293 sm = SecretsManager(CountingBackend(), SecretsConfig(cache_ttl=300))
294 await sm.get("KEY")
295 await sm.get("KEY")
296 assert call_count == 1
298 @pytest.mark.asyncio
299 async def test_env_fallback(self):
300 os.environ["FALLBACK_KEY"] = "from_env"
302 class EmptyBackend:
303 async def get(self, key):
304 return None
306 async def get_all(self, prefix=""):
307 return {}
309 async def health_check(self):
310 return True
312 sm = SecretsManager(EmptyBackend(), SecretsConfig(allow_environment_fallback=True))
313 assert await sm.get("FALLBACK_KEY") == "from_env"
314 del os.environ["FALLBACK_KEY"]
316 @pytest.mark.asyncio
317 async def test_no_env_fallback(self):
318 os.environ["FALLBACK_KEY"] = "from_env"
320 class EmptyBackend:
321 async def get(self, key):
322 return None
324 async def get_all(self, prefix=""):
325 return {}
327 async def health_check(self):
328 return True
330 sm = SecretsManager(EmptyBackend(), SecretsConfig(allow_environment_fallback=False))
331 assert await sm.get("FALLBACK_KEY") is None
332 del os.environ["FALLBACK_KEY"]
334 @pytest.mark.asyncio
335 async def test_fail_open(self):
336 class FailingBackend:
337 async def get(self, key):
338 raise BackendUnavailableError("down")
340 async def get_all(self, prefix=""):
341 raise BackendUnavailableError("down")
343 async def health_check(self):
344 return False
346 sm = SecretsManager(FailingBackend(), SecretsConfig(fail_open=True))
347 assert await sm.get("KEY") is None
349 @pytest.mark.asyncio
350 async def test_fail_closed(self):
351 class FailingBackend:
352 async def get(self, key):
353 raise BackendUnavailableError("down")
355 async def get_all(self, prefix=""):
356 raise BackendUnavailableError("down")
358 async def health_check(self):
359 return False
361 sm = SecretsManager(FailingBackend(), SecretsConfig(fail_open=False))
362 with pytest.raises(BackendUnavailableError):
363 await sm.get("KEY")
365 @pytest.mark.asyncio
366 async def test_get_all(self):
367 class MockBackend:
368 async def get(self, key):
369 return "x"
371 async def get_all(self, prefix=""):
372 return {"A": "1", "B": "2"}
374 async def health_check(self):
375 return True
377 sm = SecretsManager(MockBackend())
378 assert await sm.get_all() == {"A": "1", "B": "2"}
380 @pytest.mark.asyncio
381 async def test_health_check(self):
382 class MockBackend:
383 async def get(self, key):
384 return None
386 async def get_all(self, prefix=""):
387 return {}
389 async def health_check(self):
390 return True
392 sm = SecretsManager(MockBackend())
393 assert await sm.health_check() is True
395 @pytest.mark.asyncio
396 async def test_invalidate_single_key(self):
397 call_count = 0
399 class CountingBackend:
400 async def get(self, key):
401 nonlocal call_count
402 call_count += 1
403 return "val"
405 async def get_all(self, prefix=""):
406 return {}
408 async def health_check(self):
409 return True
411 sm = SecretsManager(CountingBackend())
412 await sm.get("K1")
413 await sm.get("K1")
414 assert call_count == 1
415 sm.invalidate_cache("K1")
416 await sm.get("K1")
417 assert call_count == 2
419 @pytest.mark.asyncio
420 async def test_invalidate_all(self):
421 call_count = 0
423 class CountingBackend:
424 async def get(self, key):
425 nonlocal call_count
426 call_count += 1
427 return "val"
429 async def get_all(self, prefix=""):
430 return {}
432 async def health_check(self):
433 return True
435 sm = SecretsManager(CountingBackend())
436 await sm.get("K1")
437 await sm.get("K2")
438 assert call_count == 2
439 sm.invalidate_cache()
440 await sm.get("K1")
441 await sm.get("K2")
442 assert call_count == 4
445# ============================================================================
446# create_secrets_manager factory
447# ============================================================================
450class TestCreateSecretsManager:
451 def test_env_backend(self):
452 sm = create_secrets_manager("env", prefix="P_")
453 assert isinstance(sm, SecretsManager)
455 def test_unknown_backend(self):
456 with pytest.raises(ValueError, match="Unknown backend"):
457 create_secrets_manager("magic")
460# ============================================================================
461# VaultSecretsBackend (mocked)
462# ============================================================================
465class TestVaultSecretsBackend:
466 @pytest.mark.asyncio
467 async def test_health_check_false_when_unreachable(self):
468 vb = VaultSecretsBackend(url="http://127.0.0.1:19999", token="fake")
469 assert await vb.health_check() is False