Files
tick-stock-panel/backend/tests/test_ext_data_rows_date_guard.py
kevin9327 79eb150de2 fix(ext-data): rows 的 date 入参先校验再拼分区路径
`_read_ext_dataframe` 把 `date` 查询参数原样拼进 `timeseries/date=<值>`, 未经校验,
于是 `date=x/../../../../kline_daily` 读到的是 `ext_data/<id>/timeseries/` 之外的
`part.parquet` 并从 /rows 返回。同一文件的 /sync、/ingest、/backfill 都先
`date.fromisoformat` 再用, 只有这条读取路径没有。

拼路径前规范成 YYYY-MM-DD, 非法值按本文件既有约定返回 400。合法日期、
分区不存在、不传 date 三种情况的行为不变。
2026-09-11 00:50:33 +09:00

93 lines
2.9 KiB
Python

"""`/rows`、`/dimension-members` 的 date 入参校验 — 非法日期返回 400 而不是拼进路径。
`_read_ext_dataframe` 用 `date=<入参>` 拼出时序分区目录名。同一文件的 `/sync`、
`/ingest`、`/backfill` 都先 `date.fromisoformat` 再用, 只有这条读取路径把原始
字符串直接拼进 `Path`, 于是 `date=x/../../../../kline_daily` 读到的是配置目录
之外的 `part.parquet`。
"""
from __future__ import annotations
from pathlib import Path
import polars as pl
import pytest
from fastapi import HTTPException
from app.api.ext_data import _read_ext_dataframe
from app.services.ext_data import ExtConfig, ExtField
def _cfg() -> ExtConfig:
return ExtConfig(
id="hot",
label="人气",
mode="timeseries",
fields=[ExtField("symbol", "string"), ExtField("heat", "float")],
)
def _layout(tmp_path: Path) -> Path:
"""建出真实目录形状: data/ext_data/hot/timeseries/ 和它外面的一份 parquet。"""
data_dir = tmp_path / "data"
partition = data_dir / "ext_data" / "hot" / "timeseries" / "date=2026-09-11"
partition.mkdir(parents=True)
pl.DataFrame({"symbol": ["000001.SZ"], "heat": [1.0]}).write_parquet(
partition / "part.parquet"
)
outside = data_dir / "kline_daily"
outside.mkdir(parents=True)
pl.DataFrame({"symbol": ["不该被读到"], "heat": [9.9]}).write_parquet(
outside / "part.parquet"
)
return data_dir
def test_rows_date_does_not_leave_the_config_directory(tmp_path: Path) -> None:
data_dir = _layout(tmp_path)
with pytest.raises(HTTPException) as excinfo:
_read_ext_dataframe(_cfg(), data_dir, "x/../../../../kline_daily")
assert excinfo.value.status_code == 400
@pytest.mark.parametrize(
"bad",
["not-a-date", "2026-13-01", "2026/09/11", "../../../../kline_daily"],
)
def test_rows_rejects_a_date_that_is_not_a_date(tmp_path: Path, bad: str) -> None:
data_dir = _layout(tmp_path)
with pytest.raises(HTTPException) as excinfo:
_read_ext_dataframe(_cfg(), data_dir, bad)
assert excinfo.value.status_code == 400
def test_rows_still_reads_the_named_partition(tmp_path: Path) -> None:
data_dir = _layout(tmp_path)
df, active = _read_ext_dataframe(_cfg(), data_dir, "2026-09-11")
assert active == "2026-09-11"
assert df.get_column("symbol").to_list() == ["000001.SZ"]
def test_rows_returns_empty_for_a_valid_date_with_no_partition(tmp_path: Path) -> None:
data_dir = _layout(tmp_path)
df, active = _read_ext_dataframe(_cfg(), data_dir, "2026-01-02")
assert active == "2026-01-02"
assert df.is_empty()
def test_rows_without_a_date_still_picks_the_latest_partition(tmp_path: Path) -> None:
data_dir = _layout(tmp_path)
df, active = _read_ext_dataframe(_cfg(), data_dir, None)
assert active == "2026-09-11"
assert df.get_column("symbol").to_list() == ["000001.SZ"]