diff --git a/src/api_key_manager.py b/src/api_key_manager.py index d29ac03..b9fb625 100644 --- a/src/api_key_manager.py +++ b/src/api_key_manager.py @@ -48,8 +48,18 @@ class APIKeyManager: """Load and decrypt API keys""" if not os.path.exists(self.api_keys_file): return {} - with open(self.api_keys_file, 'r', encoding="utf-8") as f: - encrypted_keys = json.load(f) + try: + with open(self.api_keys_file, 'r', encoding="utf-8") as f: + encrypted_keys = json.load(f) + except (json.JSONDecodeError, OSError) as e: + # A corrupt/truncated api_keys.json must not crash load() (called on + # startup via app_initializer) — treat it as no stored keys. + logger.warning("Failed to read API keys file: %s", e) + return {} + if not isinstance(encrypted_keys, dict): + # Legacy/wrong shape (e.g. a list) — .items() would raise. Ignore it. + logger.warning("API keys file has unexpected shape (%s); ignoring", type(encrypted_keys).__name__) + return {} decrypted = {} for provider, key in encrypted_keys.items(): diff --git a/tests/test_api_key_manager_corrupt_load.py b/tests/test_api_key_manager_corrupt_load.py new file mode 100644 index 0000000..b9ee347 --- /dev/null +++ b/tests/test_api_key_manager_corrupt_load.py @@ -0,0 +1,32 @@ +"""Regression: APIKeyManager.load() must not crash on a corrupt/wrong-shape file. + +load() is called during startup (app_initializer). It had no try/except around +`json.load` and called `encrypted_keys.items()` directly, so a corrupt/truncated +api_keys.json raised JSONDecodeError and a legacy list-shaped file raised +AttributeError — both crashing app startup. It now returns {} instead. +""" +from src.api_key_manager import APIKeyManager + + +def _mgr(tmp_path): + return APIKeyManager(str(tmp_path)) + + +def test_corrupt_json_returns_empty(tmp_path): + (tmp_path / "api_keys.json").write_text("{not valid json", encoding="utf-8") + assert _mgr(tmp_path).load() == {} + + +def test_list_shape_returns_empty(tmp_path): + (tmp_path / "api_keys.json").write_text('["openai", "anthropic"]', encoding="utf-8") + assert _mgr(tmp_path).load() == {} + + +def test_missing_file_returns_empty(tmp_path): + assert _mgr(tmp_path).load() == {} + + +def test_valid_roundtrip(tmp_path): + mgr = _mgr(tmp_path) + mgr.save("openai", "sk-secret") + assert mgr.load() == {"openai": "sk-secret"}