Files
shy3130 908b385501 feat(ext-data): 拉取接口支持 API Key 鉴权 + 扩展数据弹窗加宽可滚动
后端:
- PullConfig 新增 auth (none/bearer/header/query, 与自定义行情源
  AuthConfig 同口径); 拉取/测试/回补共用 _request_json, 鉴权注入
  只有一套口径, 用户自定义 Headers 仍优先于 UA 标识头
- Key 本体存 secrets_store (data/user_data/secrets.json, 0600),
  不落 config.json; EXT_{ID}_API_KEY 环境变量兜底; 配置了鉴权但
  未设 Key 时 fail-closed 拒绝请求
- 新增 GET/PUT /api/ext-data/{id}/api-key (GET 只返回脱敏值);
  删除配置时清理残留 Key; 历史 config.json 无 auth 字段读为 None
  行为不变; PUT /pull 不带 auth 时沿用现有鉴权

前端:
- 拉取面板新增"接口鉴权"区块: 方式下拉 + 请求头名/参数名 + Key
  密码框 (输入新 Key 覆盖, 清空保存删除), 随保存/测试/开关一起生效
- SettingsModal 加 width prop, 扩展数据设置弹窗与 EditExtDialog
  加宽至 max-w-2xl; SettingsModal 加 max-h-[88vh] + 内容区独立
  滚动, 矮视口下不再被截断
2026-09-07 22:54:05 +08:00

242 lines
8.8 KiB
Python

