Files
local-ci-cd-system/tests/python/test_ssh_transport.py
T
Simone dc8449a0d7
Lint / pssa (push) Successful in 34s
Lint / python (push) Successful in 41s
test: cover easy missing branches in report/config/credentials/ssh/wait/job
2026-05-14 23:23:55 +02:00

245 lines
7.7 KiB
Python

"""Tests for SshTransport (paramiko mocked).
Covers AGENTS.md error #12 indirectly: with paramiko there is no native
``ssh-keygen`` invocation, so the PowerShell stderr-handling pitfall does
not apply. The default permissive host-key policy mirrors the
``StrictHostKeyChecking=no`` / ``UserKnownHostsFile=NUL`` defaults of
``scripts/_Transport.psm1``.
"""
from __future__ import annotations
import sys
import types
from typing import Any
import pytest
from ci_orchestrator.transport.errors import TransportCommandError, TransportConnectError
from ci_orchestrator.transport.ssh import SshTransport
class _FakeChannel:
def __init__(self, rc: int) -> None:
self._rc = rc
def recv_exit_status(self) -> int:
return self._rc
class _FakeStream:
def __init__(self, data: bytes, rc: int = 0) -> None:
self._data = data
self.channel = _FakeChannel(rc)
def read(self) -> bytes:
return self._data
class _FakeSFTP:
def __init__(self) -> None:
self.puts: list[tuple[str, str]] = []
self.gets: list[tuple[str, str]] = []
def __enter__(self) -> _FakeSFTP:
return self
def __exit__(self, *_e: object) -> None:
pass
def put(self, local: str, remote: str) -> None:
self.puts.append((local, remote))
def get(self, remote: str, local: str) -> None:
self.gets.append((remote, local))
class _FakeSSHClient:
def __init__(self) -> None:
self.connect_kwargs: dict[str, Any] = {}
self.policy: Any = None
self.next_stdout = b""
self.next_stderr = b""
self.next_rc = 0
self.commands: list[str] = []
self.sftp = _FakeSFTP()
self.closed = False
def set_missing_host_key_policy(self, policy: Any) -> None:
self.policy = policy
def load_host_keys(self, _path: str) -> None:
pass
def connect(self, **kwargs: Any) -> None:
self.connect_kwargs = kwargs
def exec_command(self, command: str, timeout: float | None = None) -> Any:
self.commands.append(command)
return (
None,
_FakeStream(self.next_stdout, self.next_rc),
_FakeStream(self.next_stderr, self.next_rc),
)
def open_sftp(self) -> _FakeSFTP:
return self.sftp
def close(self) -> None:
self.closed = True
def _install_fake_paramiko(monkeypatch: pytest.MonkeyPatch, client: _FakeSSHClient) -> Any:
fake = types.ModuleType("paramiko")
fake.SSHClient = lambda: client # type: ignore[attr-defined]
fake.AutoAddPolicy = lambda: "auto-add" # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "paramiko", fake)
return fake
def test_run_returns_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeSSHClient()
client.next_stdout = b"hello\n"
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", username="ci_build", key_path="/tmp/key")
result = t.run("echo hello")
assert result.ok
assert result.stdout == "hello\n"
assert client.commands == ["echo hello"]
def test_run_raises_on_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeSSHClient()
client.next_rc = 2
client.next_stderr = b"oops"
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
with pytest.raises(TransportCommandError):
t.run("bad")
def test_close_is_idempotent_and_swallows(monkeypatch: pytest.MonkeyPatch) -> None:
class _ExplodingClient(_FakeSSHClient):
def close(self) -> None: # type: ignore[override]
raise RuntimeError("already closed")
client = _ExplodingClient()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
t._connect() # type: ignore[attr-defined]
t.close()
t.close() # idempotent on already-closed (no client)
def test_context_manager_connects_and_closes(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeSSHClient()
_install_fake_paramiko(monkeypatch, client)
with SshTransport("10.0.0.2", key_path="/tmp/key") as t:
assert isinstance(t, SshTransport)
assert client.closed
def test_known_hosts_file_is_loaded(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
kh = tmp_path / "known_hosts"
kh.write_text("")
loaded: list[str] = []
class _RecordingClient(_FakeSSHClient):
def load_host_keys(self, p: str) -> None: # type: ignore[override]
loaded.append(p)
client = _RecordingClient()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key", known_hosts=str(kh))
t.run("ls")
assert loaded == [str(kh)]
def test_is_ready_returns_true_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeSSHClient()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
assert t.is_ready() is True
def test_is_ready_returns_false_on_connect_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _BoomClient(_FakeSSHClient):
def connect(self, **_kw: Any) -> None: # type: ignore[override]
raise RuntimeError("net down")
client = _BoomClient()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
assert t.is_ready() is False
def test_copy_wraps_sftp_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
class _BadSFTP(_FakeSFTP):
def put(self, _l: str, _r: str) -> None: # type: ignore[override]
raise RuntimeError("sftp boom")
client = _FakeSSHClient()
client.sftp = _BadSFTP()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
with pytest.raises(TransportConnectError):
t.copy("/x", "/y")
def test_fetch_wraps_sftp_error(monkeypatch: pytest.MonkeyPatch) -> None:
class _BadSFTP(_FakeSFTP):
def get(self, _r: str, _l: str) -> None: # type: ignore[override]
raise RuntimeError("sftp boom")
client = _FakeSSHClient()
client.sftp = _BadSFTP()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
with pytest.raises(TransportConnectError):
t.fetch("/y", "/x")
def test_default_policy_is_auto_add(monkeypatch: pytest.MonkeyPatch) -> None:
"""Mirrors permissive `StrictHostKeyChecking=no` default for ephemeral CI VMs."""
client = _FakeSSHClient()
_install_fake_paramiko(monkeypatch, client)
SshTransport("10.0.0.2", key_path="/tmp/key").run("true")
assert client.policy == "auto-add"
def test_copy_and_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeSSHClient()
_install_fake_paramiko(monkeypatch, client)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
t.copy("local.bin", "/remote/local.bin")
t.fetch("/remote/out.bin", "out.bin")
assert client.sftp.puts == [("local.bin", "/remote/local.bin")]
assert client.sftp.gets == [("/remote/out.bin", "out.bin")]
def test_is_ready_returns_false_on_connect_failure(monkeypatch: pytest.MonkeyPatch) -> None:
fake = types.ModuleType("paramiko")
class _Boom:
def set_missing_host_key_policy(self, _p: object) -> None:
pass
def connect(self, **_kw: Any) -> None:
raise OSError("refused")
def close(self) -> None:
pass
fake.SSHClient = lambda: _Boom() # type: ignore[attr-defined]
fake.AutoAddPolicy = lambda: object() # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "paramiko", fake)
t = SshTransport("10.0.0.2", key_path="/tmp/key")
assert t.is_ready() is False
with pytest.raises(TransportConnectError):
t.run("x")