mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
用户反馈资金流扩展表点「手动拉取」报错、板块轮动的资金流下拉为空。 排查出三个独立缺陷: 1. 日期一致性校验用错判据 (ext_pull.py) 报错: 「接口返回的日期与请求日期不一致, 已拒绝写入该分区」。 _assert_rows_date 无条件校验响应 date 必须等于请求日期, 但该契约的 目的只是防「接口忽略 ?date= 却把当日值写进历史分区」, 仅对按日查询 接口有意义。资金流/人气等当日快照接口只有最新一个交易日的数据, 周末/节假日/数据源 T+1 时响应日期本就落后于请求日期, 被误判成 「接口忽略日期参数」而整表拒写。 修复: 新增 _assert_rows_date_if_dated, 仅在 pull.date_param 已配置 时校验; 历史回补的 fail-closed 防污染契约不变。 2. schema 端点一直抛异常走兜底 (api/ext_data.py) 「SELECT column_name, data_type FROM (DESCRIBE ...)」在 DuckDB 1.5 抛 BinderException —— 该版本输出列名是 column_type (1.4 及以前才是 data_type)。异常被吞后回退到配置字段, 那些对象只有 dtype 没有 type, 前端按 type 判断数值列的资金流下拉因此永远为空。 修复: 按实际输出列名取值, 两个版本都可用; 兜底分支键名统一为 type。 同时关掉 hive 分区推断 —— date=YYYY-MM-DD 目录会让 DuckDB 凭空多出 一个 date 列, 污染列选择器。 前端 NUMERIC_TYPES 由写死的 5 个简写改为类型族匹配, 覆盖 DOUBLE / BIGINT / DECIMAL 等 Parquet 真实类型名。 3. 分页配置保存后被静默丢弃 (api/ext_data.py) PullConfigReq 缺少 page_param / page_size_param / page_size / page_start / max_pages, 前端「拉取设置」提交这些值后 Pydantic 直接 忽略, 保存后再打开退回默认的单次请求, 且不报错。 修复: 补齐请求模型字段并透传到 PullConfig。 验证: 三处均先写复现用例 (未修复代码上 7 个用例失败), 修复后全绿; 扩展相关后端测试 202 passed; 前端 SectorRotationCard 7 passed; pnpm build 通过; 真实接口验证 —— 修复前 400「日期不一致」, 修复后 200 写入 5580 行, schema-all 返回 DOUBLE/BIGINT/VARCHAR 真实类型。 Signed-off-by: shy3130 <shy3130@qq.com>
390 lines
14 KiB
Python
390 lines
14 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", {})
|
|
|
|
|
|
def test_apply_auth_rejects_hub_admin_session_cookie(monkeypatch) -> None:
|
|
"""定时拉取不能用管理后台会话 Cookie: 它会过期, 换机器也无法沿用。"""
|
|
_seed_key(monkeypatch, "tickflow_hub_access=eyJleHAiOjE3OTI0OTAwMDB9.sig")
|
|
with pytest.raises(ValueError) as err:
|
|
ext_pull._apply_auth(
|
|
"demo", {"type": "header", "header": "Cookie"}, "https://x.test/d", {},
|
|
)
|
|
message = str(err.value)
|
|
assert "会话 Cookie" in message
|
|
assert "API Key" in message
|
|
|
|
|
|
def test_apply_auth_rejects_session_cookie_even_when_mixed(monkeypatch) -> None:
|
|
_seed_key(monkeypatch, "foo=1; tickflow_hub_access=abc.def; bar=2")
|
|
with pytest.raises(ValueError):
|
|
ext_pull._apply_auth(
|
|
"demo", {"type": "header", "header": "cookie"}, "https://x.test/d", {},
|
|
)
|
|
|
|
|
|
def test_apply_auth_allows_plain_cookie_header_value(monkeypatch) -> None:
|
|
"""非会话 Cookie 的普通 header 凭据不受影响。"""
|
|
_seed_key(monkeypatch, "sk-12345678")
|
|
headers: dict[str, str] = {}
|
|
ext_pull._apply_auth("demo", {"type": "header", "header": "Cookie"}, "https://x.test/d", headers)
|
|
assert headers["Cookie"] == "sk-12345678"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _request_json 共用请求链 (标识头 + 鉴权 + UA 不被覆盖)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeResp:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
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"
|
|
|
|
|
|
def test_request_json_surfaces_auth_rejection_with_actionable_message(monkeypatch) -> None:
|
|
"""401/403 不应只抛裸 HTTP 错误: 要提示改用服务方签发的 API Key。"""
|
|
_seed_key(monkeypatch, "sk-12345678")
|
|
|
|
class _Unauthorized(_FakeResp):
|
|
status_code = 401
|
|
text = '{"detail":"access gate required"}'
|
|
|
|
class _Client(_FakeClient):
|
|
async def request(self, method, url, **kwargs):
|
|
return _Unauthorized()
|
|
|
|
monkeypatch.setattr(ext_pull.httpx, "AsyncClient", _Client)
|
|
pull = PullConfig(url="https://x.test/d", auth={"type": "bearer"})
|
|
|
|
with pytest.raises(ValueError) as err:
|
|
asyncio.run(ext_pull._request_json(pull, "demo"))
|
|
message = str(err.value)
|
|
assert "HTTP 401" in message
|
|
assert "API Key" in message
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_configure_pull_persists_pagination_fields(monkeypatch, tmp_path) -> None:
|
|
"""PUT /pull 必须保存分页设置 (回归: PullConfigReq 缺 page_* 字段时静默丢弃)。
|
|
|
|
前端「拉取设置」会提交 page_param/page_size_param/page_size/page_start/
|
|
max_pages; 请求模型漏声明这些键时 Pydantic 直接忽略, 保存后回读退化成
|
|
单次请求且不报错。
|
|
"""
|
|
monkeypatch.setattr("app.api.ext_data.pull_scheduler.refresh", lambda *a, **k: None)
|
|
store = _make_config(tmp_path)
|
|
req = _request(tmp_path)
|
|
|
|
configure_pull(req, "demo", PullConfigReq(
|
|
url="https://x.test/d",
|
|
page_param="page",
|
|
page_size_param="size",
|
|
page_size=100,
|
|
page_start=0,
|
|
max_pages=7,
|
|
))
|
|
|
|
pull = store.get("demo").pull
|
|
assert (pull.page_param, pull.page_size_param, pull.page_size) == ("page", "size", 100)
|
|
assert (pull.page_start, pull.max_pages) == (0, 7)
|
|
|
|
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 保存阶段拦截会话 Cookie (配置/Key 两处)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_configure_pull_rejects_session_cookie_key(tmp_path, monkeypatch) -> None:
|
|
"""保存拉取配置时, 已存的会话 Cookie 凭据必须被拒绝 (不能让坏配置继续跑)。"""
|
|
from app.api import ext_data as ext_api
|
|
|
|
config = ExtConfig(
|
|
id="demo",
|
|
label="demo",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol"), ExtField("score", "float")],
|
|
pull=PullConfig(url="https://x.test/d"),
|
|
)
|
|
store = ExtConfigStore(tmp_path)
|
|
store.upsert(config, keep_strategy_cache=True)
|
|
|
|
monkeypatch.setattr(
|
|
"app.secrets_store.load",
|
|
lambda: {ext_api_key_field("demo"): "tickflow_hub_access=abc.def"},
|
|
)
|
|
monkeypatch.delenv("EXT_DEMO_API_KEY", raising=False)
|
|
|
|
monkeypatch.setattr(ext_api, "_store", lambda request: store)
|
|
request = SimpleNamespace(
|
|
app=SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)),
|
|
),
|
|
),
|
|
)
|
|
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as err:
|
|
ext_api.configure_pull(
|
|
request,
|
|
"demo",
|
|
PullConfigReq(url="https://x.test/d", auth={"type": "header", "header": "Cookie"}),
|
|
)
|
|
assert err.value.status_code == 400
|
|
assert "API Key" in err.value.detail
|
|
|
|
|
|
def test_set_pull_api_key_rejects_session_cookie(tmp_path, monkeypatch) -> None:
|
|
from fastapi import HTTPException
|
|
|
|
from app.api import ext_data as ext_api
|
|
|
|
config = ExtConfig(
|
|
id="demo",
|
|
label="demo",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol")],
|
|
pull=PullConfig(url="https://x.test/d", auth={"type": "header", "header": "Cookie"}),
|
|
)
|
|
store = ExtConfigStore(tmp_path)
|
|
store.upsert(config, keep_strategy_cache=True)
|
|
monkeypatch.setattr(ext_api, "_store", lambda request: store)
|
|
|
|
with pytest.raises(HTTPException) as err:
|
|
ext_api.set_pull_api_key(
|
|
SimpleNamespace(), "demo",
|
|
ApiKeyReq(key="tickflow_hub_access=eyJleHAiOjF9.sig"),
|
|
)
|
|
assert err.value.status_code == 400
|
|
assert "会话 Cookie" in err.value.detail
|