from __future__ import annotations import asyncio import json from typing import Any import pytest from fastapi import WebSocketDisconnect import k1link.web.app as app_module from k1link.device_plugins.xgrids_k1.facade import XGRIDS_K1_PLUGIN_ID from k1link.web.app import INVALID_REQUEST_DETAIL, app from k1link.web.plugin_runtime import PluginExecutionError class _StateDispatcher: async def invoke( self, plugin_id: str, action_id: str, payload: dict[str, Any], ) -> dict[str, Any]: del plugin_id, action_id, payload return {"phase": "idle"} class _FailingSendWebSocket: def __init__(self, failure: Exception) -> None: self.failure = failure self.accepted = False async def accept(self) -> None: self.accepted = True async def send_json(self, payload: dict[str, Any]) -> None: del payload raise self.failure async def close(self, *, code: int, reason: str) -> None: del code, reason async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]: body = json.dumps(payload).encode() request_sent = False response_messages: list[dict[str, Any]] = [] async def receive() -> dict[str, Any]: nonlocal request_sent if request_sent: return {"type": "http.disconnect"} request_sent = True return {"type": "http.request", "body": body, "more_body": False} async def send(message: dict[str, Any]) -> None: response_messages.append(message) scope: dict[str, Any] = { "type": "http", "asgi": {"version": "3.0", "spec_version": "2.3"}, "http_version": "1.1", "method": "POST", "scheme": "http", "path": path, "raw_path": path.encode(), "query_string": b"", "root_path": "", "headers": [ (b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()), ], "client": ("127.0.0.1", 41000), "server": ("127.0.0.1", 8765), } await app(scope, receive, send) start = next( message for message in response_messages if message["type"] == "http.response.start" ) response_body = b"".join( message.get("body", b"") for message in response_messages if message["type"] == "http.response.body" ) return int(start["status"]), response_body.decode() @pytest.mark.parametrize( ("path", "wrap_input"), [ ( f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/network.provision", True, ), ("/api/connect", False), ], ) def test_validation_errors_do_not_echo_sensitive_request_values( path: str, wrap_input: bool, ) -> None: sensitive_value = "x" * 300 action_input = { "device_id": "synthetic-device", "ssid": "synthetic-network", "password": sensitive_value, "compatibility_attestation": { "firmware_version": "3.0.2", "topology": "direct-lan", "verification": "live-device-info", }, } if wrap_input: state_status, state_response = asyncio.run( _post_json( ( f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/" "state.read" ), {"input": {}}, ) ) assert state_status == 200 snapshot_runtime_id = json.loads(state_response)["state"][ "snapshot_runtime_id" ] action_input["expected_snapshot_runtime_id"] = snapshot_runtime_id payload = {"input": action_input} if wrap_input else action_input status_code, response_text = asyncio.run(_post_json(path, payload)) assert status_code == 422 assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL} assert sensitive_value not in response_text assert sensitive_value[:32] not in response_text assert "input_value" not in response_text def test_legacy_ble_scan_requires_an_explicit_snapshot_runtime_header() -> None: status_code, response_text = asyncio.run( _post_json("/api/ble/scan", {"duration_seconds": 1}) ) assert status_code == 422 assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL} def test_plugin_expected_state_preserves_its_non_gateway_http_status( monkeypatch: pytest.MonkeyPatch, ) -> None: class ExpectedStateDispatcher: async def invoke( self, plugin_id: str, action_id: str, payload: dict[str, Any], ) -> dict[str, Any]: del plugin_id, action_id, payload raise PluginExecutionError( "K1 не сообщил адрес общей локальной сети", http_status_code=409, reason_code="connection-verify-address-unavailable", ) monkeypatch.setattr(app_module, "plugin_dispatcher", ExpectedStateDispatcher()) status_code, response_text = asyncio.run( _post_json( "/api/v1/device-plugins/test.plugin/actions/connection.verify", {"input": {}}, ) ) assert status_code == 409 assert json.loads(response_text) == {"detail": "K1 не сообщил адрес общей локальной сети"} @pytest.mark.parametrize( "failure", [ WebSocketDisconnect(code=1001), RuntimeError("handler is closed"), RuntimeError( "unable to perform operation on ; " "the handler is closed" ), ], ) def test_device_plugin_events_treats_proven_transport_disconnect_as_completion( monkeypatch: pytest.MonkeyPatch, failure: Exception, ) -> None: websocket = _FailingSendWebSocket(failure) monkeypatch.setattr(app_module, "plugin_dispatcher", _StateDispatcher()) asyncio.run(app_module.device_plugin_events(websocket, "test.plugin")) assert websocket.accepted is True def test_device_plugin_events_propagates_arbitrary_send_runtime_error( monkeypatch: pytest.MonkeyPatch, ) -> None: websocket = _FailingSendWebSocket(RuntimeError("plugin state serialization failed")) monkeypatch.setattr(app_module, "plugin_dispatcher", _StateDispatcher()) with pytest.raises(RuntimeError, match="plugin state serialization failed"): asyncio.run(app_module.device_plugin_events(websocket, "test.plugin"))