feat(tests): add comprehensive security and robustness tests for archives and deep links

This commit is contained in:
Ivan
2026-04-29 18:27:59 -05:00
parent b7afdad209
commit ef7f42c190
8 changed files with 668 additions and 58 deletions
@@ -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",
},
},
)
+163
View File
@@ -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
+21
View File
@@ -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=/
);
});
});
+22 -58
View File
@@ -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");
});
});