"""备份失败不得伪装成功,损坏文件及断链不得进入恢复。""" 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]