"""Clonezilla backups page: image listing, grouping, metadata, serial mismatch, delete/move safety, retention preview/apply, tar download. Run from the webapp folder: PYTHONPATH=.: python -m unittest discover -s tests -v Every config path points into a temp folder (env vars before config is imported, then patched per test), so nothing on the host is touched. """ import io import json import os import shutil import sys import tarfile import tempfile import time import unittest from unittest import mock _T = tempfile.mkdtemp(prefix="pxe-cz-test-") for _k, _sub in {"SAMBA_SHARE": "samba", "CLONEZILLA_SHARE": "cz", "BLANCCO_REPORTS": "br", "ENROLLMENT_SHARE": "enr", "WEB_ROOT": "www", "IMAGING_DIR": "img", "UPDATES_DIR": "upd", "TFTP_ROOT": "tftp", "BIOS_DIR": "bios", "UPLOAD_DIR": "upl", "SAMBA_LOG_DIR": "smblog"}.items(): os.environ.setdefault(_k, os.path.join(_T, _sub)) os.makedirs(os.environ[_k], exist_ok=True) for _k in ("AUDIT_LOG", "HARDWARE_STATE", "BLANCCO_STATE", "DNSMASQ_LEASES", "APACHE_ACCESS_LOG", "DNSMASQ_SYSLOG", "NIC_STATUS_FILE", "NIC_CONF", "DNSMASQ_NIC_CONF", "DNSMASQ_CONF", "CLONEZILLA_STATE"): os.environ.setdefault(_k, os.path.join(_T, _k.lower())) os.makedirs(os.path.join(os.environ["ENROLLMENT_SHARE"], "ppkgs"), exist_ok=True) WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, WEBAPP) os.chdir(WEBAPP) import config # noqa: E402 import app as A # noqa: E402 from services import clonezilla_images as CZ # noqa: E402 DMI = """This image was saved from this machine with DMI info at 2025-01-01: # dmidecode 3.5 Handle 0x0001, DMI type 1, 27 bytes System Information \tManufacturer: Dell Inc. \tProduct Name: OptiPlex 7010 \tSerial Number: DMI777 \tUUID: 4c4c4544 Handle 0x0002, DMI type 2, 15 bytes Base Board Information \tSerial Number: BOARD1 """ def make_image(root, name, meta=None, disk="sda", parts="sda1 sda2", data=b"x" * 3000, dmi=None, saved_by=None): d = os.path.join(root, name) os.makedirs(d) with open(os.path.join(d, "disk"), "w") as f: f.write(disk + "\n") with open(os.path.join(d, "parts"), "w") as f: f.write(parts + "\n") with open(os.path.join(d, "sda1.ntfs-ptcl-img.zst"), "wb") as f: f.write(data) with open(os.path.join(d, "clonezilla-img"), "w") as f: f.write("This image was saved by Clonezilla at 2025-01-01.\n") if saved_by: f.write(f"Saved by {saved_by}.\n") if dmi: with open(os.path.join(d, "Info-dmi.txt"), "w") as f: f.write(dmi) if meta is not None: with open(os.path.join(d, "pxe-meta.txt"), "w") as f: f.write("".join(f"{k}={v}\n" for k, v in meta.items())) return d def good_meta(machine, serial="SER1", model="OptiPlex 7090", **kw): m = {"machine": machine, "serial": serial, "model": model, "manufacturer": "Dell Inc.", "disk": "sda", "disk_model": "WDC SSD", "disk_size_bytes": "256060514304", "clonezilla": "clonezilla-live-3.3.3-37-amd64", "bits": "64", "firmware": "uefi", "save": "ok", "check": "ok", "date": "2026-10-15 14:03 EDT"} m.update(kw) return m class Base(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp(prefix="cz-case-") self.share = os.path.join(self.tmp, "share") os.makedirs(self.share) self.state = os.path.join(self.tmp, "state", "clonezilla-settings.json") p1 = mock.patch.object(config, "CLONEZILLA_SHARE", self.share) p2 = mock.patch.object(config, "CLONEZILLA_STATE", self.state) p1.start() p2.start() self.addCleanup(p1.stop) self.addCleanup(p2.stop) self.addCleanup(shutil.rmtree, self.tmp, True) self.c = A.app.test_client() with self.c.session_transaction() as s: s["_csrf_token"] = "t" def post(self, url, data=None): d = {"_csrf_token": "t"} d.update(data or {}) return self.c.post(url, data=d) class TestListing(Base): def test_detects_only_image_dirs(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) os.makedirs(os.path.join(self.share, "random-folder")) os.makedirs(os.path.join(self.share, "only-disk")) open(os.path.join(self.share, "only-disk", "disk"), "w").close() # disk but no image file open(os.path.join(self.share, "disk"), "w").close() # a stray file at the top os.makedirs(os.path.join(self.share, ".hidden-2026-01-01-0000")) names = [i["name"] for i in CZ.list_images()] self.assertEqual(names, ["6501-2026-10-15-1403"]) def test_symlinked_dir_is_not_listed(self): outside = os.path.join(self.tmp, "outside") os.makedirs(outside) make_image(outside, "6501-2026-01-01-0000") os.symlink(os.path.join(outside, "6501-2026-01-01-0000"), os.path.join(self.share, "6501-2026-01-01-0000")) self.assertEqual(CZ.list_images(), []) def test_meta_is_read(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", check_date="2026-10-15 14:40 EDT")) i = CZ.list_images()[0] self.assertEqual((i["machine"], i["machine_source"]), ("6501", "meta")) self.assertEqual(i["serial"], "SER1") self.assertEqual(i["model"], "OptiPlex 7090") self.assertEqual(i["bits"], "64") self.assertEqual(i["clonezilla"], "clonezilla-live-3.3.3-37-amd64") self.assertEqual(i["status"], "ok") self.assertEqual(i["disks"], ["sda"]) self.assertEqual(i["parts"], ["sda1", "sda2"]) self.assertEqual(i["disk_size"], 256060514304) self.assertGreater(i["size"], 0) def test_manual_image_falls_back_to_name_dmi_and_clonezilla_img(self): d = make_image(self.share, "wj-7001-2025-03-04-0915", dmi=DMI, saved_by="clonezilla-live-3.2.0-5-i686") old = time.time() - 30 * 86400 os.utime(d, (old, old)) i = CZ.list_images()[0] self.assertEqual((i["machine"], i["machine_source"]), ("WJ-7001", "name")) self.assertEqual((i["serial"], i["model"], i["manufacturer"]), ("DMI777", "OptiPlex 7010", "Dell Inc.")) self.assertEqual(i["clonezilla"], "clonezilla-live-3.2.0-5-i686") self.assertEqual(i["bits"], "32") self.assertEqual(i["status"], "manual") def test_fresh_image_without_meta_is_in_progress(self): make_image(self.share, "6501-2026-10-15-1403") self.assertEqual(CZ.list_images()[0]["status"], "in-progress") def test_failed_statuses(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", save="failed", check="not-run")) make_image(self.share, "6501-2026-10-14-1403", good_meta("6501", check="failed")) st = {i["name"]: i["status"] for i in CZ.list_images()} self.assertEqual(st, {"6501-2026-10-15-1403": "failed", "6501-2026-10-14-1403": "check-failed"}) def test_unreadable_and_odd_contents_never_500(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) bad = make_image(self.share, "6502-2026-10-15-1403", good_meta("6502")) with open(os.path.join(bad, "pxe-meta.txt"), "wb") as f: f.write(b"\xff\xfe machine=\x00garbage\nno equals sign\n=\n") os.makedirs(os.path.join(self.share, "locked-2026-01-01-0000")) os.chmod(os.path.join(self.share, "locked-2026-01-01-0000"), 0) self.addCleanup(os.chmod, os.path.join(self.share, "locked-2026-01-01-0000"), 0o755) with open(os.path.join(self.share, "weird \u00e9 name.zip"), "wb") as f: f.write(b"PK") r = self.c.get("/backups") self.assertEqual(r.status_code, 200) self.assertIn(b"6501-2026-10-15-1403", r.data) def test_share_missing_still_renders(self): shutil.rmtree(self.share) r = self.c.get("/backups") self.assertEqual(r.status_code, 200) self.assertIn(b"No backups yet", r.data) class TestGrouping(Base): def test_grouped_newest_first_with_zips_and_unassigned(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) make_image(self.share, "6501-2026-09-01-0800", good_meta("6501")) make_image(self.share, "6501-2026-10-15-1403-2", good_meta("6501")) make_image(self.share, "6502-2026-08-01-0800", good_meta("6502", serial="S2")) d = make_image(self.share, "2026-07-01-10-img") # Clonezilla's default autoname old = time.time() - 30 * 86400 os.utime(d, (old, old)) with open(os.path.join(self.share, "6502.zip"), "wb") as f: f.write(b"PK") groups = CZ.grouped() self.assertEqual([g["machine"] for g in groups], ["6502", "6501", ""]) # zip just written: 6502 newest g6501 = next(g for g in groups if g["machine"] == "6501") self.assertEqual([i["name"] for i in g6501["images"]], ["6501-2026-10-15-1403-2", "6501-2026-10-15-1403", "6501-2026-09-01-0800"]) g6502 = next(g for g in groups if g["machine"] == "6502") self.assertEqual([z["filename"] for z in g6502["zips"]], ["6502.zip"]) page = self.c.get("/backups").data self.assertIn(b"No machine number", page) self.assertIn(b"6502.zip", page) def test_machine_case_is_normalised(self): make_image(self.share, "wj-1-2026-10-15-1403", good_meta("wj-1")) make_image(self.share, "WJ-1-2026-10-14-1403") self.assertEqual([g["machine"] for g in CZ.grouped()], ["WJ-1"]) def test_serial_mismatch_flag(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", serial="AAA111")) make_image(self.share, "6501-2026-10-14-1403", good_meta("6501", serial="aaa111 ")) make_image(self.share, "6502-2026-10-14-1403", good_meta("6502", serial="X")) groups = {g["machine"]: g for g in CZ.grouped()} self.assertFalse(groups["6501"]["mismatch"]) make_image(self.share, "6501-2026-10-13-1403", good_meta("6501", serial="BBB222")) groups = {g["machine"]: g for g in CZ.grouped()} self.assertTrue(groups["6501"]["mismatch"]) self.assertEqual(groups["6501"]["serials"], ["AAA111", "BBB222"]) self.assertEqual([i["serial_differs"] for i in groups["6501"]["images"]], [False, False, True]) self.assertFalse(groups["6502"]["mismatch"]) page = self.c.get("/backups").data self.assertIn(b"Serial mismatch", page) self.assertIn(b"different serial numbers", page) class TestDeleteMove(Base): def test_delete_image(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) r = self.post("/backups/image/6501-2026-10-15-1403/delete") self.assertEqual(r.status_code, 302) self.assertFalse(os.path.exists(os.path.join(self.share, "6501-2026-10-15-1403"))) def test_delete_needs_csrf(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) r = self.c.post("/backups/image/6501-2026-10-15-1403/delete") self.assertEqual(r.status_code, 403) self.assertTrue(os.path.isdir(os.path.join(self.share, "6501-2026-10-15-1403"))) def test_delete_refuses_unsafe_names(self): outside = os.path.join(self.tmp, "outside") make_image(outside, "victim") os.makedirs(os.path.join(self.share, "plain-folder")) os.symlink(os.path.join(outside, "victim"), os.path.join(self.share, "link")) for bad in ("..", ".", "...", "%2e%2e", "..%2Foutside%2Fvictim", "link", "plain-folder", "nope", ".hidden"): with self.subTest(bad=bad): self.post(f"/backups/image/{bad}/delete") for bad in ("..", "../outside/victim", "link", "plain-folder", "", "a/b", "a\\b", "x\x00"): with self.subTest(direct=bad), self.assertRaises(CZ.ImageError): CZ.delete_image(bad) self.assertTrue(os.path.isdir(os.path.join(outside, "victim"))) self.assertTrue(os.path.isdir(os.path.join(self.share, "plain-folder"))) self.assertTrue(os.path.islink(os.path.join(self.share, "link"))) def test_move_renames_and_updates_meta(self): make_image(self.share, "6510-2026-10-15-1403", good_meta("6510")) r = self.post("/backups/image/6510-2026-10-15-1403/move", {"machine": "6501"}) self.assertEqual(r.status_code, 302) new = os.path.join(self.share, "6501-2026-10-15-1403") self.assertTrue(os.path.isdir(new)) meta = CZ.read_meta(new) self.assertEqual(meta["machine"], "6501") self.assertEqual(meta["moved_from"], "6510") self.assertEqual(meta["serial"], "SER1") self.assertEqual([i["name"] for i in CZ.list_images()], ["6501-2026-10-15-1403"]) def test_move_clash_gets_suffix_and_case_twin_counts(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) make_image(self.share, "6510-2026-10-15-1403", good_meta("6510")) self.assertEqual(CZ.move_image("6510-2026-10-15-1403", "6501"), "6501-2026-10-15-1403-2") make_image(self.share, "ab-2026-10-15-1403", good_meta("ab")) make_image(self.share, "x-2026-10-15-1403", good_meta("x")) self.assertEqual(CZ.move_image("x-2026-10-15-1403", "AB"), "AB-2026-10-15-1403-2") def test_move_manual_image_without_stamp(self): d = make_image(self.share, "2026-07-01-10-img") os.utime(d, (1700000000, 1700000000)) new = CZ.move_image("2026-07-01-10-img", "6501") self.assertRegex(new, r"^6501-\d{4}-\d{2}-\d{2}-\d{4}$") self.assertEqual(CZ.read_meta(os.path.join(self.share, new))["machine"], "6501") def test_move_refuses_bad_machine(self): make_image(self.share, "6510-2026-10-15-1403", good_meta("6510")) for bad in ("", "-6501", "65 01", "../x", "a/b", "x" * 33, "65_01"): with self.subTest(bad=bad), self.assertRaises(CZ.ImageError): CZ.move_image("6510-2026-10-15-1403", bad) self.assertTrue(os.path.isdir(os.path.join(self.share, "6510-2026-10-15-1403"))) class TestRetention(Base): def _three(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) make_image(self.share, "6501-2026-10-01-1403", good_meta("6501")) make_image(self.share, "6501-2026-09-01-1403", good_meta("6501")) make_image(self.share, "6502-2026-09-01-1403", good_meta("6502")) def test_settings_validate_and_persist(self): self.assertEqual(CZ.settings(), {"keep_last": 0, "warn_percent": 85}) r = self.post("/backups/settings", {"keep_last": "2", "warn_percent": "90"}) self.assertEqual(r.status_code, 302) with open(self.state) as f: self.assertEqual(json.load(f), {"keep_last": 2, "warn_percent": 90}) for bad in (("-1", "85"), ("101", "85"), ("2", "40"), ("x", "85")): with self.subTest(bad=bad), self.assertRaises(CZ.ImageError): CZ.save_settings(*bad) self.assertEqual(CZ.settings()["keep_last"], 2) def test_preview_lists_without_deleting(self): self._three() CZ.save_settings(1, 85) r = self.c.get("/backups/retention") self.assertEqual(r.status_code, 200) self.assertIn(b"6501-2026-10-01-1403", r.data) self.assertIn(b"6501-2026-09-01-1403", r.data) self.assertIn(b"will be DELETED", r.data) self.assertEqual(len(CZ.list_images()), 4) def test_plan_keeps_newest_good_and_newer_failed(self): make_image(self.share, "6501-2026-10-16-0900", good_meta("6501", save="failed")) # newer, failed: kept make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", check="failed")) # check failed: not good make_image(self.share, "6501-2026-10-01-1403", good_meta("6501")) # newest good: kept make_image(self.share, "6501-2026-09-01-1403", good_meta("6501")) # delete d = make_image(self.share, "2026-07-01-10-img") # unassigned: never os.utime(d, (1700000000, 1700000000)) plan = [p["name"] for p in CZ.retention_plan(1)] self.assertEqual(plan, ["6501-2026-09-01-1403"]) self.assertEqual(CZ.retention_plan(0), []) self.assertEqual(CZ.retention_plan(5), []) def test_apply_deletes_only_planned_names(self): self._three() CZ.save_settings(1, 85) r = self.c.post("/backups/retention/apply", data={ "_csrf_token": "t", "name": ["6501-2026-10-01-1403", "6501-2026-09-01-1403", "6501-2026-10-15-1403", "6502-2026-09-01-1403", "../x"]}) self.assertEqual(r.status_code, 302) left = sorted(i["name"] for i in CZ.list_images()) self.assertEqual(left, ["6501-2026-10-15-1403", "6502-2026-09-01-1403"]) def test_stale_preview_cannot_delete_a_now_kept_image(self): self._three() CZ.save_settings(1, 85) planned = [p["name"] for p in CZ.retention_plan()] shutil.rmtree(os.path.join(self.share, "6501-2026-10-15-1403")) # newest removed meanwhile deleted, skipped = CZ.apply_retention(planned) self.assertEqual(deleted, ["6501-2026-09-01-1403"]) self.assertEqual(skipped, ["6501-2026-10-01-1403"]) self.assertTrue(os.path.isdir(os.path.join(self.share, "6501-2026-10-01-1403"))) def test_apply_needs_csrf_and_setting(self): self._three() r = self.c.post("/backups/retention/apply", data={"name": ["6501-2026-09-01-1403"]}) self.assertEqual(r.status_code, 403) self.post("/backups/retention/apply", {"name": "6501-2026-09-01-1403"}) # keep_last 0: nothing self.assertEqual(len(CZ.list_images()), 4) class TestTarAndSpace(Base): def test_tar_download_streams_the_folder(self): data = os.urandom(3 * 1024 * 1024 + 123) d = make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"), data=data) os.makedirs(os.path.join(d, "sub")) with open(os.path.join(d, "sub", "f.txt"), "w") as f: f.write("hello") os.symlink("/etc/passwd", os.path.join(d, "evil-link")) r = self.c.get("/backups/image/6501-2026-10-15-1403/tar") self.assertEqual(r.status_code, 200) self.assertEqual(r.mimetype, "application/x-tar") self.assertIn('filename="6501-2026-10-15-1403.tar"', r.headers["Content-Disposition"]) self.assertTrue(r.is_streamed) body = r.get_data() self.assertEqual(len(body) % tarfile.RECORDSIZE, 0) with tarfile.open(fileobj=io.BytesIO(body)) as t: names = t.getnames() self.assertIn("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst", names) self.assertIn("6501-2026-10-15-1403/sub/f.txt", names) self.assertNotIn("6501-2026-10-15-1403/evil-link", names) self.assertEqual(t.extractfile("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst").read(), data) self.assertEqual(t.extractfile("6501-2026-10-15-1403/sub/f.txt").read(), b"hello") def test_tar_of_shrinking_file_stays_valid(self): d = make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"), data=b"y" * 5000) gen = CZ.tar_stream("6501-2026-10-15-1403") chunks = [] for c in gen: chunks.append(c) if len(chunks) == 1: # first header out: shrink a file before it is read with open(os.path.join(d, "sda1.ntfs-ptcl-img.zst"), "wb") as f: f.write(b"y" * 10) with tarfile.open(fileobj=io.BytesIO(b"".join(chunks))) as t: m = t.getmember("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst") self.assertEqual(len(t.extractfile(m).read()), m.size) def test_tar_bad_name_redirects(self): for bad in ("..", "nope", "plain"): with self.subTest(bad=bad): r = self.c.get(f"/backups/image/{bad}/tar") self.assertEqual(r.status_code, 302) def test_disk_space_and_warning(self): make_image(self.share, "6501-2026-10-15-1403", good_meta("6501")) sp = CZ.disk_space(50) self.assertGreater(sp["total"], 0) fake = shutil._ntuple_diskusage(100, 95, 5) with mock.patch.object(CZ.shutil, "disk_usage", return_value=fake): self.assertTrue(CZ.disk_space(90)["warn"]) self.assertFalse(CZ.disk_space(96)["warn"]) page = self.c.get("/backups").data self.assertIn(b"Share disk space", page) self.assertIn(b"95% full", page) def test_zip_routes_still_work(self): with open(os.path.join(self.share, "6501.zip"), "wb") as f: f.write(b"PK-zip") r = self.c.get("/backups/download/6501.zip") self.assertEqual(r.data, b"PK-zip") r.close() self.post("/backups/delete/6501.zip") self.assertFalse(os.path.exists(os.path.join(self.share, "6501.zip"))) if __name__ == "__main__": unittest.main()