From e2586e905267c1927e4f6dbdea9a130865d37578 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Wed, 7 Jan 2026 19:20:56 -0600 Subject: [PATCH] feat(tests): add comprehensive telemetry and interface tests - Introduced new test files for telemetry functionality, including integration, fuzzing, and extended tests to ensure robustness and performance. - Added tests for parsing LXMF display names and telemetry data, addressing potential bugs and ensuring correct handling of various input formats. - Implemented performance tests for the InterfacesPage component, validating rendering efficiency with a large number of discovered interfaces. - Enhanced existing tests for markdown rendering and link utilities to cover additional edge cases and improve stability. --- tests/backend/test_csp_logic.py | 113 ++++++++++ .../test_display_name_and_telemetry.py | 96 ++++++++ tests/backend/test_fuzzing.py | 32 +++ tests/backend/test_fuzzing_telemetry.py | 99 ++++++++ tests/backend/test_telemetry_extended.py | 47 ++++ tests/backend/test_telemetry_integration.py | 130 +++++++++++ tests/frontend/InterfacesPerformance.test.js | 146 ++++++++++++ tests/frontend/LanguageSelector.test.js | 3 +- tests/frontend/LinkUtils.test.js | 47 ++++ tests/frontend/MarkdownRenderer.test.js | 212 ++++++++++++++++++ tests/frontend/Performance.test.js | 12 + 11 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 tests/backend/test_csp_logic.py create mode 100644 tests/backend/test_display_name_and_telemetry.py create mode 100644 tests/backend/test_fuzzing_telemetry.py create mode 100644 tests/backend/test_telemetry_extended.py create mode 100644 tests/backend/test_telemetry_integration.py create mode 100644 tests/frontend/InterfacesPerformance.test.js create mode 100644 tests/frontend/LinkUtils.test.js create mode 100644 tests/frontend/MarkdownRenderer.test.js diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py new file mode 100644 index 0000000..da61cc4 --- /dev/null +++ b/tests/backend/test_csp_logic.py @@ -0,0 +1,113 @@ +from unittest.mock import MagicMock, patch, AsyncMock + +import pytest +import RNS +from aiohttp import web + +from meshchatx.meshchat import ReticulumMeshChat + + +@pytest.fixture +def mock_rns_minimal(): + with ( + patch("RNS.Reticulum") as mock_rns, + patch("RNS.Transport"), + patch("LXMF.LXMRouter"), + patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"), + ): + mock_rns_instance = mock_rns.return_value + mock_rns_instance.configpath = "/tmp/mock_config" + mock_rns_instance.is_connected_to_shared_instance = False + mock_rns_instance.transport_enabled.return_value = True + + mock_id = MagicMock(spec=RNS.Identity) + mock_id.hash = b"test_hash_32_bytes_long_01234567" + mock_id.hexhash = mock_id.hash.hex() + mock_id.get_private_key.return_value = b"test_private_key" + yield mock_id + + +@pytest.mark.asyncio +async def test_csp_header_logic(mock_rns_minimal, tmp_path): + storage_dir = str(tmp_path / "storage") + config_dir = str(tmp_path / "config") + + with patch("meshchatx.meshchat.generate_ssl_certificate"): + app_instance = ReticulumMeshChat( + identity=mock_rns_minimal, + storage_dir=storage_dir, + reticulum_config_dir=config_dir, + ) + + # Mock the config values + app_instance.config.csp_extra_connect_src.set("https://api.example.com") + app_instance.config.map_tile_server_url.set( + "https://tiles.example.com/{z}/{x}/{y}.png" + ) + + # Mock a request and handler + request = MagicMock(spec=web.Request) + request.path = "/" + request.app = {} + + # We need to mock the handler to return a real response + async def mock_handler(req): + return web.Response(text="test") + + # Call _define_routes to get the security_middleware + routes = web.RouteTableDef() + _, _, security_middleware = app_instance._define_routes(routes) + + response = await security_middleware(request, mock_handler) + + csp = response.headers.get("Content-Security-Policy", "") + assert "https://api.example.com" in csp + assert "https://tiles.example.com" in csp + assert "default-src 'self'" in csp + + +@pytest.mark.asyncio +async def test_config_update_csp(mock_rns_minimal, tmp_path): + storage_dir = str(tmp_path / "storage") + config_dir = str(tmp_path / "config") + + with patch("meshchatx.meshchat.generate_ssl_certificate"): + app_instance = ReticulumMeshChat( + identity=mock_rns_minimal, + storage_dir=storage_dir, + reticulum_config_dir=config_dir, + ) + + # Find the config update handler + config_update_handler = None + for route in app_instance.get_routes(): + if route.path == "/api/v1/config" and route.method == "PATCH": + config_update_handler = route.handler + break + + assert config_update_handler is not None + + # Mock request with new CSP settings + request_data = { + "csp_extra_connect_src": "https://api1.com, https://api2.com", + "csp_extra_img_src": "https://img.com", + } + + request = MagicMock(spec=web.Request) + # request.json() must be awaited, so it should return an awaitable + request.json = AsyncMock(return_value=request_data) + + # To avoid the JSON serialization error of MagicMock in get_config_dict, + # we mock get_config_dict to return a serializable dict. + with patch.object( + app_instance, "get_config_dict", return_value={"status": "ok"} + ): + with patch.object(app_instance, "send_config_to_websocket_clients"): + response = await config_update_handler(request) + assert response.status == 200 + + assert ( + app_instance.config.csp_extra_connect_src.get() + == "https://api1.com, https://api2.com" + ) + assert app_instance.config.csp_extra_img_src.get() == "https://img.com" diff --git a/tests/backend/test_display_name_and_telemetry.py b/tests/backend/test_display_name_and_telemetry.py new file mode 100644 index 0000000..5b14cc3 --- /dev/null +++ b/tests/backend/test_display_name_and_telemetry.py @@ -0,0 +1,96 @@ +import base64 +from unittest.mock import MagicMock, patch +import LXMF +from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name +from meshchatx.src.backend.telemetry_utils import Telemeter +import RNS.vendor.umsgpack as msgpack + + +def test_parse_lxmf_display_name_bug_fix(): + """ + Test that parse_lxmf_display_name handles both bytes and strings + in the msgpack list, fixing the 'str' object has no attribute 'decode' bug. + """ + # 1. Test with bytes (normal case) + display_name_bytes = b"Test User" + app_data_list = [display_name_bytes, None, None] + app_data_bytes = msgpack.packb(app_data_list) + app_data_base64 = base64.b64encode(app_data_bytes).decode() + + assert parse_lxmf_display_name(app_data_base64) == "Test User" + + # 2. Test with string (the bug case where msgpack already decoded it) + # We simulate this by mocking msgpack.unpackb to return strings + display_name_str = "Test User Str" + app_data_list_str = [display_name_str, None, None] + + with patch("RNS.vendor.umsgpack.unpackb", return_value=app_data_list_str): + # The input app_data_base64 doesn't really matter much here since we mock unpackb, + # but it must be valid base64 for the initial decode. + assert parse_lxmf_display_name(app_data_base64) == "Test User Str" + + # 3. Test with bytes directly passed (as in meshchat.py updated call) + assert parse_lxmf_display_name(app_data_bytes) == "Test User" + + +def test_lxmf_telemetry_decoding(): + """ + Test decoding of LXMF telemetry fields. + """ + # Create some dummy telemetry data + ts = 1736264575 + lat, lon = 52.5200, 13.4050 + + # Use Telemeter.pack to create valid telemetry bytes + location = { + "latitude": lat, + "longitude": lon, + "altitude": 100, + "speed": 10, + "bearing": 90, + "accuracy": 5, + "last_update": ts, + } + + packed_telemetry = Telemeter.pack(time_utc=ts, location=location) + + # Decode it back + unpacked = Telemeter.from_packed(packed_telemetry) + + assert unpacked is not None + assert unpacked["time"]["utc"] == ts + assert unpacked["location"]["latitude"] == lat + assert unpacked["location"]["longitude"] == lon + assert unpacked["location"]["altitude"] == 100.0 + assert unpacked["location"]["speed"] == 10.0 + assert unpacked["location"]["bearing"] == 90.0 + assert unpacked["location"]["accuracy"] == 5.0 + + +def test_lxmf_telemetry_mapping_in_app(): + """ + Test how the app handles telemetry fields from an LXMF message. + """ + # Mock lxmf_message + lxmf_message = MagicMock(spec=LXMF.LXMessage) + source_hash = b"\x01" * 32 + lxmf_message.source_hash = source_hash + lxmf_message.hash = b"\x02" * 32 + + ts = 1736264575 + packed_telemetry = Telemeter.pack( + time_utc=ts, location={"latitude": 1.23, "longitude": 4.56} + ) + + lxmf_message.get_fields.return_value = {LXMF.FIELD_TELEMETRY: packed_telemetry} + + # Test unpacking directly using the same logic as in meshchat.py + fields = lxmf_message.get_fields() + assert LXMF.FIELD_TELEMETRY in fields + + telemetry_data = fields[LXMF.FIELD_TELEMETRY] + unpacked = Telemeter.from_packed(telemetry_data) + + assert unpacked["time"]["utc"] == ts + assert unpacked["location"]["latitude"] == 1.23 + assert unpacked["location"]["longitude"] == 4.56 diff --git a/tests/backend/test_fuzzing.py b/tests/backend/test_fuzzing.py index 708d803..37686e7 100644 --- a/tests/backend/test_fuzzing.py +++ b/tests/backend/test_fuzzing.py @@ -103,6 +103,38 @@ def test_display_name_parsing_fuzzing(app_data_base64): pytest.fail(f"Display name parsing crashed with data {app_data_base64}: {e}") +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None) +@given( + fields_data=st.dictionaries( + st.integers(min_value=0, max_value=255), st.binary(min_size=0, max_size=1000) + ) +) +def test_lxmf_fields_parsing_fuzzing(fields_data): + """Fuzz the parsing of LXMF message fields.""" + try: + # This simulates how meshchat.py processes fields in on_lxmf_delivery + for field_id, field_data in fields_data.items(): + if field_id == 0x01: # FIELD_COMMANDS + try: + import umsgpack + + commands = umsgpack.unpackb(field_data) + if isinstance(commands, list): + for cmd in commands: + if isinstance(cmd, dict): + for k, v in cmd.items(): + pass + elif isinstance(commands, dict): + for k, v in commands.items(): + pass + except Exception: + pass + elif field_id == 0x02: # FIELD_TELEMETRY + Telemeter.from_packed(field_data) + except Exception: + pass + + @pytest.fixture def temp_dir(tmp_path): return str(tmp_path) diff --git a/tests/backend/test_fuzzing_telemetry.py b/tests/backend/test_fuzzing_telemetry.py new file mode 100644 index 0000000..d407ef1 --- /dev/null +++ b/tests/backend/test_fuzzing_telemetry.py @@ -0,0 +1,99 @@ +from hypothesis import given, settings, strategies as st +from meshchatx.src.backend.telemetry_utils import Telemeter + +# Strategies for telemetry data +st_lat_lon = st.floats(min_value=-90, max_value=90) +st_alt = st.floats(min_value=-10000, max_value=100000) +st_speed = st.floats(min_value=0, max_value=2000) +st_bearing = st.floats(min_value=0, max_value=360) +st_accuracy = st.floats(min_value=0, max_value=10000) +st_timestamp = st.integers(min_value=0, max_value=2**32 - 1) + + +@settings(deadline=None) +@given( + lat=st_lat_lon, + lon=st_lat_lon, + alt=st_alt, + speed=st_speed, + bearing=st_bearing, + acc=st_accuracy, + ts=st_timestamp, +) +def test_fuzz_pack_location(lat, lon, alt, speed, bearing, acc, ts): + packed = Telemeter.pack_location(lat, lon, alt, speed, bearing, acc, ts) + if packed is not None: + unpacked = Telemeter.unpack_location(packed) + if unpacked: + # Check for reasonable precision (we use 1e6 for lat/lon, 1e2 for others) + assert abs(unpacked["latitude"] - lat) < 0.000002 + assert abs(unpacked["longitude"] - lon) < 0.000002 + assert abs(unpacked["altitude"] - alt) < 0.02 + assert abs(unpacked["speed"] - speed) < 0.02 + assert abs(unpacked["bearing"] - bearing) < 0.02 + assert abs(unpacked["accuracy"] - acc) < 0.02 + assert unpacked["last_update"] == ts + + +@settings(deadline=None) +@given( + charge=st.integers(min_value=0, max_value=100), + charging=st.integers(min_value=0, max_value=1), + rssi=st.integers(min_value=-150, max_value=0), + snr=st.floats(min_value=-20, max_value=20), + q=st.integers(min_value=0, max_value=100), +) +def test_fuzz_full_telemetry_packing(charge, charging, rssi, snr, q): + battery = {"charge_percent": charge, "charging": charging} + physical_link = {"rssi": rssi, "snr": snr, "q": q} + + packed = Telemeter.pack(battery=battery, physical_link=physical_link) + unpacked = Telemeter.from_packed(packed) + + assert unpacked["battery"]["charge_percent"] == charge + assert unpacked["battery"]["charging"] == charging + assert unpacked["physical_link"]["rssi"] == rssi + assert abs(unpacked["physical_link"]["snr"] - snr) < 0.01 + assert unpacked["physical_link"]["q"] == q + + +@settings(deadline=None) +@given(data=st.binary(min_size=0, max_size=2000)) +def test_fuzz_from_packed_random_bytes(data): + # This should never crash + try: + Telemeter.from_packed(data) + except Exception: + pass + + +@settings(deadline=None) +@given( + commands=st.lists( + st.dictionaries( + keys=st.one_of(st.integers(), st.text()), + values=st.one_of(st.integers(), st.text(), st.floats(), st.booleans()), + ), + max_size=10, + ) +) +def test_fuzz_command_parsing(commands): + # This simulates how commands are handled in meshchat.py + processed_commands = [] + for cmd in commands: + new_cmd = {} + for k, v in cmd.items(): + try: + if isinstance(k, str): + if k.startswith("0x"): + new_cmd[int(k, 16)] = v + else: + new_cmd[int(k)] = v + else: + new_cmd[k] = v + except (ValueError, TypeError): + new_cmd[k] = v + processed_commands.append(new_cmd) + + # Just ensure no crash + assert len(processed_commands) == len(commands) diff --git a/tests/backend/test_telemetry_extended.py b/tests/backend/test_telemetry_extended.py new file mode 100644 index 0000000..bdc8e57 --- /dev/null +++ b/tests/backend/test_telemetry_extended.py @@ -0,0 +1,47 @@ +import time +from meshchatx.src.backend.telemetry_utils import Telemeter + + +def test_pack_unpack_battery_and_link(): + battery = {"charge_percent": 85, "charging": 1} + physical_link = {"rssi": -90, "snr": 8, "q": 95} + ts = int(time.time()) + + packed = Telemeter.pack(time_utc=ts, battery=battery, physical_link=physical_link) + assert isinstance(packed, bytes) + + unpacked = Telemeter.from_packed(packed) + assert unpacked["time"]["utc"] == ts + assert unpacked["battery"]["charge_percent"] == battery["charge_percent"] + assert unpacked["battery"]["charging"] == battery["charging"] + assert unpacked["physical_link"]["rssi"] == physical_link["rssi"] + assert unpacked["physical_link"]["snr"] == physical_link["snr"] + assert unpacked["physical_link"]["q"] == physical_link["q"] + + +def test_telemeter_from_packed_robustness(): + # Test with corrupted umsgpack data + assert Telemeter.from_packed(b"\xff\xff\xff") is None + # Test with empty data + assert Telemeter.from_packed(b"") is None + # Test with valid umsgpack but unexpected structure + from RNS.vendor import umsgpack + + invalid_structure = umsgpack.packb({"not_a_sensor": 123}) + assert Telemeter.from_packed(invalid_structure) == {} + + +def test_telemeter_unpack_location_robustness(): + # Test with insufficient elements + assert Telemeter.unpack_location([b"lat", b"lon"]) is None + # Test with invalid types + assert Telemeter.unpack_location(["not_bytes"] * 7) is None + + +def test_sideband_request_format_compatibility(): + # Sideband telemetry request command is 0x01 + # It can be a simple int 0x01 or a dict {0x01: timebase} + + # This test is more about the logic in on_lxmf_delivery, + # but we can verify our assumptions about command structure here if needed. + pass diff --git a/tests/backend/test_telemetry_integration.py b/tests/backend/test_telemetry_integration.py new file mode 100644 index 0000000..a18249f --- /dev/null +++ b/tests/backend/test_telemetry_integration.py @@ -0,0 +1,130 @@ +import pytest +import time +import json +from unittest.mock import MagicMock, patch +from meshchatx.meshchat import ReticulumMeshChat +from meshchatx.src.backend.telemetry_utils import Telemeter + + +@pytest.fixture +def mock_app(): + # We create a simple mock object that has the methods/attributes + # needed by process_incoming_telemetry and other telemetry logic. + app = MagicMock(spec=ReticulumMeshChat) + + # Mock database + app.database = MagicMock() + app.database.telemetry = MagicMock() + + # Mock context + app.current_context = MagicMock() + app.current_context.database = app.database + app.current_context.local_lxmf_destination = MagicMock() + app.current_context.local_lxmf_destination.hexhash = "local_hash" + + # Mock reticulum + app.reticulum = MagicMock() + app.reticulum.get_packet_rssi.return_value = -70 + app.reticulum.get_packet_snr.return_value = 12.5 + app.reticulum.get_packet_q.return_value = 85 + + # Mock websocket_broadcast + app.websocket_broadcast = MagicMock() + + # Attach the actual method we want to test if possible, + # but since it's an instance method, we might need to bind it. + app.process_incoming_telemetry = ReticulumMeshChat.process_incoming_telemetry.__get__(app, ReticulumMeshChat) + + return app + + +@pytest.mark.asyncio +async def test_process_incoming_telemetry_single(mock_app): + source_hash = "source_hash" + location = {"latitude": 50.0, "longitude": 10.0} + packed_telemetry = Telemeter.pack(location=location) + + mock_lxmf_message = MagicMock() + mock_lxmf_message.hash = b"msg_hash" + + mock_app.process_incoming_telemetry(source_hash, packed_telemetry, mock_lxmf_message) + + # Verify database call + mock_app.database.telemetry.upsert_telemetry.assert_called() + call_args = mock_app.database.telemetry.upsert_telemetry.call_args[1] + assert call_args["destination_hash"] == source_hash + assert call_args["data"] == packed_telemetry + + +@pytest.mark.asyncio +async def test_process_incoming_telemetry_stream(mock_app): + # This simulates receiving a telemetry stream (e.g. from Sideband collector) + entries = [ + ( + "peer1", + int(time.time()) - 60, + Telemeter.pack(location={"latitude": 1.0, "longitude": 1.0}), + ), + ( + "peer2", + int(time.time()), + Telemeter.pack(location={"latitude": 2.0, "longitude": 2.0}), + ), + ] + + mock_lxmf_message = MagicMock() + mock_lxmf_message.hash = b"stream_msg_hash" + + # We call it directly for each entry as process_incoming_telemetry is refactored + # to handle single entries, and on_lxmf_delivery loops over streams. + for entry_source, entry_timestamp, entry_data in entries: + mock_app.process_incoming_telemetry( + entry_source, entry_data, mock_lxmf_message, timestamp_override=entry_timestamp + ) + + assert mock_app.database.telemetry.upsert_telemetry.call_count == 2 + + +@pytest.mark.asyncio +async def test_telemetry_request_parsing(mock_app): + # Test that on_lxmf_delivery correctly identifies telemetry requests + # and calls handle_telemetry_request. + mock_lxmf_message = MagicMock() + # 0x01 is SidebandCommands.TELEMETRY_REQUEST + # We mock get_fields to return a command request + mock_lxmf_message.get_fields.return_value = {0x01: [{0x01: int(time.time())}]} + mock_lxmf_message.source_hash = b"source_hash_bytes" + mock_lxmf_message.hash = b"msg_hash" + mock_lxmf_message.destination_hash = b"dest_hash" + + # We need to mock handle_telemetry_request on the app + mock_app.handle_telemetry_request = MagicMock() + + # Bind on_lxmf_delivery + mock_app.on_lxmf_delivery = ReticulumMeshChat.on_lxmf_delivery.__get__(mock_app, ReticulumMeshChat) + + # Mocking dependencies + mock_app.is_destination_blocked.return_value = False + mock_app.current_context.config.telemetry_enabled.get.return_value = True + mock_app.database.contacts.get_contact_by_identity_hash.return_value = {"is_telemetry_trusted": True} + mock_app.database.messages.get_lxmf_message_by_hash.return_value = {} # To avoid JSON error + + # Also need SidebandCommands + from meshchatx.src.backend.sideband_commands import SidebandCommands + # (SidebandCommands is likely already imported in meshchat.py) + + # Call it + mock_app.on_lxmf_delivery(mock_lxmf_message) + + # Verify handle_telemetry_request was called + mock_app.handle_telemetry_request.assert_called_with("736f757263655f686173685f6279746573") + + +@pytest.mark.asyncio +async def test_tracking_toggle_endpoint(mock_app): + # Mock database responses + mock_app.database.telemetry.is_tracking.return_value = False + + # We can't easily test the web endpoint here without more setup, + # but we can test the logic it calls if it was refactored into a method. + pass diff --git a/tests/frontend/InterfacesPerformance.test.js b/tests/frontend/InterfacesPerformance.test.js new file mode 100644 index 0000000..0cb9c4a --- /dev/null +++ b/tests/frontend/InterfacesPerformance.test.js @@ -0,0 +1,146 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi } from "vitest"; +import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue"; + +// Mock dependencies +vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ + default: { + config: { theme: "light" }, + hasPendingInterfaceChanges: false, + modifiedInterfaceNames: new Set(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ + default: { + formatBytes: (b) => `${b} B`, + isInterfaceEnabled: () => true, + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({ + default: { + relaunch: vi.fn(), + }, +})); + +// Mock axios +global.axios = { + get: vi.fn((url) => { + if (url.includes("/api/v1/reticulum/interfaces")) { + return Promise.resolve({ data: { interfaces: {} } }); + } + if (url.includes("/api/v1/app/info")) { + return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } }); + } + if (url.includes("/api/v1/interface-stats")) { + return Promise.resolve({ data: { interface_stats: { interfaces: [] } } }); + } + if (url.includes("/api/v1/reticulum/discovery")) { + return Promise.resolve({ data: { discovery: { discover_interfaces: true } } }); + } + if (url.includes("/api/v1/reticulum/discovered-interfaces")) { + return Promise.resolve({ data: { interfaces: [], active: [] } }); + } + return Promise.resolve({ data: {} }); + }), + post: vi.fn(() => Promise.resolve({ data: {} })), + patch: vi.fn(() => Promise.resolve({ data: {} })), +}; +window.axios = global.axios; + +// Mock MaterialDesignIcon +const MaterialDesignIcon = { + template: '
', + props: ["iconName"], +}; + +describe("InterfacesPage Performance", () => { + it("renders InterfacesPage with 1000 disconnected discovered interfaces", async () => { + const numDiscovered = 1000; + const discoveredInterfaces = Array.from({ length: numDiscovered }, (_, i) => ({ + name: `Discovered ${i}`, + type: "UDPInterface", + reachable_on: `192.168.1.${i}`, + port: 4242, + discovery_hash: `hash_${i}`, + })); + + const start = performance.now(); + const wrapper = mount(InterfacesPage, { + global: { + components: { + MaterialDesignIcon, + Toggle: { template: "
" }, + ImportInterfacesModal: { + template: "
", + methods: { show: vi.fn() }, + }, + Interface: { template: "
" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.setData({ + discoveredInterfaces, + activeTab: "overview", // This is where discovered interfaces are shown in the template I saw + }); + + const end = performance.now(); + console.log(`Rendered ${numDiscovered} discovered interfaces in ${(end - start).toFixed(2)}ms`); + + // Check if animations are present + const pulsingElements = wrapper.findAll(".animate-pulse"); + expect(pulsingElements.length).toBe(numDiscovered); + + expect(end - start).toBeLessThan(5000); + }); + + it("stops pulsing animations after 30 seconds", async () => { + const iface = { + name: "Discovered 1", + type: "UDPInterface", + reachable_on: "192.168.1.1", + port: 4242, + discovery_hash: "hash_1", + disconnected_at: Date.now() - 31000, // 31 seconds ago + }; + + const wrapper = mount(InterfacesPage, { + global: { + components: { + MaterialDesignIcon, + Toggle: { template: "
" }, + ImportInterfacesModal: { + template: "
", + methods: { show: vi.fn() }, + }, + Interface: { template: "
" }, + }, + mocks: { + $t: (key) => key, + $router: { push: vi.fn() }, + }, + }, + }); + + await wrapper.setData({ + discoveredInterfaces: [iface], + activeTab: "overview", + }); + + const pulsingElements = wrapper.findAll(".animate-pulse"); + expect(pulsingElements.length).toBe(0); + }); +}); diff --git a/tests/frontend/LanguageSelector.test.js b/tests/frontend/LanguageSelector.test.js index 8e77e9e..a8180e6 100644 --- a/tests/frontend/LanguageSelector.test.js +++ b/tests/frontend/LanguageSelector.test.js @@ -43,10 +43,11 @@ describe("LanguageSelector.vue", () => { await wrapper.find("button").trigger("click"); const languageButtons = wrapper.findAll(".fixed button"); - expect(languageButtons).toHaveLength(3); + expect(languageButtons).toHaveLength(4); expect(languageButtons[0].text()).toContain("English"); expect(languageButtons[1].text()).toContain("Deutsch"); expect(languageButtons[2].text()).toContain("Русский"); + expect(languageButtons[3].text()).toContain("Italiano"); }); it("emits language-change when a different language is selected", async () => { diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js new file mode 100644 index 0000000..e2b113f --- /dev/null +++ b/tests/frontend/LinkUtils.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import LinkUtils from "@/js/LinkUtils"; + +describe("LinkUtils.js", () => { + describe("renderNomadNetLinks", () => { + it("detects nomadnet:// links with hash and path", () => { + const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; + const result = LinkUtils.renderNomadNetLinks(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + + it("detects bare hash and path links", () => { + const text = "1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; + const result = LinkUtils.renderNomadNetLinks(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + + it("detects nomadnet:// links with just hash", () => { + const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderNomadNetLinks(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + }); + + describe("renderStandardLinks", () => { + it("detects http links", () => { + const text = "visit http://example.com"; + const result = LinkUtils.renderStandardLinks(text); + expect(result).toContain(' { + const text = "visit https://example.com/path?query=1"; + const result = LinkUtils.renderStandardLinks(text); + expect(result).toContain(' { + it("detects both types of links", () => { + const text = "Check https://google.com and nomadnet://1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderAllLinks(text); + expect(result).toContain('href="https://google.com"'); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + }); +}); diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js new file mode 100644 index 0000000..058657d --- /dev/null +++ b/tests/frontend/MarkdownRenderer.test.js @@ -0,0 +1,212 @@ +import { describe, it, expect } from "vitest"; +import MarkdownRenderer from "@/js/MarkdownRenderer"; + +describe("MarkdownRenderer.js", () => { + describe("render", () => { + it("renders basic text correctly", () => { + expect(MarkdownRenderer.render("Hello")).toContain("Hello"); + }); + + it("renders bold text correctly", () => { + const result = MarkdownRenderer.render("**Bold**"); + expect(result).toContain("Bold"); + }); + + it("renders italic text correctly", () => { + const result = MarkdownRenderer.render("*Italic*"); + expect(result).toContain("Italic"); + }); + + it("renders bold and italic text correctly", () => { + const result = MarkdownRenderer.render("***Bold and Italic***"); + expect(result).toContain("Bold and Italic"); + }); + + it("renders headers correctly", () => { + expect(MarkdownRenderer.render("# Header 1")).toContain(" { + const result = MarkdownRenderer.render("`code`"); + expect(result).toContain(" { + const result = MarkdownRenderer.render("```python\nprint('hello')\n```"); + expect(result).toContain(" { + const result = MarkdownRenderer.render("Para 1\n\nPara 2"); + expect(result).toContain(" { + it("escapes script tags", () => { + const malformed = ""; + const result = MarkdownRenderer.render(malformed); + expect(result).not.toContain("\n```"; + const result = MarkdownRenderer.render(malformed); + expect(result).toContain("<script>"); + }); + + it("escapes html in inline code", () => { + const malformed = "``"; + const result = MarkdownRenderer.render(malformed); + expect(result).toContain("<script>"); + }); + }); + + describe("nomadnet links", () => { + it("detects nomadnet:// links with hash and path", () => { + const text = "check this out: nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; + const result = MarkdownRenderer.render(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + expect(result).toContain("nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"); + }); + + it("detects bare hash and path links", () => { + const text = "node is at 1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; + const result = MarkdownRenderer.render(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + expect(result).toContain("1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"); + }); + + it("detects nomadnet:// links with just hash", () => { + const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4"; + const result = MarkdownRenderer.render(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + + it("does not detect invalid hashes", () => { + const text = "not-a-hash:/page/index.mu"; + const result = MarkdownRenderer.render(text); + expect(result).not.toContain("nomadnet-link"); + }); + }); + + describe("fuzzing: stability testing", () => { + const generateRandomString = (length) => { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;':\",./<>?`~ \n\r\t"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + it("handles random inputs without crashing (100 iterations)", () => { + for (let i = 0; i < 100; i++) { + const randomText = generateRandomString(Math.floor(Math.random() * 1000)); + expect(() => { + MarkdownRenderer.render(randomText); + }).not.toThrow(); + } + }); + + it("handles deeply nested or complex markdown patterns without crashing", () => { + const complex = "# ".repeat(100) + "**".repeat(100) + "```".repeat(100) + "```\n".repeat(10); + expect(() => { + MarkdownRenderer.render(complex); + }).not.toThrow(); + }); + + it("handles large inputs correctly (1MB of random text)", () => { + const largeText = generateRandomString(1024 * 1024); + const start = Date.now(); + const result = MarkdownRenderer.render(largeText); + const end = Date.now(); + + expect(typeof result).toBe("string"); + // performance check: should be relatively fast (less than 500ms for 1MB usually) + expect(end - start).toBeLessThan(1000); + }); + + it("handles potential ReDoS patterns (repeated separators)", () => { + // Test patterns that often cause ReDoS in poorly written markdown parsers (can never be too careful, especially on public testnets) + const redosPatterns = [ + "*".repeat(10000), // Long string of bold markers + "#".repeat(10000), // Long string of header markers + "`".repeat(10000), // Long string of backticks + " ".repeat(10000) + "\n", // Long string of whitespace + "[](".repeat(5000), // Unclosed links (if we added them) + "** ".repeat(5000), // Bold marker followed by space repeated + ]; + + redosPatterns.forEach((pattern) => { + const start = Date.now(); + MarkdownRenderer.render(pattern); + const end = Date.now(); + expect(end - start).toBeLessThan(100); // Should be very fast + }); + }); + + it("handles unicode homoglyphs and special characters without interference", () => { + const homoglyphs = [ + "**bold**", + "∗∗notbold∗∗", // unicode asterisks + "# header", + "# not header", // fullwidth hash + "`code`", + "`notcode`", // fullwidth backtick + ]; + homoglyphs.forEach((text) => { + const result = MarkdownRenderer.render(text); + expect(typeof result).toBe("string"); + }); + }); + + it("handles malformed or unclosed markdown tags gracefully", () => { + const malformed = [ + "**bold", + "```python\nprint(1)", + "#header", // no space + "`code", + "___triple", + "**bold*italic**", + "***bolditalic**", + ]; + malformed.forEach((text) => { + expect(() => MarkdownRenderer.render(text)).not.toThrow(); + }); + }); + }); + + describe("strip", () => { + it("strips markdown correctly", () => { + const md = "# Header\n**Bold** *Italic* `code` ```\nblock\n```"; + const stripped = MarkdownRenderer.strip(md); + expect(stripped).toContain("Header"); + expect(stripped).toContain("Bold"); + expect(stripped).toContain("Italic"); + expect(stripped).toContain("code"); + expect(stripped).toContain("[Code Block]"); + expect(stripped).not.toContain("# "); + expect(stripped).not.toContain("**"); + expect(stripped).not.toContain("` "); + }); + }); +}); diff --git a/tests/frontend/Performance.test.js b/tests/frontend/Performance.test.js index a32fb34..dfb9744 100644 --- a/tests/frontend/Performance.test.js +++ b/tests/frontend/Performance.test.js @@ -16,6 +16,18 @@ vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ formatTimeAgo: () => "1 hour ago", formatBytes: () => "1 KB", formatDestinationHash: (h) => h, + escapeHtml: (t) => + t.replace( + /[&<>"']/g, + (m) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[m] + ), }, }));