"""Tests for ``validate host`` (C5) and ``validate guest`` (C6). CLI registration smoke tests (``--help``) are kept; behavioural tests mock the backend, keyring, systemctl, and transports. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from click.testing import CliRunner import ci_orchestrator.commands.validate as validate_module from ci_orchestrator.__main__ import cli from ci_orchestrator.backends.errors import ( BackendNotAvailable, BackendOperationFailed, ) from ci_orchestrator.config import BackendConfig, Config, Paths from ci_orchestrator.credentials import Credential from ci_orchestrator.transport.errors import ( TransportCommandError, TransportConnectError, ) # ── --help smoke tests (kept) ─────────────────────────────────────────────── def test_validate_help_lists_subcommands() -> None: result = CliRunner().invoke(cli, ["validate", "--help"]) assert result.exit_code == 0, result.output assert "host" in result.output assert "guest" in result.output def test_validate_guest_help() -> None: result = CliRunner().invoke(cli, ["validate", "guest", "--help"]) assert result.exit_code == 0, result.output assert "--host" in result.output # ── helpers ───────────────────────────────────────────────────────────────── def _make_config(tmp_path: Path) -> Config: root = tmp_path / "ci" templates = root / "templates" build_vms = root / "build-vms" artifacts = root / "artifacts" keys = root / "keys" for p in (root, templates, build_vms, artifacts, keys): p.mkdir(parents=True, exist_ok=True) return Config( paths=Paths( root=root, templates=templates, build_vms=build_vms, artifacts=artifacts, keys=keys, ), backend=BackendConfig(type="workstation"), guest_cred_target="BuildVMGuest", ) class _FakeBackend: def __init__(self, snapshots: dict[str, list[str]] | None = None) -> None: self.vmrun_path = "/usr/bin/vmrun" self._snapshots = snapshots or {} def list_snapshots(self, handle: Any) -> list[str]: return self._snapshots.get(handle.identifier, []) def _make_template_vmx(config: Config, name: str) -> str: vmx_dir = config.paths.templates / name vmx_dir.mkdir(parents=True, exist_ok=True) vmx = vmx_dir / f"{name}.vmx" vmx.write_text('config.version = "8"', encoding="utf-8") return str(vmx) # ── validate host: individual checks ──────────────────────────────────────── def test_check_vmrun_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: config = _make_config(tmp_path) monkeypatch.setattr(validate_module, "load_backend", lambda _c: _FakeBackend()) result = validate_module._check_vmrun(config) assert result.ok assert "vmrun" in result.detail def test_check_vmrun_no_path_attr( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) class _NoPath: pass monkeypatch.setattr(validate_module, "load_backend", lambda _c: _NoPath()) result = validate_module._check_vmrun(config) assert result.ok assert "backend loaded" in result.detail def test_check_vmrun_unavailable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) def _raise(_c: Any) -> Any: raise BackendNotAvailable("vmrun missing") monkeypatch.setattr(validate_module, "load_backend", _raise) result = validate_module._check_vmrun(config) assert not result.ok assert "missing" in result.detail def test_check_snapshots_backend_unavailable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) def _raise(_c: Any) -> Any: raise BackendNotAvailable("no vmrun") monkeypatch.setattr(validate_module, "load_backend", _raise) result = validate_module._check_snapshots(config) assert not result.ok assert "backend unavailable" in result.detail def test_check_snapshots_list_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) _make_template_vmx(config, "LinuxBuild2404") class _Backend: vmrun_path = "/usr/bin/vmrun" def list_snapshots(self, _handle: Any) -> list[str]: raise BackendOperationFailed("listSnapshots", 1, "boom") monkeypatch.setattr(validate_module, "load_backend", lambda _c: _Backend()) result = validate_module._check_snapshots(config) assert not result.ok assert "listSnapshots failed" in result.detail def test_check_keyring_runtime_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) class _Store: def get(self, target: str) -> Credential: raise RuntimeError("keyring is not installed") monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) result = validate_module._check_keyring(config) assert not result.ok assert "keyring is not installed" in result.detail def test_check_permissions_not_writable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) def _no_access(_path: Any, _mode: int) -> bool: return False monkeypatch.setattr(validate_module.os, "access", _no_access) result = validate_module._check_permissions(config) assert not result.ok assert "not read/write" in result.detail def test_check_snapshots_ok( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) win_vmx = _make_template_vmx(config, "WinBuild2025") backend = _FakeBackend({win_vmx: ["BaseClean"]}) monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend) result = validate_module._check_snapshots(config) assert result.ok, result.detail assert "WinBuild2025->BaseClean" in result.detail def test_check_snapshots_missing_snapshot( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) lin_vmx = _make_template_vmx(config, "LinuxBuild2404") backend = _FakeBackend({lin_vmx: ["SomethingElse"]}) monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend) result = validate_module._check_snapshots(config) assert not result.ok assert "BaseClean-Linux" in result.detail def test_check_snapshots_no_templates( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) backend = _FakeBackend({}) monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend) result = validate_module._check_snapshots(config) assert not result.ok assert "no known template VMX found" in result.detail def test_check_keyring_ok( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) class _Store: def get(self, target: str) -> Credential: return Credential(username="ci_build", password="x") monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) result = validate_module._check_keyring(config) assert result.ok assert "ci_build" in result.detail def test_check_keyring_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) class _Store: def get(self, target: str) -> Credential: raise KeyError(target) monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) result = validate_module._check_keyring(config) assert not result.ok def test_check_permissions_ok(tmp_path: Path) -> None: config = _make_config(tmp_path) result = validate_module._check_permissions(config) assert result.ok, result.detail def test_check_permissions_missing_dir(tmp_path: Path) -> None: config = _make_config(tmp_path) # Remove a required dir. config.paths.build_vms.rmdir() result = validate_module._check_permissions(config) assert not result.ok assert "missing" in result.detail def test_check_units_no_systemctl(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None) result = validate_module._check_units() assert not result.ok assert "systemctl" in result.detail def test_check_units_active(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl") monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "active") result = validate_module._check_units() assert result.ok def test_check_units_inactive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl") monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "inactive") result = validate_module._check_units() assert not result.ok assert "inactive" in result.detail def test_systemctl_is_active_invokes_subprocess( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl") class _CP: stdout = "active\n" monkeypatch.setattr( validate_module.subprocess, "run", lambda *a, **k: _CP() ) assert validate_module._systemctl_is_active("act-runner.service") == "active" def test_systemctl_is_active_no_binary(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None) assert validate_module._systemctl_is_active("x") is None def test_systemctl_is_active_timeout(monkeypatch: pytest.MonkeyPatch) -> None: import subprocess as _sp monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl") def _boom(*_a: Any, **_k: Any) -> Any: raise _sp.TimeoutExpired(cmd="systemctl", timeout=10.0) monkeypatch.setattr(validate_module.subprocess, "run", _boom) assert validate_module._systemctl_is_active("x") is None # ── validate host: end-to-end command ─────────────────────────────────────── def test_validate_host_all_pass( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) win_vmx = _make_template_vmx(config, "WinBuild2025") backend = _FakeBackend({win_vmx: ["BaseClean"]}) class _Store: def get(self, target: str) -> Credential: return Credential("ci_build", "x") monkeypatch.setattr(validate_module, "load_config", lambda: config) monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend) monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl") monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "active") result = CliRunner().invoke(cli, ["validate", "host"]) assert result.exit_code == 0, result.output assert "All host checks passed" in result.output def test_validate_host_reports_failures_and_exits_nonzero( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) def _raise(_c: Any) -> Any: raise BackendNotAvailable("vmrun missing") class _Store: def get(self, target: str) -> Credential: raise KeyError(target) monkeypatch.setattr(validate_module, "load_config", lambda: config) monkeypatch.setattr(validate_module, "load_backend", _raise) monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None) result = CliRunner().invoke(cli, ["validate", "host"]) assert result.exit_code == 1, result.output assert "FAIL" in result.output assert "check(s) failed" in result.output # ── validate guest ────────────────────────────────────────────────────────── def test_resolve_use_winrm_explicit() -> None: assert validate_module._resolve_use_winrm(True, None) is True assert validate_module._resolve_use_winrm(False, "windows") is False def test_resolve_use_winrm_inferred() -> None: assert validate_module._resolve_use_winrm(None, "linux") is False assert validate_module._resolve_use_winrm(None, "windows") is True def test_resolve_use_winrm_requires_hint() -> None: import click as _click with pytest.raises(_click.UsageError): validate_module._resolve_use_winrm(None, None) class _FakeTransport: """Records connect/run/close and raises a configured error on run.""" def __init__(self, *args: Any, raise_on_run: Exception | None = None, **kw: Any) -> None: self._raise = raise_on_run self.closed = False def run(self, *_a: Any, **_k: Any) -> Any: if self._raise is not None: raise self._raise class _R: returncode = 0 return _R() def close(self) -> None: self.closed = True def _install_keyring_store(monkeypatch: pytest.MonkeyPatch) -> None: class _Store: def get(self, target: str) -> Credential: return Credential("Administrator", "pw") monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) def test_probe_winrm_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: config = _make_config(tmp_path) _install_keyring_store(monkeypatch) transport = _FakeTransport() monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport) result = validate_module._probe_winrm("10.0.0.5", config) assert result.ok assert transport.closed def test_probe_winrm_auth_fail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) _install_keyring_store(monkeypatch) transport = _FakeTransport(raise_on_run=TransportConnectError("auth denied")) monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport) result = validate_module._probe_winrm("10.0.0.5", config) assert not result.ok assert "auth denied" in result.detail def test_probe_winrm_command_fail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) _install_keyring_store(monkeypatch) transport = _FakeTransport(raise_on_run=TransportCommandError(1, "", "boom")) monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport) result = validate_module._probe_winrm("10.0.0.5", config) assert not result.ok assert "command failed" in result.detail def test_probe_winrm_credential_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) class _Store: def get(self, target: str) -> Credential: raise KeyError(target) monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store) result = validate_module._probe_winrm("10.0.0.5", config) assert not result.ok assert "credential lookup failed" in result.detail def test_probe_ssh_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: config = _make_config(tmp_path) transport = _FakeTransport() monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport) result = validate_module._probe_ssh("10.0.0.6", config) assert result.ok assert transport.closed def test_probe_ssh_connect_fail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) transport = _FakeTransport(raise_on_run=TransportConnectError("no route")) monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport) result = validate_module._probe_ssh("10.0.0.6", config) assert not result.ok assert "no route" in result.detail def test_probe_ssh_command_fail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) transport = _FakeTransport(raise_on_run=TransportCommandError(2, "", "nope")) monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport) result = validate_module._probe_ssh("10.0.0.6", config) assert not result.ok assert "command failed" in result.detail def test_validate_guest_ssh_ok( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) transport = _FakeTransport() monkeypatch.setattr(validate_module, "load_config", lambda: config) monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport) result = CliRunner().invoke( cli, ["validate", "guest", "--host", "10.0.0.6", "--ssh"] ) assert result.exit_code == 0, result.output assert "OK" in result.output def test_validate_guest_winrm_fail_exits_nonzero( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) _install_keyring_store(monkeypatch) transport = _FakeTransport(raise_on_run=TransportConnectError("bad creds")) monkeypatch.setattr(validate_module, "load_config", lambda: config) monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport) result = CliRunner().invoke( cli, ["validate", "guest", "--host", "10.0.0.5", "--winrm"] ) assert result.exit_code == 1, result.output assert "FAIL" in result.output def test_validate_guest_requires_transport_hint( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) monkeypatch.setattr(validate_module, "load_config", lambda: config) result = CliRunner().invoke(cli, ["validate", "guest", "--host", "10.0.0.5"]) assert result.exit_code != 0 assert "transport" in result.output.lower() def test_validate_guest_infers_from_guest_os( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: config = _make_config(tmp_path) transport = _FakeTransport() monkeypatch.setattr(validate_module, "load_config", lambda: config) monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport) result = CliRunner().invoke( cli, ["validate", "guest", "--host", "10.0.0.6", "--guest-os", "linux"] ) assert result.exit_code == 0, result.output