"""Настоящий TCP: некорректные пакеты, фрейминг и фоновые отправки.""" import asyncio import json import pytest from test_client_run import RunningServer def run(scenario): asyncio.run(asyncio.wait_for(scenario(), 5)) async def open_at_state(server, state="new", connection_payload=None): count = len(server.records) client = server.client_module.Client(port=server.port, timeout=1) server.clients.append(client) client.reader, client.writer = await asyncio.open_connection("127.0.0.1", server.port) async with asyncio.timeout(1): while len(server.records) == count: await asyncio.sleep(0) session = server.records[-1] if state in {"token", "ready", "air"}: await client.send("connection", connection_payload if connection_payload is not None else { "name": "raw-client", "type": "test", "in": [], "out": [], }) token = await client.recv() assert token["command"] == "token_ask" client.uid, client.upf = token["payload"], token["upf"] if state in {"ready", "air"}: await client.send("token_answer", {"token": None, "upf": None}) assert await client.recv() == {"command": "handshake_ask"} if state == "air": await client.send("handshake_answer", {"status": True}) await client.heartbeat() assert session.state == state return client, session async def assert_bad_peer_isolated( server, bad, session, healthy, timing, rejection_type=None, rejection_key=None, allowed_responses=(), background_start=None, ): handler = server.handlers[-1] raw = await asyncio.wait_for(bad.reader.read(), 1) responses = [json.loads(line) for line in raw.splitlines()] assert responses == list(allowed_responses[:len(responses)]) assert session.state == "stop" assert session.uid in server.sessions.sessions if background_start is not None: # token_answer queues handshake_ask before inspecting its payload. # Closing this peer may make that already queued send fail normally. tasks = timing.tasks[background_start:] results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 1) for task, result in zip(tasks, results): if isinstance(result, BaseException): assert isinstance(result, ConnectionError) timing.accept_task_error(task, result) await healthy.telemetry(50, 90) assert await healthy.heartbeat() == {"command": "heartbeat_answer"} assert server.sessions[healthy.uid].last_data["telemetry"] == {"temperature": 50, "battery": 90} cleanup = await timing.sleeping("manager") assert cleanup.delay == 60 timing.clock.advance(60) cleanup.release() results = await asyncio.wait_for(asyncio.gather( cleanup.task, handler, return_exceptions=True, ), 1) errors = [result for result in results if isinstance(result, BaseException)] errors.extend(server.peer_errors.get(handler, [])) for error in errors: assert rejection_type is not None and isinstance(error, rejection_type) if rejection_key is not None: assert error.args == (rejection_key,) server.accept_peer_rejection(handler, error) assert session.uid not in server.sessions.sessions assert session.uid not in server.router.session_routes assert all(session.uid not in routes for routes in server.router.session_routes.values()) server.assert_no_unexpected_errors() @pytest.mark.parametrize("raw,repetitions,rejection_type", [ (b"{broken\n", 1, json.JSONDecodeError), (b"null\n", 1, AttributeError), (b"[]\n", 1, AttributeError), (b"{}\n", 1, None), (b'{"command":[],"payload":{}}\n', 5, None), ], ids=["broken-json", "null", "array", "missing-command", "command-error-limit"]) @pytest.mark.usefixtures("make_manager") def test_invalid_packet_disconnects_only_its_peer(client_module, timing, raw, repetitions, rejection_type): """Проверяет изоляцию и очистку плохого пакета; неверная команда достигает лимита ошибок.""" async def scenario(): async with timing, RunningServer(client_module) as server: healthy = await server.client("healthy") bad, session = await open_at_state(server) bad.writer.write(raw * repetitions) await bad.writer.drain() await assert_bad_peer_isolated(server, bad, session, healthy, timing, rejection_type) run(scenario) @pytest.mark.parametrize("state,command", [("new", "connection"), ("token", "token_answer"), ("ready", "handshake_answer")]) @pytest.mark.parametrize("payload_case", ["missing", "null", "array", "string", "number", "boolean"]) @pytest.mark.usefixtures("make_manager") def test_invalid_control_payload_disconnects_only_its_peer(client_module, timing, state, command, payload_case): """Проверяет отключение и очистку, когда управляющий payload нельзя разобрать как объект.""" async def scenario(): async with timing, RunningServer(client_module) as server: healthy = await server.client("healthy") bad, session = await open_at_state(server, state) packet = {"command": command} if payload_case != "missing": packet["payload"] = {"null": None, "array": [], "string": "bad", "number": 1, "boolean": True}[payload_case] background_start = len(timing.tasks) bad.writer.write((json.dumps(packet) + "\n").encode()) await bad.writer.drain() await assert_bad_peer_isolated( server, bad, session, healthy, timing, AttributeError, allowed_responses=({"command": "handshake_ask"},) if state == "token" else (), background_start=background_start, ) run(scenario) @pytest.mark.parametrize("status", [ pytest.param(True, id="true"), pytest.param(False, id="false"), ]) @pytest.mark.usefixtures("make_manager") def test_handshake_status_follows_protocol(client_module, timing, status): """Проверяет True/Other как успех и False как ожидание повторного handshake; Other — 0.""" async def scenario(): async with timing, RunningServer(client_module) as server: healthy = await server.client("healthy") client, session = await open_at_state(server, "ready") await client.send("handshake_answer", {"status": status}) if status is False: retry = await timing.sleeping("session", server.handlers[-1]) assert session.state == "ready" assert session.uid not in server.router.session_routes assert retry.delay > 0 assert await healthy.heartbeat() == {"command": "heartbeat_answer"} retry.release() assert await client.recv() == {"command": "handshake_ask"} assert session.state == "ready" await client.send("handshake_answer", {"status": True}) assert await client.heartbeat() == {"command": "heartbeat_answer"} assert session.state == "air" assert session.uid in server.router.session_routes assert await healthy.heartbeat() == {"command": "heartbeat_answer"} run(scenario) @pytest.mark.parametrize("token_case", ["missing", "null", "robot-token"]) def test_connection_preserves_robot_auth_token(client_module, token_case): """Проверяет хранение auth без whitelist-проверки и независимо от UID/upf восстановления.""" async def scenario(): async with RunningServer(client_module) as server: healthy = await server.client("healthy") payload = {"name": "auth-client", "type": "test", "in": [], "out": []} expected = "robot-auth-token" if token_case == "robot-token" else None if token_case != "missing": payload["token"] = expected client, session = await open_at_state(server, "air", payload) assert session.static_meta["auth"] == expected assert client.uid == session.uid and client.upf == session.upf assert await healthy.heartbeat() == {"command": "heartbeat_answer"} run(scenario) @pytest.mark.parametrize("payload", [None, [], "unused", 1, True]) def test_heartbeat_does_not_require_an_object_payload(client_module, payload): """Проверяет ответ и продление heartbeat, который не использует содержимое payload.""" async def scenario(): async with RunningServer(client_module) as server: client = await server.client("heartbeat-payload") healthy = await server.client("healthy") session = server.sessions[client.uid] deadline = session.timeout await client.send("heartbeat_ask", payload) assert await client.recv() == {"command": "heartbeat_answer"} assert session.state == "air" and session.timeout >= deadline assert session.last_data == {} assert await healthy.heartbeat() == {"command": "heartbeat_answer"} run(scenario) @pytest.mark.usefixtures("make_manager") def test_missing_heartbeat_payload_is_isolated_or_accepted(client_module, timing): """Проверяет heartbeat без payload: обработку либо локальное отключение с очисткой.""" async def scenario(): async with timing, RunningServer(client_module) as server: healthy = await server.client("healthy") client, session = await open_at_state(server, "air") client.writer.write(b'{"command":"heartbeat_ask"}\n') await client.writer.drain() raw = await asyncio.wait_for(client.reader.readline(), 1) if raw: assert json.loads(raw) == {"command": "heartbeat_answer"} assert session.state == "air" assert await healthy.heartbeat() == {"command": "heartbeat_answer"} else: await assert_bad_peer_isolated( server, client, session, healthy, timing, KeyError, "payload", ) run(scenario) @pytest.mark.parametrize("payload", [None, [1, {"ok": True}], "telemetry", 42, False]) def test_data_payload_is_forwarded_without_dictionary_conversion(client_module, payload): """Проверяет сохранение и маршрутизацию JSON-значений payload без преобразования в словарь.""" async def scenario(): async with RunningServer(client_module) as server: sender = await server.client("sender") receiver = await server.client("receiver") server.router.session_routes[sender.uid].add(receiver.uid) await sender.send("telemetry", payload) await sender.heartbeat() assert await receiver.recv() == {"command": "telemetry", "payload": payload} assert server.sessions[sender.uid].last_data == {"telemetry": payload} assert await receiver.heartbeat() == {"command": "heartbeat_answer"} run(scenario) def test_json_packet_split_between_tcp_writes(client_module): """Проверяет сборку одного JSON-пакета из нескольких записей до завершающего LF.""" async def scenario(): async with RunningServer(client_module) as server: client = await server.client("fragmented") session = server.sessions[client.uid] data = b'{"command":"telemetry","payload":{"battery":42}}\n' for fragment in (data[:8], data[8:30], data[30:-1]): client.writer.write(fragment) await client.writer.drain() # A response to a second client proves the loop had a chance to read fragments. peer = await server.client("peer") await peer.heartbeat() assert "telemetry" not in session.last_data client.writer.write(data[-1:]) await client.writer.drain() await client.heartbeat() assert session.last_data["telemetry"] == {"battery": 42} run(scenario) def test_several_json_packets_in_one_tcp_write(client_module): """Проверяет порядок нескольких JSON-строк, отправленных одной TCP-записью.""" async def scenario(): async with RunningServer(client_module) as server: client = await server.client("coalesced") packets = [ {"command": "telemetry", "payload": {"battery": 43}}, {"command": "status", "payload": {"ok": True}}, {"command": "telemetry", "payload": {"battery": 42}}, {"command": "heartbeat_ask", "payload": {}}, ] client.writer.write(b"".join((json.dumps(p) + "\n").encode() for p in packets)) await client.writer.drain() assert await client.recv() == {"command": "heartbeat_answer"} assert server.sessions[client.uid].last_data == {"telemetry": {"battery": 42}, "status": {"ok": True}} run(scenario) @pytest.mark.usefixtures("make_manager") def test_watcher_and_tcp_finally_schedule_cleanup_once(client_module, timing, monkeypatch): """Проверяет гонку watcher/EOF: одно закрытие транспорта и один таймер очистки.""" async def scenario(): async with timing, RunningServer(client_module) as server: client = await server.client("racing") session = server.sessions[client.uid] calls, removed = [], [] close_requests = [] close_started, finish_close = asyncio.Event(), asyncio.Event() original_close = session.connection.close original_remove = server.router.remove original_manager_close = server.sessions.close async def counted_close(): calls.append(session.uid) close_started.set() await finish_close.wait() await original_close() async def observed_manager_close(uid): close_requests.append(uid) await original_manager_close(uid) def counted_remove(uid): removed.append(uid) original_remove(uid) monkeypatch.setattr(session.connection, "close", counted_close) monkeypatch.setattr(server.router, "remove", counted_remove) monkeypatch.setattr(server.sessions, "close", observed_manager_close) poll = await timing.sleeping("session", session.watcher_task) timing.clock.advance(session.timeout - timing.clock()) poll.release() try: await asyncio.wait_for(close_started.wait(), 1) # Hold the watcher's close open while the real handler reaches finally. client.writer.write_eof() await asyncio.wait_for(server.handlers[0], 1) assert close_requests == [session.uid, session.uid] finally: finish_close.set() assert await asyncio.wait_for(client.reader.read(), 1) == b"" cleanup = await timing.sleeping("manager") assert calls == [session.uid] await asyncio.sleep(0) assert timing.queues["manager"].empty() timing.clock.advance(60) cleanup.release() await asyncio.wait_for(cleanup.task, 1) await asyncio.wait_for(asyncio.gather(session.watcher_task, *server.handlers), 1) assert removed == [session.uid] assert session.uid not in server.sessions.sessions run(scenario) async def assert_send_error_observed(caplog, failure): async with asyncio.timeout(1): while not any(record.exc_info and record.exc_info[1] is failure for record in caplog.records): await asyncio.sleep(0) def test_background_send_failure_is_observable_and_isolated(client_module, monkeypatch, caplog): """Проверяет видимость фонового ConnectionResetError, закрытие по EOF и работу другого клиента.""" async def scenario(): async with RunningServer(client_module) as server: healthy = await server.client("healthy") bad = await server.client("broken-send") session = server.sessions[bad.uid] failure = ConnectionResetError("send regression") raised = asyncio.Event() async def failing_send(packet): raised.set() raise failure monkeypatch.setattr(session.connection, "send", failing_send) session.send({"command": "telemetry", "payload": {"battery": 87}}) await asyncio.wait_for(raised.wait(), 1) await assert_send_error_observed(caplog, failure) assert await healthy.heartbeat() == {"command": "heartbeat_answer"} # A send exception in its own task need not close the reader task. # The actual TCP EOF must still close the session normally. await server.disconnect(bad) assert await healthy.heartbeat() == {"command": "heartbeat_answer"} run(scenario) def test_disconnect_while_background_send_is_waiting(client_module, monkeypatch, caplog): """Проверяет разрыв во время фоновой отправки и наблюдаемость ошибки без повторного close.""" async def scenario(): async with RunningServer(client_module) as server: healthy = await server.client("healthy") client = await server.client("pending-send") session = server.sessions[client.uid] started, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event() original_send = session.connection.send original_close = session.connection.close closes = [] failures = [] async def pending_send(packet): started.set() try: await release.wait() await original_send(packet) except ConnectionError as error: failures.append(error) raise finally: finished.set() async def counted_close(): closes.append(session.uid) await original_close() monkeypatch.setattr(session.connection, "send", pending_send) monkeypatch.setattr(session.connection, "close", counted_close) session.send({"command": "telemetry", "payload": {}}) try: await asyncio.wait_for(started.wait(), 1) await server.disconnect(client) finally: release.set() await asyncio.wait_for(finished.wait(), 1) await asyncio.sleep(0) assert closes == [session.uid] assert await healthy.heartbeat() == {"command": "heartbeat_answer"} for failure in failures: await assert_send_error_observed(caplog, failure) run(scenario)