mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-01 16:35:44 +00:00
feat(tests): add comprehensive security and robustness tests for archives and deep links
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Robustness tests for REST handlers (archives, bundled docs, lxmf send) and related WS paths."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
|
||||
def _handler(app, method: str, path: str):
|
||||
for route in app.get_routes():
|
||||
if route.method == method and route.path == path:
|
||||
return route.handler
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_archives_returns_json_for_edge_case_queries(mock_app):
|
||||
handler = _handler(mock_app, "GET", "/api/v1/nomadnet/archives")
|
||||
assert handler is not None
|
||||
|
||||
edge_queries = [
|
||||
{},
|
||||
{"q": "", "page": "1", "limit": "100"},
|
||||
{"q": "%00%3Cscript%3E", "page": "not-a-number", "limit": "not-a-number"},
|
||||
{"page": "-5", "limit": "0"},
|
||||
{"limit": "99999"},
|
||||
]
|
||||
for query in edge_queries:
|
||||
request = MagicMock()
|
||||
request.query = query
|
||||
response = await handler(request)
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.body)
|
||||
assert isinstance(payload.get("archives"), list)
|
||||
assert "pagination" in payload
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=60,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=None,
|
||||
)
|
||||
@given(
|
||||
q=st.text(max_size=800),
|
||||
page=st.one_of(
|
||||
st.integers(min_value=-1000, max_value=10**9),
|
||||
st.text(max_size=40),
|
||||
),
|
||||
limit=st.one_of(
|
||||
st.integers(min_value=-1000, max_value=10**9),
|
||||
st.text(max_size=40),
|
||||
),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_archives_query_fuzz(mock_app, q, page, limit):
|
||||
handler = _handler(mock_app, "GET", "/api/v1/nomadnet/archives")
|
||||
assert handler is not None
|
||||
|
||||
request = MagicMock()
|
||||
request.query = {
|
||||
"q": q,
|
||||
"page": str(page),
|
||||
"limit": str(limit),
|
||||
}
|
||||
response = await handler(request)
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.body)
|
||||
assert isinstance(payload.get("archives"), list)
|
||||
assert "pagination" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_archives_requires_nonempty_ids(mock_app):
|
||||
handler = _handler(mock_app, "DELETE", "/api/v1/nomadnet/archives")
|
||||
assert handler is not None
|
||||
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value={"ids": []})
|
||||
response = await handler(request)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meshchatx_docs_content_requires_path(mock_app):
|
||||
handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/content")
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.query = {}
|
||||
response = await handler(request)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=40,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=None,
|
||||
)
|
||||
@given(path=st.text(min_size=1, max_size=600))
|
||||
@pytest.mark.asyncio
|
||||
async def test_meshchatx_docs_content_path_fuzz(mock_app, path):
|
||||
handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/content")
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.query = {"path": path}
|
||||
response = await handler(request)
|
||||
assert response.status in (200, 404)
|
||||
json.loads(response.body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxmf_messages_send_requires_lxmf_message(mock_app):
|
||||
handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value={})
|
||||
response = await handler(request)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxmf_messages_send_requires_destination_and_content(mock_app):
|
||||
handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={"lxmf_message": {"fields": {}}},
|
||||
)
|
||||
response = await handler(request)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxmf_messages_send_rejects_bad_image_field(mock_app):
|
||||
handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"lxmf_message": {
|
||||
"destination_hash": "aa",
|
||||
"content": "x",
|
||||
"fields": {
|
||||
"image": {"image_type": "png", "image_bytes": "@@@not-base64@@@"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
response = await handler(request)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nomadnet_file_download_invalid_hex_does_not_raise(mock_app):
|
||||
await mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{
|
||||
"type": "nomadnet.file.download",
|
||||
"nomadnet_file_download": {
|
||||
"destination_hash": "gg",
|
||||
"file_path": "/file/x.txt",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -272,6 +272,169 @@ def test_meshchatx_map_numeric_params_fuzzing(mock_app, lat, lon, z, extra):
|
||||
assert 0 <= mq["zoom"] <= 22
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxm_ingest_docs_uri_with_reticulum(mock_app):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{
|
||||
"type": "lxm.ingest_uri",
|
||||
"uri": "meshchatx://docs?reticulum=manual/interfaces.html%23section",
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_app.message_router.ingest_lxm_uri.assert_not_called()
|
||||
payload = json.loads(mock_client.send_str.call_args[0][0])
|
||||
assert payload["type"] == "lxm.ingest_uri.result"
|
||||
assert payload["status"] == "success"
|
||||
assert payload["ingest_type"] == "docs_view"
|
||||
assert payload["docs_query"]["reticulum"] == "manual/interfaces.html#section"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxm_ingest_docs_uri_meshchat_alias(mock_app):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{"type": "lxm.ingest_uri", "uri": "meshchat://docs?path=manual/index.html"},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
payload = json.loads(mock_client.send_str.call_args[0][0])
|
||||
assert payload["ingest_type"] == "docs_view"
|
||||
assert payload["docs_query"]["reticulum"] == "manual/index.html"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxm_ingest_docs_uri_open_index(mock_app):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{"type": "lxm.ingest_uri", "uri": "meshchatx://docs"},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
payload = json.loads(mock_client.send_str.call_args[0][0])
|
||||
assert payload["ingest_type"] == "docs_view"
|
||||
assert "docs_query" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxm_ingest_docs_hostname_spoof_not_docs_view(mock_app):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{"type": "lxm.ingest_uri", "uri": "meshchatx://docs-foo?reticulum=evil"},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
payload = json.loads(mock_client.send_str.call_args[0][0])
|
||||
assert payload.get("ingest_type") != "docs_view"
|
||||
mock_app.message_router.ingest_lxm_uri.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rel",
|
||||
[
|
||||
"<script>alert(1)</script>",
|
||||
"'; DROP TABLE docs;--",
|
||||
"a" * 8000,
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lxm_ingest_docs_opaque_reticulum_roundtrip(mock_app, rel):
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
q = urlencode({"reticulum": rel}, quote_via=quote)
|
||||
uri = f"meshchatx://docs?{q}"
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{"type": "lxm.ingest_uri", "uri": uri},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
payload = json.loads(mock_client.send_str.call_args[0][0])
|
||||
assert payload["ingest_type"] == "docs_view"
|
||||
assert payload["docs_query"]["reticulum"] == rel
|
||||
|
||||
|
||||
@settings(
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=None,
|
||||
max_examples=80,
|
||||
)
|
||||
@given(tail=st.text(max_size=800))
|
||||
def test_meshchatx_docs_query_tail_fuzzing(mock_app, tail):
|
||||
uri = "meshchatx://docs?" + tail
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_app.message_router.ingest_lxm_uri = MagicMock()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
|
||||
async def _run():
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{"type": "lxm.ingest_uri", "uri": uri},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
loop.run_until_complete(_run())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
mock_client.send_str.assert_called()
|
||||
raw = mock_client.send_str.call_args[0][0]
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
assert payload["type"] == "lxm.ingest_uri.result"
|
||||
assert payload["status"] == "success"
|
||||
assert payload["ingest_type"] == "docs_view"
|
||||
|
||||
|
||||
def test_telemetry_pack_location_xss_like_strings_return_none():
|
||||
from meshchatx.src.backend.telemetry_utils import Telemeter
|
||||
|
||||
|
||||
@@ -412,6 +412,27 @@ async def test_rnode_persists_flow_control_and_id_callsign(temp_dir):
|
||||
assert saved["airtime_limit_short"] == 33.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rnode_ble_uart_port_persisted(temp_dir):
|
||||
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
||||
|
||||
async with make_app(temp_dir, config) as handler:
|
||||
payload = {
|
||||
"name": "RadioBLE",
|
||||
"type": "RNodeInterface",
|
||||
"port": "ble://aa:bb:cc:dd:ee:ff",
|
||||
"frequency": 868000000,
|
||||
"bandwidth": 125000,
|
||||
"txpower": 7,
|
||||
"spreadingfactor": 8,
|
||||
"codingrate": 5,
|
||||
}
|
||||
response = await handler(make_request(payload))
|
||||
body = json.loads(response.body)
|
||||
assert response.status == 200, body
|
||||
assert config["interfaces"]["RadioBLE"]["port"] == "ble://aa:bb:cc:dd:ee:ff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rnode_frequency_mhz_decimal_normalized_to_hz(temp_dir):
|
||||
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import ArchivesPage from "@/components/archives/ArchivesPage.vue";
|
||||
|
||||
function mountArchives() {
|
||||
const routerPush = vi.fn();
|
||||
return {
|
||||
wrapper: mount(ArchivesPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: { push: routerPush },
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
ArchiveSidebar: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
routerPush,
|
||||
};
|
||||
}
|
||||
|
||||
function randText(len) {
|
||||
const alphabet = "abc<>\"'`\\/\u0000\n\r`topic_id=";
|
||||
let s = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
s += alphabet[(Math.random() * alphabet.length) | 0];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
describe("Archives page viewing-archive surface (security / fuzz)", () => {
|
||||
const nastyPaths = [
|
||||
"/page/article.mu`topic_id=40",
|
||||
"/page/article.mu`topic_id=40`extra",
|
||||
"/forum/thread.mu`sort=hot",
|
||||
"/../../../etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"<script>alert(1)</script>",
|
||||
"a".repeat(6000),
|
||||
];
|
||||
|
||||
const nastyContents = [
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"<svg/onload=alert(1)>",
|
||||
"`>>{{constructor.constructor('return this')()}}",
|
||||
"# Title\n[link](javascript:alert(1))",
|
||||
"\x00".repeat(20),
|
||||
];
|
||||
|
||||
it("renderFullContent never throws; returns a string for fuzzed paths and bodies", () => {
|
||||
const { wrapper } = mountArchives();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const page_path = nastyPaths[i % nastyPaths.length] + randText(i % 7);
|
||||
const content = nastyContents[i % nastyContents.length] + randText(40);
|
||||
const archive = {
|
||||
page_path,
|
||||
content,
|
||||
destination_hash: "a".repeat(64),
|
||||
hash: "b".repeat(64),
|
||||
id: i + 1,
|
||||
};
|
||||
expect(() => wrapper.vm.renderFullContent(archive)).not.toThrow();
|
||||
const out = wrapper.vm.renderFullContent(archive);
|
||||
expect(typeof out).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("archiveViewerClasses stays an array for adversarial page_path values", () => {
|
||||
const { wrapper } = mountArchives();
|
||||
for (const page_path of nastyPaths) {
|
||||
wrapper.vm.viewingArchive = { page_path };
|
||||
expect(Array.isArray(wrapper.vm.archiveViewerClasses)).toBe(true);
|
||||
}
|
||||
wrapper.vm.viewingArchive = null;
|
||||
});
|
||||
|
||||
it("openInNomadnet uses router.push with nomadnetwork route and query only", () => {
|
||||
const { wrapper, routerPush } = mountArchives();
|
||||
wrapper.vm.openInNomadnet({
|
||||
id: 40,
|
||||
destination_hash: "deadbeef",
|
||||
page_path: "/page/article.mu`topic_id=40",
|
||||
});
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: "nomadnetwork",
|
||||
params: { destinationHash: "deadbeef" },
|
||||
query: {
|
||||
path: "/page/article.mu`topic_id=40",
|
||||
archive_id: 40,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("muExportBasename neutralizes path separators in the basename", () => {
|
||||
const { wrapper } = mountArchives();
|
||||
const base = wrapper.vm.muExportBasename({
|
||||
page_path: "../../../secret/x.mu",
|
||||
hash: "abc",
|
||||
});
|
||||
expect(base.includes("/")).toBe(false);
|
||||
expect(base.includes("..")).toBe(false);
|
||||
});
|
||||
|
||||
it("onArchiveContentClick handles nomadnet links and fragment anchors without throwing", () => {
|
||||
const { wrapper, routerPush } = mountArchives();
|
||||
const holder = document.createElement("div");
|
||||
holder.innerHTML =
|
||||
'<a class="nomadnet-link" data-nomadnet-url="abc123:/p.mu`q=1">n</a>' + '<a href="#frag%20ment">f</a>';
|
||||
document.body.appendChild(holder);
|
||||
try {
|
||||
const nomadA = holder.querySelector("a.nomadnet-link");
|
||||
const fragA = holder.querySelector('a[href^="#"]');
|
||||
const clickOn = (el) => {
|
||||
const ev = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(ev, "target", { value: el });
|
||||
wrapper.vm.onArchiveContentClick(ev);
|
||||
};
|
||||
clickOn(nomadA);
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: "nomadnetwork",
|
||||
params: { destinationHash: "abc123" },
|
||||
query: { path: "/p.mu`q=1" },
|
||||
});
|
||||
routerPush.mockClear();
|
||||
clickOn(fragA);
|
||||
} finally {
|
||||
document.body.removeChild(holder);
|
||||
}
|
||||
const noop = document.createElement("div");
|
||||
const noopEv = new MouseEvent("click");
|
||||
Object.defineProperty(noopEv, "target", { value: noop });
|
||||
expect(() => wrapper.vm.onArchiveContentClick(noopEv)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import App from "../../meshchatx/src/frontend/components/App.vue";
|
||||
import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
|
||||
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
|
||||
default: {
|
||||
send: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("meshchatx://docs deep links (security / fuzz)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("routes only hostname docs, not docs- prefix spoof", () => {
|
||||
const push = vi.fn();
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, "meshchatx://docs-foo?reticulum=evil");
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts meshchat alias and path-style manual path", () => {
|
||||
const push = vi.fn();
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, "meshchat://docs/manual/interfaces.html");
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: "documentation",
|
||||
query: { reticulum: encodeURIComponent("manual/interfaces.html") },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes XSS-shaped reticulum through encodeURIComponent only (opaque to router)", () => {
|
||||
const push = vi.fn();
|
||||
const malicious = "<script>alert(1)</script>";
|
||||
App.methods.handleProtocolLink.call(
|
||||
{ $router: { push } },
|
||||
`meshchatx://docs?reticulum=${encodeURIComponent(malicious)}`
|
||||
);
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: "documentation",
|
||||
query: { reticulum: encodeURIComponent(malicious) },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat javascript: prefix as docs link", () => {
|
||||
const push = vi.fn();
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, "javascript:meshchatx://docs?reticulum=x");
|
||||
expect(push).not.toHaveBeenCalledWith(expect.objectContaining({ name: "documentation" }));
|
||||
});
|
||||
|
||||
it("fuzz random query tails without throwing", () => {
|
||||
const push = vi.fn();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const tail = `x=${encodeURIComponent(`${i}\u0000<script>`)}`;
|
||||
expect(() =>
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, `meshchatx://docs?${tail}`)
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("onWebsocketMessage docs_view navigates like handleProtocolLink", async () => {
|
||||
const push = vi.fn().mockResolvedValue(undefined);
|
||||
await App.methods.onWebsocketMessage.call(
|
||||
{ $router: { push } },
|
||||
{
|
||||
data: JSON.stringify({
|
||||
type: "lxm.ingest_uri.result",
|
||||
status: "success",
|
||||
ingest_type: "docs_view",
|
||||
message: "Opening documentation.",
|
||||
docs_query: { reticulum: "manual/interfaces.html#x" },
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: "documentation",
|
||||
query: { reticulum: encodeURIComponent("manual/interfaces.html#x") },
|
||||
});
|
||||
expect(ToastUtils.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onWebsocketMessage docs_view without docs_query opens documentation index", async () => {
|
||||
const push = vi.fn().mockResolvedValue(undefined);
|
||||
await App.methods.onWebsocketMessage.call(
|
||||
{ $router: { push } },
|
||||
{
|
||||
data: JSON.stringify({
|
||||
type: "lxm.ingest_uri.result",
|
||||
status: "success",
|
||||
ingest_type: "docs_view",
|
||||
message: "Opening documentation.",
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(push).toHaveBeenCalledWith({ name: "documentation" });
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,32 @@ describe("App.vue deep link protocol handling (security-oriented)", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("routes meshchatx://docs with reticulum query to documentation page", () => {
|
||||
const push = vi.fn();
|
||||
App.methods.handleProtocolLink.call(
|
||||
{ $router: { push } },
|
||||
"meshchatx://docs?reticulum=" + encodeURIComponent("manual/interfaces.html#foo")
|
||||
);
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: "documentation",
|
||||
query: {
|
||||
reticulum: encodeURIComponent("manual/interfaces.html#foo"),
|
||||
},
|
||||
});
|
||||
push.mockClear();
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, "meshchat://docs?path=manual/index.html");
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: "documentation",
|
||||
query: { reticulum: encodeURIComponent("manual/index.html") },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes meshchatx://docs without query to documentation index", () => {
|
||||
const push = vi.fn();
|
||||
App.methods.handleProtocolLink.call({ $router: { push } }, "meshchatx://docs");
|
||||
expect(push).toHaveBeenCalledWith({ name: "documentation" });
|
||||
});
|
||||
|
||||
it("sends map deep links to lxm.ingest_uri unchanged over WebSocket", () => {
|
||||
const uri = "meshchatx://map?lat=1&lon=2&z=4&label=" + encodeURIComponent("<img src=x onerror=alert(1)>");
|
||||
App.methods.handleProtocolLink.call({ $router: { push: vi.fn() } }, uri);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundledReticulumManualDeepLink } from "../../meshchatx/src/frontend/js/reticulumDocsNavigation.js";
|
||||
|
||||
describe("bundledReticulumManualDeepLink", () => {
|
||||
it("builds meshchatx://docs with reticulum query", () => {
|
||||
const u = bundledReticulumManualDeepLink("manual/interfaces.html#common-interface-options");
|
||||
expect(u.startsWith("meshchatx://docs?")).toBe(true);
|
||||
const parsed = new URL(u.replace(/^meshchatx:/, "https:"));
|
||||
expect(parsed.searchParams.get("reticulum")).toBe("manual/interfaces.html#common-interface-options");
|
||||
});
|
||||
|
||||
it("supports meshchat scheme alias", () => {
|
||||
expect(bundledReticulumManualDeepLink("manual/index.html", "meshchat")).toMatch(
|
||||
/^meshchat:\/\/docs\?reticulum=/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
getDestinationPath,
|
||||
@@ -6,76 +8,38 @@ import {
|
||||
runDestinationPathFinder,
|
||||
} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
|
||||
|
||||
describe("reticulumPathfinding", () => {
|
||||
it("getDestinationPath uses destination path API", async () => {
|
||||
describe("reticulumPathfinding.js", () => {
|
||||
it("getDestinationPath builds expected URL and forwards params", () => {
|
||||
const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) };
|
||||
await getDestinationPath(api, "abcd", { request: "1", timeout: 4 });
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
|
||||
params: { request: "1", timeout: 4 },
|
||||
getDestinationPath(api, "deadbeef", { request: true, timeout: 12 });
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/deadbeef/path", {
|
||||
params: { request: "1", timeout: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces request true to string", async () => {
|
||||
it("getDestinationPath uses raw hash segment (caller must validate)", () => {
|
||||
const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
|
||||
await getDestinationPath(api, "h1", { request: true });
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h1/path", {
|
||||
params: { request: "1" },
|
||||
});
|
||||
getDestinationPath(api, "not/a/hex", {});
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/not/a/hex/path", expect.any(Object));
|
||||
});
|
||||
|
||||
it("coerces request false to string", async () => {
|
||||
const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
|
||||
await getDestinationPath(api, "h2", { request: false });
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h2/path", {
|
||||
params: { request: "0" },
|
||||
});
|
||||
it("postRequestPath and postDropPath hit destination endpoints", () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({}) };
|
||||
postRequestPath(api, "ab");
|
||||
postDropPath(api, "ab");
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/request-path");
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/drop-path");
|
||||
});
|
||||
|
||||
it("postRequestPath and postDropPath hit expected routes", async () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
|
||||
await postRequestPath(api, "aaaabbbbccccddddeeeeffffaaaabbbb");
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/aaaabbbbccccddddeeeeffffaaaabbbb/request-path");
|
||||
await postDropPath(api, "x");
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/x/drop-path");
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder quick posts request-path", async () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
|
||||
const r = await runDestinationPathFinder(api, "q1", "quick");
|
||||
expect(r.ok).toBe(true);
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/q1/request-path");
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder force uses GET with wait", async () => {
|
||||
const api = {
|
||||
get: vi.fn().mockResolvedValue({ data: { path: { hops: 1 } } }),
|
||||
};
|
||||
const r = await runDestinationPathFinder(api, "f1", "force", { forceTimeout: 9 });
|
||||
expect(r.path.hops).toBe(1);
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/f1/path", {
|
||||
params: { request: "1", timeout: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder drop_then_request drops then posts", async () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
|
||||
await runDestinationPathFinder(api, "d1", "drop_then_request");
|
||||
expect(api.post).toHaveBeenNthCalledWith(1, "/api/v1/destination/d1/drop-path");
|
||||
expect(api.post).toHaveBeenNthCalledWith(2, "/api/v1/destination/d1/request-path");
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder drop_then_request continues if drop fails with handler", async () => {
|
||||
const onDrop = vi.fn();
|
||||
const api = {
|
||||
post: vi.fn().mockRejectedValueOnce(new Error("no drop")).mockResolvedValue({ data: {} }),
|
||||
};
|
||||
await runDestinationPathFinder(api, "d2", "drop_then_request", { onDropPathError: onDrop });
|
||||
expect(onDrop).toHaveBeenCalled();
|
||||
expect(api.post).toHaveBeenLastCalledWith("/api/v1/destination/d2/request-path");
|
||||
it("runDestinationPathFinder quick only posts request-path", async () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({}) };
|
||||
const r = await runDestinationPathFinder(api, "h", "quick");
|
||||
expect(r).toEqual({ ok: true, path: null });
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder rejects unknown mode", async () => {
|
||||
const api = { get: vi.fn(), post: vi.fn() };
|
||||
await expect(runDestinationPathFinder(api, "z", "invalid")).rejects.toThrow("unknown path finder mode");
|
||||
await expect(runDestinationPathFinder(api, "h", "invalid-mode")).rejects.toThrow("unknown path finder mode");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user