test(fuzzing): add extensive fuzzing tests for WebSocket API, LXMF message handling, and telemetry functions

This commit is contained in:
2026-01-03 23:15:48 -06:00
parent 83d18f4bb3
commit a67e7da8af
3 changed files with 1799 additions and 135 deletions

View File

@@ -119,10 +119,10 @@ async def test_app_shutdown_endpoint(mock_rns_minimal, temp_dir):
# conftest.py mocks asyncio.sleep to return almost immediately.
for _ in range(10):
await asyncio.sleep(0)
# Verify that exit_app was called
# app_instance.exit_app.assert_called_once_with(0)
# Since it's in a task, we might need to check if it was called
# but sys.exit might not have been reached yet or was called in a different context
# For this test, verifying the endpoint exists and returns 200 is sufficient.
# Since it's in a task, we might need to check if it was called
# but sys.exit might not have been reached yet or was called in a different context
# For this test, verifying the endpoint exists and returns 200 is sufficient.

View File

@@ -0,0 +1,466 @@
import os
import random
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import LXMF
import pytest
import RNS
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir(tmp_path):
return str(tmp_path)
@pytest.fixture
def mock_app(temp_dir):
real_identity_class = RNS.Identity
class MockIdentityClass(real_identity_class):
def __init__(self, *args, **kwargs):
self.hash = b"test_hash_32_bytes_long_01234567"
self.hexhash = self.hash.hex()
with ExitStack() as stack:
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ConfigManager")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.MessageHandler")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.AnnounceManager")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.ArchiverManager")
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TelephoneManager")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.VoicemailManager")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RingtoneManager")
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
)
stack.enter_context(
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
)
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
stack.enter_context(patch("LXMF.LXMRouter"))
stack.enter_context(patch("LXST.Primitives.Telephony"))
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
mock_reticulum_class = stack.enter_context(patch("RNS.Reticulum"))
mock_reticulum_class.MTU = 1200
mock_reticulum_class.return_value.MTU = 1200
mock_transport_class = stack.enter_context(patch("RNS.Transport"))
mock_transport_class.MTU = 1200
mock_transport_class.return_value.MTU = 1200
stack.enter_context(patch("threading.Thread"))
stack.enter_context(
patch.object(
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
)
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
)
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
)
)
stack.enter_context(
patch.object(
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
)
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
)
app = ReticulumMeshChat(
identity=mock_id,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.active_downloads = {}
app.download_id_counter = 0
app.database = MagicMock()
# Setup basic config mocks
app.config = MagicMock()
for attr in [
"display_name",
"theme",
"language",
"voicemail_greeting",
"map_default_lat",
"map_default_lon",
]:
getattr(app.config, attr).get.return_value = "test"
for attr in ["auto_announce_enabled", "voicemail_enabled"]:
getattr(app.config, attr).get.return_value = False
app.config.lxmf_inbound_stamp_cost.get.return_value = 8
return app
# WebSocket API Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
data=st.recursive(
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
lambda children: st.one_of(
st.lists(children), st.dictionaries(st.text(), children)
),
)
)
@pytest.mark.asyncio
async def test_websocket_api_recursive_fuzzing(mock_app, data):
"""Fuzz the websocket API with recursive random data structures."""
mock_client = MagicMock()
mock_client.send_str = MagicMock(side_effect=lambda d: None)
# We must ensure "type" is present if it's a dict, or it might fail early
# but that's also part of fuzzing - seeing if it crashes without "type".
try:
await mock_app.on_websocket_data_received(mock_client, data)
except (KeyError, TypeError, ValueError, AttributeError):
# These are expected for malformed data
pass
except Exception as e:
pytest.fail(f"WebSocket API crashed with recursive data: {e}")
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(msg_type=st.text(), payload=st.dictionaries(st.text(), st.text()))
@pytest.mark.asyncio
async def test_websocket_api_type_fuzzing(mock_app, msg_type, payload):
"""Fuzz the websocket API with random types and flat dictionary payloads."""
mock_client = MagicMock()
mock_client.send_str = MagicMock(side_effect=lambda d: None)
data = {"type": msg_type}
data.update(payload)
try:
await mock_app.on_websocket_data_received(mock_client, data)
except (KeyError, TypeError, ValueError, AttributeError):
pass
except Exception as e:
pytest.fail(f"WebSocket API crashed with type={msg_type}: {e}")
# URI Parsing Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(uri=st.text(min_size=0, max_size=2000))
@pytest.mark.asyncio
async def test_lxm_uri_parsing_fuzzing(mock_app, uri):
"""Fuzz LXM URI ingestion logic."""
try:
# Assuming there's a method or logic that handles lxm:// URIs
if hasattr(mock_app, "ingest_lxm_uri"):
await mock_app.ingest_lxm_uri(uri)
# Also test it through the websocket interface if it exists there
mock_client = MagicMock()
await mock_app.on_websocket_data_received(
mock_client, {"type": "lxm.ingest_uri", "uri": uri}
)
except (KeyError, TypeError, ValueError, AttributeError):
pass
except Exception as e:
# Some specific exceptions might be okay depending on implementation,
# but generic crash is not.
if "hex" not in str(e).lower(): # Ignore hex decoding errors which are common
pytest.fail(f"URI ingestion crashed with uri={uri}: {e}")
# LXMF Packet/Message Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
content=st.binary(min_size=0, max_size=5000),
title=st.text(min_size=0, max_size=500),
fields=st.dictionaries(st.integers(min_value=0, max_value=255), st.binary()),
)
def test_lxmf_message_construction_fuzzing(mock_app, content, title, fields):
"""Fuzz LXMF message construction and field handling."""
try:
# Mock a destination
dest = MagicMock()
dest.hash = os.urandom(16)
# Test field assignment
lxmf_msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
for k, v in fields.items():
lxmf_msg.fields[k] = v
# Test encoding/decoding if possible
lxmf_msg.pack()
# LXMF.LXMessage.unpack(packed) # Might need more mocks
except Exception:
pass
# Database Migration/Data Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
data=st.dictionaries(
st.text(), st.one_of(st.text(), st.integers(), st.binary(), st.none())
),
)
def test_database_record_fuzzing(mock_app, table_name, data):
"""Fuzz database record insertion logic (simulated)."""
# This tests the DAO layer's resilience to weird data types if they aren't properly sanitized
try:
dao = None
if table_name == "messages" and hasattr(mock_app.database, "messages"):
dao = mock_app.database.messages
elif table_name == "announces" and hasattr(mock_app.database, "announces"):
dao = mock_app.database.announces
if dao and hasattr(dao, "upsert"):
dao.upsert(data)
except Exception:
pass
# Config Update Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
config_updates=st.dictionaries(
st.sampled_from(
[
"display_name",
"auto_announce_enabled",
"theme",
"language",
"voicemail_enabled",
"voicemail_greeting",
"map_default_lat",
"map_default_lon",
"lxmf_inbound_stamp_cost",
]
),
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
)
)
@pytest.mark.asyncio
async def test_config_update_fuzzing(mock_app, config_updates):
"""Fuzz the update_config method with various types for known keys."""
try:
if hasattr(mock_app, "update_config"):
await mock_app.update_config(config_updates)
except (TypeError, ValueError, AttributeError):
pass
except Exception as e:
pytest.fail(f"Config update crashed: {e}")
# LXMF Paper URI Generation Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(destination_hash=st.text(), content=st.text(), title=st.text())
@pytest.mark.asyncio
async def test_lxm_generate_paper_uri_fuzzing(
mock_app, destination_hash, content, title
):
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
mock_client = MagicMock()
mock_client.send_str = MagicMock(side_effect=lambda d: None)
data = {
"type": "lxm.generate_paper_uri",
"destination_hash": destination_hash,
"content": content,
"title": title,
}
try:
await mock_app.on_websocket_data_received(mock_client, data)
except (KeyError, TypeError, ValueError, AttributeError):
pass
except Exception as e:
# Ignore common hex decoding or identity not found errors
if "hex" not in str(e).lower() and "identity not found" not in str(e).lower():
pytest.fail(f"Paper URI generation crashed: {e}")
# Telemetry Packet Fuzzing (More intensive)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(data=st.binary(min_size=0, max_size=10000))
def test_telemetry_deep_fuzzing(data):
"""Deep fuzz the telemetry unpacking logic with large random payloads."""
from meshchatx.src.backend.telemetry_utils import Telemeter
try:
Telemeter.from_packed(data)
except Exception:
pass
# Markdown Renderer Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(text=st.text(min_size=0, max_size=10000))
def test_markdown_renderer_fuzzing(text):
"""Fuzz the markdown to HTML renderer."""
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
try:
html_out = MarkdownRenderer.render(text)
assert isinstance(html_out, str)
except Exception as e:
pytest.fail(f"MarkdownRenderer crashed: {e}")
# LXMF Message Dictionary Conversion Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
content=st.binary(min_size=0, max_size=1000),
title=st.text(min_size=0, max_size=100),
fields_data=st.dictionaries(st.integers(0, 255), st.binary()),
)
def test_lxmf_to_dict_conversion_fuzzing(mock_app, content, title, fields_data):
"""Fuzz the LXMF message to dictionary conversion logic."""
from meshchatx.src.backend.lxmf_utils import convert_lxmf_message_to_dict
try:
# Create a real-ish LXMessage but fuzzed
dest = MagicMock()
dest.hash = os.urandom(16)
msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
for k, v in fields_data.items():
msg.fields[k] = v
# Manually set some fields that might be expected
msg.hash = os.urandom(16)
msg.source_hash = os.urandom(16)
msg.destination_hash = os.urandom(16)
msg.incoming = random.choice([True, False])
msg.progress = random.random()
msg.rssi = random.randint(-120, 0)
msg.snr = random.randint(-20, 20)
msg.q = random.randint(0, 100)
msg.delivery_attempts = random.randint(0, 10)
result = convert_lxmf_message_to_dict(msg)
assert isinstance(result, dict)
except Exception:
# LXMF might throw internal errors on weird fields, which is okay as long as it doesn't crash the whole app
pass
# LXMF Delivery Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
content=st.binary(min_size=0, max_size=2000),
title=st.text(min_size=0, max_size=100),
)
def test_on_lxmf_delivery_fuzzing(mock_app, content, title):
"""Fuzz the LXMF delivery handler with various message contents."""
try:
dest = MagicMock()
dest.hash = os.urandom(16)
msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
# Add random fields
msg.fields[LXMF.FIELD_TELEMETRY] = os.urandom(random.randint(0, 100))
# These are usually set by LXMF when receiving
msg.source_hash = os.urandom(16)
msg.hash = os.urandom(16)
msg.signature = os.urandom(64)
mock_app.on_lxmf_delivery(msg)
except Exception:
pass
# Announce Ingestion Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
aspect=st.text(),
destination_hash=st.binary(min_size=0, max_size=64),
app_data=st.binary(min_size=0, max_size=1000),
)
def test_on_lxmf_announce_received_fuzzing(
mock_app, aspect, destination_hash, app_data
):
"""Fuzz the announce received handler."""
try:
announced_identity = MagicMock()
announced_identity.hash = os.urandom(32)
announced_identity.get_public_key = MagicMock(return_value=os.urandom(32))
mock_app.on_lxmf_announce_received(
aspect,
destination_hash,
announced_identity,
None, # reticulum instance
app_data,
)
except Exception:
pass
# Interface Config Parser Fuzzing (More intensive)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(config_text=st.text(min_size=0, max_size=10000))
def test_interface_config_parser_deep_fuzzing(config_text):
"""Deep fuzz the interface configuration parser."""
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
try:
InterfaceConfigParser.parse(config_text)
except Exception:
# We don't care if it fails to parse, just that it doesn't crash the process
pass
# Telemeter Roundtrip Fuzzing
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
battery=st.floats(0, 100, allow_nan=False, allow_infinity=False),
uptime=st.integers(0, 2**32 - 1),
load=st.floats(0, 100, allow_nan=False, allow_infinity=False),
temperature=st.floats(-50, 100, allow_nan=False, allow_infinity=False),
)
def test_telemeter_roundtrip_fuzzing(battery, uptime, load, temperature):
"""Fuzz telemeter packing and unpacking in a roundtrip."""
from meshchatx.src.backend.telemetry_utils import Telemeter
try:
t = Telemeter(
battery=battery, uptime=uptime, load=load, temperature=temperature
)
packed = t.pack()
unpacked = Telemeter.from_packed(packed)
assert isinstance(unpacked, Telemeter)
except Exception:
pass

View File

File diff suppressed because it is too large Load Diff