fischer-agentkit/tests/unit/llm/test_migration.py

224 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""U15 — migrate_api_keys_to_secrets 顶层函数单元测试。
测试 ``src/agentkit/llm/migration.py`` 中的 ``migrate_api_keys_to_secrets(config_path)``
读取 ``agentkit.yaml``,把每个 LLM provider 的 plaintext ``api_key`` 迁移到
``SecretsStore`` 加密存储,并把更新后的 providers 段写回 YAML。
覆盖场景:
1. Happy path — plaintext → secrets_storeYAML 写回report 标记 migrated
2. Idempotent — 已迁移配置再次调用返回 skipped不重复加密
3. Empty config — 无 providers 时返回 {}YAML 仍可读
4. Source missing — config_path 不存在时抛 FileNotFoundError函数未捕获
5. Secrets store write failure — set_secret 抛异常时 status=failedplaintext 保留(事务性)
6. Dual source re-migrate — api_key_source=dual 时重新迁移覆盖旧 encrypted 值
隔离策略:
- 用 ``tmp_path`` 隔离 YAML 文件 I/O
- patch ``agentkit.channels.secrets.SecretsStore`` 注入可控实例(避免 env 依赖)
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
from agentkit.channels.secrets import SecretsStore
from agentkit.llm.migration import migrate_api_keys_to_secrets
# ----------------------------------------------------------------------
# 测试辅助
# ----------------------------------------------------------------------
def _write_config(path: Path, providers: dict[str, dict[str, object]]) -> None:
"""写一个最小的 agentkit.yaml仅 llm.providers 段)。"""
data: dict[str, dict[str, object]] = {"llm": {"providers": providers}}
path.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def _make_real_store() -> SecretsStore:
"""构造一个真实可用的 SecretsStore注入固定 master_key不依赖 env"""
return SecretsStore(master_key=b"x" * 32)
# ----------------------------------------------------------------------
# 1. Happy path
# ----------------------------------------------------------------------
def test_happy_path_migrates_plaintext_to_secrets_store(tmp_path: Path) -> None:
"""plaintext api_key → secrets_storeYAML 写回report 标记 migrated。"""
config_path = tmp_path / "agentkit.yaml"
_write_config(
config_path,
{
"openai": {
"api_key": "sk-secret",
"base_url": "https://api.openai.com/v1",
"type": "openai",
}
},
)
with patch("agentkit.channels.secrets.SecretsStore", return_value=_make_real_store()):
report = migrate_api_keys_to_secrets(config_path)
assert report == {"openai": {"status": "migrated", "source": "secrets_store"}}
# YAML 应已更新plaintext 清空encrypted 写入source 标记
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
pconf = raw["llm"]["providers"]["openai"]
assert pconf["api_key"] == ""
assert pconf["api_key_encrypted"] is not None
assert pconf["api_key_source"] == "secrets_store"
# ----------------------------------------------------------------------
# 2. Idempotent
# ----------------------------------------------------------------------
def test_idempotent_second_call_returns_skipped(tmp_path: Path) -> None:
"""已迁移的配置再次调用 → skipped不重复加密。"""
config_path = tmp_path / "agentkit.yaml"
_write_config(
config_path,
{
"openai": {
"api_key": "sk-secret",
"base_url": "https://api.openai.com/v1",
"type": "openai",
}
},
)
with patch("agentkit.channels.secrets.SecretsStore", return_value=_make_real_store()):
first_report = migrate_api_keys_to_secrets(config_path)
second_report = migrate_api_keys_to_secrets(config_path)
assert first_report == {"openai": {"status": "migrated", "source": "secrets_store"}}
assert second_report == {"openai": {"status": "skipped", "source": "secrets_store"}}
# 第二次调用后 YAML 仍一致plaintext 仍为空)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
pconf = raw["llm"]["providers"]["openai"]
assert pconf["api_key"] == ""
assert pconf["api_key_source"] == "secrets_store"
# ----------------------------------------------------------------------
# 3. Empty config
# ----------------------------------------------------------------------
def test_empty_config_returns_empty_report(tmp_path: Path) -> None:
"""无 providers 时返回 {}YAML 仍可读且 providers 段为空。"""
config_path = tmp_path / "agentkit.yaml"
config_path.write_text("llm:\n providers: {}\n", encoding="utf-8")
with patch("agentkit.channels.secrets.SecretsStore", return_value=_make_real_store()):
report = migrate_api_keys_to_secrets(config_path)
assert report == {}
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["llm"]["providers"] == {}
# ----------------------------------------------------------------------
# 4. Source missing
# ----------------------------------------------------------------------
def test_source_missing_raises_file_not_found(tmp_path: Path) -> None:
"""config_path 不存在时抛 FileNotFoundError函数未捕获向上传播"""
missing = tmp_path / "nonexistent.yaml"
with pytest.raises(FileNotFoundError):
migrate_api_keys_to_secrets(missing)
# ----------------------------------------------------------------------
# 5. Secrets store write failure
# ----------------------------------------------------------------------
def test_secrets_store_write_failure_marks_failed_and_retains_plaintext(
tmp_path: Path,
) -> None:
"""set_secret 抛异常 → status=failedplaintext 保留(事务性,不部分迁移)。"""
config_path = tmp_path / "agentkit.yaml"
_write_config(
config_path,
{
"openai": {
"api_key": "sk-secret",
"base_url": "",
"type": "openai",
}
},
)
# mock storeset_secret 抛异常get_secret 不会被调用到)
fake_store = MagicMock(spec=SecretsStore)
fake_store.set_secret = AsyncMock(side_effect=RuntimeError("redis down"))
with patch("agentkit.channels.secrets.SecretsStore", return_value=fake_store):
report = migrate_api_keys_to_secrets(config_path)
assert report == {"openai": {"status": "failed", "source": "plaintext", "error": "redis down"}}
# plaintext 应保留未清空source 仍为 plaintext
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
pconf = raw["llm"]["providers"]["openai"]
assert pconf["api_key"] == "sk-secret"
assert pconf["api_key_source"] == "plaintext"
# set_secret 被调用一次(验证读 get_secret 未执行)
fake_store.set_secret.assert_awaited_once()
# ----------------------------------------------------------------------
# 6. Dual source re-migrate (secret overwrite path)
# ----------------------------------------------------------------------
def test_dual_source_re_migrates_and_overwrites_encrypted(tmp_path: Path) -> None:
"""api_key_source=dual已有 encrypted 但 plaintext 保留)→ 重新迁移覆盖旧值。"""
stale_encrypted = (
'{"key":"llm:provider:openai:api_key","value":"old","nonce":"n","salt":"s",'
'"key_id":"default","created_at":"","updated_at":""}'
)
config_path = tmp_path / "agentkit.yaml"
_write_config(
config_path,
{
"openai": {
"api_key": "sk-secret",
"base_url": "",
"type": "openai",
"api_key_encrypted": stale_encrypted,
"api_key_source": "dual",
}
},
)
with patch("agentkit.channels.secrets.SecretsStore", return_value=_make_real_store()):
report = migrate_api_keys_to_secrets(config_path)
assert report == {"openai": {"status": "migrated", "source": "secrets_store"}}
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
pconf = raw["llm"]["providers"]["openai"]
assert pconf["api_key"] == ""
assert pconf["api_key_source"] == "secrets_store"
# encrypted 应被新值覆盖(不等于原来的 stale 占位)
assert pconf["api_key_encrypted"] != stale_encrypted
assert pconf["api_key_encrypted"] is not None