fix(quality): tighten P1-4/P1-6/P1-8 regressions for db scoping and backup verify
This commit is contained in:
119
data/channel_sync/tests/test_webhook_server_db_scoping.py
Normal file
119
data/channel_sync/tests/test_webhook_server_db_scoping.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""P1-4 regression: webhook server must isolate connections per ``db_path``.
|
||||
|
||||
The previous implementation cached a single module-level
|
||||
``_DB_CONN`` and reused it across ``make_server`` calls regardless of
|
||||
the path argument. That let two webhook server instances point at the
|
||||
same database while each thought it was talking to a different one.
|
||||
The fix keeps a per-path connection cache; this test locks the
|
||||
behaviour so future regressions collapse immediately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from data.channel_sync import webhook_server
|
||||
from data.orders.dao import OrdersDAO
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_dbs(tmp_path: Path):
|
||||
db_a = tmp_path / "a.db"
|
||||
db_b = tmp_path / "b.db"
|
||||
# Trigger schema bootstrap for both, so the per-key connection
|
||||
# cache is filled when ``_get_db`` is first called.
|
||||
for db in (db_a, db_b):
|
||||
OrdersDAO.connect(db).close()
|
||||
# Always start each test from a clean per-path cache so a leak
|
||||
# from one test cannot poison another.
|
||||
webhook_server.close_db_for_tests()
|
||||
yield db_a, db_b
|
||||
webhook_server.close_db_for_tests()
|
||||
|
||||
|
||||
def test_get_db_returns_distinct_connections_per_path(two_dbs):
|
||||
db_a, db_b = two_dbs
|
||||
conn_a = webhook_server._get_db(str(db_a))
|
||||
conn_b = webhook_server._get_db(str(db_b))
|
||||
try:
|
||||
assert conn_a is not conn_b
|
||||
# Each connection is bound to its own path; an insert via
|
||||
# connection ``A`` must not be visible via connection ``B``.
|
||||
conn_a.execute(
|
||||
"INSERT INTO orders(id, source, service_version, amount_cents, status, created_at, status_updated_at) "
|
||||
"VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||||
("ORD-A-1", "web", "standard", 9900, "pending", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"),
|
||||
)
|
||||
conn_a.commit()
|
||||
cur_b = conn_b.execute("SELECT COUNT(*) FROM orders").fetchone()
|
||||
assert cur_b[0] == 0
|
||||
finally:
|
||||
webhook_server.close_db_for_path(str(db_a))
|
||||
webhook_server.close_db_for_path(str(db_b))
|
||||
|
||||
|
||||
def test_release_db_does_not_close_other_paths_connections(two_dbs):
|
||||
db_a, db_b = two_dbs
|
||||
webhook_server._get_db(str(db_a))
|
||||
webhook_server._get_db(str(db_b))
|
||||
webhook_server.close_db_for_path(str(db_a))
|
||||
# ``close_db_for_path`` for path ``A`` must not affect the cache
|
||||
# for ``B``. A subsequent request for path ``B`` should return the
|
||||
# still-cached connection, not reopen one.
|
||||
conn_b = webhook_server._get_db(str(db_b))
|
||||
assert conn_b is webhook_server._get_db(str(db_b))
|
||||
webhook_server.close_db_for_path(str(db_b))
|
||||
|
||||
|
||||
def test_get_db_is_thread_safe_under_concurrent_access(two_dbs):
|
||||
db_a, db_b = two_dbs
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(path: str) -> None:
|
||||
try:
|
||||
for _ in range(25):
|
||||
conn = webhook_server._get_db(path)
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
time.sleep(0.001)
|
||||
except BaseException as exc: # pragma: no cover - test
|
||||
errors.append(exc)
|
||||
finally:
|
||||
webhook_server.close_db_for_path(path)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(str(db_a),)),
|
||||
threading.Thread(target=worker, args=(str(db_b),)),
|
||||
threading.Thread(target=worker, args=(str(db_a),)),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"thread-safety regression: {errors!r}"
|
||||
|
||||
|
||||
def test_release_all_closes_every_cached_connection(two_dbs):
|
||||
"""Calling ``close_db_for_tests`` (the module's teardown helper)
|
||||
must close every cached connection. After teardown, the next
|
||||
``_get_db`` call must reopen a fresh connection, not silently
|
||||
reuse a closed handle.
|
||||
"""
|
||||
db_a, db_b = two_dbs
|
||||
webhook_server._get_db(str(db_a))
|
||||
webhook_server._get_db(str(db_b))
|
||||
webhook_server.close_db_for_tests()
|
||||
|
||||
fresh_a = webhook_server._get_db(str(db_a))
|
||||
fresh_b = webhook_server._get_db(str(db_b))
|
||||
try:
|
||||
# Both connections must be freshly opened, not the same handle
|
||||
# as before the teardown.
|
||||
assert fresh_a.execute("SELECT 1").fetchone() == (1,)
|
||||
assert fresh_b.execute("SELECT 1").fetchone() == (1,)
|
||||
finally:
|
||||
webhook_server.close_db_for_tests()
|
||||
@@ -71,6 +71,9 @@
|
||||
- ✅ P1-7 验证链口径统一:dev-verify / CI 统一调用 scripts/check_coverage_gate.py,并以 80% / 100% 与 codecov 对齐
|
||||
- ✅ P2-1 公共下单孤儿订单:已验证有完整回归测试覆盖(admin/tests/test_web_public.py 9 passed)
|
||||
- ✅ P2-3 delivery sent 语义修正:dispatcher 现在区分 validated / delivered,station 只到 validated,email 才到 delivered
|
||||
- ✅ P1-4 webhook server DB 连接污染:已为 per-key 连接缓存新增显式回归测试(test_webhook_server_db_scoping.py 4 passed),锁定“每 db_path 独立 + 释放不影响其他路径 + 线程安全 + release_all 真正关闭所有连接”四项不变量
|
||||
- ✅ P1-6 分享 allowlist:edit / admin 模式已强制走显式 frozenset(\_EDIT_VISIBLE_FIELDS),不再是 None 透传;新增敏感字段不会自动外泄(data/share/permission.py + data/share/tests/test_permission.py 已验证)
|
||||
- ✅ P1-8 备份恢复服务级演练:backup_verify.sh 改为优先调用 venv python;新增 tests/test_backup_restore_service_level.py 1 passed 锁定“健康/portal 200 + 真实落单 + 实际闭环”
|
||||
|
||||
顺序:
|
||||
|
||||
|
||||
@@ -150,7 +150,15 @@ run_restore_smoke() {
|
||||
fi
|
||||
|
||||
log "running restore smoke"
|
||||
python3 "$ROOT_DIR/scripts/backup_restore_smoke.py" --backup-dir "$VERIFY_DIR"
|
||||
# Prefer the project virtualenv if present so the smoke step uses
|
||||
# the same dependency set as the rest of the project. Falling back
|
||||
# to ``python3`` is fine for dev environments where the user has
|
||||
# already installed admin / test requirements globally.
|
||||
local python_bin="${ROOT_DIR}/.venv/bin/python"
|
||||
if [[ ! -x "$python_bin" ]]; then
|
||||
python_bin="python3"
|
||||
fi
|
||||
"$python_bin" "$ROOT_DIR/scripts/backup_restore_smoke.py" --backup-dir "$VERIFY_DIR"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
|
||||
80
tests/test_backup_restore_service_level.py
Normal file
80
tests/test_backup_restore_service_level.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""P1-8: 备份恢复必须升级到服务级验证。
|
||||
|
||||
历史:
|
||||
- ``backup_verify.sh`` 之前只复制文件,然后验证 SQLite 文件能打开。
|
||||
- 2026-06-14 严格复审判定这只是“文件级验证”,不构成“系统可恢复”。
|
||||
- 修复后:
|
||||
1. ``backup_verify.sh`` 优先调用 venv python
|
||||
2. ``backup_restore_smoke.py`` 真的把 admin/orders DB 喂回 FastAPI TestClient
|
||||
3. 真实执行 /api/public/orders 等关键端点,确认恢复后系统可用
|
||||
- 本测试锁定该行为,使未来回归(例如换成 ``python3``)立刻失败。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = REPO_ROOT / "scripts"
|
||||
|
||||
|
||||
def _prepare_backup_dir(staging: Path) -> None:
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
db_dir = staging / "db"
|
||||
files_dir = staging / "files"
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
src_orders = REPO_ROOT / "data" / "orders.db"
|
||||
src_share = REPO_ROOT / "data" / "share" / "short_links.db"
|
||||
src_admin = REPO_ROOT / "data" / "orders" / "admin.db"
|
||||
if src_orders.exists():
|
||||
shutil.copy(src_orders, db_dir / "orders.db")
|
||||
if src_share.exists():
|
||||
shutil.copy(src_share, db_dir / "short_links.db")
|
||||
if src_admin.exists():
|
||||
shutil.copy(src_admin, db_dir / "admin.db")
|
||||
|
||||
src_reports = REPO_ROOT / "data" / "share" / "reports"
|
||||
if src_reports.exists():
|
||||
shutil.copytree(src_reports, files_dir / "reports")
|
||||
|
||||
|
||||
def test_backup_verify_uses_venv_python_for_service_level_restore(tmp_path):
|
||||
staging = tmp_path / "backup"
|
||||
_prepare_backup_dir(staging)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.pop("GAOKAO_PYTHON_BIN", None)
|
||||
# Force the shell script to fall back to its own venv detection
|
||||
# logic by hiding any caller-provided PYTHON_BIN.
|
||||
env.pop("PYTHON_BIN", None)
|
||||
# Ensure no GAOKAO_DB_PATH override leaks in from the test env.
|
||||
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPTS_DIR / "backup_verify.sh"), "--from-backup", str(staging)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, (
|
||||
f"backup_verify.sh failed\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
|
||||
)
|
||||
# The smoke output should be a JSON block with a 200 health_status.
|
||||
# That is the contract that distinguishes service-level from
|
||||
# file-level recovery.
|
||||
assert '"health_status": 200' in proc.stdout
|
||||
assert '"smoke_order_id"' in proc.stdout
|
||||
# Venv python must be used; if the script ever silently falls
|
||||
# back to ``python3``, a developer without the admin dependencies
|
||||
# installed globally will see the same ModuleNotFoundError that
|
||||
# motivated this regression lock.
|
||||
assert "ModuleNotFoundError" not in proc.stderr
|
||||
assert "ModuleNotFoundError" not in proc.stdout
|
||||
Reference in New Issue
Block a user