feat: implement LAN bind no-auth warning logic and related state management; add tests for banner visibility and dismissal

This commit is contained in:
Ivan 2026-08-14 12:13:28 -05:00
parent 4d3fa4cf87
commit 6977c7d65b
No known key found for this signature in database
14 changed files with 435 additions and 430 deletions

Binary file not shown.

View file

@ -4,6 +4,7 @@ import { reactive } from "vue";
const globalState = reactive({
authSessionResolved: true,
authEnabled: false,
isLoopbackBind: true,
authenticated: false,
pluginsEnabled: true,
detailedOutboundSendStatus: false,

View file

@ -1,6 +1,6 @@
/**
* Optional runtime override for micron-parser-go WASM (IndexedDB).
* GitHub releases are verified with SHASUMS256.txt before install.
* Install via local .wasm upload only. Build ships bundled WASM under /vendor/.
*/
const DB_NAME = "meshchatx_micron_wasm_override";
@ -8,43 +8,12 @@ const DB_VERSION = 1;
const STORE = "kv";
const KEY = "runtime_override";
/** @typedef {{ source: "github"|"upload", releaseTag: string, wasmSri: string, wasmBytes: ArrayBuffer, expectedSha256Hex: string|null }} MicronWasmRuntimeOverrideRecord */
export const MICRON_PARSER_GO_RELEASE_DOWNLOAD_BASE =
"https://github.com/Quad4-Software/Micron-Parser-Go/releases/download";
/** @typedef {{ source: "upload", releaseTag: string, wasmSri: string, wasmBytes: ArrayBuffer, expectedSha256Hex: string|null }} MicronWasmRuntimeOverrideRecord */
export const WASM_FILENAME = "micron-parser-go.wasm";
export const SHASUMS256_FILENAME = "SHASUMS256.txt";
export const MAX_WASM_OVERRIDE_BYTES = 14 * 1024 * 1024;
/**
* Same-origin MeshChat API path; server proxies only Quad4-Software/Micron-Parser-Go release assets (CSP).
* @param {string} tag
* @param {string} assetName SHASUMS256.txt | micron-parser-go.wasm
*/
export function micronParserGoReleaseProxyUrl(tag, assetName) {
const params = new URLSearchParams();
params.set("tag", tag);
params.set("asset", assetName);
return `/api/v1/tools/micron-parser-go-release?${params.toString()}`;
}
async function readFetchErrorDetail(res) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) {
try {
const j = await res.json();
if (j && typeof j.error === "string" && j.error) {
return j.error;
}
} catch {
/* ignore */
}
}
return String(res.status);
}
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
@ -59,34 +28,6 @@ function openDb() {
});
}
/**
* Parses GNU/BSD shasum output for a single filename.
* @param {string} text
* @param {string} filename e.g. micron-parser-go.wasm
* @returns {string|null} lowercase hex sha256 or null if not found
*/
export function parseShasums256ForFilename(text, filename) {
if (text == null || filename == null) {
return null;
}
const lines = String(text).split(/\r?\n/);
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith("#")) {
continue;
}
const m = line.match(/^([a-fA-F0-9]{64})\s+\*?(\S+)\s*$/);
if (!m) {
continue;
}
const name = m[2].trim();
if (name === filename || name.endsWith(`/${filename}`)) {
return m[1].toLowerCase();
}
}
return null;
}
/** @param {ArrayBuffer} buf */
export async function sha256HexOfBuffer(buf) {
const d = await crypto.subtle.digest("SHA-256", buf);
@ -105,79 +46,6 @@ export async function computeWasmSriSha384(buf) {
return `sha384-${base64}`;
}
/**
* Fetches SHASUMS256.txt and WASM from a GitHub release tag; verifies SHA-256 of WASM.
* Does not write to storage.
* @param {string} tag e.g. v1.0.5
* @param {{ signal?: AbortSignal }} [opts]
* @returns {Promise<Omit<MicronWasmRuntimeOverrideRecord, never>>}
*/
export async function fetchWasmFromGitHubReleaseVerified(tag, opts = {}) {
const { signal } = opts;
const t = String(tag || "").trim();
if (!t) {
throw new Error("Micron WASM update: release tag is required");
}
let sumsRes;
try {
sumsRes = await fetch(micronParserGoReleaseProxyUrl(t, SHASUMS256_FILENAME), { signal, cache: "no-store" });
} catch (e) {
const msg = e && e.name === "AbortError" ? "Request aborted" : (e && e.message) || String(e);
throw new Error(`Micron WASM update: could not fetch release metadata (${msg})`);
}
if (!sumsRes.ok) {
const detail = await readFetchErrorDetail(sumsRes);
throw new Error(`Micron WASM update: SHASUMS256 fetch failed (${detail}). Check the tag and your network.`);
}
let sumsText;
try {
sumsText = await sumsRes.text();
} catch (e) {
throw new Error(`Micron WASM update: could not read SHASUMS256 (${e && e.message})`);
}
const expectedHex = parseShasums256ForFilename(sumsText, WASM_FILENAME);
if (!expectedHex) {
throw new Error(`Micron WASM update: ${WASM_FILENAME} not listed in ${SHASUMS256_FILENAME} for this release.`);
}
let wasmRes;
try {
wasmRes = await fetch(micronParserGoReleaseProxyUrl(t, WASM_FILENAME), { signal, cache: "no-store" });
} catch (e) {
const msg = e && e.name === "AbortError" ? "Request aborted" : (e && e.message) || String(e);
throw new Error(`Micron WASM update: could not download WASM (${msg})`);
}
if (!wasmRes.ok) {
const detail = await readFetchErrorDetail(wasmRes);
throw new Error(`Micron WASM update: WASM download failed (${detail})`);
}
let buf;
try {
buf = await wasmRes.arrayBuffer();
} catch (e) {
throw new Error(`Micron WASM update: could not read WASM body (${e && e.message})`);
}
if (buf.byteLength > MAX_WASM_OVERRIDE_BYTES) {
throw new Error(`Micron WASM update: WASM exceeds maximum size (${MAX_WASM_OVERRIDE_BYTES} bytes).`);
}
if (buf.byteLength < 4096) {
throw new Error("Micron WASM update: WASM file is too small to be valid.");
}
const actualHex = await sha256HexOfBuffer(buf);
if (actualHex !== expectedHex) {
throw new Error(
"Micron WASM update: SHA-256 mismatch after download. Refusing to install (possible tampering or corrupt transfer)."
);
}
const wasmSri = await computeWasmSriSha384(buf);
return {
source: "github",
releaseTag: t,
wasmSri,
wasmBytes: buf,
expectedSha256Hex: expectedHex,
};
}
function assertSri(wasmSri) {
if (typeof wasmSri !== "string" || !/^sha384-[A-Za-z0-9+/=]+$/.test(wasmSri)) {
throw new Error("Micron WASM update: invalid SRI format");
@ -199,8 +67,7 @@ export async function setMicronWasmRuntimeOverride(record) {
throw new Error(`Micron WASM update: WASM exceeds maximum size (${MAX_WASM_OVERRIDE_BYTES} bytes).`);
}
assertSri(record.wasmSri);
const source = record.source === "upload" ? "upload" : "github";
const releaseTag = String(record.releaseTag || "").trim() || (source === "github" ? "unknown" : "upload");
const releaseTag = String(record.releaseTag || "").trim() || "upload";
const expectedSha256Hex = record.expectedSha256Hex == null ? null : String(record.expectedSha256Hex).toLowerCase();
const db = await openDb();
try {
@ -208,7 +75,7 @@ export async function setMicronWasmRuntimeOverride(record) {
const st = tx.objectStore(STORE);
st.put(
{
source,
source: "upload",
releaseTag,
wasmSri: record.wasmSri,
wasmBytes: buf,
@ -241,7 +108,7 @@ export async function getMicronWasmRuntimeOverride() {
return null;
}
return {
source: raw.source === "upload" ? "upload" : "github",
source: "upload",
releaseTag: String(raw.releaseTag || ""),
wasmSri: String(raw.wasmSri),
wasmBytes: raw.wasmBytes,

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: 0BSD
/**
* Browser-only LAN bind warning. Electron and Android bind locally and
* never show this. Headless LAN binds keep running. The UI warns instead.
*/
export const LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY = "meshchatx_lan_bind_no_auth_banner_dismissed";
export function isLanBindNoAuthBannerDismissed() {
try {
return localStorage.getItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY) === "1";
} catch {
return false;
}
}
export function dismissLanBindNoAuthBanner() {
try {
localStorage.setItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY, "1");
} catch {
/* ignore storage failures */
}
}
export function shouldShowLanBindNoAuthBanner({
isElectron = false,
isAndroid = false,
authEnabled = false,
isLoopbackBind = true,
routeName = "",
dismissed = false,
} = {}) {
if (dismissed || isLanBindNoAuthBannerDismissed()) {
return false;
}
if (isElectron || isAndroid) {
return false;
}
if (routeName === "auth") {
return false;
}
if (authEnabled) {
return false;
}
return isLoopbackBind === false;
}

View file

@ -391,6 +391,9 @@ if (networkReady) {
try {
const statusResponse = await window.api.get("/api/v1/status");
GlobalState.demoMode = !!statusResponse.data?.demo_mode;
if (typeof statusResponse.data?.is_loopback_bind === "boolean") {
GlobalState.isLoopbackBind = statusResponse.data.is_loopback_bind;
}
} catch {
// status optional during early boot
}
@ -407,6 +410,9 @@ if (networkReady) {
GlobalState.authEnabled = !!status.auth_enabled;
GlobalState.authenticated = !!status.authenticated;
GlobalState.demoMode = !!status.demo_mode;
if (typeof status.is_loopback_bind === "boolean") {
GlobalState.isLoopbackBind = status.is_loopback_bind;
}
GlobalState.authSessionResolved = true;
if (!status.auth_enabled) {

View file

@ -120,10 +120,6 @@
"method": "GET",
"path": "/api/v1/community-interfaces"
},
{
"method": "POST",
"path": "/api/v1/community-interfaces/refresh"
},
{
"method": "GET",
"path": "/api/v1/comports"
@ -996,10 +992,6 @@
"method": "GET",
"path": "/api/v1/repository-server/list"
},
{
"method": "POST",
"path": "/api/v1/repository-server/refresh-bundled"
},
{
"method": "GET",
"path": "/api/v1/repository-server/status"
@ -1708,18 +1700,6 @@
"method": "POST",
"path": "/api/v1/telephone/voicemails/{id}/read"
},
{
"method": "GET",
"path": "/api/v1/tools/micron-parser-go-release"
},
{
"method": "GET",
"path": "/api/v1/tools/rnode/download_firmware"
},
{
"method": "GET",
"path": "/api/v1/tools/rnode/latest_release"
},
{
"method": "POST",
"path": "/api/v1/translator/install-languages"

View file

@ -94,7 +94,7 @@ describe("electron/mainHelpers", () => {
expect(
isTrustedIpcEvent({
sender: { getURL: () => "file:///opt/meshchatx/electron/loading.html" },
}),
})
).toBe(true);
expect(isTrustedIpcEvent({})).toBe(false);
expect(isTrustedIpcEvent(null)).toBe(false);

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import AppShellBanners from "@/components/layout/AppShellBanners.vue";
describe("AppShellBanners LAN bind warning", () => {
it("shows the LAN banner, emits open-settings, and dismiss", async () => {
const wrapper = mount(AppShellBanners, {
props: {
showLanBindNoAuth: true,
lanBindNoAuthLabel: "LAN bind without a password",
openSettingsLabel: "Open settings",
dismissLanBindNoAuthLabel: "Dismiss",
},
});
expect(wrapper.text()).toContain("LAN bind without a password");
const settingsButton = wrapper.findAll("button").find((b) => b.text() === "Open settings");
expect(settingsButton).toBeTruthy();
await settingsButton.trigger("click");
expect(wrapper.emitted("open-settings")).toHaveLength(1);
const dismissButton = wrapper.findAll("button").find((b) => b.text() === "Dismiss");
expect(dismissButton).toBeTruthy();
await dismissButton.trigger("click");
expect(wrapper.emitted("dismiss-lan-bind-no-auth")).toHaveLength(1);
wrapper.unmount();
});
it("hides the LAN banner when the prop is off", () => {
const wrapper = mount(AppShellBanners, {
props: {
showLanBindNoAuth: false,
lanBindNoAuthLabel: "LAN bind without a password",
openSettingsLabel: "Open settings",
dismissLanBindNoAuthLabel: "Dismiss",
},
});
expect(wrapper.text()).not.toContain("LAN bind without a password");
wrapper.unmount();
});
});

View file

@ -1,131 +1,37 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
WASM_FILENAME,
clearMicronWasmRuntimeOverride,
computeWasmSriSha384,
fetchWasmFromGitHubReleaseVerified,
getMicronWasmRuntimeOverride,
parseShasums256ForFilename,
setMicronWasmRuntimeOverride,
sha256HexOfBuffer,
} from "../../meshchatx/src/frontend/js/MicronWasmRuntimeOverride.js";
import {
invalidateNomadMicronWasmPreload,
refreshMicronWasmRuntimeOverrideCache,
} from "../../meshchatx/src/frontend/js/MicronWasmLoader.js";
import { refreshMicronWasmRuntimeOverrideCache } from "../../meshchatx/src/frontend/js/MicronWasmLoader.js";
describe("MicronWasmRuntimeOverride.js", () => {
beforeEach(async () => {
await clearMicronWasmRuntimeOverride();
refreshMicronWasmRuntimeOverrideCache();
invalidateNomadMicronWasmPreload();
});
afterEach(async () => {
await clearMicronWasmRuntimeOverride();
refreshMicronWasmRuntimeOverrideCache();
invalidateNomadMicronWasmPreload();
vi.restoreAllMocks();
});
it("parseShasums256ForFilename reads hex line", () => {
const text = "c2b0f7de7f241719b54f99b12dc8215e262bdbecb8a5b2a25de3848408aa44cb micron-parser-go.wasm\n";
expect(parseShasums256ForFilename(text, WASM_FILENAME)).toBe(
"c2b0f7de7f241719b54f99b12dc8215e262bdbecb8a5b2a25de3848408aa44cb"
);
});
it("parseShasums256ForFilename supports BSD asterisk prefix", () => {
const text = "aa".repeat(32) + " *micron-parser-go.wasm\n";
expect(parseShasums256ForFilename(text, WASM_FILENAME)).toBe("aa".repeat(32));
});
it("parseShasums256ForFilename returns null when filename missing", () => {
expect(parseShasums256ForFilename("abcd".repeat(16) + " other.bin\n", WASM_FILENAME)).toBe(null);
});
it("setMicronWasmRuntimeOverride round-trips via IndexedDB", async () => {
const wasmBytes = new Uint8Array(5000).fill(7).buffer;
const wasmBytes = new Uint8Array(4096).fill(7).buffer;
const wasmSri = await computeWasmSriSha384(wasmBytes);
await setMicronWasmRuntimeOverride({
source: "upload",
releaseTag: "test.wasm",
releaseTag: "custom.wasm",
wasmSri,
wasmBytes,
expectedSha256Hex: null,
});
const got = await getMicronWasmRuntimeOverride();
expect(got).not.toBeNull();
expect(got.source).toBe("upload");
expect(got.releaseTag).toBe("test.wasm");
expect(got.releaseTag).toBe("custom.wasm");
expect(got.wasmSri).toBe(wasmSri);
expect(got.wasmBytes.byteLength).toBe(5000);
});
it("fetchWasmFromGitHubReleaseVerified rejects hash mismatch", async () => {
const wasmBytes = new Uint8Array(5000).fill(9).buffer;
const expectedHex = await sha256HexOfBuffer(wasmBytes);
const wrongHex = "0".repeat(64);
const sums = `${wrongHex} ${WASM_FILENAME}\n`;
vi.stubGlobal(
"fetch",
vi.fn((url) => {
const u = String(url);
if (u.includes("micron-parser-go-release") && u.includes("asset=SHASUMS256.txt")) {
return Promise.resolve(new Response(sums, { status: 200 }));
}
if (u.includes("micron-parser-go-release") && u.includes("asset=micron-parser-go.wasm")) {
return Promise.resolve(new Response(wasmBytes, { status: 200 }));
}
return Promise.resolve(new Response("", { status: 404 }));
})
);
await expect(fetchWasmFromGitHubReleaseVerified("v9.9.9")).rejects.toThrow(/SHA-256 mismatch/);
});
it("fetchWasmFromGitHubReleaseVerified succeeds when SHASUMS matches WASM", async () => {
const wasmBytes = new Uint8Array(5000).fill(3).buffer;
const expectedHex = await sha256HexOfBuffer(wasmBytes);
const sums = `${expectedHex} ${WASM_FILENAME}\n`;
vi.stubGlobal(
"fetch",
vi.fn((url) => {
const u = String(url);
if (u.includes("micron-parser-go-release") && u.includes("asset=SHASUMS256.txt")) {
return Promise.resolve(new Response(sums, { status: 200 }));
}
if (u.includes("micron-parser-go-release") && u.includes("asset=micron-parser-go.wasm")) {
return Promise.resolve(new Response(wasmBytes, { status: 200 }));
}
return Promise.resolve(new Response("", { status: 404 }));
})
);
const rec = await fetchWasmFromGitHubReleaseVerified("v1.0.0");
expect(rec.source).toBe("github");
expect(rec.releaseTag).toBe("v1.0.0");
expect(rec.expectedSha256Hex).toBe(expectedHex);
expect(rec.wasmBytes.byteLength).toBe(5000);
expect(rec.wasmSri).toMatch(/^sha384-/);
});
it("fetchWasmFromGitHubReleaseVerified maps network failure to readable error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new TypeError("Failed to fetch")))
);
await expect(fetchWasmFromGitHubReleaseVerified("v1.0.0")).rejects.toThrow(/could not fetch release metadata/i);
});
it("fetchWasmFromGitHubReleaseVerified handles missing SHASUMS line", async () => {
vi.stubGlobal(
"fetch",
vi.fn((url) => {
if (String(url).includes("asset=SHASUMS256.txt")) {
return Promise.resolve(new Response("deadbeef\n", { status: 200 }));
}
return Promise.resolve(new Response("", { status: 404 }));
})
);
await expect(fetchWasmFromGitHubReleaseVerified("v1.0.0")).rejects.toThrow(/not listed/);
expect(got.wasmBytes.byteLength).toBe(4096);
});
});

View file

@ -990,5 +990,34 @@ describe("NomadNetworkPage.vue", () => {
expect(ToastUtils.error).toHaveBeenCalled();
wrapper.unmount();
});
it("oversized page failure toasts failed_to_load_page instead of hanging", async () => {
const wrapper = mountNomadNetworkPage({
destinationHash: "",
embedded: true,
isActive: true,
});
await wrapper.vm.$nextTick();
wrapper.vm.isLoadingNodePage = true;
wrapper.vm.currentPageDownloadId = 4243;
wrapper.vm.nodePagePath = `${"c".repeat(32)}:/page/index.mu`;
await wrapper.vm.onWebsocketMessage({
data: JSON.stringify({
type: "nomadnet.page.download",
download_id: 4243,
nomadnet_page_download: {
status: "failure",
destination_hash: "",
page_path: "",
failure_reason: "page_too_large",
},
}),
});
expect(wrapper.vm.isLoadingNodePage).toBe(false);
expect(ToastUtils.error).toHaveBeenCalledWith("nomadnet.failed_to_load_page");
wrapper.unmount();
});
});
});

View file

@ -25,26 +25,6 @@ describe("RNodeFlasherPage.vue", () => {
toastSuccess.mockClear();
toastInfo.mockClear();
toastWarning.mockClear();
window.fetch = vi.fn().mockImplementation((url) => {
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
tag_name: "v1.0",
assets: [
{
name: "firmware.zip",
browser_download_url:
"https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
},
],
}),
});
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
});
afterEach(() => {
@ -77,20 +57,6 @@ describe("RNodeFlasherPage.vue", () => {
expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device");
});
it("requests latest_release without a repo query (GitHub default on server)", async () => {
mountRNodeFlasherPage();
await vi.waitFor(() => {
expect(window.fetch).toHaveBeenCalled();
});
const releaseCalls = window.fetch.mock.calls.filter(
(c) => typeof c[0] === "string" && c[0].includes("latest_release")
);
expect(releaseCalls.length).toBeGreaterThanOrEqual(1);
const u = releaseCalls[0][0];
expect(u).toBe("/api/v1/tools/rnode/latest_release");
expect(u).not.toContain("?");
});
it("toggles advanced mode", async () => {
const wrapper = mountRNodeFlasherPage();
expect(wrapper.vm.showAdvanced).toBe(false);
@ -119,146 +85,10 @@ describe("RNodeFlasherPage.vue", () => {
expect(options.length).toBeGreaterThan(1);
});
it("resolves recommended asset url from the release when present", () => {
const wrapper = mountRNodeFlasherPage();
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
wrapper.vm.latestRelease = {
assets: [{ name: "firmware.zip", browser_download_url: "https://gitea/example.zip" }],
};
expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe("https://gitea/example.zip");
});
it("falls back to the GitHub releases/latest/download URL when the release lookup failed", () => {
const wrapper = mountRNodeFlasherPage();
wrapper.vm.selectedProduct = { firmware_filename: "rnode_firmware_heltec32v3.zip" };
wrapper.vm.latestRelease = null;
const url = wrapper.vm._resolveRecommendedAssetUrl();
expect(url).toBe(
"https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_heltec32v3.zip"
);
});
it("uses model firmware_filename when present for fallback URL", () => {
const wrapper = mountRNodeFlasherPage();
wrapper.vm.selectedProduct = { models: [] };
wrapper.vm.selectedModel = { firmware_filename: "rnode_firmware_tbeam.zip" };
wrapper.vm.latestRelease = null;
expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe(
"https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_tbeam.zip"
);
});
it("links footer firmware and flasher pages to GitHub", () => {
const wrapper = mountRNodeFlasherPage();
const html = wrapper.html();
expect(html).toContain('href="https://github.com/markqvist/RNode_Firmware"');
expect(html).toContain('href="https://github.com/liamcottle/rnode-flasher"');
});
it("downloadRecommendedFirmware requests proxied download with encoded GitHub URL", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]);
const assetUrl = "https://github.com/markqvist/RNode_Firmware/releases/download/v1.0/firmware.zip";
window.fetch = vi.fn().mockImplementation((url) => {
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
tag_name: "v1.0",
assets: [{ name: "firmware.zip", browser_download_url: assetUrl }],
}),
});
}
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
expect(url).toContain(encodeURIComponent(assetUrl));
return Promise.resolve({
ok: true,
blob: () => Promise.resolve(new Blob([zipBytes], { type: "application/zip" })),
});
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
const wrapper = mount(RNodeFlasherPage, {
global: {
mocks: {
$t: (key, params) => key + (params ? JSON.stringify(params) : ""),
$router: { push: vi.fn() },
},
stubs: {
MaterialDesignIcon: {
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
props: ["iconName"],
},
"v-icon": true,
"v-progress-circular": true,
"v-progress-linear": true,
RNodeFirmwareSelector: {
name: "RNodeFirmwareSelectorStub",
template: "<div />",
methods: { setFile: vi.fn() },
},
},
},
});
await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
await wrapper.vm.downloadRecommendedFirmware();
await flushPromises();
expect(toastSuccess).toHaveBeenCalledWith("tools.rnode_flasher.alerts.firmware_downloaded");
expect(wrapper.vm.firmwareFile).not.toBe(null);
expect(wrapper.vm.firmwareFile.name).toBe("firmware.zip");
});
it("downloadRecommendedFirmware shows error when no firmware filename", async () => {
const wrapper = mountRNodeFlasherPage();
wrapper.vm.selectedProduct = null;
wrapper.vm.selectedModel = null;
await wrapper.vm.downloadRecommendedFirmware();
expect(toastError).toHaveBeenCalledWith("tools.rnode_flasher.errors.firmware_not_found_in_release");
});
it("downloadRecommendedFirmware shows error when download fails", async () => {
window.fetch = vi.fn().mockImplementation((url) => {
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
tag_name: "v1.0",
assets: [
{
name: "firmware.zip",
browser_download_url:
"https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
},
],
}),
});
}
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
return Promise.resolve({
ok: false,
status: 502,
statusText: "Bad Gateway",
json: () => Promise.resolve({ error: "upstream broke" }),
});
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
const wrapper = mountRNodeFlasherPage();
await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
await wrapper.vm.downloadRecommendedFirmware();
expect(toastError).toHaveBeenCalled();
const msg = toastError.mock.calls[0][0];
expect(msg).toContain("tools.rnode_flasher.errors.failed_download");
expect(msg).toContain("upstream broke");
});
});

