Files
gaokao-volunteer-system/tests/test_backup_workflow.py
Hermes Agent e1fd92975b
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
fix(tests): 修复 P1-4/P1-7 改动引发的 2 个测试失败
1. test_backup_workflow: JWT secret 从 'secret'(6字符) 改为 32+ 字符
   - 原因: P1-4 /health readiness 修复后,短 JWT → settings_valid=false → 503
   - backup smoke 脚本启动 admin 时 /health 返回 503 导致测试失败
   - 修复: 使用足够长的测试 JWT secret

2. test_score_range_fullchain_100_script: default_output_path 断言更新
   - 原因: P1-7 把产物路径从 reports/ 改为 /tmp/,但 default_output_path 函数仍指向旧路径
   - 修复: default_output_path 统一用 /tmp/ + 去掉日期后缀

验证: pytest 1306 passed, 3 skipped, 0 failed
2026-06-27 19:54:37 +08:00

313 lines
10 KiB
Python

from __future__ import annotations
import json
import os
import sqlite3
import subprocess
from pathlib import Path
from admin.db import ensure_schema
from data.orders.dao import OrdersDAO
from data.orders.models import Order
from data.orders.schema import apply_schema
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _bootstrap_share_db(db_path: str) -> None:
conn = sqlite3.connect(db_path)
try:
conn.execute("CREATE TABLE IF NOT EXISTS short_links (id INTEGER PRIMARY KEY)")
conn.commit()
finally:
conn.close()
def _list_tables(db_path: Path) -> list[str]:
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
return [row[0] for row in rows]
finally:
conn.close()
def _count_rows(db_path: Path, table_name: str) -> int:
conn = sqlite3.connect(db_path)
try:
row = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()
return int(row[0])
finally:
conn.close()
def _prepare_backup_sources(settings, tmp_path: Path) -> dict[str, str]:
ensure_schema(settings.db_path)
apply_schema(settings.orders_db_path).close()
_bootstrap_share_db(settings.share_db_path)
share_report_dir = Path(settings.share_report_dir)
share_report_dir.mkdir(parents=True, exist_ok=True)
(share_report_dir / "sample-report.json").write_text("{}", encoding="utf-8")
portal_upload_dir = Path(settings.portal_upload_dir)
portal_upload_dir.mkdir(parents=True, exist_ok=True)
portal_order_dir = portal_upload_dir / "ORDER-1"
portal_order_dir.mkdir(parents=True, exist_ok=True)
(portal_order_dir / "score.pdf").write_bytes(b"%PDF-1.4\nportal-upload\n")
report_path = tmp_path / "delivery-report.html"
pdf_path = tmp_path / "delivery-report.pdf"
report_path.write_text("<h1>delivery report</h1>", encoding="utf-8")
pdf_path.write_bytes(b"%PDF-1.4\ndelivery\n")
order = Order(
id="ORDER-1",
source="web",
service_version="standard",
amount_cents=9900,
status="delivered",
customer_name="备份测试家长",
customer_phone="13800138000",
candidate_name="备份测试考生",
candidate_province="湖南",
audit_report=str(report_path),
pdf_path=str(pdf_path),
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed")
env_file = tmp_path / ".env.runtime"
env_file.write_text("GAOKAO_ENV=dev\n", encoding="utf-8")
config_dir = tmp_path / "deploy-config"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir(parents=True, exist_ok=True)
(secrets_dir / "jwt_secret").write_text(
"backup-restore-smoke-jwt-secret-32chars!!", encoding="utf-8"
)
(secrets_dir / "orders_fernet_key").write_text("secret-fernet", encoding="utf-8")
(secrets_dir / "admin_pass").write_text("secret-admin-pass", encoding="utf-8")
return {
"GAOKAO_DB_PATH": settings.db_path,
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
"GAOKAO_SHARE_DB_PATH": settings.share_db_path,
"GAOKAO_SHARE_REPORT_DIR": settings.share_report_dir,
"GAOKAO_PORTAL_UPLOAD_DIR": settings.portal_upload_dir,
"GAOKAO_BACKUP_ENV_FILE": str(env_file),
"GAOKAO_BACKUP_CONFIG_DIR": str(config_dir),
"GAOKAO_BACKUP_SECRETS_DIR": str(secrets_dir),
}
def _run_snapshot(env: dict[str, str], backup_root: Path) -> Path:
proc = subprocess.run(
["bash", "scripts/backup_snapshot.sh", str(backup_root)],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
return Path(proc.stdout.strip().splitlines()[-1])
def test_backup_snapshot_creates_manifest_and_prunes_old_backups(settings, tmp_path):
env = {
**os.environ,
**_prepare_backup_sources(settings, tmp_path),
"GAOKAO_BACKUP_KEEP": "2",
}
backup_root = tmp_path / "backups"
(backup_root / "backup-20240101T000000Z").mkdir(parents=True, exist_ok=True)
(backup_root / "backup-20240102T000000Z").mkdir(parents=True, exist_ok=True)
snapshot_dir = _run_snapshot(env, backup_root)
assert snapshot_dir.is_dir()
manifest_path = snapshot_dir / "manifest.json"
assert manifest_path.is_file()
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_paths = {item["path"] for item in manifest["files"]}
assert "db/admin.db" in manifest_paths
assert "db/orders.db" in manifest_paths
assert "db/short_links.db" in manifest_paths
assert "config/.env" in manifest_paths
assert "config/deploy/docker-compose.yml" in manifest_paths
assert "secrets/jwt_secret" in manifest_paths
assert "secrets/orders_fernet_key" in manifest_paths
assert "secrets/admin_pass" in manifest_paths
assert "files/portal_uploads/ORDER-1/score.pdf" in manifest_paths
assert any(
path.startswith("files/order_artifacts/ORDER-1/audit_report/")
for path in manifest_paths
)
assert any(
path.startswith("files/order_artifacts/ORDER-1/pdf_path/")
for path in manifest_paths
)
assert any(path.startswith("files/examples/") for path in manifest_paths)
remaining = sorted(path.name for path in backup_root.iterdir() if path.is_dir())
assert len(remaining) == 2
assert "backup-20240101T000000Z" not in remaining
assert snapshot_dir.name in remaining
def test_backup_snapshot_preserves_admin_db_contents_under_wal(tmp_path):
admin_db = tmp_path / "admin.db"
live_conn = sqlite3.connect(admin_db)
try:
journal_mode = live_conn.execute("PRAGMA journal_mode=WAL").fetchone()
assert journal_mode is not None
assert str(journal_mode[0]).lower() == "wal"
live_conn.execute("PRAGMA wal_autocheckpoint = 0")
live_conn.execute(
"CREATE TABLE admin_users (id INTEGER PRIMARY KEY, username TEXT NOT NULL)"
)
live_conn.execute(
"INSERT INTO admin_users(username) VALUES (?)", ("snapshot-admin",)
)
live_conn.commit()
wal_path = admin_db.with_name(f"{admin_db.name}-wal")
assert wal_path.exists()
snapshot_dir = _run_snapshot(
{
**os.environ,
"GAOKAO_DB_PATH": str(admin_db),
},
tmp_path / "backups",
)
finally:
live_conn.close()
snapshot_admin_db = snapshot_dir / "db" / "admin.db"
assert _list_tables(admin_db) == ["admin_users"]
assert _count_rows(admin_db, "admin_users") == 1
assert "admin_users" in _list_tables(snapshot_admin_db)
assert _count_rows(snapshot_admin_db, "admin_users") == 1
def test_backup_verify_live_copy_handles_wal_sqlite(tmp_path):
admin_db = tmp_path / "admin.db"
verify_dir = tmp_path / "verify"
live_conn = sqlite3.connect(admin_db)
try:
journal_mode = live_conn.execute("PRAGMA journal_mode=WAL").fetchone()
assert journal_mode is not None
assert str(journal_mode[0]).lower() == "wal"
live_conn.execute("PRAGMA wal_autocheckpoint = 0")
live_conn.execute(
"CREATE TABLE admin_users (id INTEGER PRIMARY KEY, username TEXT NOT NULL)"
)
live_conn.execute(
"INSERT INTO admin_users(username) VALUES (?)", ("verify-admin",)
)
live_conn.commit()
wal_path = admin_db.with_name(f"{admin_db.name}-wal")
assert wal_path.exists()
proc = subprocess.run(
["bash", "scripts/backup_verify.sh", str(verify_dir), "--skip-smoke"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env={
**os.environ,
"GAOKAO_DB_PATH": str(admin_db),
},
check=False,
)
finally:
live_conn.close()
assert proc.returncode == 0, proc.stderr
staged_admin_db = verify_dir / "db" / "admin.db"
assert staged_admin_db.is_file()
assert "admin_users" in _list_tables(staged_admin_db)
assert _count_rows(staged_admin_db, "admin_users") == 1
def test_backup_verify_runs_restore_smoke_on_snapshot(settings, tmp_path):
env = {
**os.environ,
**_prepare_backup_sources(settings, tmp_path),
"GAOKAO_PAYMENT_PROVIDER": "alipay",
}
backup_root = tmp_path / "backups"
snapshot_dir = _run_snapshot(env, backup_root)
proc = subprocess.run(
["bash", "scripts/backup_verify.sh", "--from-backup", str(snapshot_dir)],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert "manifest_ok files=" in proc.stdout
assert '"portal_status": 200' in proc.stdout
assert '"portal_report": 200' in proc.stdout
assert '"portal_pdf": 200' in proc.stdout
assert "backup verification finished" in proc.stdout
def test_backup_verify_stages_live_portal_uploads_and_order_artifacts(
settings, tmp_path
):
env = {
**os.environ,
**_prepare_backup_sources(settings, tmp_path),
}
verify_dir = tmp_path / "verify"
proc = subprocess.run(
["bash", "scripts/backup_verify.sh", str(verify_dir), "--skip-smoke"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert (verify_dir / "files" / "portal_uploads" / "ORDER-1" / "score.pdf").is_file()
assert any(
path.is_file()
for path in (verify_dir / "files" / "order_artifacts").rglob("*.html")
)
assert any(
path.is_file()
for path in (verify_dir / "files" / "order_artifacts").rglob("*.pdf")
)
assert "copied order artifacts:" in proc.stdout
def test_dr_drill_template_exists_and_references_target_machine_acceptance():
report = PROJECT_ROOT / "reports" / "DR_DRILL_TEMPLATE.md"
assert report.is_file()
body = report.read_text(encoding="utf-8")
assert "目标主机" in body
assert "backup_verify.sh --from-backup" in body
assert "portal_status" in body
def test_backup_plan_references_dr_drill_template():
doc = (PROJECT_ROOT / "docs" / "BACKUP_AND_RECOVERY_PLAN.md").read_text(
encoding="utf-8"
)
assert "reports/DR_DRILL_TEMPLATE.md" in doc