"""Tests for ``ci_orchestrator artifacts collect``.""" from __future__ import annotations import json import tarfile import zipfile from pathlib import Path from typing import Any, ClassVar import pytest from click.testing import CliRunner import ci_orchestrator.commands.artifacts as artifacts_module from ci_orchestrator.__main__ import cli from ci_orchestrator.credentials import Credential from ci_orchestrator.transport.errors import TransportCommandError class _FakeResult: def __init__(self, stdout: str = "OK", returncode: int = 0) -> None: self.stdout = stdout self.stderr = "" self.returncode = returncode @property def ok(self) -> bool: return self.returncode == 0 class _FakeTransport: instances: ClassVar[list[_FakeTransport]] = [] def __init__(self, *args: Any, **kwargs: Any) -> None: self.runs: list[str] = [] self.fetched: list[tuple[str, str]] = [] self.fetch_payloads: dict[str, bytes] = {} self.run_overrides: dict[str, _FakeResult] = {} type(self).instances.append(self) def __enter__(self) -> _FakeTransport: return self def __exit__(self, *_e: object) -> None: return None def run(self, script: str, *, check: bool = True) -> _FakeResult: self.runs.append(script) for key, override in self.run_overrides.items(): if key in script: if check and not override.ok: raise TransportCommandError( override.returncode, override.stdout, override.stderr ) return override return _FakeResult() def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) payload = self.fetch_payloads.get(remote_path, b"data") Path(local_path).write_bytes(payload) @pytest.fixture(autouse=True) def _reset_instances() -> None: _FakeTransport.instances = [] def _patch(monkeypatch: pytest.MonkeyPatch) -> None: class _Store: def get(self, _t: str) -> Credential: return Credential("ci", "pwd") monkeypatch.setattr(artifacts_module, "WinRmTransport", _FakeTransport) monkeypatch.setattr(artifacts_module, "SshTransport", _FakeTransport) monkeypatch.setattr(artifacts_module, "KeyringCredentialStore", lambda: _Store()) # ── Windows happy path ──────────────────────────────────────────────────── def _zip_payload(tmp_path: Path) -> bytes: """Build a real zip whose contents the fake transport will 'fetch'.""" src = tmp_path / "zsrc" src.mkdir() (src / "binary.bin").write_bytes(b"\x01\x02\x03") (src / "log.txt").write_text("hello", encoding="utf-8") zip_local = tmp_path / "transfer.zip" with zipfile.ZipFile(zip_local, "w") as zf: for f in src.iterdir(): zf.write(f, arcname=f.name) return zip_local.read_bytes() def _win_transport_with_zip(payload: bytes) -> type: class _WinTransport(_FakeTransport): def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(payload) return _WinTransport def test_collect_windows_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) payload = _zip_payload(tmp_path) monkeypatch.setattr( artifacts_module, "WinRmTransport", _win_transport_with_zip(payload) ) out_dir = tmp_path / "artifacts" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(out_dir), ], ) assert result.exit_code == 0, result.output # Raw files extracted into host_dir (no zip-in-zip). assert (out_dir / "binary.bin").read_bytes() == b"\x01\x02\x03" assert (out_dir / "log.txt").read_text(encoding="utf-8") == "hello" cmds = _FakeTransport.instances[0].runs assert any("Compress-Archive" in c for c in cmds) # Temp transport zip on the guest is cleaned up. assert any("Remove-Item" in c and "ci-artifacts-transfer.zip" in c for c in cmds) def test_collect_windows_artifact_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) class _Missing(_FakeTransport): def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) self.run_overrides["-and (Get-ChildItem"] = _FakeResult( stdout="MISSING\r\n" ) monkeypatch.setattr(artifacts_module, "WinRmTransport", _Missing) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(tmp_path / "out"), ], ) assert result.exit_code != 0 assert "artifact not found or empty" in result.output def test_collect_windows_include_logs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) payload = _zip_payload(tmp_path) class _WithLogs(_win_transport_with_zip(payload)): # type: ignore[misc] def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) self.run_overrides["C:\\CI\\build"] = _FakeResult( stdout="C:\\CI\\build\\one.log\r\nC:\\CI\\build\\sub\\two.log\r\n" ) monkeypatch.setattr(artifacts_module, "WinRmTransport", _WithLogs) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(tmp_path / "out"), "--include-logs", ], ) assert result.exit_code == 0, result.output fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched] assert "C:\\CI\\build\\one.log" in fetched_remotes assert "C:\\CI\\build\\sub\\two.log" in fetched_remotes def test_collect_writes_manifest_when_job_id_set( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) payload = _zip_payload(tmp_path) monkeypatch.setattr( artifacts_module, "WinRmTransport", _win_transport_with_zip(payload) ) out = tmp_path / "out" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(out), "--job-id", "run-42", "--commit", "abc1234", ], ) assert result.exit_code == 0, result.output manifest_path = out / "manifest.json" assert manifest_path.is_file() data = json.loads(manifest_path.read_text(encoding="utf-8")) assert data["jobId"] == "run-42" assert data["commit"] == "abc1234" names = [f["name"] for f in data["files"]] assert "binary.bin" in names and "log.txt" in names # ── Linux happy path ────────────────────────────────────────────────────── def test_collect_linux_extracts_tarball( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) # Build a real tarball that the fake transport will "fetch". payload_dir = tmp_path / "payload" payload_dir.mkdir() (payload_dir / "binary.bin").write_bytes(b"\x01\x02\x03") (payload_dir / "log.txt").write_text("hello", encoding="utf-8") tar_local = tmp_path / "src.tar.gz" with tarfile.open(tar_local, "w:gz") as tf: for f in payload_dir.iterdir(): tf.add(f, arcname=f.name) payload_bytes = tar_local.read_bytes() class _LinuxTransport(_FakeTransport): def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) # Any fetched path returns the same tarball payload. self.fetch_payloads["__default__"] = payload_bytes def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(payload_bytes) monkeypatch.setattr(artifacts_module, "SshTransport", _LinuxTransport) out = tmp_path / "out" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--guest-artifact-path", "/opt/ci/output", "--host-artifact-dir", str(out), "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code == 0, result.output assert (out / "binary.bin").read_bytes() == b"\x01\x02\x03" assert (out / "log.txt").read_text(encoding="utf-8") == "hello" cmds = _FakeTransport.instances[0].runs assert any("tar -czf" in c for c in cmds) # Cleanup of the temp tarball on the guest was attempted. assert any("rm -f" in c for c in cmds) def test_collect_linux_no_files_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) # Empty tarball => extraction yields nothing. empty_tar = tmp_path / "empty.tar.gz" with tarfile.open(empty_tar, "w:gz"): pass payload = empty_tar.read_bytes() class _Empty(_FakeTransport): def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(payload) monkeypatch.setattr(artifacts_module, "SshTransport", _Empty) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--guest-artifact-path", "/opt/ci/output", "--host-artifact-dir", str(tmp_path / "out"), "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code != 0 assert "no files were transferred" in result.output # ── NEW: coverage gap tests ─────────────────────────────────────────────────── from ci_orchestrator.transport.errors import TransportError # noqa: E402 def test_collect_windows_include_logs_fetch_succeeds( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Line 187 (191): log fetch succeeds → file deposited, no error message.""" _patch(monkeypatch) payload = _zip_payload(tmp_path) class _LogFetchOk(_win_transport_with_zip(payload)): # type: ignore[misc] def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) # The log-listing command returns one log file path. self.run_overrides["C:\\CI\\build"] = _FakeResult( stdout="C:\\CI\\build\\build.log\r\n" ) def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) # Write something for the log file fetch too. Path(local_path).write_bytes(payload if local_path.endswith(".zip") else b"log content") monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchOk) out_dir = tmp_path / "out" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(out_dir), "--include-logs", ], ) assert result.exit_code == 0, result.output # fetch was called for both the zip and the log fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched] assert "C:\\CI\\build\\build.log" in fetched_remotes assert "fetching log" in result.output def test_collect_windows_include_logs_fetch_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Lines 192-193: log fetch raises TransportError → error message printed, continues.""" _patch(monkeypatch) payload = _zip_payload(tmp_path) class _LogFetchFail(_win_transport_with_zip(payload)): # type: ignore[misc] def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) self.run_overrides["C:\\CI\\build"] = _FakeResult( stdout="C:\\CI\\build\\fail.log\r\n" ) def fetch(self, remote_path: str, local_path: str) -> None: if remote_path.endswith(".log"): raise TransportError("log fetch boom") # Normal zip fetch self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(payload) monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchFail) out_dir = tmp_path / "out" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(out_dir), "--include-logs", ], ) # Should succeed overall (error logged but not fatal) assert result.exit_code == 0, result.output assert "log fetch failed" in result.output def test_collect_windows_include_logs_no_artifacts( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Line 199: include_logs=True, no artifacts extracted → ClickException.""" _patch(monkeypatch) # Transport that returns a real-looking zip but empty after extraction, # and returns log listing. empty_zip = tmp_path / "empty.zip" import zipfile as _zf with _zf.ZipFile(empty_zip, "w"): pass empty_zip_bytes = empty_zip.read_bytes() class _NoArtifacts(_FakeTransport): def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) # no log lines returned self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="") def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(empty_zip_bytes) monkeypatch.setattr(artifacts_module, "WinRmTransport", _NoArtifacts) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(tmp_path / "out"), "--include-logs", ], ) assert result.exit_code != 0 assert "no artifacts after collect" in result.output def test_collect_linux_key_from_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Line 261: --guest-os linux with no --ssh-key-path but config.ssh_key_path set.""" _patch(monkeypatch) # Build a real tarball for the fake transport to return. payload_dir = tmp_path / "payload" payload_dir.mkdir() (payload_dir / "out.txt").write_text("hi", encoding="utf-8") tar_local = tmp_path / "src.tar.gz" with tarfile.open(tar_local, "w:gz") as tf: for f in payload_dir.iterdir(): tf.add(f, arcname=f.name) payload_bytes = tar_local.read_bytes() captured_key: list[Any] = [] class _LinuxCapture(_FakeTransport): def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) captured_key.append(kw.get("key_path")) def fetch(self, remote_path: str, local_path: str) -> None: self.fetched.append((remote_path, str(local_path))) Path(local_path).write_bytes(payload_bytes) monkeypatch.setattr(artifacts_module, "SshTransport", _LinuxCapture) # Patch load_config to return a config that has ssh_key_path set. from ci_orchestrator.config import BackendConfig, Config, Paths fake_key = tmp_path / "ci_linux" _test_paths = Paths( root=tmp_path, templates=tmp_path, build_vms=tmp_path, artifacts=tmp_path, keys=tmp_path, ) _test_cfg = Config(paths=_test_paths, backend=BackendConfig(), ssh_key_path=fake_key) monkeypatch.setattr(artifacts_module, "load_config", lambda: _test_cfg) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--guest-artifact-path", "/opt/ci/output", "--host-artifact-dir", str(tmp_path / "out"), # no --ssh-key-path → should come from config ], ) assert result.exit_code == 0, result.output assert captured_key and captured_key[0] == str(fake_key) def test_collect_transport_error_raises_click_exception( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Line 280: TransportError during collect → ClickException.""" _patch(monkeypatch) class _Boom(_FakeTransport): def __enter__(self) -> _Boom: raise TransportError("connect failed") monkeypatch.setattr(artifacts_module, "WinRmTransport", _Boom) result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(tmp_path / "out"), ], ) assert result.exit_code != 0 assert "connect failed" in result.output def test_collect_windows_log_listing_skips_blank_lines( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Line 187 (continue): listing stdout has blank lines → skipped silently.""" _patch(monkeypatch) payload = _zip_payload(tmp_path) class _BlankLines(_win_transport_with_zip(payload)): # type: ignore[misc] def __init__(self, *a: Any, **kw: Any) -> None: super().__init__(*a, **kw) # Return listing with leading/trailing blank lines only — no real log paths. self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="\r\n \r\n") monkeypatch.setattr(artifacts_module, "WinRmTransport", _BlankLines) out_dir = tmp_path / "out" result = CliRunner().invoke( cli, [ "artifacts", "collect", "--ip-address", "10.0.0.7", "--guest-artifact-path", "C:\\CI\\output", "--host-artifact-dir", str(out_dir), "--include-logs", ], ) assert result.exit_code == 0, result.output # No "fetching log" output — all listing lines were blank and skipped. assert "fetching log" not in result.output