View file

@ -1,4 +1,4 @@
import { readFileSync } from "fs";
import { readFileSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { describe, it, expect } from "vitest";
@ -484,3 +484,136 @@ describe("behavior contracts: locale, theme, and call audio", () => {
expect(call).toMatch(/requestAudioPermission[\s\S]*promptMicrophoneAccess[\s\S]*refreshAudioDevices/);
});
});
function listVueFiles(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) {
out.push(...listVueFiles(p));
} else if (name.endsWith(".vue")) {
out.push(p);
}
}
return out;
}
const VHTML_SANITIZER_TOKENS = [
"renderMarkdown",
"renderMessageHtml",
"sanitizeNomadHtml",
"renderNomadPageByPath",
"renderNomadHtmlPage",
"renderNomadMarkdown",
"convertMicronToHtml",
"sanitizeRenderedMicronHtml",
"drawFeatureDescriptionSanitized",
"highlightMatch",
"changelogHtml",
"selectedDocContent.html",
"$t(",
"MarkdownRenderer",
];
describe("behavior contracts: security gates", () => {
it("WebSocket Origin check is wired on both upgrade paths", () => {
const src = readSource("meshchatx/src/backend/http/routes/websocket_upgrade.py");
expect(src).toContain("websocket_origin_allowed");
expect(src).toContain("_reject_forbidden_ws_origin");
expect(src).toContain('{"error": "Forbidden origin"}');
});
it("WebSocket auth fails closed except ping", () => {
const src = readSource("meshchatx/src/backend/websocket_config_guard.py");
const match = src.match(/WEBSOCKET_PUBLIC_TYPES = frozenset\(\s*\{([^}]+)\}/s);
expect(match).toBeTruthy();
const members = [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
expect(members).toEqual(["ping"]);
expect(src).toContain("websocket_type_requires_auth");
expect(src).toContain("if msg_type in WEBSOCKET_PUBLIC_TYPES:");
});
it("FileSync reserved tops include ssl", () => {
const src = readSource("meshchatx/src/backend/rns_filesync_handler.py");
const start = src.indexOf("_RESERVED_SYNC_TOP");
expect(start).toBeGreaterThan(-1);
const block = src.slice(start, src.indexOf(")", start) + 1);
expect(block).toContain('"ssl"');
});
it("plugin invoke and hooks re-hash backends and purge pycache", () => {
const manager = readSource("meshchatx/src/backend/plugin_manager.py");
const invokeStart = manager.indexOf("def invoke(");
const invokeBlock = manager.slice(invokeStart, manager.indexOf("def dispatch_hook(", invokeStart));
expect(invokeBlock).toContain("self._require_untampered_backend(record)");
const hookStart = manager.indexOf("def dispatch_hook(");
const hookBlock = manager.slice(hookStart, hookStart + 800);
expect(hookBlock).toContain("self._require_untampered_backend(record)");
const runtime = readSource("meshchatx/src/backend/plugin_python_runtime.py");
expect(runtime).toContain("def _purge_entry_pycache");
expect(runtime).toContain("self._purge_entry_pycache(entry_path)");
});
it("plugin network scan parses hostname instead of prefix-matching loopback", () => {
const src = readSource("meshchatx/src/backend/plugin_permissions.py");
const start = src.indexOf("def _is_external_http_url");
const block = src.slice(start, src.indexOf("\ndef ", start + 1));
expect(block).toContain("urlparse(value)");
expect(block).toContain("parsed.hostname");
expect(block).not.toMatch(/"127\.0\.0\.1" in /);
expect(block).not.toMatch(/"localhost" in /);
});
it("Electron ipcMain.handle is wrapped by trustedIpcHandle", () => {
const main = readSource("electron/main.js");
expect(main).toContain("function trustedIpcHandle");
expect(main).toContain("isTrustedIpcEvent");
expect(main).not.toMatch(/ipcMain\.handle\("/);
});
it("v-html sites name a sanitizer and do not use a bare file-level disable", () => {
const vueRoot = join(process.cwd(), "meshchatx/src/frontend/components");
const files = listVueFiles(vueRoot);
const withVHtml = [];
for (const abs of files) {
const src = readFileSync(abs, "utf8");
if (!src.includes("v-html")) {
continue;
}
withVHtml.push(abs);
expect(src, abs).not.toMatch(/eslint-disable\s+vue\/no-v-html\s*-->/);
const named = VHTML_SANITIZER_TOKENS.some((token) => src.includes(token));
expect(named, abs).toBe(true);
}
expect(withVHtml.length).toBeGreaterThan(5);
});
it("LAN bind warning is a banner, not a process exit", () => {
const app = readSource("meshchatx/src/frontend/components/App.vue");
expect(app).toContain("showLanBindNoAuthBanner");
expect(app).toContain("shouldShowLanBindNoAuthBanner");
expect(app).toContain("lan_bind_no_auth_banner");
const banners = readSource("meshchatx/src/frontend/components/layout/AppShellBanners.vue");
expect(banners).toContain("showLanBindNoAuth");
expect(banners).toContain("lanBindNoAuthLabel");
const helper = readSource("meshchatx/src/frontend/js/lanBindWarning.js");
expect(helper).toContain("isElectron");
expect(helper).toContain("isAndroid");
expect(helper).toContain("dismissLanBindNoAuthBanner");
expect(helper).toContain("isLanBindNoAuthBannerDismissed");
expect(helper).not.toContain("process.exit");
expect(helper).not.toContain("sys.exit");
});
it("mesh payload caps stay named constants with drop-not-hang reasons", () => {
const announce = readSource("meshchatx/src/backend/announce_manager.py");
expect(announce).toContain("MAX_ANNOUNCE_APP_DATA_BYTES = 2048");
const nomad = readSource("meshchatx/src/backend/nomadnet_downloader.py");
expect(nomad).toContain("MAX_NOMAD_PAGE_BYTES = 512 * 1024");
expect(nomad).toContain("page_too_large");
const rrc = readSource("meshchatx/src/backend/rrc/protocol.py");
expect(rrc).toContain("DEFAULT_MAX_MSG_BYTES = 350");
const geo = readSource("meshchatx/src/backend/map_geo_validator.py");
expect(geo).toContain('raise GeoValidationError("file_too_large")');
});
});

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
dismissLanBindNoAuthBanner,
isLanBindNoAuthBannerDismissed,
LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY,
shouldShowLanBindNoAuthBanner,
} from "@/js/lanBindWarning.js";
describe("shouldShowLanBindNoAuthBanner", () => {
const lanNoAuth = {
isElectron: false,
isAndroid: false,
authEnabled: false,
isLoopbackBind: false,
routeName: "messages",
dismissed: false,
};
beforeEach(() => {
localStorage.removeItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY);
});
afterEach(() => {
localStorage.removeItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY);
});
it("shows for browser LAN bind without auth", () => {
expect(shouldShowLanBindNoAuthBanner(lanNoAuth)).toBe(true);
});
it("hides on electron, android, loopback, auth, or auth route", () => {
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isElectron: true })).toBe(false);
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isAndroid: true })).toBe(false);
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isLoopbackBind: true })).toBe(false);
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, authEnabled: true })).toBe(false);
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, routeName: "auth" })).toBe(false);
});
it("stays hidden after dismiss is persisted", () => {
dismissLanBindNoAuthBanner();
expect(isLanBindNoAuthBannerDismissed()).toBe(true);
expect(shouldShowLanBindNoAuthBanner(lanNoAuth)).toBe(false);
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, dismissed: true })).toBe(false);
});
});

