Files
NODEDC_MISSION_CORE/tests/test_web_validation_security.py
T

246 lines
7.5 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"
),
],
)
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"))