feat: various bug fixes

This commit is contained in:
Ivan 2026-08-13 10:01:56 -05:00
parent 27bebd6083
commit e91ff458c7
No known key found for this signature in database
32 changed files with 825 additions and 219 deletions

Binary file not shown.

View file

@ -83,9 +83,14 @@ class BotHandler:
def _load_state(self):
try:
with open(self.state_file, encoding="utf-8") as f:
self.bots_state = json.load(f)
# Ensure all storage paths are absolute
for entry in self.bots_state:
loaded = json.load(f)
if not isinstance(loaded, list):
self.bots_state = []
return
kept = []
for entry in loaded:
if not isinstance(entry, dict):
continue
if "storage_dir" in entry:
entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
if entry.get("bot_config_dir"):
@ -96,6 +101,14 @@ class BotHandler:
entry["reticulum_config_dir"] = os.path.abspath(
os.path.expanduser(entry["reticulum_config_dir"]),
)
if self._jailed_bot_dirs(entry) is None:
logger.warning(
"Dropping bot %s: storage path is outside the identity bots directory",
entry.get("id"),
)
continue
kept.append(entry)
self.bots_state = kept
except FileNotFoundError:
self.bots_state = []
except Exception:
@ -198,10 +211,21 @@ class BotHandler:
break
if entry is None:
raise ValueError(f"Unknown bot: {bot_id}")
storage_dir = entry.get("storage_dir")
jailed = self._jailed_bot_dirs(entry)
if jailed is None:
raise ValueError("invalid bot storage directory")
storage_dir, _bot_config_dir = jailed
path = BotHandler._subprocess_log_path(storage_dir)
if not path:
return {"log": None, "truncated": False, "total_bytes": 0}
if os.path.exists(path):
jailed_path = self._jailed_file_under(
storage_dir,
"meshchatx_bot_subprocess.log",
)
if jailed_path is None:
raise ValueError("invalid bot storage directory")
path = jailed_path
try:
total = os.path.getsize(path)
except OSError:
@ -528,9 +552,10 @@ class BotHandler:
pid = entry.get("pid")
if not pid or not self._is_pid_alive(pid):
raise RuntimeError("bot is not running")
sd = entry.get("storage_dir")
if not sd:
raise RuntimeError("bot has no storage directory")
jailed = self._jailed_bot_dirs(entry)
if jailed is None:
raise RuntimeError("invalid bot storage directory")
sd, _bot_config_dir = jailed
req = os.path.join(sd, "meshchatx_request_announce")
try:
with open(req, "w", encoding="utf-8") as f:
@ -550,6 +575,78 @@ class BotHandler:
return None
return real
def _jailed_bot_dirs(self, entry):
"""Return jailed (storage_dir, bot_config_dir) or None if either path escapes."""
if not entry:
return None
storage_dir = self._jailed_bot_storage_dir(entry.get("storage_dir"))
if not storage_dir:
return None
raw_cfg = entry.get("bot_config_dir")
if raw_cfg:
bot_config_dir = self._jailed_bot_storage_dir(raw_cfg)
if not bot_config_dir:
return None
else:
bot_config_dir = os.path.join(storage_dir, "config")
return storage_dir, bot_config_dir
def _jailed_file_under(self, root, *parts):
if not root:
return None
candidate = os.path.join(root, *parts)
if not os.path.exists(candidate):
return None
real = os.path.realpath(candidate)
root_real = os.path.realpath(root)
if real != root_real and not real.startswith(root_real + os.sep):
return None
return real
def get_bot_identity_path(self, bot_id):
entry = None
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if not entry:
return None
jailed = self._jailed_bot_dirs(entry)
if jailed is None:
return None
storage_dir, bot_config_dir = jailed
for path in (
self._jailed_file_under(bot_config_dir, "identity"),
self._jailed_file_under(bot_config_dir, "lxmf", "identity"),
):
if path:
return path
reticulum_config_dir = entry.get("reticulum_config_dir")
if reticulum_config_dir:
allowed_shared = os.path.realpath(
os.path.join(self.bot_reticulum_config_dir, "identity"),
)
candidate = os.path.realpath(
os.path.join(os.path.expanduser(reticulum_config_dir), "identity"),
)
if candidate == allowed_shared and os.path.isfile(candidate):
return candidate
for path in (
self._jailed_file_under(storage_dir, "config", "identity"),
self._jailed_file_under(storage_dir, "identity"),
self._jailed_file_under(storage_dir, "config", "lxmf", "identity"),
self._jailed_file_under(storage_dir, "lxmf", "identity"),
):
if path:
return path
return None
def delete_bot(self, bot_id):
# Stop it first
self.stop_bot(bot_id)
@ -580,56 +677,6 @@ class BotHandler:
return True
return False
def get_bot_identity_path(self, bot_id):
entry = None
for e in self.bots_state:
if e.get("id") == bot_id:
entry = e
break
if not entry:
return None
storage_dir = entry.get("storage_dir")
if not storage_dir:
return None
bot_config_dir = entry.get("bot_config_dir")
if bot_config_dir:
id_path_bot_cfg = os.path.join(bot_config_dir, "identity")
if os.path.exists(id_path_bot_cfg):
return id_path_bot_cfg
id_path_lxmf_cfg = os.path.join(bot_config_dir, "lxmf", "identity")
if os.path.exists(id_path_lxmf_cfg):
return id_path_lxmf_cfg
reticulum_config_dir = entry.get("reticulum_config_dir")
if reticulum_config_dir:
id_path_shared = os.path.join(reticulum_config_dir, "identity")
if os.path.exists(id_path_shared):
return id_path_shared
# LXMFy stores identity in the 'config' subdirectory by default
id_path = os.path.join(storage_dir, "config", "identity")
if os.path.exists(id_path):
return id_path
# Fallback to direct identity file if it was moved or configured differently
id_path_alt = os.path.join(storage_dir, "identity")
if os.path.exists(id_path_alt):
return id_path_alt
# LXMFy may nest inside config/lxmf
id_path_lxmf = os.path.join(storage_dir, "config", "lxmf", "identity")
if os.path.exists(id_path_lxmf):
return id_path_lxmf
id_path_lxmf_root = os.path.join(storage_dir, "lxmf", "identity")
if os.path.exists(id_path_lxmf_root):
return id_path_lxmf_root
return None
def _load_identity_for_bot(self, bot_id):
identity_path = self.get_bot_identity_path(bot_id)
if not identity_path:

