import json from pathlib import Path from urllib.parse import unquote import pytest import core import features.backups as backups from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd FIXTURES = Path(__file__).resolve().parent / "fixtures" def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path: path = dirs["backups"] / vm / backup_id path.mkdir(parents=True) (path / "vm.xml").write_text(DUMPXML_MIGRATABLE) for target in disks: if complete: (path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60) meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note} (path / "meta.json").write_text(json.dumps(meta)) return path class FakeVirsh: """Имитация virsh с состоянием: машина выключается после `virsh shutdown`.""" def __init__(self, running=True, shuts_down=True, exists=("web01", "db01")): self.running = running self.shuts_down = shuts_down self.exists = set(exists) self.calls: list[list[str]] = [] self.defined_xml = "" def __call__(self, cmd, timeout=12, **_kwargs): self.calls.append(list(cmd)) if cmd[:2] == ["virsh", "domstate"]: return fake_result("running" if self.running else "shut off") if cmd[:2] == ["virsh", "shutdown"]: if self.shuts_down: self.running = False return fake_result() if cmd[:2] == ["virsh", "destroy"]: self.running = False return fake_result() if cmd[:2] == ["virsh", "start"]: self.running = True return fake_result() if cmd[:2] == ["virsh", "dominfo"]: return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain") if cmd[:2] == ["virsh", "define"]: self.defined_xml = Path(cmd[2]).read_text() return fake_result(f"Domain defined from {cmd[2]}") if cmd[:2] == ["virsh", "undefine"]: self.exists.discard(cmd[2]) return fake_result() return fake_run_cmd(cmd, timeout) def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0): calls: list[list[str]] = [] def stream(cmd, on_line): calls.append(list(cmd)) Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000) for line in progress_lines: on_line(line) return code stream.calls = calls return stream @pytest.fixture() def fast_worker(monkeypatch, data_dirs): """Операции выполняются синхронно, ожидание выключения — мгновенно.""" monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation)) monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001) monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05) monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3) return data_dirs # ---------------------------------------------------------------- парсеры def test_parse_progress(): assert backups.parse_progress(" (12.34/100%)") == 12 assert backups.parse_progress("(100.00/100%)") == 100 assert backups.parse_progress("qemu-img: Could not open") is None def test_parse_qemu_img_info_fixture(): info = backups.parse_qemu_img_info(QEMU_IMG_INFO) assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"} assert backups.parse_qemu_img_info("not json")["actual_size"] == 0 assert backups.parse_qemu_img_info("[1, 2]")["format"] == "" def test_vm_disks_skips_cdrom(data_dirs): disks = backups.vm_disks("web01") assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))] assert DOMBLKLIST_DETAILS.count("cdrom") == 1 def test_stream_cmd_splits_carriage_returns(): lines: list[str] = [] code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append) assert code == 0 assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"] def test_rewrite_xml_renames_and_detaches_identity(): xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"}) assert "web02" in xml assert "" not in xml and "id=" not in xml.split(">", 1)[0] assert "" in xml or "" in xml # привод CD остаётся как был def test_rewrite_xml_converts_block_disk_and_drops_nvram(): source = """oldhvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/old_VARS.fd """ xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"}) assert "" not in xml assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml def test_format_backup_date(): assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00" assert backups.format_backup_date("garbage") == "garbage" def test_clean_note_limits_length_and_control_chars(): assert backups.clean_note(" до\x00обновления\n ") == "до обновления" assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX # ---------------------------------------------------------------- хранилище def test_list_and_group_backups(data_dirs): make_backup(data_dirs, "web01", "20260917-0300", size=100) make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением") make_backup(data_dirs, "old-vm", "20260901-1200", size=50) (data_dirs["backups"] / "web01" / "junk").mkdir() (data_dirs["backups"] / "..hidden").mkdir() items = backups.list_backups() assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")] assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True groups = backups.group_by_vm(items, {"web01"}) assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)] assert backups.list_backups("web01")[0]["id"] == "20260924-0300" assert backups.storage_summary(items)["used_bytes"] == 350 def test_backup_path_rejects_traversal(data_dirs): assert backups.backup_path("../etc", "20260924-0300") is None assert backups.backup_path("web01", "../../x") is None assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300" def test_incomplete_backup_is_flagged(data_dirs): make_backup(data_dirs, complete=False) assert backups.read_backup("web01", "20260924-0300")["complete"] is False # ---------------------------------------------------------------- создание копии def test_create_backup_refuses_without_space(data_dirs, monkeypatch): monkeypatch.setattr(backups, "free_bytes", lambda path: 1024) with pytest.raises(backups.BackupError) as exc: backups.create_backup("web01") assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value) def test_create_backup_rejects_unknown_vm(fast_worker): with pytest.raises(backups.BackupError): backups.create_backup("nope") with pytest.raises(backups.BackupError): backups.create_backup("bad name") def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch): virsh = FakeVirsh(running=True) monkeypatch.setattr(core, "run_cmd", virsh) stream = fake_stream() monkeypatch.setattr(backups, "stream_cmd", stream) queued = backups.create_backup("web01", " ночная\tкопия ") operation = core.read_operation(queued["id"]) assert operation["status"] == "success", operation["message"] assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"] assert ["virsh", "shutdown", "web01"] in virsh.calls assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls assert virsh.calls[-1] == ["virsh", "start", "web01"] assert ["virsh", "destroy", "web01"] not in virsh.calls assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]] path = fast_worker["backups"] / "web01" / operation["backup_id"] meta = json.loads((path / "meta.json").read_text()) assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda" assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json") assert "Сайт компании" in (path / "vm.xml").read_text() log = core.read_operation(operation["id"])["log_tail"] assert "48%" in log and "Машина запущена снова" in log def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch): monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) operation = backups.create_backup("db01", wait=True) assert operation["status"] == "success" and operation["type"] == "backup" def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch): virsh = FakeVirsh(running=True, shuts_down=False) monkeypatch.setattr(core, "run_cmd", virsh) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) operation = backups.create_backup("web01") fresh = core.read_operation(operation["id"]) assert fresh["status"] == "error" and "не выключилась" in fresh["message"] assert ["virsh", "destroy", "web01"] not in virsh.calls assert not (fast_worker["backups"] / "web01").exists() def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch): virsh = FakeVirsh(running=True) monkeypatch.setattr(core, "run_cmd", virsh) monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1)) operation = backups.create_backup("web01") fresh = core.read_operation(operation["id"]) assert fresh["status"] == "error" and "vda" in fresh["message"] assert not (fast_worker["backups"] / "web01").exists() assert virsh.calls[-1] == ["virsh", "start", "web01"] def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch): monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди backups.create_backup("web01") with pytest.raises(backups.BackupError) as exc: backups.create_backup("web01") assert "уже идёт" in str(exc.value) # ---------------------------------------------------------------- восстановление def test_restore_under_new_name(fast_worker, monkeypatch): make_backup(fast_worker) virsh = FakeVirsh(running=False) monkeypatch.setattr(core, "run_cmd", virsh) stream = fake_stream() monkeypatch.setattr(backups, "stream_cmd", stream) operation = backups.start_restore("web01", "20260924-0300", "web02", False) fresh = core.read_operation(operation["id"]) assert fresh["status"] == "success", fresh["message"] assert fresh["vm_name"] == "web02" target = fast_worker["images"] / "web02.qcow2" assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2"), str(target)]] assert target.exists() assert "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and "web01" in virsh.defined_xml def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch): make_backup(fast_worker) virsh = FakeVirsh(running=False) base = virsh.__call__ def with_iso(cmd, timeout=12, **kw): if cmd[:2] == ["virsh", "domblklist"]: virsh.calls.append(list(cmd)) return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso")) return base(cmd, timeout, **kw) monkeypatch.setattr(core, "run_cmd", with_iso) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) backups.start_restore("web01", "20260924-0300", "web01", True) eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"] assert eject in virsh.calls assert virsh.calls.index(eject) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]) def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker): make_backup(fast_worker) with pytest.raises(backups.BackupError) as exc: backups.start_restore("web01", "20260924-0300", "-bad", False) assert exc.value.code == "name" make_backup(fast_worker, backup_id="20260901-0000", complete=False) with pytest.raises(backups.BackupError) as exc: backups.start_restore("web01", "20260901-0000", "web02", False) assert "неполная" in str(exc.value) def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch): make_backup(fast_worker) virsh = FakeVirsh(running=False) base = virsh.__call__ def failing_define(cmd, timeout=12, **kw): if cmd[:2] == ["virsh", "define"]: return fake_result(ok=False, stderr="error: XML error: bad thing") return base(cmd, timeout, **kw) monkeypatch.setattr(core, "run_cmd", failing_define) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) operation = backups.start_restore("web01", "20260924-0300", "web02", False) fresh = core.read_operation(operation["id"]) assert fresh["status"] == "error" and "bad thing" in fresh["message"] assert not (fast_worker["images"] / "web02.qcow2").exists() def test_restore_disk_path_avoids_collisions(data_dirs): (data_dirs["images"] / "web02.qcow2").write_bytes(b"x") assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2" assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2" (data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x") assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2" # ---------------------------------------------------------------- страницы def test_backup_pages_require_login(client): for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"): response = client.get(path, follow_redirects=False) assert response.status_code == 303 and response.headers["location"] == "/login" def test_backups_page_lists_groups(logged_in, data_dirs): make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением") make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3) html = logged_in.get("/backups").text assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html assert 'href="/backups/web01/20260924-0300/restore"' in html assert "машина удалена" in html and 'href="/vm/web01/backups"' in html assert "Резервные копии" in html def test_backups_page_empty_state(logged_in): html = logged_in.get("/backups").text assert "Копий пока нет" in html def test_vm_backups_page_and_tab_link(logged_in, data_dirs): make_backup(data_dirs) html = logged_in.get("/vm/web01/backups").text assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html assert "24 сентября 2026, 03:00" in html assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/" def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch): monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False) assert response.status_code == 303 and response.headers["location"].startswith("/operations/") operation_id = response.headers["location"].rsplit("/", 1)[1] assert core.read_operation(operation_id)["status"] == "success" assert logged_in.get(f"/operations/{operation_id}").status_code == 200 def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch): monkeypatch.setattr(backups, "free_bytes", lambda path: 0) response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False) assert response.status_code == 303 assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"]) assert "Недостаточно места" in logged_in.get(response.headers["location"]).text def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch): make_backup(fast_worker) monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) monkeypatch.setattr(backups, "stream_cmd", fake_stream()) html = logged_in.get("/backups/web01/20260924-0300/restore").text assert 'name="replace"' in html and "Заменить существующую машину web01" in html response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""}) assert response.status_code == 400 and "уже есть на сервере" in response.text response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False) assert response.status_code == 303 and response.headers["location"].startswith("/operations/") response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"}) assert response.status_code == 400 assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=") def test_delete_backup_route(logged_in, data_dirs): path = make_backup(data_dirs) response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False) assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=") assert not path.exists() and not path.parent.exists() response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False) assert response.headers["location"].startswith("/backups?backup_error=") def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path): victim = tmp_path / "victim" victim.mkdir() (victim / "20260924-0300").mkdir() logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False) logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False) assert (victim / "20260924-0300").exists()