View file

@ -0,0 +1,118 @@
// SPDX-License-Identifier: 0BSD
/**
* Shared XSS corpus against every HTML sanitizer the UI ships.
* Formatted markup may remain. Scriptable nodes and executable URLs must not.
*/
import { describe, expect, it } from "vitest";
import MarkdownRenderer from "@/js/MarkdownRenderer.js";
import MicronParser from "@/js/MicronParser.js";
import {
renderNomadHtmlPage,
renderNomadMarkdown,
sanitizeNomadHtmlDocument,
sanitizeNomadHtmlFragment,
} from "@/js/NomadPageRenderer.js";
import { sanitizeKmlText } from "@/js/mapExchange/kmlSanitize.js";
export const XSS_PAYLOADS = [
{ name: "javascript href", input: '<a href="javascript:alert(1)">x</a>' },
{ name: "javascript uppercase", input: '<a href="JAVASCRIPT:alert(1)">x</a>' },
{ name: "data html href", input: '<a href="data:text/html,<script>alert(1)</script>">x</a>' },
{ name: "base tag", input: '<base href="https://evil.example/"><p>ok</p>' },
{ name: "svg script", input: "<svg><script>alert(1)</script></svg><p>ok</p>" },
{ name: "svg onload", input: '<svg onload="alert(1)"></svg><p>ok</p>' },
{ name: "img onerror", input: '<img src=x onerror="alert(1)"><p>ok</p>' },
{
name: "css url javascript",
input: '<style>body{background:url("javascript:alert(1)")}</style><p>ok</p>',
},
{
name: "css url https",
input: '<style>p{background:url("https://evil.example/x.png")}</style><p>ok</p>',
},
{ name: "nested markdown js", input: "[x](javascript:alert(1))" },
{ name: "nested markdown image", input: "![x](javascript:alert(1))" },
{ name: "script tag", input: "<script>alert(1)</script><p>ok</p>" },
{ name: "iframe", input: '<iframe src="javascript:alert(1)"></iframe><p>ok</p>' },
{ name: "micron js link", input: "`[click`javascript:alert(1)]`" },
];
export function assertNoScriptableHtml(html, payloadName) {
expect(typeof html, payloadName).toBe("string");
const doc = new DOMParser().parseFromString(`<div id="xss-root">${html}</div>`, "text/html");
const root = doc.getElementById("xss-root") || doc.body;
expect(root.querySelector("script, iframe, object, embed, base"), payloadName).toBeNull();
for (const el of root.querySelectorAll("[href], [src]")) {
for (const attr of ["href", "src"]) {
const v = el.getAttribute(attr);
if (!v) {
continue;
}
const lower = v.trim().toLowerCase();
expect(lower.startsWith("javascript:"), `${payloadName} ${attr}`).toBe(false);
expect(lower.startsWith("vbscript:"), `${payloadName} ${attr}`).toBe(false);
expect(lower.startsWith("data:text/html"), `${payloadName} ${attr}`).toBe(false);
}
}
for (const el of root.querySelectorAll("*")) {
for (const attr of [...el.attributes]) {
expect(attr.name.toLowerCase().startsWith("on"), `${payloadName} ${attr.name}`).toBe(false);
}
}
for (const styleEl of root.querySelectorAll("style")) {
const css = (styleEl.textContent || "").toLowerCase();
expect(css, payloadName).not.toMatch(/url\s*\(\s*["']?\s*javascript:/);
expect(css, payloadName).not.toMatch(/expression\s*\(/);
}
for (const el of root.querySelectorAll("[style]")) {
const css = (el.getAttribute("style") || "").toLowerCase();
expect(css, payloadName).not.toMatch(/url\s*\(\s*["']?\s*javascript:/);
}
}
function wrapKml(inner) {
return `<?xml version="1.0"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><name>P</name>
<description><![CDATA[${inner}]]></description>
<Point><coordinates>1,2,0</coordinates></Point>
</Placemark></Document></kml>`;
}
describe("shared XSS sanitizer oracles", () => {
it("MarkdownRenderer never emits a scriptable node", () => {
for (const { name, input } of XSS_PAYLOADS) {
assertNoScriptableHtml(MarkdownRenderer.render(input), `render ${name}`);
assertNoScriptableHtml(MarkdownRenderer.renderBasic(input), `renderBasic ${name}`);
}
});
it("Nomad HTML and markdown sanitizers never emit a scriptable node", () => {
for (const { name, input } of XSS_PAYLOADS) {
assertNoScriptableHtml(sanitizeNomadHtmlFragment(input), `fragment ${name}`);
assertNoScriptableHtml(sanitizeNomadHtmlDocument(input), `document ${name}`);
assertNoScriptableHtml(renderNomadHtmlPage(input), `html page ${name}`);
assertNoScriptableHtml(renderNomadMarkdown(input), `markdown ${name}`);
}
});
it("MicronParser never emits a scriptable node", () => {
const parser = new MicronParser(true, false);
for (const { name, input } of XSS_PAYLOADS) {
assertNoScriptableHtml(MicronParser.sanitizeRenderedMicronHtml(input), `sanitize ${name}`);
assertNoScriptableHtml(parser.convertMicronToHtml(input), `convert ${name}`);
}
});
it("KML sanitizer never emits a scriptable node", () => {
for (const { name, input } of XSS_PAYLOADS) {
const out = sanitizeKmlText(wrapKml(input));
assertNoScriptableHtml(out.text, `kml ${name}`);
const lower = out.text.toLowerCase();
expect(lower, `kml href ${name}`).not.toMatch(/<href>\s*(javascript:|vbscript:|data:text\/html)/);
expect(lower, `kml xlink ${name}`).not.toMatch(/xlink:href\s*=\s*["']?\s*javascript:/);
}
});
});