mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
367 lines
11 KiB
Python
367 lines
11 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
"""Hypothesis property/fuzz coverage for the generic RNS Link API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from meshchatx.meshchat import ReticulumMeshChat
|
|
from meshchatx.src.backend import rns_link_manager as rlm
|
|
from meshchatx.src.backend.websocket_config_guard import (
|
|
WEBSOCKET_MUTATOR_TYPES,
|
|
websocket_type_requires_auth,
|
|
)
|
|
from tests.backend.ws_json_contract_schemas import assert_ws_message_matches_schema
|
|
|
|
st_aspect = st.text(
|
|
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789._-"),
|
|
min_size=1,
|
|
max_size=64,
|
|
).filter(lambda value: any(ch.isalnum() for ch in value))
|
|
|
|
st_hex_hash = st.binary(min_size=16, max_size=16).map(lambda b: b.hex())
|
|
|
|
st_request_id = st.text(min_size=1, max_size=64)
|
|
|
|
st_nasty_payload = st.one_of(
|
|
st.binary(min_size=0, max_size=256),
|
|
st.text(min_size=0, max_size=128).map(lambda s: s.encode("utf-8", errors="ignore")),
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_link_cache():
|
|
with rlm._rns_links_lock:
|
|
rlm.rns_cached_links.clear()
|
|
rlm._link_failure_counts.clear()
|
|
yield
|
|
with rlm._rns_links_lock:
|
|
rlm.rns_cached_links.clear()
|
|
rlm._link_failure_counts.clear()
|
|
|
|
|
|
def _make_app():
|
|
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
|
|
app._rns_link_tasks = {}
|
|
app._rns_request_receipts = {}
|
|
app.rns_link_manager = MagicMock()
|
|
return app
|
|
|
|
|
|
def _capture_client():
|
|
client = MagicMock()
|
|
sent: list[dict] = []
|
|
|
|
async def capture_send(payload: str):
|
|
sent.append(json.loads(payload))
|
|
|
|
client.send_str = AsyncMock(side_effect=capture_send)
|
|
return client, sent
|
|
|
|
|
|
@given(aspect=st.text(min_size=0, max_size=80))
|
|
@settings(max_examples=80, deadline=None)
|
|
def test_split_aspect_never_crashes(aspect):
|
|
try:
|
|
app_name, sub = rlm._split_aspect(aspect)
|
|
except ValueError:
|
|
return
|
|
assert isinstance(app_name, str) and app_name
|
|
assert isinstance(sub, list)
|
|
assert all(isinstance(part, str) and part for part in sub)
|
|
|
|
|
|
@given(aspect=st_aspect, dest=st.binary(min_size=16, max_size=16))
|
|
@settings(max_examples=60, deadline=None)
|
|
def test_cache_roundtrip_property(aspect, dest):
|
|
link = MagicMock()
|
|
link.status = rlm.RNS.Link.ACTIVE
|
|
rlm._cache_link_if_active(aspect, dest, link)
|
|
assert rlm.get_cached_active_link(aspect, dest) is link
|
|
rlm._uncache_link_if_matches(aspect, dest, link)
|
|
assert rlm.get_cached_active_link(aspect, dest) is None
|
|
|
|
|
|
@given(
|
|
dest=st.binary(min_size=16, max_size=16),
|
|
failures=st.integers(min_value=1, max_value=8),
|
|
)
|
|
@settings(max_examples=40, deadline=None)
|
|
def test_failure_recycle_threshold_property(dest, failures):
|
|
key = ("app.aspect", dest)
|
|
link = MagicMock()
|
|
with rlm._rns_links_lock:
|
|
rlm.rns_cached_links[key] = link
|
|
rlm._link_failure_counts.pop(key, None)
|
|
recycled_at = None
|
|
for i in range(failures):
|
|
_count, recycled = rlm._record_failure_and_maybe_recycle(key)
|
|
if recycled:
|
|
recycled_at = i + 1
|
|
break
|
|
if failures >= rlm._LINK_RECYCLE_FAILURE_THRESHOLD:
|
|
assert recycled_at == rlm._LINK_RECYCLE_FAILURE_THRESHOLD
|
|
assert key not in rlm.rns_cached_links
|
|
link.teardown.assert_called()
|
|
else:
|
|
assert recycled_at is None
|
|
assert key in rlm.rns_cached_links
|
|
|
|
|
|
def test_rns_link_mutators_require_auth():
|
|
for msg_type in (
|
|
"rns.link.open",
|
|
"rns.link.identify",
|
|
"rns.link.request",
|
|
"rns.link.send",
|
|
"rns.link.close",
|
|
):
|
|
assert msg_type in WEBSOCKET_MUTATOR_TYPES
|
|
assert websocket_type_requires_auth(msg_type) is True
|
|
|
|
|
|
@given(
|
|
dest_hex=st.one_of(st_hex_hash, st.text(min_size=0, max_size=40), st.none()),
|
|
aspect=st.one_of(st_aspect, st.text(min_size=0, max_size=40), st.none()),
|
|
request_id=st_request_id,
|
|
)
|
|
@settings(max_examples=50, deadline=None)
|
|
def test_rns_link_open_parse_failures_never_raise(dest_hex, aspect, request_id):
|
|
async def _run():
|
|
app = _make_app()
|
|
app.rns_link_manager.open_link = AsyncMock(
|
|
return_value=(MagicMock(), False, None),
|
|
)
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_open(
|
|
client,
|
|
{
|
|
"destination_hash": dest_hex,
|
|
"aspect": aspect,
|
|
"request_id": request_id,
|
|
},
|
|
)
|
|
assert sent
|
|
for payload in sent:
|
|
assert payload["type"] == "rns.link.open"
|
|
assert_ws_message_matches_schema(payload)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@given(
|
|
dest_hex=st_hex_hash,
|
|
aspect=st_aspect,
|
|
payload=st_nasty_payload,
|
|
request_id=st.text(min_size=1, max_size=24),
|
|
)
|
|
@settings(max_examples=40, deadline=None)
|
|
def test_rns_link_send_fuzz_never_raises(dest_hex, aspect, payload, request_id):
|
|
async def _run():
|
|
app = _make_app()
|
|
app.rns_link_manager.send_packet = MagicMock(return_value=(True, None))
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_send(
|
|
client,
|
|
{
|
|
"destination_hash": dest_hex,
|
|
"aspect": aspect,
|
|
"request_id": request_id,
|
|
"payload_b64": base64.b64encode(payload).decode("ascii"),
|
|
},
|
|
)
|
|
assert sent
|
|
assert sent[-1]["type"] == "rns.link.send"
|
|
assert_ws_message_matches_schema(sent[-1])
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@given(
|
|
dest_hex=st_hex_hash,
|
|
aspect=st_aspect,
|
|
path=st.one_of(st.text(min_size=0, max_size=64), st.none()),
|
|
data_b64=st.one_of(
|
|
st.none(),
|
|
st.just(""),
|
|
st.just("!!!not-base64!!!"),
|
|
st.just("%%%%"),
|
|
st.binary(min_size=0, max_size=64).map(
|
|
lambda b: base64.b64encode(b).decode("ascii"),
|
|
),
|
|
),
|
|
request_id=st.text(min_size=1, max_size=24),
|
|
)
|
|
@settings(max_examples=40, deadline=None)
|
|
def test_rns_link_request_fuzz_never_raises(
|
|
dest_hex,
|
|
aspect,
|
|
path,
|
|
data_b64,
|
|
request_id,
|
|
):
|
|
async def _run():
|
|
app = _make_app()
|
|
app.rns_link_manager.open_link = AsyncMock(
|
|
return_value=(None, False, "no_path_to_destination"),
|
|
)
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_request(
|
|
client,
|
|
{
|
|
"destination_hash": dest_hex,
|
|
"aspect": aspect,
|
|
"path": path,
|
|
"data_b64": data_b64,
|
|
"request_id": request_id,
|
|
},
|
|
)
|
|
assert sent
|
|
for payload in sent:
|
|
assert payload["type"] == "rns.link.request"
|
|
assert_ws_message_matches_schema(payload)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rns_link_open_success_contract():
|
|
app = _make_app()
|
|
app.rns_link_manager.open_link = AsyncMock(return_value=(MagicMock(), True, None))
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_open(
|
|
client,
|
|
{
|
|
"destination_hash": "aa" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "req-open",
|
|
"auto_identify": True,
|
|
},
|
|
)
|
|
success = [p for p in sent if p.get("status") == "success"]
|
|
assert success
|
|
assert_ws_message_matches_schema(success[-1])
|
|
assert success[-1]["identified"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rns_link_close_and_identify_contracts():
|
|
app = _make_app()
|
|
app.rns_link_manager.identify = MagicMock(return_value=(True, None))
|
|
app.rns_link_manager.close = MagicMock(return_value=True)
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_identify(
|
|
client,
|
|
{
|
|
"destination_hash": "bb" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "req-id",
|
|
},
|
|
)
|
|
await app._handle_rns_link_close(
|
|
client,
|
|
{
|
|
"destination_hash": "bb" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "req-close",
|
|
},
|
|
)
|
|
assert {p["type"] for p in sent} == {"rns.link.identify", "rns.link.close"}
|
|
for payload in sent:
|
|
assert_ws_message_matches_schema(payload)
|
|
assert payload["status"] == "success"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rns_link_invalid_payload_b64():
|
|
app = _make_app()
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_send(
|
|
client,
|
|
{
|
|
"destination_hash": "aa" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "bad-b64",
|
|
"payload_b64": "%%%",
|
|
},
|
|
)
|
|
assert sent[-1]["status"] == "failure"
|
|
assert sent[-1]["failure_reason"] == "invalid_payload_b64"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rns_link_request_missing_path():
|
|
app = _make_app()
|
|
client, sent = _capture_client()
|
|
await app._handle_rns_link_request(
|
|
client,
|
|
{
|
|
"destination_hash": "aa" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "no-path",
|
|
},
|
|
)
|
|
assert sent[-1]["status"] == "failure"
|
|
assert sent[-1]["failure_reason"] == "missing_path"
|
|
|
|
|
|
def _run_async_immediate(coro):
|
|
return asyncio.create_task(coro)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rns_link_mutator_rejected_without_auth(mock_app):
|
|
mock_app.config.auth_enabled.set(True)
|
|
mock_app.config.auth_password_hash.set("hash")
|
|
client = MagicMock()
|
|
client.request = MagicMock()
|
|
client.send_str = AsyncMock()
|
|
|
|
with (
|
|
patch.object(mock_app, "_websocket_session_authorized", return_value=False),
|
|
patch(
|
|
"meshchatx.meshchat.AsyncUtils.run_async",
|
|
side_effect=_run_async_immediate,
|
|
),
|
|
):
|
|
await mock_app.on_websocket_data_received(
|
|
client,
|
|
{
|
|
"type": "rns.link.open",
|
|
"destination_hash": "aa" * 16,
|
|
"aspect": "microrn.mgmt",
|
|
"request_id": "auth-denied",
|
|
},
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
client.send_str.assert_awaited()
|
|
payload = client.send_str.await_args.args[0]
|
|
assert "Authentication required" in payload
|
|
|
|
|
|
@given(
|
|
event=st.sampled_from(["packet_received", "link_closed"]),
|
|
dest=st_hex_hash,
|
|
aspect=st_aspect,
|
|
payload=st.one_of(st.none(), st.binary(min_size=0, max_size=32)),
|
|
)
|
|
@settings(max_examples=40, deadline=None)
|
|
def test_rns_link_event_schema_property(event, dest, aspect, payload):
|
|
message = {
|
|
"type": "rns.link.event",
|
|
"event": event,
|
|
"destination_hash": dest,
|
|
"aspect": aspect,
|
|
}
|
|
if payload is not None:
|
|
message["payload_b64"] = base64.b64encode(payload).decode("ascii")
|
|
assert_ws_message_matches_schema(message)
|