""" Tests fuer Phase 15 (2026-08-21) -- Dateitransfer-500er, SSH-Wiedergabe- Haenger und Live-Mitschau aktiver Sitzungen. Drei voneinander unabhaengige Bugs/Features, gemeldet in derselben Session: 1) Dateitransfer-Upload/-Download endete bei einem Fehler als nackter Klartext-500 ("Internal Server Error") statt JSON -- das Frontend crashte beim res.json() mit "Unexpected token 'I' ...". Zwei voneinander UNABHAENGIGE Ursachen in app/ssh_proxy/sftp.py: a) _log_transfer() wurde mit dem falschen Keyword-Argumentnamen aufgerufen (av_scan_result= statt av_result=) -- das feuerte bei JEDEM erfolgreichen Transfer, ausserhalb jedes try/except. b) except SSH_SETUP_ERRORS deckte nur Fehler VOR der Anmeldung ab; asyncssh.Error (echter Verbindungsfehler waehrend asyncssh.connect(), oder ein SFTP-Fehler NACH der Anmeldung wie SFTPPermissionDenied) lief unbehandelt durch. Siehe project memory: dateitransfer_upload_500_phase15.md. 2) "SSH-Wiedergabe funktioniert nicht": static/js/admin.js dekodierte jeden aufgezeichneten output-Chunk einzeln ueber decodeURIComponent(escape(atob(...))) als JS-String. Ein Mehrbyte-UTF-8- Zeichen (Umlaute etc.), das GENAU an einer process.stdout.read()- Chunk-Grenze zerschnitten wurde, liess das an dieser Stelle mit "URI malformed" abstuerzen -- innerhalb eines try-losen setTimeout, was die gesamte Wiedergabe fuer immer haengen liess. Reiner JS-Bug (kein Python-Aequivalent hier direkt testbar) -- separat mit einem Node-Skript verifiziert (siehe Session-Notizen); dieser Testfall sichert stattdessen die BACKEND-Seite ab, die diesen Chunk ueberhaupt erst erzeugt. 3) "Aktive Session kann man nicht mitschauen": neue read-only Live-Mitschau fuer Superadmins, GET /ws/sessions/{id}/watch (app/ssh_proxy/ terminal_ws.py) + app/security/active_sessions.py (Beobachter-Queues pro Sitzung, dieselbe best-effort/put_nowait-Philosophie wie log_stream.py). """ from __future__ import annotations import base64 import io # Bug-Fix Punkt 1: UploadFile(file, *, filename=...) braucht ein # dateiaehnliches Objekt als erstes Argument, keine rohen bytes # (siehe UploadFile-Konstruktionen unten). import asyncssh import pytest from fastapi import HTTPException, WebSocketDisconnect from app.security import active_sessions from app.ssh_proxy import sftp as sftp_module from app.ssh_proxy import terminal_ws as terminal_ws_module class _FakeUser: def __init__(self, *, is_admin=True, id=1, username="admin"): self.is_admin = is_admin self.id = id self.username = username def _fake_request() -> "sftp_module.Request": """Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): starlette.Request verlangt seit jeher ein `scope`-Dict als Pflichtargument (KEIN API-Drift der Anwendung -- die Tests riefen schlicht Request() ohne Argumente auf). Minimaler, aber vollstaendiger ASGI-HTTP-Scope: `client` wird von sftp.py's _client_ip()-Helfer gelesen (request.client.host), `headers` muss als Liste vorhanden sein (Starlette baut Request.headers direkt daraus, KeyError bei fehlendem Key).""" return sftp_module.Request({"type": "http", "client": ("127.0.0.1", 12345), "headers": []}) class _FakeConn: """Minimaler aiosqlite-Ersatz fuer diese Tests -- genug fuer die INSERT/UPDATE/SELECT-Aufrufe aus sftp.py/terminal_ws.py, ohne echte SQLite-Semantik nachzubilden (das leisten test_phase8/9/12/13 bereits fuer die jeweils betroffenen Endpunkte).""" def __init__(self, select_result=None): self._select_result = select_result async def execute(self, sql, params=()): class _Cursor: lastrowid = 1 async def fetchone(self_inner): return self._select_result return _Cursor() async def commit(self): pass # -------------------------------------------------------------------------- # 1a) Regression: _log_transfer() muss mit dem PARAMETERNAMEN aufgerufen # werden, den es tatsaechlich definiert (av_result), nicht mit einem # Namen, der zufaellig plausibel klingt (av_scan_result). Ein erfolg- # reicher Transfer darf NIE eine TypeError werfen. # -------------------------------------------------------------------------- async def test_upload_erfolgreicher_transfer_wirft_keine_typeerror(monkeypatch): fake_conn = _FakeConn() monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) async def fake_write_audit_event(*a, **kw): pass monkeypatch.setattr(sftp_module, "write_audit_event", fake_write_audit_event) class _OkRemoteFile: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def write(self, data): self.written = data class _OkSftp: async def __aenter__(self): return self async def __aexit__(self, *a): return False def open(self, path, mode): return _OkRemoteFile() class _OkSshConn: def start_sftp_client(self): return _OkSftp() def close(self): pass async def fake_connect_to_host(conn, host_id, *, user_id=None): # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host() # verlangt inzwischen user_id als Keyword-Argument (echte Signatur- # erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest # entgegennehmen koennen, auch wenn er es hier nicht auswertet. return _OkSshConn() monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) request = _fake_request() upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt") result = await sftp_module.upload_file( host_id=1, request=request, remote_path="/tmp/test.txt", file=upload, host={"file_transfer_enabled": True}, user=_FakeUser(), ) assert result["status"] == "ok" assert result["size"] == len(b"hallo welt") # -------------------------------------------------------------------------- # 1b) asyncssh.Error (Verbindungsfehler NACH SSH_SETUP_ERRORS-Pruefung, oder # ein SFTP-Fehler nach erfolgreicher Anmeldung) muss als HTTPException # ankommen -- NIE unbehandelt durchlaufen. # -------------------------------------------------------------------------- async def test_upload_asyncssh_error_wird_zu_http_exception_400(monkeypatch): fake_conn = _FakeConn() monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) async def fake_connect_to_host(conn, host_id, *, user_id=None): # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host() # verlangt inzwischen user_id als Keyword-Argument (echte Signatur- # erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest # entgegennehmen koennen, auch wenn er es hier nicht auswertet. # Weiterer Drift (echte asyncssh-Signatur via inspect.signature # bestaetigt): asyncssh.Error.__init__(self, code: int, reason: str, # lang: str = 'en-US') verlangt `code` als PFLICHT-Positionsargument, # nicht nur `reason`. Der alte Fake-Aufruf asyncssh.Error(reason=...) # loeste selbst einen TypeError aus, BEVOR die eigentlich zu testende # except asyncssh.Error-Behandlung in sftp.py ueberhaupt erreicht wurde # -- der TypeError fiel unter das allgemeine except Exception- # Auffangnetz und ergab faelschlich 500 statt 400. Der konkrete # code-Wert ist fuer den Test irrelevant (nur die Klasse asyncssh.Error # zaehlt fuer den except-Zweig in sftp.py); DISC_CONNECTION_LOST # ist eine echte asyncssh-Konstante (asyncssh.DISC_CONNECTION_LOST) und # passt inhaltlich. raise asyncssh.Error(asyncssh.DISC_CONNECTION_LOST, "Connection refused") monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) request = _fake_request() upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt") with pytest.raises(HTTPException) as excinfo: await sftp_module.upload_file( host_id=1, request=request, remote_path="/tmp/test.txt", file=upload, host={"file_transfer_enabled": True}, user=_FakeUser(), ) assert excinfo.value.status_code == 400 async def test_upload_sftp_permission_denied_wird_zu_http_exception_400(monkeypatch): """SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. Zielverzeichnis nicht vorhanden/keine Berechtigung) sind asyncssh.SFTPError -- eine Unterklasse von asyncssh.Error, KEINE der SSH_SETUP_ERRORS.""" fake_conn = _FakeConn() monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) class _DeniedSftp: async def __aenter__(self): return self async def __aexit__(self, *a): return False def open(self, path, mode): raise asyncssh.SFTPPermissionDenied(reason="Permission denied") class _SshConn: def start_sftp_client(self): return _DeniedSftp() def close(self): pass async def fake_connect_to_host(conn, host_id, *, user_id=None): # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host() # verlangt inzwischen user_id als Keyword-Argument (echte Signatur- # erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest # entgegennehmen koennen, auch wenn er es hier nicht auswertet. return _SshConn() monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) request = _fake_request() upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt") with pytest.raises(HTTPException) as excinfo: await sftp_module.upload_file( host_id=1, request=request, remote_path="/root/blocked.txt", file=upload, host={"file_transfer_enabled": True}, user=_FakeUser(), ) assert excinfo.value.status_code == 400 async def test_upload_unerwarteter_fehler_wird_zu_http_exception_500_nicht_unbehandelt(monkeypatch): """Letztes Auffangnetz: selbst ein voellig unverwandter Bug (hier simuliert per AttributeError) darf NIE unbehandelt bis Starlettes Klartext-500 durchlaufen -- das Frontend braucht immer JSON.""" fake_conn = _FakeConn() monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) async def fake_connect_to_host(conn, host_id, *, user_id=None): # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host() # verlangt inzwischen user_id als Keyword-Argument (echte Signatur- # erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest # entgegennehmen koennen, auch wenn er es hier nicht auswertet. raise AttributeError("simuliert einen unverwandten kuenftigen Bug") monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) request = _fake_request() upload = sftp_module.UploadFile(io.BytesIO(b"hallo welt"), filename="test.txt") with pytest.raises(HTTPException) as excinfo: await sftp_module.upload_file( host_id=1, request=request, remote_path="/tmp/test.txt", file=upload, host={"file_transfer_enabled": True}, user=_FakeUser(), ) assert excinfo.value.status_code == 500 async def test_download_datei_zu_gross_bleibt_413_und_wird_nicht_zu_500(monkeypatch): """Regressionsschutz fuer den neuen Catch-all: die bereits bestehende HTTPException(413) aus dem inneren try-Block darf NICHT vom neuen 'except Exception' verschluckt und zu einem 500 umgemuenzt werden.""" fake_conn = _FakeConn() monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn) class _Stat: size = sftp_module.MAX_UPLOAD_BYTES + 1 class _HugeSftp: """Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): download_file() nutzt `sftp = await ssh_conn.start_sftp_client()` (KEIN `async with`, anders als upload_file() -- reales asyncssh erlaubt beides ueber seinen @async_context_manager-Dekorator) und ruft im Fehlerfall `sftp.exit()` SYNCHRON auf (kein await, echte asyncssh-API). Der vorherige Fake hatte weder ein passendes async start_sftp_client() noch ueberhaupt eine exit()-Methode.""" async def stat(self, path): return _Stat() def exit(self): pass class _SshConn: async def start_sftp_client(self): return _HugeSftp() def close(self): pass async def fake_connect_to_host(conn, host_id, *, user_id=None): # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): connect_to_host() # verlangt inzwischen user_id als Keyword-Argument (echte Signatur- # erweiterung, app/ssh_proxy/proxy.py) -- dieser Fake muss es zumindest # entgegennehmen koennen, auch wenn er es hier nicht auswertet. return _SshConn() monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host) request = _fake_request() with pytest.raises(HTTPException) as excinfo: await sftp_module.download_file( host_id=1, request=request, remote_path="/tmp/huge.bin", host={"file_transfer_enabled": True}, user=_FakeUser(), ) assert excinfo.value.status_code == 413 # -------------------------------------------------------------------------- # 3) Live-Mitschau: app/security/active_sessions.py Beobachter-Queues. # -------------------------------------------------------------------------- async def test_active_sessions_broadcast_erreicht_alle_beobachter(event_loop=None): import asyncio task = asyncio.current_task() # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 1): register() # verlangt seit E.4/Teil F user_id als drittes Pflichtargument (echte # Signaturerweiterung der Anwendung, kein Test-Freibleiber -- die # konkrete ID ist fuer diesen Test irrelevant, es geht nur um die # Beobachter-/Broadcast-Mechanik). active_sessions.register(session_id=4242, task=task, user_id=1) try: q1 = active_sessions.add_watcher(4242) q2 = active_sessions.add_watcher(4242) assert q1 is not None and q2 is not None and q1 is not q2 active_sessions.broadcast(4242, b"chunk-1") assert q1.get_nowait() == b"chunk-1" assert q2.get_nowait() == b"chunk-1" active_sessions.remove_watcher(4242, q1) active_sessions.broadcast(4242, b"chunk-2") assert q2.get_nowait() == b"chunk-2" assert q1.empty() finally: active_sessions.unregister(4242) async def test_active_sessions_add_watcher_auf_unbekannter_sitzung_gibt_none(): assert active_sessions.get(999999) is None assert active_sessions.add_watcher(999999) is None # -------------------------------------------------------------------------- # 3) Live-Mitschau: WS-Route -- Zugriffskontrolle + Frame-Weiterleitung. # -------------------------------------------------------------------------- class _FakeWebSocket: def __init__(self): self.accepted = False self.sent = [] self.closes = [] self.client = type("C", (), {"host": "10.0.0.5"})() async def accept(self): self.accepted = True async def send_json(self, obj): self.sent.append(obj) async def close(self, code=1000, reason=""): self.closes.append((code, reason)) async def test_watch_ssh_session_lehnt_nicht_admin_ab(monkeypatch): async def as_normal_user(ws): return _FakeUser(is_admin=False) monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_normal_user) ws = _FakeWebSocket() await terminal_ws_module.watch_ssh_session(ws, 1) assert ws.closes == [(4403, "")] assert ws.accepted is False async def test_watch_ssh_session_lehnt_beendete_sitzung_ab(monkeypatch): async def as_admin(ws): return _FakeUser(is_admin=True) monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin) monkeypatch.setattr( terminal_ws_module, "get_db", lambda: _FakeConn(select_result=("ssh", "2026-08-21T10:00:00Z")), ) ws = _FakeWebSocket() await terminal_ws_module.watch_ssh_session(ws, 1) assert ws.closes == [(4404, "")] assert ws.accepted is False async def test_watch_ssh_session_lehnt_rdp_ab(monkeypatch): async def as_admin(ws): return _FakeUser(is_admin=True) monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin) monkeypatch.setattr( terminal_ws_module, "get_db", lambda: _FakeConn(select_result=("rdp", None)) ) ws = _FakeWebSocket() await terminal_ws_module.watch_ssh_session(ws, 1) assert ws.closes == [(4404, "")] assert ws.accepted is False async def test_watch_ssh_session_leitet_frames_weiter_und_raeumt_beobachter_auf(monkeypatch): import asyncio async def as_admin(ws): return _FakeUser(is_admin=True) monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin) monkeypatch.setattr( terminal_ws_module, "get_db", lambda: _FakeConn(select_result=("ssh", None)) ) audit_calls = [] async def fake_write_audit_event(conn, **kw): audit_calls.append(kw) monkeypatch.setattr(terminal_ws_module, "write_audit_event", fake_write_audit_event) active_sessions.register(session_id=1, task=asyncio.current_task(), user_id=1) try: class _WsThenDisconnect(_FakeWebSocket): def __init__(self): super().__init__() self._n = 0 async def send_json(self, obj): await super().send_json(obj) self._n += 1 if self._n >= 2: raise WebSocketDisconnect() ws = _WsThenDisconnect() async def feeder(): await asyncio.sleep(0.01) active_sessions.broadcast(1, b"frame-1") await asyncio.sleep(0.01) active_sessions.broadcast(1, b"frame-2") feeder_task = asyncio.create_task(feeder()) await terminal_ws_module.watch_ssh_session(ws, 1) await feeder_task assert ws.accepted is True assert [f["data"] for f in ws.sent] == [ base64.b64encode(b"frame-1").decode(), base64.b64encode(b"frame-2").decode(), ] # Beobachter muss nach dem Trennen entfernt sein (kein Leak). assert len(active_sessions.get(1).watchers) == 0 assert len(audit_calls) == 1 assert audit_calls[0]["event_type"] == "session_watch_started" finally: active_sessions.unregister(1)