View file

@ -346,9 +346,17 @@ def register_bots_routes(routes, app):
status=500,
)
@routes.get("/api/v1/bots/export")
@routes.post("/api/v1/bots/export")
async def bots_export(request):
bot_id = request.query.get("bot_id")
bot_id = None
try:
data = await request.json()
if isinstance(data, dict):
bot_id = data.get("bot_id")
except Exception:
bot_id = None
if not bot_id:
bot_id = request.query.get("bot_id")
if not bot_id:
return web.json_response(

View file

@ -134,7 +134,7 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
def register_identities_routes(routes, app):
@routes.get("/api/v1/identity/backup/download")
@routes.post("/api/v1/identity/backup/download")
async def identity_backup_download(request):
try:
info = app.backup_identity()
@ -155,7 +155,7 @@ def register_identities_routes(routes, app):
status=500,
)
@routes.get("/api/v1/identity/backup/base32")
@routes.post("/api/v1/identity/backup/base32")
async def identity_backup_base32(request):
try:
return web.json_response(
@ -266,7 +266,7 @@ def register_identities_routes(routes, app):
status=500,
)
@routes.get("/api/v1/identities/export-all")
@routes.post("/api/v1/identities/export-all")
async def identities_export_all(request):
try:
all_bytes = app.identity_manager.get_all_identity_backup_bytes()

View file

