From 3b72902317ebff1912be4116801331d5dd056068 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 9 May 2026 21:00:38 -0500 Subject: [PATCH] test(nomadnet): add tests for query-param file downloads and docs fixes - Add backend tests for NomadnetFileDownloader data parameter - Add WS order test verifying data reaches the downloader - Add docs_manager tests for index.html generation and directory rejection - Extend security fuzzing to cover data field in file downloads - Add frontend tests for parseNomadnetworkUrl with query strings - Add frontend tests for downloadNomadNetFile data payload - Fix meshchat.py NomadnetFileDownloader kwarg name regression --- meshchatx/meshchat.py | 6 +- tests/backend/test_docs_manager.py | 35 ++++++ .../test_nomadnet_download_ws_order.py | 50 ++++++++ .../backend/test_nomadnet_downloader_boost.py | 31 +++++ tests/backend/test_security_fuzzing.py | 6 +- tests/frontend/NomadNetworkPage.test.js | 113 ++++++++++++++++++ 6 files changed, 236 insertions(+), 5 deletions(-) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 3c74e64..5da111f 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -14110,10 +14110,10 @@ class ReticulumMeshChat: downloader = NomadnetFileDownloader( destination_hash, file_path, + on_file_download_success, + on_file_download_failure, + on_file_download_progress, data=request_data, - on_file_download_success=on_file_download_success, - on_file_download_failure=on_file_download_failure, - on_file_download_progress=on_file_download_progress, on_phase=on_file_download_phase, reticulum=getattr(self, "reticulum", None), ) diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py index 5947247..0a418a3 100644 --- a/tests/backend/test_docs_manager.py +++ b/tests/backend/test_docs_manager.py @@ -331,3 +331,38 @@ def test_extract_docs_malformed_zip(docs_manager, temp_dirs): finally: if os.path.exists(zip_path): os.remove(zip_path) + + +def test_populate_meshchatx_docs_generates_index_html(tmp_path): + public_dir = tmp_path / "public" + public_dir.mkdir() + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + (docs_dir / "README.md").write_text("# Hello\nWorld") + (docs_dir / "FAQ.md").write_text("# FAQ\nQ&A") + + config = MagicMock() + dm = DocsManager(config, str(public_dir), project_root=str(tmp_path)) + dm.populate_meshchatx_docs() + + index_path = os.path.join(dm.meshchatx_docs_dir, "index.html") + assert os.path.exists(index_path) + content = open(index_path, encoding="utf-8").read() + assert "MeshChatX Documentation" in content + assert "README.html" in content + assert "FAQ.html" in content + + +def test_get_doc_content_rejects_directory_path(tmp_path): + public_dir = tmp_path / "public" + public_dir.mkdir() + config = MagicMock() + dm = DocsManager(config, str(public_dir)) + + # Ensure meshchatx_docs_dir exists as a directory + os.makedirs(dm.meshchatx_docs_dir, exist_ok=True) + + # Passing "." should resolve to the directory itself, not a file + assert dm.get_doc_content(".") is None + assert dm.get_doc_content("..") is None + assert dm.get_doc_content("") is None diff --git a/tests/backend/test_nomadnet_download_ws_order.py b/tests/backend/test_nomadnet_download_ws_order.py index 1ca453f..69285bc 100644 --- a/tests/backend/test_nomadnet_download_ws_order.py +++ b/tests/backend/test_nomadnet_download_ws_order.py @@ -89,3 +89,53 @@ async def test_nomadnet_file_download_started_before_download_async_scheduled( assert events[0][1]["type"] == "nomadnet.file.download" assert events[0][1]["nomadnet_file_download"]["status"] == "started" assert events[1][0] == "run_async" + + +@pytest.mark.asyncio +async def test_nomadnet_file_download_with_data_passed_to_downloader( + mock_app, monkeypatch +): + """Query-param data from the WS payload must reach NomadnetFileDownloader.""" + mock_app._try_serve_local_page_node_file = MagicMock(return_value=None) + + from meshchatx.src.backend import nomadnet_downloader + + captured = {} + orig_init = nomadnet_downloader.NomadnetFileDownloader.__init__ + + def capturing_init(self, *args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + # Don't call real init to avoid RNS side-effects + self.is_cancelled = False + self.destination_hash = args[0] + self.path = args[1] + self.data = kwargs.get("data") + + monkeypatch.setattr( + nomadnet_downloader.NomadnetFileDownloader, "__init__", capturing_init + ) + + mock_ws = MagicMock() + mock_ws.send_str = AsyncMock() + + dh = "c" * 32 + await mock_app.on_websocket_data_received( + mock_ws, + { + "type": "nomadnet.file.download", + "nomadnet_file_download": { + "destination_hash": dh, + "file_path": "/files/report.pdf", + "data": "version=2&format=raw", + }, + }, + ) + + assert captured["kwargs"].get("data") == "version=2&format=raw" + assert captured["args"][0] == bytes.fromhex(dh) + assert captured["args"][1] == "/files/report.pdf" + + monkeypatch.setattr( + nomadnet_downloader.NomadnetFileDownloader, "__init__", orig_init + ) diff --git a/tests/backend/test_nomadnet_downloader_boost.py b/tests/backend/test_nomadnet_downloader_boost.py index 662feb9..fbdfefc 100644 --- a/tests/backend/test_nomadnet_downloader_boost.py +++ b/tests/backend/test_nomadnet_downloader_boost.py @@ -188,6 +188,37 @@ def test_file_downloader_list_response_short_list_no_crash(): on_fail.assert_called_once_with("unsupported_response") +def test_file_downloader_stores_data_parameter(): + on_ok = MagicMock() + on_fail = MagicMock() + on_progress = MagicMock() + fd = NomadnetFileDownloader( + b"ab" * 8, + "/file/data.bin", + on_ok, + on_fail, + on_progress, + data="query=value&other=123", + ) + assert fd.data == "query=value&other=123" + + +def test_file_downloader_passes_data_to_parent(): + on_ok = MagicMock() + on_fail = MagicMock() + on_progress = MagicMock() + fd = NomadnetFileDownloader( + b"ab" * 8, + "/file/data.bin", + on_ok, + on_fail, + on_progress, + data="foo=bar", + ) + # NomadnetDownloader stores data as the 3rd positional arg + assert fd.data == "foo=bar" + + def test_cache_lock_serializes_mutations(): mock_link = MagicMock() mock_link.status = RNS.Link.ACTIVE diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py index aac2fb2..ea1f406 100644 --- a/tests/backend/test_security_fuzzing.py +++ b/tests/backend/test_security_fuzzing.py @@ -1430,9 +1430,10 @@ def test_nomadnet_page_archive_add_fuzzing( @given( destination_hash=st.text(min_size=0, max_size=200), file_path=st.text(min_size=0, max_size=2000), + data=st.one_of(st.none(), st.text(min_size=0, max_size=2000)), ) -def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path): - """Fuzz nomadnet.file.download WebSocket handler (path traversal, malformed hash).""" +def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path, data): + """Fuzz nomadnet.file.download WebSocket handler (path traversal, malformed hash, data).""" import asyncio loop = asyncio.new_event_loop() @@ -1443,6 +1444,7 @@ def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path): "nomadnet_file_download": { "destination_hash": destination_hash, "file_path": file_path, + "data": data, }, } loop.run_until_complete( diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js index 0a23bce..b1a26f1 100644 --- a/tests/frontend/NomadNetworkPage.test.js +++ b/tests/frontend/NomadNetworkPage.test.js @@ -404,4 +404,117 @@ describe("NomadNetworkPage.vue", () => { expect(wrapper.vm.hasPageLoadFailed).toBe(false); }); }); + + describe("parseNomadnetworkUrl", () => { + it("parses absolute URL with query string", () => { + const wrapper = mountNomadNetworkPage(); + const dest = "a".repeat(32); + const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/file/report.pdf?version=2&format=raw`); + expect(result).toEqual({ + destination_hash: dest, + path: "/file/report.pdf", + query: "version=2&format=raw", + }); + }); + + it("parses relative URL with query string", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.defaultNodePagePath = "/page/index.mu"; + const result = wrapper.vm.parseNomadnetworkUrl(":/file/data.bin?key=val"); + expect(result).toEqual({ + destination_hash: null, + path: "/file/data.bin", + query: "key=val", + }); + }); + + it("parses node-only URL without query", () => { + const wrapper = mountNomadNetworkPage(); + const dest = "b".repeat(32); + const result = wrapper.vm.parseNomadnetworkUrl(dest); + expect(result).toEqual({ + destination_hash: dest, + path: wrapper.vm.defaultNodePagePath, + query: null, + }); + }); + + it("parses absolute URL without query", () => { + const wrapper = mountNomadNetworkPage(); + const dest = "c".repeat(32); + const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/page/index.mu`); + expect(result).toEqual({ + destination_hash: dest, + path: "/page/index.mu", + query: null, + }); + }); + + it("returns null for unsupported URL", () => { + const wrapper = mountNomadNetworkPage(); + expect(wrapper.vm.parseNomadnetworkUrl("not-a-url")).toBeNull(); + }); + + it("handles empty query string after ?", () => { + const wrapper = mountNomadNetworkPage(); + const dest = "d".repeat(32); + const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/file/x.txt?`); + expect(result.path).toBe("/file/x.txt"); + expect(result.query).toBe(""); + }); + }); + + describe("downloadNomadNetFile", () => { + let WebSocketConnection; + + beforeEach(async () => { + // Re-import to get the mocked module + WebSocketConnection = (await import("@/js/WebSocketConnection")).default; + WebSocketConnection.send.mockClear(); + }); + + it("includes data in websocket payload when provided", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.downloadNomadNetFile( + "a".repeat(32), + "/file/data.bin", + "version=2&format=raw", + vi.fn(), + vi.fn(), + vi.fn(), + ); + expect(WebSocketConnection.send).toHaveBeenCalledOnce(); + const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); + expect(payload.type).toBe("nomadnet.file.download"); + expect(payload.nomadnet_file_download.data).toBe("version=2&format=raw"); + }); + + it("omits data field when data is null", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.downloadNomadNetFile( + "b".repeat(32), + "/file/data.bin", + null, + vi.fn(), + vi.fn(), + vi.fn(), + ); + const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); + expect(payload.nomadnet_file_download).not.toHaveProperty("data"); + }); + + it("omits data field when data is undefined", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.downloadNomadNetFile( + "c".repeat(32), + "/file/data.bin", + undefined, + vi.fn(), + vi.fn(), + vi.fn(), + ); + const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); + expect(payload.nomadnet_file_download).not.toHaveProperty("data"); + }); + }); });