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
This commit is contained in:
Ivan
2026-05-09 21:00:38 -05:00
parent 500818941d
commit 3b72902317
6 changed files with 236 additions and 5 deletions
+3 -3
View File
@@ -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),
)
+35
View File
@@ -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
@@ -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
)
@@ -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
+4 -2
View File
@@ -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(
+113
View File
@@ -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");
});
});
});