Files
Haocode/tests/test_config_isolation.py
T
2026-09-17 16:30:02 +08:00

260 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归
运行: python tests/test_config_isolation.py
(仓库惯例:无 pytest 依赖,独立可跑;GUI 部分自动走 offscreen
完成证据(REPAIR_BACKLOG.md P0-01):
1. 打开路径拦截器记录到的配置打开路径全部位于临时目录,真实配置路径从未被打开;
该断言只用路径字符串比较,不读取、不散列真实配置文件;
2. 临时配置读写用例通过,临时数据库之外没有数据库写入;
3. 缺失配置与损坏配置各有一个回归用例:日志含明确警告、返回安全默认值、无异常;
4. 静态扫描:源码中不存在绕过统一路径解析(core/config_paths)的运行时配置读取。
"""
import ast
import contextlib
import io
import json
import os
import sqlite3
import sys
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
sys.path.insert(0, _ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from tests._test_env import isolate, default_config # noqa: E402
_TMP = isolate("cfgiso") # 必须在 import MainWindow 之前
_REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
# ======================================================================
# 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容)
# ======================================================================
class OpenInterceptor:
def __init__(self):
self.ops = [] # (path, mode)
self._real_open = None
def __enter__(self):
self._real_open = __builtins__["open"] if isinstance(
__builtins__, dict) else __builtins__.open
import builtins
builtins.open = self._open
return self
def _open(self, file, mode="r", *args, **kwargs):
try:
p = os.fspath(file)
except TypeError:
p = None
if p is not None:
self.ops.append((os.path.abspath(str(p)), str(mode)))
return self._real_open(file, mode, *args, **kwargs)
def __exit__(self, *exc):
import builtins
builtins.open = self._real_open
return False
# ---- 断言辅助 ----
def config_ops(self):
# 含原子写的临时文件 config.json.tmp
return [(p, m) for p, m in self.ops
if os.path.basename(p).startswith("config.json")]
def real_config_opened(self):
return [op for op in self.ops
if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)]
class SqliteInterceptor:
def __init__(self):
self.paths = []
self._real_connect = None
def __enter__(self):
self._real_connect = sqlite3.connect
sqlite3.connect = self._connect
return self
def _connect(self, database, *args, **kwargs):
self.paths.append(str(database))
return self._real_connect(database, *args, **kwargs)
def __exit__(self, *exc):
sqlite3.connect = self._real_connect
return False
# ======================================================================
# 1) 静态扫描:不得存在绕过统一路径解析的配置读取
# (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外;
# 文档字符串与注释不计)
# ======================================================================
def _docstring_values(tree) -> set:
vals = set()
def _first_expr_body(body):
if body and isinstance(body[0], ast.Expr) and \
isinstance(body[0].value, ast.Constant) and \
isinstance(body[0].value.value, str):
vals.add(body[0].value.value)
_first_expr_body(tree.body)
for node in ast.walk(tree):
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
_first_expr_body(node.body)
return vals
def static_scan():
allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py"))
bad = []
scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")]
scan_files = [os.path.join(_ROOT, "main.py")]
targets = list(scan_files)
for d in scan_dirs:
for dirpath, _dirnames, filenames in os.walk(d):
if "__pycache__" in dirpath:
continue
for fn in filenames:
if fn.endswith(".py"):
targets.append(os.path.join(dirpath, fn))
for path in targets:
with open(path, "r", encoding="utf-8") as f:
src = f.read()
tree = ast.parse(src, filename=path)
docstrings = _docstring_values(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str) \
and "config.json" in node.value \
and node.value not in docstrings:
if os.path.normcase(path) != allowed:
bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}")
return bad
bad_literal = static_scan()
check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)",
not bad_literal, f"发现: {bad_literal}")
# ======================================================================
# 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常
# ======================================================================
from core.config_paths import config_path, load_config # noqa: E402
import core.llm_engine as le # noqa: E402
saved_env = os.environ.get("HAOCODE_CONFIG_FILE")
_missing = os.path.join(_TMP["base"], "no_such_config.json")
os.environ["HAOCODE_CONFIG_FILE"] = _missing
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("缺失配置:返回安全空 dict", _res == {})
check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log)
_corrupt = os.path.join(_TMP["base"], "corrupt.json")
with open(_corrupt, "w", encoding="utf-8") as f:
f.write("{ this is not valid json")
os.environ["HAOCODE_CONFIG_FILE"] = _corrupt
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("损坏配置:返回安全空 dict", _res == {})
check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log)
_notdict = os.path.join(_TMP["base"], "notdict.json")
with open(_notdict, "w", encoding="utf-8") as f:
f.write("[1, 2, 3]")
os.environ["HAOCODE_CONFIG_FILE"] = _notdict
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log)
os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置
check("环境变量恢复:config_path 回到临时目录",
os.path.normcase(config_path()) == os.path.normcase(_TMP["config"]))
# ======================================================================
# 3) 临时配置读取(llm_engine 统一入口)
# ======================================================================
_cfg_now = le._load_config()
check("llm_engine._load_config 读取临时配置(含测试 provider",
"testprov" in _cfg_now.get("providers", {}))
# ======================================================================
# 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录
# ======================================================================
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
import ui.views.bash_panel as bp # noqa: E402
app = QApplication.instance() or QApplication(sys.argv)
oi = OpenInterceptor()
si = SqliteInterceptor()
with oi, si:
win = MainWindow()
# 宽度记录写回(config.json 写路径)
bp.save_panel_width(340)
win.close()
win.deleteLater()
app.processEvents()
check("MainWindow 构造期间真实配置从未被打开",
not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}")
_cfg_ops = oi.config_ops()
check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)",
len(_cfg_ops) >= 1, f"记录: {_cfg_ops}")
_check_base = os.path.normcase(os.path.abspath(_TMP["base"]))
_outside = [op for op in _cfg_ops
if not os.path.normcase(op[0]).startswith(_check_base)]
check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}")
_write_ops = [op for op in _cfg_ops if "w" in op[1]]
check("配置写路径(bash_panel 宽度)落在临时目录",
bool(_write_ops) and not [op for op in _write_ops
if not os.path.normcase(op[0]).startswith(_check_base)],
f"写记录: {_write_ops}")
check("save_panel_width 写回可被读回", bp.load_panel_width() == 340)
_db_outside = [p for p in si.paths
if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base)
or p in (":memory:", ""))]
check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}")
check("MainWindow.config_data 来自临时配置(providers 无真实凭据)",
set(win.config_data.get("providers", {}).keys()) == {"testprov"})
# ======================================================================
# 汇总
# 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误,
# 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。
# ======================================================================
print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True)
os._exit(0 if ok else 1)