@ -131,66 +131,93 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
zipfile,
)
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
PATH_PROBE_MIN_TIMEOUT_S = 1
PATH_PROBE_MAX_TIMEOUT_S = 600
PATH_WAIT_REQUIRES_POST_MESSAGE = (
"Waiting for a path requires POST. GET /path is a snapshot only."
)
def parse_path_probe_timeout(raw, *, default=15):
if raw is None or raw == "":
raw = default
try:
timeout_seconds = int(raw)
except (TypeError, ValueError):
return None, "Timeout must be an integer."
if (
timeout_seconds < PATH_PROBE_MIN_TIMEOUT_S
or timeout_seconds > PATH_PROBE_MAX_TIMEOUT_S
):
return None, (
f"Timeout must be between {PATH_PROBE_MIN_TIMEOUT_S} and "
f"{PATH_PROBE_MAX_TIMEOUT_S} seconds."
)
return timeout_seconds, None
async def read_path_probe_timeout_raw(request, default=15):
query = getattr(request, "query", None) or {}
if "timeout" in query:
return query.get("timeout")
method = str(getattr(request, "method", "GET") or "GET").upper()
if method == "POST":
try:
body = await request.json()
except Exception:
body = None
if isinstance(body, dict) and body.get("timeout") is not None:
return body.get("timeout")
return default
def local_destination_hashes(app):
hashes: set[str] = set()
with contextlib.suppress(Exception):
if app.current_context and app.current_context.identity:
hashes.add(app.current_context.identity.hash.hex())
with contextlib.suppress(Exception):
if app.local_lxmf_destination is not None:
hashes.add(app.local_lxmf_destination.hash.hex())
with contextlib.suppress(Exception):
if app.current_context and app.current_context.message_router:
pdest = app.current_context.message_router.propagation_destination
if pdest is not None and getattr(pdest, "hash", None):
hashes.add(pdest.hash.hex())
return hashes
def register_path_probe_routes(routes, app):
# get path to destination
@routes.get("/api/v1/destination/{destination_hash}/path")
async def destination_path(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
def maybe_resend_failed_for_current(destination_hash_str):
ctx = app.current_context
if (
ctx is not None
and ctx.running
and ctx.config.auto_resend_failed_messages_when_announce_received.get()
):
AsyncUtils.run_async(
app.resend_failed_messages_for_destination(
destination_hash_str,
context=ctx,
),
)
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
destination_hash_hex = destination_hash.hex()
local_hashes: set[str] = set()
with contextlib.suppress(Exception):
if app.current_context and app.current_context.identity:
local_hashes.add(app.current_context.identity.hash.hex())
with contextlib.suppress(Exception):
if app.local_lxmf_destination is not None:
local_hashes.add(app.local_lxmf_destination.hash.hex())
with contextlib.suppress(Exception):
if app.current_context and app.current_context.message_router:
pdest = app.current_context.message_router.propagation_destination
if pdest is not None and getattr(pdest, "hash", None):
local_hashes.add(pdest.hash.hex())
if destination_hash_hex in local_hashes:
return web.json_response(
{
"path": {
"hops": 0,
"next_hop": destination_hash_hex,
"next_hop_interface": "Local",
},
"path_stale": False,
"path_unresponsive": False,
def local_path_response(destination_hash_hex):
return web.json_response(
{
"path": {
"hops": 0,
"next_hop": destination_hash_hex,
"next_hop_interface": "Local",
},
)
"path_stale": False,
"path_unresponsive": False,
},
)
# check if user wants to request the path from the network right now
request_query_param = request.query.get("request", "false")
should_request_now = request_query_param in ("true", "1")
if should_request_now:
# determine how long we should wait for a path response
timeout_seconds = int(request.query.get("timeout", 15))
timeout_after_seconds = time.time() + timeout_seconds
reticulum = app.reticulum if hasattr(app, "reticulum") else None
reticulum_pathfinding.prepare_fresh_path_request(
reticulum,
destination_hash,
)
# wait until we have a path, or give up after the configured timeout
while (
not RNS.Transport.has_path(destination_hash)
and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
# ensure path is known
def destination_path_snapshot(destination_hash):
if not RNS.Transport.has_path(destination_hash):
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
return web.json_response(
@ -200,7 +227,6 @@ def register_path_probe_routes(routes, app):
},
)
# determine next hop and hop count
hops = RNS.Transport.hops_to(destination_hash)
if not isinstance(hops, int):
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
@ -219,7 +245,6 @@ def register_path_probe_routes(routes, app):
):
next_hop_bytes = None
# ensure next hop provided
if next_hop_bytes is None:
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
return web.json_response(
@ -248,6 +273,62 @@ def register_path_probe_routes(routes, app):
},
)
@routes.get("/api/v1/destination/{destination_hash}/path")
async def destination_path(request):
destination_hash = request.match_info.get("destination_hash", "")
destination_hash = bytes.fromhex(destination_hash)
destination_hash_hex = destination_hash.hex()
request_query_param = request.query.get("request", "false")
if request_query_param in ("true", "1"):
return web.json_response(
{"message": PATH_WAIT_REQUIRES_POST_MESSAGE},
status=400,
)
if destination_hash_hex in local_destination_hashes(app):
return local_path_response(destination_hash_hex)
return destination_path_snapshot(destination_hash)
@routes.post("/api/v1/destination/{destination_hash}/path")
async def destination_path_wait(request):
destination_hash = request.match_info.get("destination_hash", "")
try:
destination_hash_bytes = bytes.fromhex(destination_hash)
except Exception:
return web.json_response(
{"message": "invalid destination hash"},
status=400,
)
destination_hash_hex = destination_hash_bytes.hex()
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
if timeout_error:
return web.json_response({"message": timeout_error}, status=400)
if destination_hash_hex in local_destination_hashes(app):
return local_path_response(destination_hash_hex)
timeout_after_seconds = time.time() + timeout_seconds
reticulum = app.reticulum if hasattr(app, "reticulum") else None
reticulum_pathfinding.prepare_fresh_path_request(
reticulum,
destination_hash_bytes,
)
while (
not RNS.Transport.has_path(destination_hash_bytes)
and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
if RNS.Transport.has_path(destination_hash_bytes):
maybe_resend_failed_for_current(destination_hash_hex)
return destination_path_snapshot(destination_hash_bytes)
# drop path to destination
# drop path to destination
@ -290,19 +371,8 @@ def register_path_probe_routes(routes, app):
destination_hash_bytes,
)
# if path is already available, resend failed messages for this destination
if RNS.Transport.has_path(destination_hash_bytes):
for _ctx in list(app.contexts.values()):
if (
_ctx.running
and _ctx.config.auto_resend_failed_messages_when_announce_received.get()
):
AsyncUtils.run_async(
app.resend_failed_messages_for_destination(
destination_hash,
context=_ctx,
),
)
maybe_resend_failed_for_current(destination_hash)
return web.json_response(
{
@ -400,7 +470,7 @@ def register_path_probe_routes(routes, app):
# this allows us to ping/probe any active lxmf.delivery destination and get rtt/snr/rssi data on demand
# https://github.com/markqvist/LXMF/blob/9ff76c0473e9d4107e079f266dd08144bb74c7c8/LXMF/LXMRouter.py#L234
# https://github.com/markqvist/LXMF/blob/9ff76c0473e9d4107e079f266dd08144bb74c7c8/LXMF/LXMRouter.py#L1374
@routes.get("/api/v1/ping/{destination_hash}/lxmf.delivery")
@routes.post("/api/v1/ping/{destination_hash}/lxmf.delivery")
async def ping_lxmf_delivery(request):
# get path params
destination_hash_str = request.match_info.get("destination_hash", "")
@ -413,16 +483,11 @@ def register_path_probe_routes(routes, app):
status=400,
)
try:
timeout_seconds = int(request.query.get("timeout", 15))
except (TypeError, ValueError):
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
if timeout_error:
return web.json_response(
{"message": "Ping failed. Timeout must be an integer."},
status=400,
)
if timeout_seconds < 1:
return web.json_response(
{"message": "Ping failed. Timeout must be at least 1 second."},
{"message": f"Ping failed. {timeout_error}"},
status=400,
)
@ -513,18 +578,7 @@ def register_path_probe_routes(routes, app):
rtt_milliseconds = round(rtt * 1000, 3)
rtt_duration_string = f"{rtt_milliseconds} ms"
# resend any previously failed messages to this destination now that path is available
for _ctx in list(app.contexts.values()):
if (
_ctx.running
and _ctx.config.auto_resend_failed_messages_when_announce_received.get()
):
AsyncUtils.run_async(
app.resend_failed_messages_for_destination(
destination_hash_str,
context=_ctx,
),
)
maybe_resend_failed_for_current(destination_hash_str)
return web.json_response(
{

View file

@ -388,11 +388,15 @@ export default {
const pingToastKey = "conversation-ping";
ToastUtils.loading(this.$t("messages.ping_in_progress"), 0, pingToastKey);
try {
const response = await window.api.get(`/api/v1/ping/${destinationHash}/lxmf.delivery`, {
params: {
timeout: 30,
},
});
const response = await window.api.post(
`/api/v1/ping/${destinationHash}/lxmf.delivery`,
{},
{
params: {
timeout: 30,
},
}
);
const pingResult = response.data.ping_result;
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);

View file

@ -37,7 +37,7 @@
</div>
<div>
<label class="glass-label">{{ $t("ping.timeout_seconds") }}</label>
<input v-model="timeout" type="number" min="1" class="input-field" />
<input v-model="timeout" type="number" min="1" max="600" class="input-field" />
</div>
</div>
@ -197,7 +197,7 @@ export default {
}
// simple check to ensure destination hash is valid
if (this.timeout == null || this.timeout < 1) {
if (this.timeout == null || this.timeout < 1 || this.timeout > 600) {
DialogUtils.alert(this.$t("ping.timeout_must_be_number"));
return;
}
@ -238,12 +238,16 @@ export default {
this.seq++;
// ping destination
const response = await window.api.get(`/api/v1/ping/${this.destinationHash}/lxmf.delivery`, {
signal: this.abortController.signal,
params: {
timeout: this.timeout,
},
});
const response = await window.api.post(
`/api/v1/ping/${this.destinationHash}/lxmf.delivery`,
{},
{
signal: this.abortController.signal,
params: {
timeout: this.timeout,
},
}
);
const pingResult = response.data.ping_result;
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);

View file

@ -524,7 +524,7 @@ import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import { copyTextToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
import { getDestinationPath } from "../../js/reticulumPathfinding.js";
import { postDestinationPath } from "../../js/reticulumPathfinding.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
import {
@ -954,8 +954,7 @@ export default {
return;
}
try {
const response = await getDestinationPath(window.api, hash, {
request: "1",
const response = await postDestinationPath(window.api, hash, {
timeout: 4,
});
this.nodePathsByHash = {

View file

@ -516,9 +516,13 @@ export default {
},
async downloadIdentityFile() {
try {
const response = await window.api.get("/api/v1/identity/backup/download", {
responseType: "arraybuffer",
});
const response = await window.api.post(
"/api/v1/identity/backup/download",
{},
{
responseType: "arraybuffer",
}
);
await DownloadUtils.downloadFromApiResponse(response, "identity");
ToastUtils.success(this.$t("identities.identity_exported"));
} catch {
@ -527,7 +531,7 @@ export default {
},
async copyIdentityBase32() {
try {
const response = await window.api.get("/api/v1/identity/backup/base32");
const response = await window.api.post("/api/v1/identity/backup/base32");
const base32 = response.data?.identity_base32 ?? "";
if (!base32) {
ToastUtils.error(this.$t("identities.no_identity_available"));
@ -541,9 +545,13 @@ export default {
},
async downloadAllIdentities() {
try {
const response = await window.api.get("/api/v1/identities/export-all", {
responseType: "arraybuffer",
});
const response = await window.api.post(
"/api/v1/identities/export-all",
{},
{
responseType: "arraybuffer",
}
);
await DownloadUtils.downloadFromApiResponse(response, "identities_export.zip");
ToastUtils.success(this.$t("identities.export_all_success"));
} catch (e) {

View file

@ -400,6 +400,7 @@
<script>
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToolsPageHeader from "./ToolsPageHeader.vue";
@ -533,8 +534,17 @@ export default {
ToastUtils.error(this.$t("bots.failed_to_delete"));
}
},
exportIdentity(botId) {
window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
async exportIdentity(botId) {
try {
const response = await window.api.post(
"/api/v1/bots/export",
{ bot_id: botId },
{ responseType: "arraybuffer" }
);
await DownloadUtils.downloadFromApiResponse(response, `bot_${botId}_identity`);
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("bots.export_failed"));
}
},
startEditName(bot) {
this.editingBotId = bot.id;

View file

@ -74,20 +74,29 @@ export async function warmPathIfNeeded(api, hash, snapshot) {
}
/**
* Snapshot-only. Waiting for a path uses postDestinationPath.
*
* @param {import("axios").AxiosInstance} api
* @param {string} hash
* @param {{ request?: "0" | "1" | boolean, timeout?: number } & Record<string, string | number | boolean | undefined>} [params]
* @param {Record<string, string | number | boolean | undefined>} [params]
*/
export function getDestinationPath(api, hash, params) {
const q = { ...params };
if (q.request === true) {
q.request = "1";
} else if (q.request === false) {
q.request = "0";
}
delete q.request;
return api.get(destinationPath(hash), { params: q });
}
/**
* @param {import("axios").AxiosInstance} api
* @param {string} hash
* @param {{ timeout?: number }} [options]
*/
export function postDestinationPath(api, hash, options) {
const timeout = options?.timeout;
const params = timeout == null ? {} : { timeout };
return api.post(destinationPath(hash), {}, { params });
}
export function postRequestPath(api, hash) {
return api.post(`/api/v1/destination/${hash}/request-path`);
}
@ -113,10 +122,7 @@ export async function runDestinationPathFinder(api, hash, mode, options) {
return { ok: true, path: null };
}
if (mode === "force") {
const res = await getDestinationPath(api, hash, {
request: "1",
timeout: forceTimeout,
});
const res = await postDestinationPath(api, hash, { timeout: forceTimeout });
return { ok: true, path: res.data?.path ?? null };
}
if (mode === "drop_then_request") {

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Bot konnte nicht gestoppt werden",
"delete_bot": "Bot löschen",
"export_identity": "Identität exportieren",
"export_failed": "Bot-Identität konnte nicht exportiert werden",
"bot_deleted": "Bot erfolgreich gelöscht",
"failed_to_delete": "Bot konnte nicht gelöscht werden",
"more_bots_coming": "Weitere Bots folgen in Kürze!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Failed to stop bot",
"delete_bot": "Delete Bot",
"export_identity": "Export Identity",
"export_failed": "Could not export bot identity",
"bot_deleted": "Bot deleted successfully",
"failed_to_delete": "Failed to delete bot",
"more_bots_coming": "More bots coming soon!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Error al detener bot",
"delete_bot": "Eliminar bot",
"export_identity": "Exportar identidad",
"export_failed": "No se pudo exportar la identidad del bot",
"bot_deleted": "Bot eliminado con éxito",
"failed_to_delete": "Error al eliminar bot",
"more_bots_coming": "¡Más bots pronto!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Botin pysäytys epäonnistui",
"delete_bot": "Poista botti",
"export_identity": "Vie identiteetti",
"export_failed": "Botin identiteettiä ei voitu viedä",
"bot_deleted": "Botin poisto onnistui",
"failed_to_delete": "Botin poisto epäonnistui",
"more_bots_coming": "Lisää botteja tulossa!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Échec de l'arrêt du robot",
"delete_bot": "Supprimer Bot",
"export_identity": "Identité d'exportation",
"export_failed": "Impossible d'exporter l'identité du bot",
"bot_deleted": "Bot supprimé avec succès",
"failed_to_delete": "Impossible de supprimer le bot",
"more_bots_coming": "D'autres robots arrivent bientôt !",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Impossibile fermare il bot",
"delete_bot": "Elimina Bot",
"export_identity": "Esporta Identità",
"export_failed": "Impossibile esportare l'identità del bot",
"bot_deleted": "Bot eliminato con successo",
"failed_to_delete": "Impossibile eliminare il bot",
"more_bots_coming": "Altri bot in arrivo!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Stoppen van bot is mislukt",
"delete_bot": "Bot verwijderen",
"export_identity": "Identiteit exporteren",
"export_failed": "Botidentiteit kon niet worden geëxporteerd",
"bot_deleted": "Bot is succesvol verwijderd",
"failed_to_delete": "Verwijderen van bot is mislukt",
"more_bots_coming": "Er komen nog meer bots aan.",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "Не удалось остановить бота",
"delete_bot": "Удалить бота",
"export_identity": "Экспорт личности",
"export_failed": "Не удалось экспортировать личность бота",
"bot_deleted": "Бот успешно удален",
"failed_to_delete": "Не удалось удалить бота",
"more_bots_coming": "Скоро появятся новые боты!",

View file

@ -27,6 +27,7 @@
"failed_to_stop": "停止机器人失败",
"delete_bot": "删除机器人",
"export_identity": "导出身份",
"export_failed": "无法导出机器人身份",
"bot_deleted": "机器人删除成功",
"failed_to_delete": "删除机器人失败",
"more_bots_coming": "更多机器人即将推出!",

View file

@ -89,7 +89,7 @@
"path": "/api/v1/bots/delete"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/bots/export"
},
{
@ -136,6 +136,10 @@
"method": "PATCH",
"path": "/api/v1/config"
},
{
"method": "POST",
"path": "/api/v1/database/auto-recover"
},
{
"method": "POST",
"path": "/api/v1/database/backup"
@ -164,10 +168,6 @@
"method": "POST",
"path": "/api/v1/database/recover"
},
{
"method": "POST",
"path": "/api/v1/database/auto-recover"
},
{
"method": "POST",
"path": "/api/v1/database/restore"
@ -220,6 +220,10 @@
"method": "GET",
"path": "/api/v1/destination/{destination_hash}/path"
},
{
"method": "POST",
"path": "/api/v1/destination/{destination_hash}/path"
},
{
"method": "POST",
"path": "/api/v1/destination/{destination_hash}/request-path"
@ -433,7 +437,7 @@
"path": "/api/v1/identities/create"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/identities/export-all"
},
{
@ -445,11 +449,11 @@
"path": "/api/v1/identities/{identity_hash}"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/identity/backup/base32"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/identity/backup/download"
},
{
@ -585,11 +589,11 @@
"path": "/api/v1/lxmf/propagation-node/stop"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/lxmf/propagation-node/stop-sync"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/lxmf/propagation-node/sync"
},
{
@ -885,7 +889,7 @@
"path": "/api/v1/path-table"
},
{
"method": "GET",
"method": "POST",
"path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
},
{

View file

@ -281,7 +281,7 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
),
HttpJsonContract("GET", "/api/v1/identities", IDENTITIES_LIST_SCHEMA),
HttpJsonContract(
"GET",
"POST",
"/api/v1/identity/backup/base32",
IDENTITY_BACKUP_BASE32_SCHEMA,
),
@ -661,7 +661,6 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
"/api/v1/telephone/voicemails/{id}/audio",
"/api/v1/lxmf/propagation-node/sync",
"/api/v1/lxmf/propagation-node/stop-sync",
"/api/v1/ping/{destination_hash}/lxmf.delivery",
)
_HTTP_JSON_GET_EXCLUDED_PATTERNS: tuple[re.Pattern[str], ...] = (

View file

@ -69,14 +69,18 @@ def test_bot_handler_env_override_wins_over_app_reticulum_config_dir(
def test_bot_handler_load_save_state(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
test_state = [{"id": "bot1", "enabled": True, "storage_dir": "some/path"}]
storage = os.path.join(handler.bots_dir, "bot1")
os.makedirs(storage, exist_ok=True)
test_state = [{"id": "bot1", "enabled": True, "storage_dir": storage}]
handler.bots_state = test_state
handler._save_state()
# New handler instance to load state
handler2 = BotHandler(temp_identity_dir)
assert len(handler2.bots_state) == 1
assert handler2.bots_state[0]["id"] == "bot1"
assert os.path.realpath(handler2.bots_state[0]["storage_dir"]) == os.path.realpath(
storage,
)
def test_get_available_templates(temp_identity_dir):
@ -319,3 +323,99 @@ def test_read_subprocess_log_unknown_bot(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
with pytest.raises(ValueError, match="Unknown bot"):
handler.read_subprocess_log("nope")
def _outside_bait_identity(tmp_path):
outside = tmp_path / "outside-bot"
outside.mkdir()
bait = outside / "identity"
bait.write_bytes(b"SECRET_BAIT_BYTES")
log_path = outside / "meshchatx_bot_subprocess.log"
log_path.write_text("OUTSIDE_LOG\n", encoding="utf-8")
return outside, bait, log_path
def test_get_bot_identity_path_rejects_escaped_storage(temp_identity_dir, tmp_path):
handler = BotHandler(temp_identity_dir)
outside, bait, _log = _outside_bait_identity(tmp_path)
bot_id = "escaped"
handler.bots_state = [
{
"id": bot_id,
"storage_dir": str(outside),
"bot_config_dir": str(outside),
},
]
assert handler.get_bot_identity_path(bot_id) is None
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
def test_load_state_drops_escaped_storage(temp_identity_dir, tmp_path):
handler = BotHandler(temp_identity_dir)
outside, bait, _log = _outside_bait_identity(tmp_path)
handler.bots_state = [
{
"id": "escaped",
"enabled": True,
"storage_dir": str(outside),
"bot_config_dir": str(outside),
},
]
handler._save_state()
handler2 = BotHandler(temp_identity_dir)
assert handler2.bots_state == []
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
def test_read_subprocess_log_rejects_escaped_storage(temp_identity_dir, tmp_path):
handler = BotHandler(temp_identity_dir)
outside, bait, log_path = _outside_bait_identity(tmp_path)
handler.bots_state = [
{
"id": "escaped",
"storage_dir": str(outside),
"bot_config_dir": str(outside),
},
]
with pytest.raises(ValueError, match="invalid bot storage directory"):
handler.read_subprocess_log("escaped")
assert log_path.read_text(encoding="utf-8") == "OUTSIDE_LOG\n"
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
@patch.object(BotHandler, "_is_pid_alive", return_value=True)
def test_request_announce_rejects_escaped_storage(
mock_alive,
temp_identity_dir,
tmp_path,
):
handler = BotHandler(temp_identity_dir)
outside, bait, _log = _outside_bait_identity(tmp_path)
handler.bots_state = [
{
"id": "escaped",
"storage_dir": str(outside),
"bot_config_dir": str(outside),
"pid": 99999,
},
]
with pytest.raises(RuntimeError, match="invalid bot storage directory"):
handler.request_announce("escaped")
assert not (outside / "meshchatx_request_announce").exists()
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
@pytest.mark.skipif(os.name == "nt", reason="symlink jail oracle is POSIX")
def test_get_bot_identity_path_rejects_symlink_out_bot_config_dir(
temp_identity_dir,
tmp_path,
):
handler = BotHandler(temp_identity_dir)
bot_id = handler.start_bot("echo", "Echo")
storage = os.path.join(handler.bots_dir, bot_id)
outside, bait, _log = _outside_bait_identity(tmp_path)
cfg_link = os.path.join(storage, "config_link")
os.symlink(str(outside), cfg_link)
handler.bots_state[0]["bot_config_dir"] = cfg_link
assert handler.get_bot_identity_path(bot_id) is None
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"

View file

@ -0,0 +1,22 @@
# SPDX-License-Identifier: 0BSD
"""Identity and bot key exports must be POST so CSRF middleware applies."""
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
def test_identity_and_bot_key_exports_are_post_not_get(mock_app):
mutators = [
"/api/v1/identities/export-all",
"/api/v1/identity/backup/download",
"/api/v1/identity/backup/base32",
"/api/v1/bots/export",
]
for path in mutators:
assert _handler(mock_app, "POST", path) is not None, path
assert _handler(mock_app, "GET", path) is None, path

View file

@ -344,7 +344,7 @@ async def test_destination_path_returns_local_hop_zero_for_local_destinations(mo
path_handler = next(
r.handler
for r in mock_app.get_routes()
if r.path == "/api/v1/destination/{destination_hash}/path"
if r.path == "/api/v1/destination/{destination_hash}/path" and r.method == "GET"
)
request = SimpleNamespace(
match_info={"destination_hash": local_hash},

View file

@ -3,10 +3,12 @@
"""Regression tests for the LXMF delivery ping HTTP endpoint."""
import json
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.src.backend.http.routes.path_probe import PATH_WAIT_REQUIRES_POST_MESSAGE
def _find_handler(app, path, method):
for route in app.get_routes():
@ -15,19 +17,44 @@ def _find_handler(app, path, method):
return None
def _make_request(match_info=None, query=None):
def _make_request(match_info=None, query=None, method="POST"):
request = MagicMock()
request.match_info = match_info or {}
request.query = query or {}
request.method = method
return request
def _two_running_contexts(mock_app):
current = MagicMock()
current.running = True
current.config.auto_resend_failed_messages_when_announce_received.get.return_value = True
other = MagicMock()
other.running = True
other.config.auto_resend_failed_messages_when_announce_received.get.return_value = (
True
)
mock_app.current_context = current
mock_app.contexts = {"current": current, "other": other}
mock_app.resend_failed_messages_for_destination = MagicMock(
return_value=MagicMock(),
)
return current, other
@pytest.mark.asyncio
async def test_ping_is_post_not_get(mock_app):
path = "/api/v1/ping/{destination_hash}/lxmf.delivery"
assert _find_handler(mock_app, path, "POST") is not None
assert _find_handler(mock_app, path, "GET") is None
@pytest.mark.asyncio
async def test_ping_rejects_invalid_destination_hash(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/ping/{destination_hash}/lxmf.delivery",
"GET",
"POST",
)
assert handler is not None
response = await handler(
@ -46,7 +73,7 @@ async def test_ping_rejects_non_integer_timeout(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/ping/{destination_hash}/lxmf.delivery",
"GET",
"POST",
)
assert handler is not None
response = await handler(
@ -65,7 +92,7 @@ async def test_ping_rejects_zero_timeout(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/ping/{destination_hash}/lxmf.delivery",
"GET",
"POST",
)
assert handler is not None
response = await handler(
@ -75,3 +102,187 @@ async def test_ping_rejects_zero_timeout(mock_app):
),
)
assert response.status == 400
@pytest.mark.asyncio
async def test_ping_rejects_timeout_above_600(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/ping/{destination_hash}/lxmf.delivery",
"POST",
)
assert handler is not None
response = await handler(
_make_request(
match_info={"destination_hash": "ab" * 16},
query={"timeout": "601"},
),
)
assert response.status == 400
data = json.loads(response.body)
assert "600" in data["message"]
@pytest.mark.asyncio
async def test_get_path_request_true_is_400_and_does_not_prepare(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/destination/{destination_hash}/path",
"GET",
)
assert handler is not None
dest = "a" * 32
req = _make_request(
match_info={"destination_hash": dest},
query={"request": "true", "timeout": "1"},
method="GET",
)
with patch(
"meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
) as pfp:
response = await handler(req)
assert response.status == 400
data = json.loads(response.body)
assert data["message"] == PATH_WAIT_REQUIRES_POST_MESSAGE
pfp.assert_not_called()
@pytest.mark.asyncio
async def test_post_path_rejects_timeout_above_600(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/destination/{destination_hash}/path",
"POST",
)
assert handler is not None
response = await handler(
_make_request(
match_info={"destination_hash": "ab" * 16},
query={"timeout": "601"},
),
)
assert response.status == 400
data = json.loads(response.body)
assert "600" in data["message"]
@pytest.mark.asyncio
async def test_post_path_rejects_non_integer_timeout(mock_app):
handler = _find_handler(
mock_app,
"/api/v1/destination/{destination_hash}/path",
"POST",
)
assert handler is not None
response = await handler(
_make_request(
match_info={"destination_hash": "ab" * 16},
query={"timeout": "nope"},
),
)
assert response.status == 400
data = json.loads(response.body)
assert "integer" in data["message"].lower()
@pytest.mark.asyncio
async def test_request_path_resend_uses_current_context_only(mock_app):
current, other = _two_running_contexts(mock_app)
handler = _find_handler(
mock_app,
"/api/v1/destination/{destination_hash}/request-path",
"POST",
)
dest = "b" * 32
req = _make_request(match_info={"destination_hash": dest})
with (
patch("meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request"),
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
response = await handler(req)
assert response.status == 200
mock_app.resend_failed_messages_for_destination.assert_called_once()
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
assert kwargs["context"] is current
assert kwargs["context"] is not other
@pytest.mark.asyncio
async def test_post_path_resend_uses_current_context_only(mock_app):
current, other = _two_running_contexts(mock_app)
mock_app.reticulum = MagicMock()
mock_app.reticulum.get_next_hop.return_value = bytes(16)
mock_app.reticulum.get_next_hop_if_name.return_value = "if0"
handler = _find_handler(
mock_app,
"/api/v1/destination/{destination_hash}/path",
"POST",
)
dest = "c" * 32
req = _make_request(
match_info={"destination_hash": dest},
query={"timeout": "1"},
)
with (
patch("meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request"),
patch(
"meshchatx.meshchat.reticulum_pathfinding.path_metadata_for_api",
return_value={"path_stale": False, "path_unresponsive": False},
),
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
response = await handler(req)
assert response.status == 200
mock_app.resend_failed_messages_for_destination.assert_called_once()
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
assert kwargs["context"] is current
assert kwargs["context"] is not other
@pytest.mark.asyncio
async def test_ping_resend_uses_current_context_only(mock_app):
import RNS
current, other = _two_running_contexts(mock_app)
mock_app.reticulum = MagicMock()
mock_app.recall_identity = MagicMock(return_value=MagicMock())
handler = _find_handler(
mock_app,
"/api/v1/ping/{destination_hash}/lxmf.delivery",
"POST",
)
dest = "ab" * 16
receipt = MagicMock()
receipt.status = RNS.PacketReceipt.DELIVERED
receipt.proof_packet.hops = 1
receipt.proof_packet.rssi = -50
receipt.proof_packet.snr = 5
receipt.proof_packet.q = 100
receipt.proof_packet.receiving_interface = "UDP"
receipt.proof_packet.packet_hash = b"\x00" * 16
receipt.get_rtt.return_value = 0.1
receipt.destination.hash.hex.return_value = dest
packet = MagicMock()
packet.send.return_value = receipt
with (
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=1),
patch("meshchatx.meshchat.RNS.Destination"),
patch("meshchatx.meshchat.RNS.Packet", return_value=packet),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
response = await handler(
_make_request(
match_info={"destination_hash": dest},
query={"timeout": "5"},
),
)
assert response.status == 200
mock_app.resend_failed_messages_for_destination.assert_called_once()
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
assert kwargs["context"] is current
assert kwargs["context"] is not other

View file

@ -8,7 +8,30 @@ import pytest
@pytest.mark.asyncio
async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
async def test_get_destination_path_with_request_is_400(mock_app):
h = next(
r.handler
for r in mock_app.get_routes()
if r.path == "/api/v1/destination/{destination_hash}/path" and r.method == "GET"
)
dest = "a" * 32
req = SimpleNamespace(
match_info={"destination_hash": dest},
query={"request": "1", "timeout": "1"},
method="GET",
)
with patch(
"meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
) as pfp:
response = await h(req)
pfp.assert_not_called()
assert response.status == 400
data = json.loads(response.body)
assert "POST" in data["message"]
@pytest.mark.asyncio
async def test_post_destination_path_calls_prepare_fresh(mock_app):
mock_app.reticulum = MagicMock()
mock_app.reticulum.get_next_hop.return_value = bytes(16)
mock_app.reticulum.get_next_hop_if_name.return_value = "if0"
@ -16,11 +39,13 @@ async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
r.handler
for r in mock_app.get_routes()
if r.path == "/api/v1/destination/{destination_hash}/path"
and r.method == "POST"
)
dest = "a" * 32
req = SimpleNamespace(
match_info={"destination_hash": dest},
query={"request": "1", "timeout": "1"},
query={"timeout": "1"},
method="POST",
)
with (
patch(
@ -33,6 +58,10 @@ async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2),
patch("meshchatx.meshchat.asyncio.sleep", new_callable=AsyncMock),
patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=lambda coro: coro.close() if hasattr(coro, "close") else None,
),
):
response = await h(req)
pfp.assert_called_once()

View file

@ -1,6 +1,21 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import BotsPage from "@/components/tools/BotsPage.vue";
import DownloadUtils from "@/js/DownloadUtils";
import ToastUtils from "@/js/ToastUtils";
vi.mock("@/js/DownloadUtils", () => ({
default: {
downloadFromApiResponse: vi.fn(() => Promise.resolve()),
},
}));
vi.mock("@/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("BotsPage.vue", () => {
let axiosMock;
@ -126,4 +141,28 @@ describe("BotsPage.vue", () => {
params: { destinationHash: "a".repeat(32) },
});
});
it("exports bot identity through window.api and DownloadUtils", async () => {
axiosMock.post.mockResolvedValue({
data: new ArrayBuffer(4),
headers: { "content-disposition": 'attachment; filename="bot_bot1_identity"' },
});
const wrapper = mountBotsPage();
await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
await wrapper.vm.exportIdentity("bot1");
expect(axiosMock.post).toHaveBeenCalledWith(
"/api/v1/bots/export",
{ bot_id: "bot1" },
{ responseType: "arraybuffer" }
);
expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalled();
});
it("toasts when bot identity export fails", async () => {
axiosMock.post.mockRejectedValue({ response: { data: { message: "nope" } } });
const wrapper = mountBotsPage();
await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
await wrapper.vm.exportIdentity("bot1");
expect(ToastUtils.error).toHaveBeenCalledWith("nope");
});
});

View file

@ -54,7 +54,7 @@ describe("PingPage.vue", () => {
});
it("pings and displays results", async () => {
axiosMock.get.mockResolvedValue({
axiosMock.post.mockResolvedValue({
data: {
ping_result: {
rtt: 0.1234,
@ -89,7 +89,7 @@ describe("PingPage.vue", () => {
});
it("terminates previous loop when stop and start are called sequentially", async () => {
axiosMock.get.mockResolvedValue({
axiosMock.post.mockResolvedValue({
data: {
ping_result: {
rtt: 0.1,

View file

@ -207,7 +207,7 @@ describe("PropagationNodesPage", () => {
});
it("fetches path for a destination hash", async () => {
axiosMock.get.mockResolvedValueOnce({
axiosMock.post.mockResolvedValueOnce({
data: {
path: { hops: 2, next_hop_interface: "TCP Client" },
},
@ -216,9 +216,13 @@ describe("PropagationNodesPage", () => {
nodePathsByHash: {},
};
await PropagationNodesPage.methods.requestPathForNode.call(ctx, "abcd");
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
params: { request: "1", timeout: 4 },
});
expect(axiosMock.post).toHaveBeenCalledWith(
"/api/v1/destination/abcd/path",
{},
{
params: { timeout: 4 },
}
);
expect(ctx.nodePathsByHash.abcd).toEqual({ hops: 2, next_hop_interface: "TCP Client" });
});

View file

@ -39,15 +39,31 @@ describe("download wiring through DownloadUtils", () => {
headers: {},
});
}
return Promise.resolve({ data: {}, headers: {} });
}),
post: vi.fn().mockImplementation((url) => {
if (String(url).includes("/identity/backup/download")) {
return Promise.resolve({
data: new ArrayBuffer(2),
headers: {},
});
}
if (String(url).includes("/identities/export-all")) {
return Promise.resolve({
data: new ArrayBuffer(3),
headers: {},
});
}
if (String(url).includes("/api/v1/bots/export")) {
return Promise.resolve({
data: new ArrayBuffer(5),
headers: {
"content-disposition": 'attachment; filename="bot_bot1_identity"',
},
});
}
return Promise.resolve({ data: {}, headers: {} });
}),
post: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
@ -116,6 +132,11 @@ describe("download wiring through DownloadUtils", () => {
await wrapper.vm.downloadIdentityFile();
expect(axiosMock.post).toHaveBeenCalledWith(
"/api/v1/identity/backup/download",
{},
expect.objectContaining({ responseType: "arraybuffer" })
);
expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.any(ArrayBuffer) }),
"identity"

View file

@ -3,6 +3,7 @@
import { describe, it, expect, vi } from "vitest";
import {
getDestinationPath,
postDestinationPath,
postRequestPath,
postDropPath,
runDestinationPathFinder,
@ -13,11 +14,11 @@ import {
} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
describe("reticulumPathfinding.js", () => {
it("getDestinationPath builds expected URL and forwards params", () => {
it("getDestinationPath builds expected URL without a request query", () => {
const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) };
getDestinationPath(api, "deadbeef", { request: true, timeout: 12 });
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/deadbeef/path", {
params: { request: "1", timeout: 12 },
params: { timeout: 12 },
});
});
@ -35,6 +36,33 @@ describe("reticulumPathfinding.js", () => {
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/drop-path");
});
it("postDestinationPath posts the path URL with timeout query", () => {
const api = { post: vi.fn().mockResolvedValue({ data: { path: null } }) };
postDestinationPath(api, "deadbeef", { timeout: 4 });
expect(api.post).toHaveBeenCalledWith(
"/api/v1/destination/deadbeef/path",
{},
{
params: { timeout: 4 },
}
);
});
it("runDestinationPathFinder force posts path wait", async () => {
const api = {
post: vi.fn().mockResolvedValue({ data: { path: { hops: 2 } } }),
};
const r = await runDestinationPathFinder(api, "h", "force", { forceTimeout: 9 });
expect(r).toEqual({ ok: true, path: { hops: 2 } });
expect(api.post).toHaveBeenCalledWith(
"/api/v1/destination/h/path",
{},
{
params: { timeout: 9 },
}
);
});
it("runDestinationPathFinder quick only posts request-path", async () => {
const api = { post: vi.fn().mockResolvedValue({}) };
const r = await runDestinationPathFinder(api, "h", "quick");