connectiopn fix round 2 3
This commit is contained in:
415
tests/test_phase15.py
Normal file
415
tests/test_phase15.py
Normal file
@ -0,0 +1,415 @@
|
||||
"""
|
||||
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 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
|
||||
|
||||
|
||||
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):
|
||||
return _OkSshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
|
||||
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):
|
||||
raise asyncssh.Error(reason="Connection refused")
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
|
||||
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):
|
||||
return _SshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
|
||||
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):
|
||||
raise AttributeError("simuliert einen unverwandten kuenftigen Bug")
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.Request()
|
||||
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
|
||||
|
||||
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:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def stat(self, path):
|
||||
return _Stat()
|
||||
|
||||
class _SshConn:
|
||||
def start_sftp_client(self):
|
||||
return _HugeSftp()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
async def fake_connect_to_host(conn, host_id):
|
||||
return _SshConn()
|
||||
|
||||
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
|
||||
|
||||
request = sftp_module.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()
|
||||
active_sessions.register(session_id=4242, task=task)
|
||||
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())
|
||||
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)
|
||||
Reference in New Issue
Block a user