"""Source RCON client tests against an in-process TCP fixture. The handshake quirk we verified live: after a SERVERDATA_AUTH (type=3) the server sends a SERVERDATA_RESPONSE_VALUE (type=0) FIRST and THEN the SERVERDATA_AUTH_RESPONSE (type=2). The auth response's req_id == -1 means bad password. The client must consume both packets before sending the command. """ from __future__ import annotations import socket import struct import threading from contextlib import contextmanager from typing import Iterator import pytest from l4d2web.services.rcon import ( RconAuthError, RconError, query_status, ) def _pack(req_id: int, ptype: int, body: str) -> bytes: body_bytes = body.encode("utf-8") + b"\x00\x00" size = 4 + 4 + len(body_bytes) return struct.pack(" tuple[int, int, str]: raw_size = conn.recv(4) size = struct.unpack(" Iterator[int]: """Start a TCP server on an ephemeral port; handler(conn) runs in a thread. Handler exceptions propagate at context exit so a buggy handler surfaces as a real test failure instead of degrading into a client-side timeout. """ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("127.0.0.1", 0)) server.listen(1) port = server.getsockname()[1] server.settimeout(3.0) handler_error: list[BaseException] = [] def serve() -> None: try: conn, _ = server.accept() try: handler(conn) finally: conn.close() except BaseException as exc: handler_error.append(exc) t = threading.Thread(target=serve, daemon=True) t.start() try: yield port finally: server.close() t.join(timeout=1.0) if handler_error: raise AssertionError("fake_rcon_server handler raised") from handler_error[0] def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None: response_body = ( "hostname: Left 4 Dead 2\n" "version : 2.2.4.3 9309 secure (unknown)\n" "udp/ip : 127.0.0.1:27016 [ public 1.2.3.4:27016 ]\n" "os : Linux Dedicated\n" "map : c1m2_streets\n" "players : 1 humans, 0 bots (4 max) (not hibernating) (reserved 1860000e7e5e446)\n" "\n" "# userid name uniqueid connected ping loss state rate adr\n" '# 2 1 "Crone" STEAM_1:0:12376499 00:21 185 20 active 30000 91.55.5.100:27005\n' "#end\n" ) def handler(conn: socket.socket) -> None: req_id, ptype, body = _unpack_one(conn) assert ptype == 3 assert body == "letmein" conn.sendall(_pack(req_id, 0, "")) conn.sendall(_pack(req_id, 2, "")) cmd_id, cmd_type, cmd = _unpack_one(conn) assert cmd_type == 2 assert cmd == "status" conn.sendall(_pack(cmd_id, 0, response_body)) with fake_rcon_server(handler) as port: result = query_status("127.0.0.1", port, "letmein", timeout=2.0) assert result.map == "c1m2_streets" assert result.players == 1 assert result.bots == 0 assert result.max_players == 4 assert result.hibernating is False assert len(result.roster) == 1 p = result.roster[0] assert p.name == "Crone" assert p.steam_id_64 == "76561197985018726" # 76561197960265728 + 0 + 12376499*2 assert p.connected_seconds == 21 assert p.ping == 185 def test_auth_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None: def handler(conn: socket.socket) -> None: req_id, _, _ = _unpack_one(conn) conn.sendall(_pack(req_id, 0, "")) conn.sendall(_pack(-1, 2, "")) # bad password sentinel with fake_rcon_server(handler) as port: with pytest.raises(RconAuthError): query_status("127.0.0.1", port, "wrong", timeout=2.0) def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: def handler(conn: socket.socket) -> None: import time time.sleep(3.0) with fake_rcon_server(handler) as port: with pytest.raises(RconError): query_status("127.0.0.1", port, "x", timeout=0.3) def test_parse_duration_handles_hours() -> None: from l4d2web.services.rcon import _parse_duration assert _parse_duration("00:21") == 21 assert _parse_duration("01:23:45") == 5025 assert _parse_duration("12:00") == 720 def test_parse_duration_rejects_malformed_as_rcon_error() -> None: from l4d2web.services.rcon import _parse_duration for bad in ["", ":", "abc", "1:", ":5", "1:2:3:4"]: with pytest.raises(RconError): _parse_duration(bad)