250 lines
7.6 KiB
Python
250 lines
7.6 KiB
Python
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
|
|
|
|
|
|
class _DisconnectAfterSecondSendWebSocket:
|
|
def __init__(self) -> None:
|
|
self.accepted = False
|
|
self.send_count = 0
|
|
|
|
async def accept(self) -> None:
|
|
self.accepted = True
|
|
|
|
async def send_json(self, payload: dict[str, Any]) -> None:
|
|
del payload
|
|
self.send_count += 1
|
|
if self.send_count == 2:
|
|
raise WebSocketDisconnect(code=1001)
|
|
|
|
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 <TCPTransport closed=True>; "
|
|
"the handler is closed"
|
|
),
|
|
RuntimeError(
|
|
"Unexpected ASGI message 'websocket.send', after sending "
|
|
"'websocket.close'."
|
|
),
|
|
],
|
|
)
|
|
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_keeps_heavy_state_poll_off_live_media_cadence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
websocket = _DisconnectAfterSecondSendWebSocket()
|
|
sleeps: list[float] = []
|
|
|
|
async def record_sleep(delay: float) -> None:
|
|
sleeps.append(delay)
|
|
|
|
monkeypatch.setattr(app_module, "plugin_dispatcher", _StateDispatcher())
|
|
monkeypatch.setattr(app_module.asyncio, "sleep", record_sleep)
|
|
|
|
asyncio.run(app_module.device_plugin_events(websocket, "test.plugin"))
|
|
|
|
assert websocket.accepted is True
|
|
assert sleeps == [app_module.DEVICE_PLUGIN_EVENT_POLL_INTERVAL_SECONDS]
|
|
assert sleeps[0] == 2.0
|
|
|
|
|
|
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"))
|