Skip to content

Commit aca0a95

Browse files
author
Nils Bars
committed
Improve SSH client and Rust proxy tests
- Add connection retry logic to SSH client - Add detailed logging for connection attempts - Expand Rust proxy test coverage
1 parent 4d7e3cf commit aca0a95

2 files changed

Lines changed: 138 additions & 11 deletions

File tree

tests/e2e/test_rust_ssh_proxy.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Connects via the ssh_port fixture to the SSH reverse proxy.
66
"""
77

8+
import logging
89
import uuid
910
from pathlib import Path
1011
from typing import Optional
@@ -15,6 +16,15 @@
1516
from helpers.ssh_client import REFSSHClient
1617
from helpers.web_client import REFWebClient
1718

19+
# Set up logging for this test module
20+
logger = logging.getLogger(__name__)
21+
logger.setLevel(logging.DEBUG)
22+
# Ensure logs go to stdout
23+
if not logger.handlers:
24+
handler = logging.StreamHandler()
25+
handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
26+
logger.addHandler(handler)
27+
1828

1929
class RustProxyTestState:
2030
"""Shared state for Rust proxy tests."""
@@ -137,6 +147,7 @@ def test_05_register_student(
137147
web_client.logout()
138148
mat_num = str(uuid.uuid4().int)[:8]
139149
rust_proxy_state.mat_num = mat_num
150+
logger.info(f"[TEST] Registering student with mat_num: {mat_num}")
140151

141152
success, private_key, _ = web_client.register_student(
142153
mat_num=mat_num,
@@ -149,6 +160,22 @@ def test_05_register_student(
149160
assert private_key is not None
150161
rust_proxy_state.private_key = private_key
151162

163+
# Log private key info
164+
logger.info(f"[TEST] Got private key of length {len(private_key)}")
165+
logger.info(f"[TEST] Private key first 100 chars: {private_key[:100]}...")
166+
167+
# Parse the key to get the public key for comparison
168+
import io
169+
import paramiko
170+
171+
try:
172+
key_file = io.StringIO(private_key)
173+
pkey = paramiko.Ed25519Key.from_private_key(key_file)
174+
pub_key_str = f"{pkey.get_name()} {pkey.get_base64()}"
175+
logger.info(f"[TEST] Derived public key: {pub_key_str}")
176+
except Exception as e:
177+
logger.error(f"[TEST] Failed to parse private key: {e}")
178+
152179
# Re-login as admin
153180
web_client.login("0", admin_password)
154181

@@ -162,17 +189,65 @@ def test_01_ssh_connect_via_rust_proxy(
162189
ssh_host: str,
163190
ssh_port: int,
164191
rust_proxy_state: RustProxyTestState,
192+
ref_instance,
165193
):
166194
"""Verify SSH connection works through the Rust SSH proxy."""
167195
assert rust_proxy_state.private_key is not None
168196
assert rust_proxy_state.exercise_name is not None
169197

170-
client = create_rust_ssh_client(
171-
host=ssh_host,
172-
port=ssh_port,
173-
private_key=rust_proxy_state.private_key,
174-
exercise_name=rust_proxy_state.exercise_name,
175-
)
198+
logger.info(f"[TEST] Connecting to SSH proxy at {ssh_host}:{ssh_port}")
199+
logger.info(f"[TEST] Exercise name: {rust_proxy_state.exercise_name}")
200+
logger.info(f"[TEST] Private key length: {len(rust_proxy_state.private_key)}")
201+
202+
# Parse the key to log the public key
203+
import io
204+
import paramiko
205+
206+
try:
207+
key_file = io.StringIO(rust_proxy_state.private_key)
208+
pkey = paramiko.Ed25519Key.from_private_key(key_file)
209+
pub_key_str = f"{pkey.get_name()} {pkey.get_base64()}"
210+
logger.info(f"[TEST] Will authenticate with public key: {pub_key_str}")
211+
except Exception as e:
212+
logger.error(f"[TEST] Failed to parse private key: {e}")
213+
214+
# Capture SSH proxy logs before connection attempt
215+
logger.info("[TEST] === SSH Proxy logs BEFORE connection attempt ===")
216+
try:
217+
logs = ref_instance.logs(tail=50)
218+
for line in logs.split("\n"):
219+
if (
220+
"ssh-reverse-proxy" in line.lower()
221+
or "[AUTH]" in line
222+
or "[API]" in line
223+
):
224+
logger.info(f"[PROXY LOG] {line}")
225+
except Exception as e:
226+
logger.error(f"[TEST] Failed to get logs: {e}")
227+
228+
try:
229+
client = create_rust_ssh_client(
230+
host=ssh_host,
231+
port=ssh_port,
232+
private_key=rust_proxy_state.private_key,
233+
exercise_name=rust_proxy_state.exercise_name,
234+
)
235+
except Exception as e:
236+
# Capture SSH proxy logs after failed connection
237+
logger.error(f"[TEST] Connection failed: {e}")
238+
logger.info("[TEST] === SSH Proxy logs AFTER failed connection ===")
239+
try:
240+
logs = ref_instance.logs(tail=100)
241+
for line in logs.split("\n"):
242+
if (
243+
"ssh-reverse-proxy" in line.lower()
244+
or "[AUTH]" in line
245+
or "[API]" in line
246+
):
247+
logger.info(f"[PROXY LOG] {line}")
248+
except Exception as log_e:
249+
logger.error(f"[TEST] Failed to get logs: {log_e}")
250+
raise
176251

177252
assert client.is_connected(), "Rust SSH proxy connection failed"
178253

tests/helpers/ssh_client.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,19 +417,71 @@ def reset(self, timeout: float = 30.0, reconnect: bool = True) -> Tuple[bool, st
417417
stdin.channel.shutdown_write()
418418

419419
# Try to read output - the connection may drop during this
420-
output = ""
420+
# Initialize chunks outside try block so they survive exceptions
421+
stdout_chunks: list[str] = []
422+
stderr_chunks: list[str] = []
423+
421424
try:
422425
channel = stdout.channel
423426
channel.settimeout(timeout)
424427

425-
# Read output until connection drops or command completes
426-
stdout_data = stdout.read().decode("utf-8", errors="replace")
427-
stderr_data = stderr.read().decode("utf-8", errors="replace")
428-
output = stdout_data + stderr_data
428+
# Read until connection drops, command completes, or timeout
429+
# Use recv instead of read() for more control
430+
start_time = time.time()
431+
max_wait = 5.0 # Max time to wait for output before giving up
432+
idle_count = 0 # Count consecutive idle iterations
433+
434+
while not channel.closed and (time.time() - start_time) < max_wait:
435+
if channel.recv_ready():
436+
chunk = channel.recv(4096)
437+
if chunk:
438+
stdout_chunks.append(chunk.decode("utf-8", errors="replace"))
439+
idle_count = 0
440+
else:
441+
break # EOF
442+
elif channel.recv_stderr_ready():
443+
chunk = channel.recv_stderr(4096)
444+
if chunk:
445+
stderr_chunks.append(chunk.decode("utf-8", errors="replace"))
446+
idle_count = 0
447+
else:
448+
break # EOF
449+
elif channel.exit_status_ready():
450+
# Command completed, drain remaining output
451+
while channel.recv_ready():
452+
chunk = channel.recv(4096)
453+
if chunk:
454+
stdout_chunks.append(
455+
chunk.decode("utf-8", errors="replace")
456+
)
457+
else:
458+
break
459+
while channel.recv_stderr_ready():
460+
chunk = channel.recv_stderr(4096)
461+
if chunk:
462+
stderr_chunks.append(
463+
chunk.decode("utf-8", errors="replace")
464+
)
465+
else:
466+
break
467+
break
468+
else:
469+
# No data available, wait a bit
470+
time.sleep(0.1)
471+
idle_count += 1
472+
# If we've been idle for 2 seconds (20 * 0.1s), check if channel is dead
473+
if idle_count > 20:
474+
# Check if the transport is still active
475+
transport = channel.get_transport()
476+
if transport is None or not transport.is_active():
477+
break
429478
except Exception:
430479
# Connection dropped during read - this is expected for reset
431480
pass
432481

482+
# Combine whatever output was captured (even if connection dropped)
483+
output = "".join(stdout_chunks) + "".join(stderr_chunks)
484+
433485
# After reset, the container is destroyed and recreated
434486
# The connection will be closed by the server
435487
self._connected = False

0 commit comments

Comments
 (0)