""" Tests fuer Phase 14 -- die eigentliche Ursache des "Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar"-Dauerbrenners. Gemeldet wurde zuletzt: Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: get_server_host_key() got an unexpected keyword argument 'connect_timeout' ...und zwar in unter einer Sekunde nach dem Klick. Genau das ist der Beweis: es wurde nie ein Socket geoeffnet. asyncssh.get_server_host_key() nimmt -- anders als asyncssh.connect() -- KEIN **kwargs entgegen, 'connect_timeout' gehoert nicht zu seinen Parametern. Der Aufruf endete also schon beim Argument-Binding in einem TypeError. Warum das dreimal falsch diagnostiziert wurde: * Phase 12 fuehrte get_server_host_key(..., connect_timeout=10) ein. Der TypeError lief unbehandelt bis FastAPI -> nackter HTTP 500. * Der Phase-12-Nachtrag deutete diesen 500er als "except (asyncssh.Error, OSError) ist zu eng" und verbreiterte auf except Exception. Damit wurde der TypeError zwar gefangen -- aber als HostKeyDiscoveryError, also als "Ziel nicht erreichbar" VERKLEIDET. Erst dadurch wurde der Wortlaut ueberhaupt sichtbar. * Die Tests aus Phase 12 konnten es nicht sehen: ihre asyncssh-Attrappen waren als (host, port=22, **kw) definiert und schluckten jedes beliebige Argument klaglos. Diese Datei sichert genau diese drei Punkte ab. """ from __future__ import annotations import asyncio import inspect import asyncssh import pytest from app.ssh_proxy import proxy as proxy_module from app.ssh_proxy.proxy import ( HostKeyDiscoveryError, _verified_host_key, discover_and_store_host_key, load_host, ) try: # je nach sys.path-Modus von pytest from tests.test_phase12 import FINGERPRINT, PUBLIC_KEY, FakeConnection, FakeHostKey, _make_db except ImportError: # pragma: no cover from test_phase12 import FINGERPRINT, PUBLIC_KEY, FakeConnection, FakeHostKey, _make_db # -------------------------------------------------------------------------- # Attrappe mit der ECHTEN Signatur von asyncssh 2.18.0. Bewusst kein **kwargs: # jedes zusaetzliche Argument aus dem Produktivcode schlaegt hier genauso fehl # wie beim echten asyncssh. # -------------------------------------------------------------------------- def _strict_get_server_host_key(result=None, *, calls=None): async def _fake( host="", port=(), *, tunnel=(), proxy_command=(), family=(), flags=0, local_addr=(), sock=None, client_version=(), kex_algs=(), server_host_key_algs=(), config=(), options=None, ): if calls is not None: calls.append((host, port)) if isinstance(result, BaseException): raise result return result if result is not None else FakeHostKey() return _fake # -------------------------------------------------------------------------- # 1) Die Signatur selbst -- der Test, der den Bug im Produktivsystem gefunden # haette. Laeuft gegen das TATSAECHLICH installierte asyncssh. # -------------------------------------------------------------------------- def test_aufruf_passt_zur_echten_signatur_von_get_server_host_key(): sig = inspect.signature(asyncssh.get_server_host_key) # So ruft proxy.py auf -- muss sich binden lassen: sig.bind("10.0.0.1", port=22) def test_connect_timeout_ist_kein_parameter_von_get_server_host_key(): """Dokumentiert die Falle: connect_timeout ist bei asyncssh.connect() erlaubt (das reicht **kwargs an SSHClientConnectionOptions weiter), bei get_server_host_key() aber NICHT -- dessen Parameterliste ist abschliessend.""" sig = inspect.signature(asyncssh.get_server_host_key) hat_var_keyword = any( p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() ) if hat_var_keyword: pytest.skip("Diese asyncssh-Version nimmt **kwargs entgegen") assert "connect_timeout" not in sig.parameters with pytest.raises(TypeError): sig.bind("10.0.0.1", port=22, connect_timeout=10) async def test_fetch_server_host_key_uebergibt_nur_host_und_port(monkeypatch): calls: list = [] monkeypatch.setattr( asyncssh, "get_server_host_key", _strict_get_server_host_key(calls=calls), raising=False ) key = await proxy_module._fetch_server_host_key("10.0.0.1", 2222) assert calls == [("10.0.0.1", 2222)] assert key.get_fingerprint("sha256") == FINGERPRINT # -------------------------------------------------------------------------- # 2) Beide betroffenen Pfade end-to-end -- mit der strengen Attrappe. # Wichtig: _verified_host_key() liegt im REGULAEREN Verbindungspfad # (connect_to_host), der connect_timeout-Bug hat also nicht nur den Knopf # "Host-Key ermitteln", sondern JEDE SSH-Sitzung lahmgelegt. # -------------------------------------------------------------------------- async def test_discovery_laeuft_mit_echter_signatur_durch(tmp_path, monkeypatch): monkeypatch.setattr( asyncssh, "get_server_host_key", _strict_get_server_host_key(), raising=False ) conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None)) assert await discover_and_store_host_key(conn, 1, admin_user_id=1) == FINGERPRINT async def test_pinning_laeuft_mit_echter_signatur_durch(tmp_path, monkeypatch): monkeypatch.setattr( asyncssh, "get_server_host_key", _strict_get_server_host_key(), raising=False ) conn = FakeConnection(_make_db(tmp_path)) host = await load_host(conn, 1) key = await _verified_host_key(conn, host) assert key.get_fingerprint("sha256") == FINGERPRINT # -------------------------------------------------------------------------- # 3) Fehlerbehandlung: echte Netzwerkfehler bleiben "Ziel nicht erreichbar", # ein Aufruffehler im eigenen Code aber NICHT. # -------------------------------------------------------------------------- async def test_netzwerkfehler_bleibt_hostkeydiscoveryerror(tmp_path, monkeypatch): monkeypatch.setattr( asyncssh, "get_server_host_key", _strict_get_server_host_key(OSError("Connection refused")), raising=False, ) conn = FakeConnection(_make_db(tmp_path)) with pytest.raises(HostKeyDiscoveryError) as excinfo: await discover_and_store_host_key(conn, 1, admin_user_id=1) assert "Connection refused" in excinfo.value.reason async def test_typeerror_wird_nicht_als_unerreichbar_verkleidet(tmp_path, monkeypatch): """Der Kern der Lehre aus dieser Phase: haette der Catch-all den TypeError nicht als HostKeyDiscoveryError ausgegeben, waere die Meldung von Anfang an als Programmierfehler erkennbar gewesen.""" async def _kaputt(*a, **kw): raise TypeError("get_server_host_key() got an unexpected keyword argument 'irgendwas'") monkeypatch.setattr(asyncssh, "get_server_host_key", _kaputt, raising=False) conn = FakeConnection(_make_db(tmp_path)) with pytest.raises(TypeError): await discover_and_store_host_key(conn, 1, admin_user_id=1) host = await load_host(conn, 1) with pytest.raises(TypeError): await _verified_host_key(conn, host) async def test_zeitlimit_wird_von_aussen_gesetzt(tmp_path, monkeypatch): """Das Zeitlimit ist nicht ersatzlos entfallen, sondern liegt jetzt in asyncio.wait_for() -- unabhaengig davon, wie asyncssh seine Optionen benennt.""" async def _haengt(*a, **kw): await asyncio.sleep(5) monkeypatch.setattr(asyncssh, "get_server_host_key", _haengt, raising=False) monkeypatch.setattr(proxy_module, "HOST_KEY_CONNECT_TIMEOUT", 0.05) conn = FakeConnection(_make_db(tmp_path)) with pytest.raises(HostKeyDiscoveryError) as excinfo: await discover_and_store_host_key(conn, 1, admin_user_id=1) assert "Zeitlimit" in excinfo.value.reason