"""扩展数据拉取的 API Key 鉴权注入与密钥管理端点。
覆盖: PullConfig.auth 序列化兼容 (历史配置无 auth 字段)、_apply_auth 三型
注入与缺 Key fail-closed、_request_json 共用请求链 (标识头+鉴权)、
api-key GET/PUT 端点的脱敏语义、删除配置时清理残留密钥。
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from app.api.ext_data import (
ApiKeyReq,
PullConfigReq,
configure_pull,
get_pull_api_key,
set_pull_api_key,
)
from app.services import ext_pull
from app.services.ext_data import (
ExtConfig,
ExtConfigStore,
ExtField,
PullConfig,
ext_api_key_field,
get_ext_api_key,
)
def _auth_config(auth: dict | None, **pull_kwargs) -> ExtConfig:
return ExtConfig(
id="demo",
label="demo",
mode="snapshot",
fields=[ExtField("symbol"), ExtField("score", "float")],
pull=PullConfig(url="https://api.example.test/data", auth=auth, **pull_kwargs),
)
# ---------------------------------------------------------------------------
# PullConfig.auth 序列化
# ---------------------------------------------------------------------------
def test_pull_config_auth_roundtrip() -> None:
auth = {"type": "bearer", "header": "X-Api-Key", "param": "token"}
pull = PullConfig.from_dict(PullConfig(url="https://x.test", auth=auth).to_dict())
assert pull.auth == auth
def test_pull_config_without_auth_field_reads_as_none() -> None:
"""历史 config.json 没有 auth 字段 → None (不加鉴权, 行为不变)。"""
legacy = {"url": "https://x.test", "method": "GET", "headers": {}, "enabled": True}
assert PullConfig.from_dict(legacy).auth is None
# ---------------------------------------------------------------------------
# _apply_auth 注入
# ---------------------------------------------------------------------------
def _seed_key(monkeypatch, key: str) -> None:
monkeypatch.setattr("app.secrets_store.load", lambda: {ext_api_key_field("demo"): key})
monkeypatch.delenv("EXT_DEMO_API_KEY", raising=False)
def test_apply_auth_bearer_injects_header(monkeypatch) -> None:
_seed_key(monkeypatch, "sk-12345678")
headers: dict[str, str] = {}
url = ext_pull._apply_auth("demo", {"type": "bearer"}, "https://x.test/d", headers)
assert url == "https://x.test/d"
assert headers["Authorization"] == "Bearer sk-12345678"
def test_apply_auth_header_uses_custom_name(monkeypatch) -> None:
_seed_key(monkeypatch, "sk-12345678")
headers: dict[str, str] = {}
ext_pull._apply_auth("demo", {"type": "header", "header": "X-Api-Key"}, "https://x.test/d", headers)
assert headers["X-Api-Key"] == "sk-12345678"
def test_apply_auth_query_appends_param_url_encoded(monkeypatch) -> None:
_seed_key(monkeypatch, "sk+a&b=1")
headers: dict[str, str] = {}
url = ext_pull._apply_auth("demo", {"type": "query", "param": "apikey"}, "https://x.test/d?page=1", headers)
assert url == "https://x.test/d?page=1&apikey=sk%2Ba%26b%3D1"
assert headers == {}
def test_apply_auth_without_key_fails_closed(monkeypatch) -> None:
_seed_key(monkeypatch, "")
with pytest.raises(ValueError, match="未设置 API Key"):
ext_pull._apply_auth("demo", {"type": "bearer"}, "https://x.test/d", {})
def test_apply_auth_unknown_type_rejected(monkeypatch) -> None:
_seed_key(monkeypatch, "sk-12345678")
with pytest.raises(ValueError, match="未知鉴权类型"):
ext_pull._apply_auth("demo", {"type": "digest"}, "https://x.test/d", {})
# ---------------------------------------------------------------------------
# _request_json 共用请求链 (标识头 + 鉴权 + UA 不被覆盖)
# ---------------------------------------------------------------------------
class _FakeResp:
def raise_for_status(self) -> None:
pass
def json(self):
return [{"symbol": "600000.SH", "score": 1.0}]
class _FakeClient:
last: tuple | None = None
def __init__(self, timeout=None) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def request(self, method, url, **kwargs):
_FakeClient.last = (method, url, kwargs)
return _FakeResp()
def test_request_json_injects_auth_and_keeps_user_headers(monkeypatch) -> None:
_seed_key(monkeypatch, "sk-12345678")
monkeypatch.setattr(ext_pull.httpx, "AsyncClient", _FakeClient)
pull = PullConfig(
url="https://x.test/d",
headers={"User-Agent": "my-agent/1.0"},
auth={"type": "bearer"},
date_param="date",
)
data = asyncio.run(ext_pull._request_json(pull, "demo", day=__import__("datetime").date(2026, 9, 1)))
assert data == [{"symbol": "600000.SH", "score": 1.0}]
method, url, kwargs = _FakeClient.last
assert method == "GET"
assert url == "https://x.test/d?date=2026-09-01"
assert kwargs["headers"]["Authorization"] == "Bearer sk-12345678"
# 用户显式设置的 User-Agent 优先, 不被标识头覆盖
assert kwargs["headers"]["User-Agent"] == "my-agent/1.0"
# ---------------------------------------------------------------------------
# api-key 端点 + 删除清理
# ---------------------------------------------------------------------------
def _request(tmp_path) -> SimpleNamespace:
state = SimpleNamespace(repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)))
return SimpleNamespace(app=SimpleNamespace(state=state))
def _make_config(tmp_path) -> ExtConfigStore:
store = ExtConfigStore(tmp_path)
store.upsert(ExtConfig(
id="demo", label="demo", mode="snapshot",
fields=[ExtField("symbol"), ExtField("score", "float")],
))
return store
def test_api_key_set_get_and_clear(monkeypatch, tmp_path) -> None:
_make_config(tmp_path)
req = _request(tmp_path)
written: dict = {}
monkeypatch.setattr("app.secrets_store.save", lambda updates: written.update(updates) or updates)
monkeypatch.setattr("app.secrets_store.load", lambda: written)
monkeypatch.setattr(
"app.secrets_store.clear",
lambda *keys: [written.pop(k, None) for k in keys] or {},
)
result = set_pull_api_key(req, "demo", ApiKeyReq(key="sk-12345678"))
assert result["key_set"] is True
# 脱敏: 前缀4 + 掩码 + 后缀4, 不含完整明文
assert "sk-12345678" not in result["masked_key"]
assert result["masked_key"].startswith("sk-1")
assert written == {"ext_demo_api_key": "sk-12345678"}
status = get_pull_api_key(req, "demo")
assert status["key_set"] is True
assert "sk-12345678" not in status["masked_key"]
cleared = set_pull_api_key(req, "demo", ApiKeyReq(key=" "))
assert cleared["key_set"] is False
assert cleared["masked_key"] == ""
assert written == {}
def test_api_key_endpoint_unknown_config_404(tmp_path) -> None:
req = _request(tmp_path)
with pytest.raises(Exception, match="不存在"):
set_pull_api_key(req, "nope", ApiKeyReq(key="sk-x"))
def test_configure_pull_omitted_auth_preserves_existing(monkeypatch, tmp_path) -> None:
"""PUT /pull 请求不带 auth → 沿用现有鉴权; 显式 none → 关闭。"""
monkeypatch.setattr("app.api.ext_data.pull_scheduler.refresh", lambda *a, **k: None)
store = _make_config(tmp_path)
req = _request(tmp_path)
body = PullConfigReq(url="https://x.test/d", auth=None)
configure_pull(req, "demo", body)
assert store.get("demo").pull.auth is None
# 显式设置 bearer 后, 后续不带 auth 的保存不应清掉它
configure_pull(req, "demo", PullConfigReq(url="https://x.test/d", auth={"type": "bearer", "header": "X-Key"}))
configure_pull(req, "demo", PullConfigReq(url="https://x.test/d2"))
# model_dump 带全默认值 (param 对 bearer 无效但保留, from_dict 可原样读回)
assert store.get("demo").pull.auth == {"type": "bearer", "header": "X-Key", "param": "token"}
assert store.get("demo").pull.url == "https://x.test/d2"
configure_pull(req, "demo", PullConfigReq(url="https://x.test/d", auth={"type": "none"}))
assert store.get("demo").pull.auth == {"type": "none", "header": "Authorization", "param": "token"}
def test_delete_config_clears_residual_key(monkeypatch, tmp_path) -> None:
from app.api.ext_data import delete_config
_make_config(tmp_path)
req = _request(tmp_path)
monkeypatch.setattr("app.api.ext_data._refresh_views", lambda request: None)
cleared: list[str] = []
monkeypatch.setattr("app.secrets_store.clear", lambda *keys: cleared.extend(keys) or {})
assert delete_config(req, "demo") == {"status": "deleted"}
assert cleared == ["ext_demo_api_key"]
def test_get_ext_api_key_env_fallback(monkeypatch) -> None:
monkeypatch.setattr("app.secrets_store.load", lambda: {})
monkeypatch.setenv("EXT_DEMO_API_KEY", "env-key-123456")
assert get_ext_api_key("demo") == "env-key-123456"