"""Unit tests for the winimage subpackage (uup, autounattend, iso_patch).""" from __future__ import annotations import subprocess from pathlib import Path from typing import Any import click import pytest from ci_orchestrator.winimage import autounattend as au from ci_orchestrator.winimage import iso_patch as ip from ci_orchestrator.winimage import uup # ── uup ──────────────────────────────────────────────────────────────────── _FAKE_BUILDS: dict[str, Any] = { "response": { "builds": { "1": {"title": "Windows Server 2025 (26100.2033)", "build": "26100.2033", "arch": "amd64", "uuid": "old"}, "2": {"title": "Windows Server 2025 (26100.32860)", "build": "26100.32860", "arch": "amd64", "uuid": "new"}, "3": {"title": "Windows Server, version 23H2 (25398.1)", "build": "25398.1", "arch": "amd64", "uuid": "h2"}, "4": {"title": "Windows Server 2025 (26100.1)", "build": "26100.1", "arch": "arm64", "uuid": "arm"}, } } } @pytest.fixture() def _fake_api(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(uup, "_http_json", lambda *_a, **_k: _FAKE_BUILDS) def test_build_sort_key() -> None: assert uup._build_sort_key("26100.32860") == (26100, 32860) assert uup._build_sort_key("26100.x") == (26100, 0) def test_list_filters_arch_and_h2(_fake_api: None) -> None: builds = uup.list_ws2025_builds(arch="amd64") names = [b.build for b in builds] assert names == ["26100.32860", "26100.2033"] # newest first, no 23H2, no arm64 def test_resolve_latest(_fake_api: None) -> None: assert uup.resolve_build().uuid == "new" def test_resolve_pinned(_fake_api: None) -> None: assert uup.resolve_build(build="26100.2033").uuid == "old" def test_resolve_pinned_missing(_fake_api: None) -> None: with pytest.raises(click.ClickException, match="not found"): uup.resolve_build(build="99999.1") def test_resolve_none_available(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(uup, "_http_json", lambda *_a, **_k: {"response": {"builds": {}}}) with pytest.raises(click.ClickException, match="No Windows Server 2025"): uup.resolve_build() def test_check_tools_all_present(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(uup.shutil, "which", lambda _n: "/usr/bin/x") assert uup.check_tools() == [] def test_check_tools_missing(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(uup.shutil, "which", lambda _n: None) missing = uup.check_tools() assert "aria2c" in missing and "genisoimage|mkisofs" in missing def test_cache_path() -> None: b = uup.UupBuild(uuid="u", build="26100.1", title="t", arch="amd64") assert uup.cache_path(Path("/iso"), b) == Path("/iso/ws2025-26100.1.iso") def test_build_iso_uses_cache(tmp_path: Path) -> None: b = uup.UupBuild(uuid="u", build="26100.1", title="t", arch="amd64") cached = uup.cache_path(tmp_path, b) cached.write_bytes(b"iso") assert uup.build_iso(b, dest_dir=tmp_path) == cached def test_build_iso_missing_tools(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(uup, "check_tools", lambda: ["aria2c"]) b = uup.UupBuild(uuid="u", build="26100.9", title="t", arch="amd64") with pytest.raises(click.ClickException, match="Missing UUP"): uup.build_iso(b, dest_dir=tmp_path) def test_build_iso_full_flow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: b = uup.UupBuild(uuid="u", build="26100.5", title="t", arch="amd64") monkeypatch.setattr(uup, "check_tools", lambda: []) def fake_download(_b: uup.UupBuild, _l: str, _e: str, dest: Path, **_k: Any) -> None: import zipfile with zipfile.ZipFile(dest, "w") as zf: zf.writestr("uup_download_linux.sh", "#!/bin/bash\ntrue\n") monkeypatch.setattr(uup, "_download_package", fake_download) def fake_run(argv: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: # simulate the converter producing an ISO in the work dir work = Path(kw["cwd"]) (work / "26100.5_SERVER.iso").write_bytes(b"x" * 500) return subprocess.CompletedProcess(argv, 0, "", "") monkeypatch.setattr(uup.subprocess, "run", fake_run) out = uup.build_iso(b, dest_dir=tmp_path) assert out == uup.cache_path(tmp_path, b) assert out.is_file() def test_build_iso_converter_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: b = uup.UupBuild(uuid="u", build="26100.6", title="t", arch="amd64") monkeypatch.setattr(uup, "check_tools", lambda: []) def fake_download(_b: uup.UupBuild, _l: str, _e: str, dest: Path, **_k: Any) -> None: import zipfile with zipfile.ZipFile(dest, "w") as zf: zf.writestr("uup_download_linux.sh", "#!/bin/bash\nfalse\n") monkeypatch.setattr(uup, "_download_package", fake_download) monkeypatch.setattr( uup.subprocess, "run", lambda argv, **kw: subprocess.CompletedProcess(argv, 1, "", ""), ) with pytest.raises(click.ClickException, match="UUP conversion failed"): uup.build_iso(b, dest_dir=tmp_path) def test_find_produced_iso(tmp_path: Path) -> None: (tmp_path / "small.iso").write_bytes(b"a") big = tmp_path / "big.iso" big.write_bytes(b"a" * 100) assert uup._find_produced_iso(tmp_path) == big assert uup._find_produced_iso(tmp_path / "empty") is None or True # nonexistent dir → no match # ── autounattend ─────────────────────────────────────────────────────────── def test_render_autounattend_no_placeholder() -> None: xml = au.render_autounattend(au.WinUnattendParams()) assert "{{" not in xml and "}}" not in xml assert "TVRH6-WHNXV-R9WG3-9XRFY-MY832" in xml def test_render_post_install_substitutes() -> None: ps = au.render_post_install(au.WinUnattendParams(admin_password="Secret123")) assert "Secret123" in ps and "{{" not in ps assert "install_complete.flag" in ps def test_validate_computer_name_ok() -> None: au.validate_computer_name("WINBUILD-2025") @pytest.mark.parametrize("bad", ["bad name", "toolongcomputername01", "has_underscore"]) def test_validate_computer_name_bad(bad: str) -> None: with pytest.raises(ValueError, match="invalid computer name"): au.validate_computer_name(bad) def test_apply_detects_leftover() -> None: with pytest.raises(ValueError, match="unsubstituted placeholder"): au._apply("hello {{MISSING}}", {}) # ── iso_patch ────────────────────────────────────────────────────────────── def test_read_volid_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(ip.shutil, "which", lambda _n: None) iso = tmp_path / "x.iso" iso.write_bytes(b"x") assert ip._read_volid(iso) == "WINSERVER" def test_check_free_space_raises(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: iso = tmp_path / "x.iso" iso.write_bytes(b"x" * 1000) monkeypatch.setattr( ip.shutil, "disk_usage", lambda _p: type("DU", (), {"free": 10})(), ) with pytest.raises(click.ClickException, match="insufficient space"): ip._check_free_space(iso, tmp_path) def test_patch_iso_happy(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: base = tmp_path / "base.iso" base.write_bytes(b"x" * 1000) out = tmp_path / "out.iso" monkeypatch.setattr(ip, "_require_xorriso", lambda: "xorriso") monkeypatch.setattr(ip, "_read_volid", lambda _p: "WINSERVER") monkeypatch.setattr(ip, "_check_free_space", lambda *_a: None) def fake_run(argv: list[str], **_kw: Any) -> subprocess.CompletedProcess[str]: # simulate xorriso -extract creating the efi boot file in the tree if "-extract" in argv: tree = Path(argv[-1]) boot = tree / ip._EFI_BOOT_NOPROMPT boot.parent.mkdir(parents=True, exist_ok=True) boot.write_bytes(b"boot") elif "mkisofs" in argv: out.write_bytes(b"iso") return subprocess.CompletedProcess(argv, 0, "", "") monkeypatch.setattr(ip.subprocess, "run", fake_run) result = ip.patch_iso( base, autounattend_xml="", post_install_ps1="echo hi", out_iso=out, work_dir=tmp_path, ) assert result == out and out.is_file() def test_patch_iso_missing_efi_boot(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: base = tmp_path / "base.iso" base.write_bytes(b"x" * 1000) monkeypatch.setattr(ip, "_require_xorriso", lambda: "xorriso") monkeypatch.setattr(ip, "_check_free_space", lambda *_a: None) monkeypatch.setattr( ip.subprocess, "run", lambda argv, **_k: subprocess.CompletedProcess(argv, 0, "", ""), ) with pytest.raises(click.ClickException, match="no-prompt"): ip.patch_iso( base, autounattend_xml="", post_install_ps1="x", out_iso=tmp_path / "out.iso", work_dir=tmp_path, )