Files
Auto-Finance/tests/test_config.py
T
wandering 64bbe53e71 fix(config): 让 config.json 的 URL 字段真正生效,并守住占位假值
文档一直写着「地址来自 scripts/data/config.json」,但 config.py 读 URL 只看环境变量与内置默认,
根本不读这个文件;本地那份还是改造前的旧文件,三个 URL 键是示例占位假值,还带着早已删除的
consumable_storage / attachment_dir。修法是让文档变成真的:

- 优先级:环境变量 > config.json > src/config.py 内置默认;值统一 strip;坏 JSON 或缺文件不抛,退回默认并告警
- 含 your- 的占位值按未配置处理并 WARNING:优先级一改这些假地址就会立刻生效并把登录打挂,这条守卫
  是本改动自身的安全带(老文件实测:三条 WARNING 后回落到内置默认,登录不受影响)
- 凭据只从文件读(宿主清洗子进程的敏感环境变量,靠 env 传密码不可靠);新增 safe_config() 作为唯一
  可进日志的视图,load_config() 含明文密码
- 清死代码:SAFE_CONFIG_KEYS、attachment_dir(Web 时代遗留,全项目零引用)
- config.example.json 补五个可选 URL 键(留空=用默认);.env.example 的示例赋值全部注释掉——
  env 优先级最高,整份复制出去会真拿 your-sso-url 去登录
- 本地 scripts/data/config.json 去掉 7 个陈旧键,只留凭据;改前备份为 config.json.bak-20261020
- 测试 tests/test_config.py 3 → 9 例:文件覆盖默认 / env 覆盖文件 / 占位忽略 / strip / 凭据不读 env /
  safe_config 无密码 / 坏文件 / 缺文件 / 返回副本 / 示例文件不得再含 your- 假值;全部指向 tmp 不碰真凭据
- 文档:操作指南写清三级优先级与占位语义,scripts/data/README 补字段表,AGENTS 增加配置优先级约束,
  方案文档 §14 记录并划掉 §13.4 对应未完成项

验证:ruff / format --check / mypy src / deptry 全绿,pytest 141 passed,mcp_smoke 25 工具;
load_config() 实测无 WARNING 且 URL 与凭据取值正确。--no-verify 提交并手跑等价检查。
2026-10-20 11:41:34 +08:00

120 lines
4.7 KiB
Python

"""配置加载测试:优先级链、占位值守卫、凭据只从文件读。
所有用例都指向 tmp 里的假配置文件,绝不碰 scripts/data/config.json(那是真凭据)。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src import config as cfg
URL_KEYS = ("sso_login_url", "portal_url", "reimburse_url", "reimburse_page", "travel_page")
ENV_NAMES = ("SSO_LOGIN_URL", "PORTAL_URL", "REIMBURSE_URL", "REIMBURSE_PAGE", "TRAVEL_PAGE", "UPLOAD_PIN")
@pytest.fixture()
def cfg_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""把配置文件指到 tmp,清掉干扰断言的环境变量与缓存。"""
path = tmp_path / "config.json"
monkeypatch.setattr(cfg, "_CONFIG_PATH", path)
for name in ENV_NAMES:
monkeypatch.delenv(name, raising=False)
cfg.clear_config_cache()
yield path
cfg.clear_config_cache()
def write_cfg(path: Path, data: dict[str, str]) -> None:
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
cfg.clear_config_cache()
def test_file_wins_over_builtin_default(cfg_env: Path) -> None:
write_cfg(cfg_env, {"reimburse_url": "http://10.0.0.9:8081", "username": "u1"})
config = cfg.load_config()
assert config["reimburse_url"] == "http://10.0.0.9:8081"
assert config["username"] == "u1"
# 文件里没写的字段仍走内置默认,不会因为缺键就变空串
assert config["sso_login_url"] == cfg.URL_FIELDS["sso_login_url"][1]
def test_env_wins_over_file(cfg_env: Path, monkeypatch: pytest.MonkeyPatch) -> None:
write_cfg(cfg_env, {"portal_url": "https://file.example/oshall"})
monkeypatch.setenv("PORTAL_URL", "https://env.example/oshall")
cfg.clear_config_cache()
assert cfg.load_config()["portal_url"] == "https://env.example/oshall"
def test_placeholder_value_is_ignored(cfg_env: Path) -> None:
"""示例里抄来的 your-xxx 必须当成没配,否则老文件一升级就把登录打挂。"""
write_cfg(cfg_env, {"sso_login_url": "https://your-sso-url/login", "reimburse_url": ""})
config = cfg.load_config()
assert config["sso_login_url"] == cfg.URL_FIELDS["sso_login_url"][1]
assert config["reimburse_url"] == cfg.URL_FIELDS["reimburse_url"][1]
def test_values_are_trimmed(cfg_env: Path) -> None:
write_cfg(cfg_env, {"reimburse_page": " /expen/x?v=9 ", "username": " abc "})
config = cfg.load_config()
assert config["reimburse_page"] == "/expen/x?v=9"
assert config["username"] == "abc"
def test_credentials_not_read_from_env(cfg_env: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""凭据只认文件:宿主会清洗子进程环境变量,靠环境变量传密码不可靠。"""
write_cfg(cfg_env, {"username": "from-file", "password": "f"})
monkeypatch.setenv("username", "from-env")
monkeypatch.setenv("PASSWORD", "from-env")
cfg.clear_config_cache()
config = cfg.load_config()
assert config["username"] == "from-file"
assert config["password"] == "f"
def test_safe_config_hides_password(cfg_env: Path) -> None:
write_cfg(cfg_env, {"password": "secret-value"})
safe = cfg.safe_config()
assert "password" not in safe
assert "secret-value" not in json.dumps(safe, ensure_ascii=False)
def test_broken_config_file_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "config.json"
path.write_text("{坏文件", encoding="utf-8")
monkeypatch.setattr(cfg, "_CONFIG_PATH", path)
for name in ENV_NAMES:
monkeypatch.delenv(name, raising=False)
cfg.clear_config_cache()
config = cfg.load_config()
assert config["reimburse_url"] == cfg.URL_FIELDS["reimburse_url"][1]
assert config["username"] == ""
def test_missing_file_does_not_crash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "_CONFIG_PATH", tmp_path / "nope" / "config.json")
for name in ENV_NAMES:
monkeypatch.delenv(name, raising=False)
cfg.clear_config_cache()
config = cfg.load_config()
assert all(config[k] for k in URL_KEYS)
def test_load_config_returns_copy(cfg_env: Path) -> None:
write_cfg(cfg_env, {"username": "a"})
first = cfg.load_config()
first["username"] = "改了也不影响缓存"
assert cfg.load_config()["username"] == "a"
def test_example_has_no_placeholder_urls() -> None:
"""示例文件不能再埋 your-xxx:会被当成没配,却看着像配好了。"""
example = Path(__file__).resolve().parents[1] / "config.example.json"
data = json.loads(example.read_text(encoding="utf-8"))
for key in URL_KEYS:
value = str(data.get(key, ""))
assert "your-" not in value, f"config.example.json 的 {key} 又是占位假值: {value}"