mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 23:44:16 +08:00
后端:
- 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] + 内容区独立
滚动, 矮视口下不再被截断
242 lines
8.8 KiB
Python
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"
|