Files
local-ci-cd-system/tests/python/test_ssh_transport.py
T
Simone bd31a3f2f3 Phase A1: bootstrap Python ci_orchestrator package
- pyproject.toml (hatchling) with ruff/mypy/pytest dev deps
- src/ci_orchestrator/: config, credentials, backends (Protocol + WorkstationVmrunBackend), transport (WinRM via pypsrp, SSH via paramiko)
- CLI entry point with PoC 'wait-ready' subcommand (Windows + Linux guests)
- tests/python/: 35 unit tests, 83% coverage, all backends/transport mocked
- gitea/workflows/lint.yml: new 'python' job (ruff + mypy --strict + pytest --cov-fail-under=70)
- config.example.toml + README setup section + .gitignore Python entries

Neutral VmBackend Protocol prepared for Phase C ESXi extension.
See plans/implementation-plan-A-B.md step A1.
2026-05-14 18:34:02 +02:00

160 lines
4.8 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_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")