132 lines
5.0 KiB
Python
132 lines
5.0 KiB
Python
# -*- coding: utf-8 -*-
|
|||
|
|
"""云端 API 推理后端 + 安全网关测试(issue #45)。
|
||
|
|
|
||
|
|
覆盖:
|
||
|
|
1. 参数化与配置资产(provider/endpoint/model/api_key_env);
|
||
|
|
2. API Key 从环境变量读取(不硬编码、health 不泄露密钥);
|
||
|
|
3. dry-run(未配置 endpoint)占位输出;
|
||
|
|
4. OpenAI 兼容调用:Bearer 认证头 + mock 响应提取;
|
||
|
|
5. 安全网关:safety_checker 拒绝 → 拦截占位、不调用上游;
|
||
|
|
6. 坏响应 → RuntimeError。
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
import _bootstrap # noqa: F401
|
||
|
|
|
||
|
|
from llm_gateway.backends import CloudApiBackend # noqa: E402
|
||
|
|
|
||
|
|
CONFIG = os.path.join(
|
||
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
|
|
"config", "cloudapi.template.yaml",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestParameterization(unittest.TestCase):
|
||
|
|
"""参数化与配置资产。"""
|
||
|
|
|
||
|
|
def test_config_asset_parses(self):
|
||
|
|
import yaml
|
||
|
|
with open(CONFIG, "r", encoding="utf-8") as fh:
|
||
|
|
cfg = yaml.safe_load(fh)["cloudapi"]
|
||
|
|
b = CloudApiBackend(**{k: v for k, v in cfg.items() if k != "provider"})
|
||
|
|
self.assertEqual(b.endpoint, "https://api.deepseek.com/v1")
|
||
|
|
self.assertEqual(b.model, "deepseek-chat")
|
||
|
|
self.assertEqual(b.api_key_env, "DEEPSEEK_API_KEY")
|
||
|
|
|
||
|
|
def test_api_key_from_env(self):
|
||
|
|
with mock.patch.dict(os.environ, {"MY_KEY": "sk-secret-123"}):
|
||
|
|
b = CloudApiBackend(endpoint="http://x/v1", api_key_env="MY_KEY")
|
||
|
|
self.assertEqual(b._api_key, "sk-secret-123")
|
||
|
|
|
||
|
|
def test_health_does_not_leak_key(self):
|
||
|
|
with mock.patch.dict(os.environ, {"K": "sk-secret-123"}):
|
||
|
|
b = CloudApiBackend(api_key_env="K")
|
||
|
|
health = b.health()
|
||
|
|
self.assertEqual(health["api_key_configured"], True)
|
||
|
|
self.assertNotIn("sk-secret", str(health))
|
||
|
|
|
||
|
|
def test_name(self):
|
||
|
|
self.assertEqual(CloudApiBackend().name, "cloud-api")
|
||
|
|
|
||
|
|
|
||
|
|
class TestDryRun(unittest.TestCase):
|
||
|
|
"""未配置 endpoint:占位输出。"""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.b = CloudApiBackend()
|
||
|
|
|
||
|
|
def test_dry_run_placeholder(self):
|
||
|
|
out = self.b.generate("海绵钛是什么", ["公开资料"])
|
||
|
|
self.assertIn("[云端API占位]", out)
|
||
|
|
self.assertIn("[来源: 公开资料]", out)
|
||
|
|
|
||
|
|
|
||
|
|
class TestOpenAICompat(unittest.TestCase):
|
||
|
|
"""OpenAI 兼容调用(Bearer 认证)。"""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
with mock.patch.dict(os.environ, {"DK": "sk-abc"}):
|
||
|
|
self.b = CloudApiBackend(
|
||
|
|
endpoint="https://api.deepseek.com/v1", api_key_env="DK")
|
||
|
|
|
||
|
|
def test_generate_with_bearer_auth(self):
|
||
|
|
fake = {"choices": [{"message": {"content": "海绵钛是钛的一种形态"}}]}
|
||
|
|
with mock.patch.object(self.b, "_post_json", return_value=fake) as post:
|
||
|
|
out = self.b.generate("海绵钛是什么", ["公开资料"])
|
||
|
|
path, payload = post.call_args[0]
|
||
|
|
self.assertEqual(path, "/v1/chat/completions")
|
||
|
|
self.assertEqual(payload["model"], "deepseek-chat")
|
||
|
|
self.assertEqual(out, "海绵钛是钛的一种形态")
|
||
|
|
|
||
|
|
def test_bearer_header_sent(self):
|
||
|
|
# 验证 _post_json 实际携带 Authorization: Bearer
|
||
|
|
opened = []
|
||
|
|
def fake_urlopen(req, timeout=None):
|
||
|
|
opened.append(req)
|
||
|
|
resp = mock.MagicMock()
|
||
|
|
resp.read.return_value = b'{"choices":[]}'
|
||
|
|
cm = mock.MagicMock()
|
||
|
|
cm.__enter__.return_value = resp
|
||
|
|
return cm
|
||
|
|
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||
|
|
self.b._post_json("/v1/chat/completions", {"model": "x"})
|
||
|
|
self.assertEqual(opened[0].get_header("Authorization"), "Bearer sk-abc")
|
||
|
|
|
||
|
|
def test_bad_response_raises(self):
|
||
|
|
with mock.patch.object(self.b, "_post_json", return_value={}):
|
||
|
|
with self.assertRaises(RuntimeError):
|
||
|
|
self.b.generate("x", [])
|
||
|
|
|
||
|
|
|
||
|
|
class TestSafetyGateway(unittest.TestCase):
|
||
|
|
"""安全网关:safety_checker 复查(fail-closed)。"""
|
||
|
|
|
||
|
|
def test_checker_reject_blocks(self):
|
||
|
|
b = CloudApiBackend(endpoint="http://x/v1",
|
||
|
|
safety_checker=lambda p: "脱敏" in p)
|
||
|
|
with mock.patch.object(b, "_post_json") as post:
|
||
|
|
out = b.generate("含敏感内容", [])
|
||
|
|
post.assert_not_called() # 不调用上游
|
||
|
|
self.assertIn("拦截", out)
|
||
|
|
|
||
|
|
def test_checker_allow_passes(self):
|
||
|
|
b = CloudApiBackend(endpoint="http://x/v1",
|
||
|
|
safety_checker=lambda p: "脱敏" in p)
|
||
|
|
fake = {"choices": [{"message": {"content": "ok"}}]}
|
||
|
|
with mock.patch.object(b, "_post_json", return_value=fake) as post:
|
||
|
|
out = b.generate("脱敏后的公开问题", [])
|
||
|
|
post.assert_called_once()
|
||
|
|
self.assertEqual(out, "ok")
|
||
|
|
|
||
|
|
def test_health_reports_safety(self):
|
||
|
|
b = CloudApiBackend(safety_checker=lambda p: True)
|
||
|
|
self.assertTrue(b.health()["safety_checker"])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|