"""Tests for the Stage B (B3/B4) ``operations.inventory`` module. Template discovery (``list_templates``) and containment-checked directory listings (``list_dir``/``list_backups``/``list_artifacts``). No real ``7z``/ ``vmrun`` involved -- everything is plain files under ``tmp_path``. """ from __future__ import annotations import os import time from pathlib import Path import pytest import ci_orchestrator.operations.inventory as inventory_module from ci_orchestrator.config import BackendConfig, Config, Paths from ci_orchestrator.operations.inventory import ( FileEntry, TemplateInfo, list_artifacts, list_backups, list_dir, list_templates, ) def _make_config(root: Path) -> Config: return Config( paths=Paths( root=root, templates=root / "templates", build_vms=root / "build-vms", artifacts=root / "artifacts", keys=root / "keys", ), backend=BackendConfig(), ) @pytest.fixture(autouse=True) def _clear_dir_bytes_cache() -> None: """The B7 ``dir_bytes`` cache is module-level -- start each test clean.""" inventory_module._dir_bytes_cache.clear() # ── list_templates ─────────────────────────────────────────────────────────── def _write_vmx(path: Path, guest_os: str) -> None: path.write_text(f'.encoding = "UTF-8"\nguestOS = "{guest_os}"\n', encoding="utf-8") def test_list_templates_missing_dir_is_empty(tmp_path: Path) -> None: config = _make_config(tmp_path) # templates dir never created assert list_templates(config) == [] def test_list_templates_parses_guest_os_and_transport_ssh(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "LinuxBuild2404" tmpl_dir.mkdir(parents=True) _write_vmx(tmpl_dir / "LinuxBuild2404.vmx", "ubuntu-64") config = _make_config(tmp_path) [info] = list_templates(config) assert info.name == "LinuxBuild2404" assert info.vmx == tmpl_dir / "LinuxBuild2404.vmx" assert info.guest_os == "ubuntu-64" assert info.transport == "ssh" def test_list_templates_windows_guest_os_is_winrm_transport(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "WinBuild2025" tmpl_dir.mkdir(parents=True) _write_vmx(tmpl_dir / "WinBuild2025.vmx", "windows9srv-64") config = _make_config(tmp_path) [info] = list_templates(config) assert info.transport == "winrm" def test_list_templates_no_vmx_is_unknown_transport(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "Empty" tmpl_dir.mkdir(parents=True) config = _make_config(tmp_path) [info] = list_templates(config) assert info.vmx is None assert info.guest_os is None assert info.transport == "unknown" def test_list_templates_has_vmem_true_when_present(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "WithVmem" tmpl_dir.mkdir(parents=True) _write_vmx(tmpl_dir / "WithVmem.vmx", "ubuntu-64") (tmpl_dir / "WithVmem.vmem").write_bytes(b"\x00" * 16) config = _make_config(tmp_path) [info] = list_templates(config) assert info.has_vmem is True def test_list_templates_has_vmem_false_when_absent(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "Clean" tmpl_dir.mkdir(parents=True) _write_vmx(tmpl_dir / "Clean.vmx", "ubuntu-64") config = _make_config(tmp_path) [info] = list_templates(config) assert info.has_vmem is False def test_list_templates_dir_bytes_sums_file_sizes(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "Sized" nested = tmpl_dir / "nested" nested.mkdir(parents=True) (tmpl_dir / "a.vmdk").write_bytes(b"x" * 100) (nested / "b.vmdk").write_bytes(b"y" * 50) config = _make_config(tmp_path) [info] = list_templates(config) assert info.dir_bytes == 150 def test_list_templates_multiple_entries_sorted_by_name(tmp_path: Path) -> None: for name in ("WinBuild2022", "LinuxBuild2404", "WinBuild2025"): (tmp_path / "templates" / name).mkdir(parents=True) config = _make_config(tmp_path) names = [t.name for t in list_templates(config)] assert names == sorted(names) def test_list_templates_last_backup_picks_newest_matching_archive(tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "LinuxBuild2404" tmpl_dir.mkdir(parents=True) backups_dir = tmp_path / "backups" backups_dir.mkdir() older = backups_dir / "Template_LinuxBuild2404_20260101_000000.7z" newer = backups_dir / "Template_LinuxBuild2404_20260601_000000.7z" unrelated = backups_dir / "Template_OtherTemplate_20260701_000000.7z" older.write_bytes(b"old") unrelated.write_bytes(b"other") time.sleep(0.01) newer.write_bytes(b"new") config = _make_config(tmp_path) [info] = list_templates(config) assert info.last_backup is not None assert info.last_backup.name == "Template_LinuxBuild2404_20260601_000000.7z" @pytest.mark.parametrize( ("filename", "expected"), [ # Real archive shape: ``Template__%Y%m%d_%H%M%S.7z`` -- both the # date and the time chunk must be stripped (regression: only the time # chunk was, leaving "LinuxBuild2404_20260722"). ("Template_LinuxBuild2404_20260722_145001.7z", "LinuxBuild2404"), ("Template_WinBuild2025_20260101_000000.7z", "WinBuild2025"), # Template names containing underscores survive intact. ("Template_Win_Build_2025_20260722_145001.7z", "Win_Build_2025"), # Date-only shape (no time chunk) is still tolerated. ("Template_LinuxBuild2404_20260722.7z", "LinuxBuild2404"), # Non-matching names parse to None. ("not-a-template-archive.7z", None), ("Template_no_timestamp.txt", None), ], ) def test_template_of_backup_strips_full_timestamp(filename: str, expected: str | None) -> None: assert inventory_module._template_of_backup(filename) == expected def test_list_templates_last_backup_none_when_no_backups_dir(tmp_path: Path) -> None: (tmp_path / "templates" / "LinuxBuild2404").mkdir(parents=True) config = _make_config(tmp_path) [info] = list_templates(config) assert info.last_backup is None def test_list_templates_skips_regular_files_directly_under_templates(tmp_path: Path) -> None: templates_dir = tmp_path / "templates" templates_dir.mkdir(parents=True) (templates_dir / "README.txt").write_text("not a template", encoding="utf-8") (templates_dir / "Real").mkdir() config = _make_config(tmp_path) names = [t.name for t in list_templates(config)] assert names == ["Real"] def test_list_templates_dir_bytes_is_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: tmpl_dir = tmp_path / "templates" / "Cached" tmpl_dir.mkdir(parents=True) (tmpl_dir / "a.vmdk").write_bytes(b"x" * 10) config = _make_config(tmp_path) walk_calls: list[Path] = [] real_walk = os.walk def _counting_walk(path: object, *a: object, **kw: object) -> object: walk_calls.append(Path(str(path))) return real_walk(path, *a, **kw) # type: ignore[arg-type] monkeypatch.setattr(inventory_module.os, "walk", _counting_walk) first = list_templates(config)[0].dir_bytes second = list_templates(config)[0].dir_bytes assert first == second == 10 assert len(walk_calls) == 1, "second call must hit the TTL cache, not os.walk again" # ── list_dir / containment ─────────────────────────────────────────────────── def test_list_dir_accepts_root_itself(tmp_path: Path) -> None: (tmp_path / "f1.txt").write_bytes(b"a") entries = list_dir(tmp_path, root=tmp_path) assert [e.name for e in entries] == ["f1.txt"] def test_list_dir_accepts_real_subdir(tmp_path: Path) -> None: sub = tmp_path / "backups" sub.mkdir() (sub / "f1.7z").write_bytes(b"a") entries = list_dir(sub, root=tmp_path, pattern="*.7z") assert [e.name for e in entries] == ["f1.7z"] def test_list_dir_rejects_base_outside_root(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.txt").write_bytes(b"nope") assert list_dir(outside, root=root) == [] def test_list_dir_rejects_dotdot_escape(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.txt").write_bytes(b"nope") escaping = root / ".." / "outside" assert list_dir(escaping, root=root) == [] def test_list_dir_rejects_symlink_escaping_root(tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.txt").write_bytes(b"nope") link = root / "escape_link" link.symlink_to(outside, target_is_directory=True) assert list_dir(link, root=root) == [] def test_list_dir_missing_base_is_empty(tmp_path: Path) -> None: assert list_dir(tmp_path / "does-not-exist", root=tmp_path) == [] def test_list_dir_sorted_newest_first(tmp_path: Path) -> None: older = tmp_path / "a.txt" newer = tmp_path / "b.txt" older.write_bytes(b"old") time.sleep(0.01) newer.write_bytes(b"new") entries = list_dir(tmp_path, root=tmp_path) assert [e.name for e in entries] == ["b.txt", "a.txt"] def test_list_dir_pattern_filters_extension(tmp_path: Path) -> None: (tmp_path / "keep.7z").write_bytes(b"x") (tmp_path / "skip.txt").write_bytes(b"y") entries = list_dir(tmp_path, root=tmp_path, pattern="*.7z") assert [e.name for e in entries] == ["keep.7z"] def test_list_dir_entry_shape(tmp_path: Path) -> None: f = tmp_path / "x.7z" f.write_bytes(b"12345") [entry] = list_dir(tmp_path, root=tmp_path) assert isinstance(entry, FileEntry) assert entry.size_bytes == 5 assert entry.mtime == pytest.approx(f.stat().st_mtime) # ── list_backups / list_artifacts ──────────────────────────────────────────── def test_list_backups_reads_root_backups_dir(tmp_path: Path) -> None: backups = tmp_path / "backups" backups.mkdir() (backups / "Template_X_20260101_000000.7z").write_bytes(b"x") (backups / "notes.txt").write_bytes(b"ignored") config = _make_config(tmp_path) entries = list_backups(config) assert [e.name for e in entries] == ["Template_X_20260101_000000.7z"] def test_list_backups_missing_dir_is_empty(tmp_path: Path) -> None: config = _make_config(tmp_path) assert list_backups(config) == [] def test_list_artifacts_reads_configured_artifacts_dir(tmp_path: Path) -> None: config = _make_config(tmp_path) config.paths.artifacts.mkdir(parents=True) (config.paths.artifacts / "build.log").write_bytes(b"log") entries = list_artifacts(config) assert [e.name for e in entries] == ["build.log"] def test_list_artifacts_outside_root_is_rejected(tmp_path: Path) -> None: """``config.paths.artifacts`` pointed outside ``root`` must still 0-list. ``outside_artifacts`` sits beside ``root`` (both under ``tmp_path``) -- a sibling directory, not a descendant -- so containment must reject it. """ root = tmp_path / "root" root.mkdir() outside_artifacts = tmp_path / "artifacts-outside" outside_artifacts.mkdir() (outside_artifacts / "build.log").write_bytes(b"log") config = Config( paths=Paths( root=root, templates=root / "templates", build_vms=root / "build-vms", artifacts=outside_artifacts, keys=root / "keys", ), backend=BackendConfig(), ) assert list_artifacts(config) == [] # ── defensive OSError degrade paths ("never raise") ───────────────────────── class _BoomIterdir: """Duck-typed stand-in for a ``Path`` whose ``iterdir()`` blows up.""" def iterdir(self) -> list[Path]: raise OSError("denied") class _BoomGlob: """Duck-typed stand-in for a ``Path`` whose ``glob()`` blows up.""" def glob(self, _pattern: str) -> list[Path]: raise OSError("denied") class _BoomReadText: """Duck-typed stand-in for a ``Path`` whose ``read_text()`` blows up.""" def read_text(self, encoding: str = "utf-8", errors: str = "strict") -> str: raise OSError("denied") class _BoomResolve: """Duck-typed stand-in for a ``Path`` whose ``resolve()`` blows up.""" def resolve(self) -> Path: raise OSError("denied") def test_find_vmx_oserror_returns_none() -> None: assert inventory_module._find_vmx(_BoomIterdir()) is None # type: ignore[arg-type] def test_has_vmem_oserror_returns_false() -> None: assert inventory_module._has_vmem(_BoomGlob()) is False # type: ignore[arg-type] def test_read_guest_os_oserror_returns_empty_string() -> None: assert inventory_module._read_guest_os(_BoomReadText()) == "" # type: ignore[arg-type] def test_dir_bytes_cached_resolve_oserror_returns_zero() -> None: assert inventory_module._dir_bytes_cached(_BoomResolve()) == 0 # type: ignore[arg-type] def test_newest_backup_glob_oserror_returns_none( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: backups_dir = tmp_path / "backups" backups_dir.mkdir() monkeypatch.setattr(Path, "glob", lambda self, pattern: (_ for _ in ()).throw(OSError("denied"))) assert inventory_module._newest_backup(backups_dir, "X") is None def test_dir_bytes_cached_walk_oserror_degrades_to_zero( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: tmpl_dir = tmp_path / "T" tmpl_dir.mkdir() (tmpl_dir / "a.txt").write_bytes(b"x" * 5) def _boom_walk(_path: Path, *_a: object, **_kw: object) -> object: raise OSError("walk exploded") monkeypatch.setattr(inventory_module.os, "walk", _boom_walk) assert inventory_module._dir_bytes_cached(tmpl_dir) == 0 def test_dir_bytes_cache_recomputes_after_ttl_expiry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """B7: a cache entry older than the TTL must be recomputed, not reused forever.""" tmpl_dir = tmp_path / "templates" / "Stale" tmpl_dir.mkdir(parents=True) (tmpl_dir / "a.vmdk").write_bytes(b"x" * 10) config = _make_config(tmp_path) clock = {"t": 0.0} monkeypatch.setattr(inventory_module.time, "monotonic", lambda: clock["t"]) walk_calls: list[Path] = [] real_walk = os.walk def _counting_walk(path: object, *a: object, **kw: object) -> object: walk_calls.append(Path(str(path))) return real_walk(path, *a, **kw) # type: ignore[arg-type] monkeypatch.setattr(inventory_module.os, "walk", _counting_walk) first = list_templates(config)[0].dir_bytes clock["t"] += inventory_module._DIR_BYTES_TTL_SECONDS + 1.0 second = list_templates(config)[0].dir_bytes assert first == second == 10 assert len(walk_calls) == 2, "an expired cache entry must trigger a fresh os.walk" def test_list_templates_iterdir_oserror_returns_empty( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: templates_dir = tmp_path / "templates" templates_dir.mkdir() config = _make_config(tmp_path) original_iterdir = Path.iterdir def _boom_iterdir(self: Path) -> object: if self == templates_dir: raise OSError("denied") return original_iterdir(self) monkeypatch.setattr(Path, "iterdir", _boom_iterdir) assert list_templates(config) == [] def test_list_dir_resolve_oserror_returns_empty( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(Path, "resolve", lambda self: (_ for _ in ()).throw(OSError("denied"))) assert list_dir(tmp_path, root=tmp_path) == [] def test_list_dir_skips_subdirectories(tmp_path: Path) -> None: (tmp_path / "subdir").mkdir() (tmp_path / "file.txt").write_bytes(b"x") entries = list_dir(tmp_path, root=tmp_path) assert [e.name for e in entries] == ["file.txt"] def test_template_info_is_frozen_dataclass() -> None: info = TemplateInfo( name="X", vmx=None, guest_os=None, transport="unknown", dir_bytes=0, has_vmem=False, last_backup=None, ) with pytest.raises(AttributeError): info.name = "Y" # type: ignore[misc]