PayrollManagementSystem/backend/tests/unit/test_payroll_backup.py
liuwenxin ae12403c0b fix(备份): 修复快照位点与轮转期间的增量遗漏
统一跨平台备份入口,校验快照位点、完整性及增量链后隔离恢复。
末个 binlog 按读取位点归档,避免轮转后并发事务被续接跳过。
修复 Windows 安装器的编码、默认路径和花括号路径参数。
2026-11-03 17:18:52 +08:00

145 lines
5.6 KiB
Python

"""备份失败不得伪装成功,损坏文件及断链不得进入恢复。"""
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
spec = importlib.util.spec_from_file_location(
"payroll_backup", Path(__file__).resolve().parents[3] / "scripts/payroll_backup.py"
)
backup = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backup)
@pytest.fixture
def chain(tmp_path):
data = tmp_path / "full.sql.gz"
data.write_bytes(b"fixture")
baseline = {"kind": "FULL", "database": "payroll", "server_uuid": "server-a",
"point": {"file": "binlog.000001", "position": 100},
"entries": [{"file": data.name, "sha256": backup.checksum(data)}]}
backup.json_write(tmp_path / "full.json", baseline)
state = {"full": "full.json", "point": baseline["point"], "increments": []}
backup.json_write(tmp_path / "state.json", state)
return SimpleNamespace(directory=tmp_path, container="test", database="payroll"), baseline, state
def test_备份篡改与路径越界必须拒绝(chain):
args, _, _ = chain
(args.directory / "full.sql.gz").write_bytes(b"corrupted")
with pytest.raises(backup.BackupError, match="SHA-256"):
backup.checked_chain(args)
with pytest.raises(backup.BackupError, match="文件名"):
backup.load_manifest(args.directory, "../external.json")
def test_跨全量与位点断链拒绝恢复(chain):
args, _, state = chain
change = {"full": "other.json", "start": state["point"], "end": state["point"], "entries": []}
backup.json_write(args.directory / "increment.json", change)
state["increments"] = ["increment.json"]
backup.json_write(args.directory / "state.json", state)
with pytest.raises(backup.BackupError, match="不连续"):
backup.checked_chain(args)
def test_无更新不轮转日志也不生成增量(chain, monkeypatch):
args, baseline, state = chain
calls = []
def sql(*values):
calls.append(values)
return baseline["server_uuid"]
monkeypatch.setattr(backup, "mysql", sql)
monkeypatch.setattr(backup, "position", lambda _: state["point"])
assert backup.incremental(args)["status"] == "UNCHANGED"
assert len(calls) == 1
assert not list(args.directory.glob("increment*"))
def test_服务器更换后不续接旧链(chain, monkeypatch):
args, _, _ = chain
monkeypatch.setattr(backup, "mysql", lambda *a: "server-b")
with pytest.raises(backup.BackupError, match="身份已变化"):
backup.incremental(args)
def test_备份检查拒绝同服务器上的其他数据库(chain, monkeypatch):
args, _, _ = chain
args.database = "other_payroll"
calls = []
monkeypatch.setattr(backup, "mysql", lambda *a: calls.append(a))
with pytest.raises(backup.BackupError, match="检查目标不符"):
backup.check(args)
assert not calls
def test_轮转后的并发事务归档到末位点且续接不跳过(chain, monkeypatch):
args, baseline, state = chain
start = state["point"]
end = {"file": "binlog.000002", "position": 400}
monkeypatch.setattr(backup, "mysql", lambda *a: baseline["server_uuid"])
monkeypatch.setattr(backup, "position", lambda _: end)
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000001", "binlog.000002"])
commands = []
def archive(command, path):
commands.append(command)
path.write_bytes(b"archived")
return {"file": path.name, "sha256": backup.checksum(path)}
monkeypatch.setattr(backup, "stream_gzip", archive)
first = backup.incremental(args)
manifest = backup.load_manifest(args.directory, first["manifest"])
assert manifest["start"] == start
assert manifest["end"] == end
assert [entry["binlog"] for entry in manifest["entries"]] == [
"binlog.000001", "binlog.000002"
]
assert commands[-1][-4:] == ["head", "-c", "400", "/var/lib/mysql/binlog.000002"]
# 下一轮仍从上一轮已归档的末位点续接,跨轮不会遗漏或重复计算事务。
next_end = {"file": "binlog.000003", "position": 157}
monkeypatch.setattr(backup, "position", lambda _: next_end)
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000002", "binlog.000003"])
second = backup.incremental(args)
next_manifest = backup.load_manifest(args.directory, second["manifest"])
assert next_manifest["start"] == end
assert [entry["binlog"] for entry in next_manifest["entries"]] == [
"binlog.000002", "binlog.000003"
]
assert backup.checked_chain(args)[1] == [manifest, next_manifest]
def test_损坏清单不得启动任何恢复容器(chain, monkeypatch):
args, _, _ = chain
(args.directory / "full.sql.gz").write_bytes(b"broken")
commands = []
monkeypatch.setattr(backup, "run", lambda command, **kw: commands.append(command))
with pytest.raises(backup.BackupError):
backup.restore(args)
assert commands == []
def test_清单原子落盘不留下半成品(tmp_path):
target = tmp_path / "manifest.json"
backup.json_write(target, {"status": "SUCCEEDED"})
assert json.loads(target.read_text())["status"] == "SUCCEEDED"
assert not list(tmp_path.glob("*.partial"))
def test_MySQL探活使用TCP避开镜像初始化临时服务(monkeypatch):
commands = []
def run(command, **kwargs):
commands.append(command)
assert kwargs["data"] == b"SELECT 1"
return b"1\n"
monkeypatch.setattr(backup, "run", run)
assert backup.mysql("isolated-test", "SELECT 1") == "1"
command = commands[0]
assert "--host=127.0.0.1 --protocol=TCP" in command[command.index("-c") + 1]