diff --git a/.gitignore b/.gitignore index 5362a223..c84250f5 100644 --- a/.gitignore +++ b/.gitignore @@ -168,6 +168,9 @@ electron/backend-manifest.json scripts/private/ *.exe +# Accidental ImageMagick / print dumps (e.g. root-level "re" PostScript) +*.ps +*.eps # Local identity and rnid-style artifacts *.rid *.rsg diff --git a/Taskfile.yml b/Taskfile.yml index 6bad3120..84e6e3f5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -264,7 +264,7 @@ tasks: fi test:quick:be: - desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads, RNS link) + desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads, RNS link, EECT packs) cmds: - >- uv run pytest @@ -276,6 +276,22 @@ tasks: tests/backend/test_rns_link_fuzzing.py tests/backend/test_rns_link_plugin.py -q + - task: test:eect + + test:eect: + desc: Extended Edge Case Tester packs (seeded identity/path/auth/hostile/scarcity scenarios) + cmds: + - uv run pytest tests/backend/eect/packs -m eect -q --tb=short + + test:lv: + desc: Live Validation ladder (set MESHCHAT_LIVE_VALIDATION=1 for L2-L3) + cmds: + - MESHCHAT_LIVE_VALIDATION=1 uv run pytest tests/backend/eect/live -m live_validation -q --tb=short + + test:lv:l0: + desc: Live Validation L0 only (imports/sqlite/unicode, CI-safe) + cmds: + - uv run pytest tests/backend/eect/live/test_lv_ladder.py -m live_validation -k "l0" -q --tb=short test:quick:fe: desc: Frontend regression subset (links, micron preview, stranger banner, interface stats) diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md index ea023591..c2c00f8e 100644 --- a/docs/agents/conventions/tests.md +++ b/docs/agents/conventions/tests.md @@ -8,3 +8,11 @@ Applies when editing `tests/**/*.{py,js}`. - Prefer focused files over full suite unless the user asks for broad runs. - Landlock tests that apply the sandbox must run in a subprocess (one restrict per process). - Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells. + +## Extended Edge Case Tester (EECT) and Live Validation (LV) + +- EECT packs live under `tests/backend/eect/packs/` and use marker `eect`. +- LV ladder lives under `tests/backend/eect/live/` and uses marker `live_validation`. +- Replay a failure with `MESHCHAT_EECT_SEED=` (printed on assert failure). +- Commands: `task test:eect`, `task test:lv:l0`, `MESHCHAT_LIVE_VALIDATION=1 task test:lv`. +- LV L2/L3 are opt-in (`MESHCHAT_LIVE_VALIDATION=1` or `MESHCHAT_LIVE_RETICULUM=1`). L0/L1 stay CI-safe. diff --git a/docs/agents/overview.md b/docs/agents/overview.md index 5db2e6fb..4210c8a9 100644 --- a/docs/agents/overview.md +++ b/docs/agents/overview.md @@ -102,6 +102,8 @@ task install task format task lint task test:quick +task test:eect +task test:lv:l0 task test:backend task test:frontend task test:e2e diff --git a/docs/agents/skills/reticulum-design-gates/SKILL.md b/docs/agents/skills/reticulum-design-gates/SKILL.md index afb53758..9a8167cf 100644 --- a/docs/agents/skills/reticulum-design-gates/SKILL.md +++ b/docs/agents/skills/reticulum-design-gates/SKILL.md @@ -88,6 +88,7 @@ Allowed: - [ ] No new unauthenticated mutating HTTP/WS surface - [ ] No cross-identity leakage - [ ] Tests cover success and recoverable failure +- [ ] Mesh/identity/auth changes cite matching EECT scenario ids under `tests/backend/eect/` ## Key references diff --git a/docs/agents/skills/test-loop/SKILL.md b/docs/agents/skills/test-loop/SKILL.md index 82f0a989..46a8fa2a 100644 --- a/docs/agents/skills/test-loop/SKILL.md +++ b/docs/agents/skills/test-loop/SKILL.md @@ -22,6 +22,11 @@ pnpm exec vitest run tests/frontend/.test.js # Quick regression task test:quick +# Extended Edge Case Tester / Live Validation +task test:eect +task test:lv:l0 +MESHCHAT_LIVE_VALIDATION=1 task test:lv + # Broader task test:backend task test:frontend diff --git a/meshchatx.rsm b/meshchatx.rsm index 125c9063..c0d3f116 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/bug_report_manager.py b/meshchatx/src/backend/bug_report_manager.py index 0eb297db..d063dde0 100644 --- a/meshchatx/src/backend/bug_report_manager.py +++ b/meshchatx/src/backend/bug_report_manager.py @@ -13,6 +13,7 @@ from typing import Any import RNS from meshchatx.src.backend.announce_handler import AnnounceHandler +from meshchatx.src.backend.log_redaction import redact_diagnostic_text BUG_ASPECT = "mcx-bugs-v1" REPORT_PATH = "/report" @@ -386,7 +387,7 @@ class BugReportManager: module = entry.get("module") or "" message = entry.get("message") or "" lines.append(f"{ts}\t{level}\t{module}\t{message}") - log_text = "\n".join(lines) + log_text = redact_diagnostic_text("\n".join(lines)) if len(log_text) > MAX_PAYLOAD_CHARS: log_text = log_text[:MAX_PAYLOAD_CHARS] + "\n[truncated]" return { diff --git a/meshchatx/src/backend/favourites_layout.py b/meshchatx/src/backend/favourites_layout.py index ced477f6..9654c9f5 100644 --- a/meshchatx/src/backend/favourites_layout.py +++ b/meshchatx/src/backend/favourites_layout.py @@ -43,6 +43,7 @@ def normalize_favourites_layout(layout): section_id = section_id.strip() if ( not section_id + or "\x00" in section_id or len(section_id) > MAX_SECTION_ID_LEN or section_id in section_ids or section_id in _FORBIDDEN_SECTION_IDS @@ -99,7 +100,7 @@ def normalize_favourites_layout(layout): if not isinstance(item, str): continue h = item.strip() - if not h or len(h) > MAX_HASH_LEN or h in seen: + if not h or "\x00" in h or len(h) > MAX_HASH_LEN or h in seen: continue seen.add(h) hashes.append(h) @@ -117,9 +118,15 @@ def normalize_favourites_layout(layout): def layout_payload_too_large(raw_body_len): - """Return True when a raw request body exceeds the layout size budget.""" + """Return True when a raw request body exceeds the layout size budget. + + Negative sizes are treated as oversize so hostile Content-Length values + cannot skip the early reject path. + """ try: size = int(raw_body_len) except (TypeError, ValueError): return False + if size < 0: + return True return size > MAX_LAYOUT_JSON_BYTES diff --git a/meshchatx/src/backend/http_url_guard.py b/meshchatx/src/backend/http_url_guard.py index 2b460b77..c19d2d27 100644 --- a/meshchatx/src/backend/http_url_guard.py +++ b/meshchatx/src/backend/http_url_guard.py @@ -51,6 +51,27 @@ def normalize_loopback_http_service_base(url: str) -> str: _WS_CTRL = re.compile(r"[\x00-\x20\x7f]") +def _coerce_host_to_ip( + host: str, +) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Parse dotted/colon IPs plus decimal and 0x hex IPv4 host forms browsers accept.""" + if not host: + return None + try: + return ipaddress.ip_address(host) + except ValueError: + pass + # Integer / hex IPv4 (e.g. 2852039166, 0xa9fea9fe -> 169.254.169.254). + try: + if host.startswith("0x") or host.startswith("0X"): + return ipaddress.IPv4Address(int(host, 16)) + if host.isdigit(): + return ipaddress.IPv4Address(int(host)) + except (ValueError, OverflowError): + return None + return None + + def normalize_libretranslate_http_service_base(url: str) -> str: """Return scheme://host:port with no path, query, or fragment. @@ -58,8 +79,8 @@ def normalize_libretranslate_http_service_base(url: str) -> str: public API). Embedded credentials are rejected; non-http(s) schemes are rejected. Literal IPv4 link-local targets (169.254.0.0/16) are rejected as a common SSRF/metadata - path. Other private or loopback addresses are allowed so local servers and overlays (e.g. VPN - mesh) continue to work. + path, including decimal and hex encodings of those addresses. Other private or loopback + addresses are allowed so local servers and overlays (e.g. VPN mesh) continue to work. """ if not url or not isinstance(url, str): msg = "URL must be a non-empty string" @@ -91,11 +112,8 @@ def normalize_libretranslate_http_service_base(url: str) -> str: raise UnsafeOutboundUrlError(msg) host_for_ip_check = host_decoded.lower().strip("[]") - try: - addr = ipaddress.ip_address(host_for_ip_check) - except ValueError: - pass - else: + addr = _coerce_host_to_ip(host_for_ip_check) + if addr is not None: if addr.version == 4 and addr.is_link_local: msg = "URL must not target an IPv4 link-local address" raise UnsafeOutboundUrlError(msg) diff --git a/meshchatx/src/backend/log_redaction.py b/meshchatx/src/backend/log_redaction.py new file mode 100644 index 00000000..40027c41 --- /dev/null +++ b/meshchatx/src/backend/log_redaction.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: 0BSD +"""Redact sensitive fragments from diagnostic / bug-report log text.""" + +from __future__ import annotations + +import re + +# Absolute Unix/Windows path-like tokens (kept conservative to avoid eating hex). +_ABS_PATH_RE = re.compile( + r"(?:" + r"(?:/(?:home|Users|tmp|var|etc|opt|usr|root|run|mnt|media|data|srv|private|" + r"Volumes|Library|Applications)[^\s\"']*)" + r"|(?:[A-Za-z]:\\[^\s\"']+)" + r"|(?:\\\\[^\s\"']+)" + r")", + re.IGNORECASE, +) + +# Full 32-byte hex hashes (RNS destination / identity). Partial prefixes stay. +_FULL_HEX_HASH_RE = re.compile(r"(?= 96 hex chars continuous). +_LONG_HEX_RE = re.compile(r"(? str: + """Return text with paths, full hashes, emails, and IPv4s replaced. + + Partial destination hashes (shorter than 64 hex chars) are left alone so + operators can still correlate short display prefixes. + """ + if not text: + return text + out = _PEM_RE.sub(REDACTED, text) + out = _ABS_PATH_RE.sub(REDACTED, out) + out = _LONG_HEX_RE.sub(REDACTED, out) + out = _FULL_HEX_HASH_RE.sub(REDACTED, out) + out = _EMAIL_RE.sub(REDACTED, out) + out = _IPV4_RE.sub(REDACTED, out) + out = _BEARER_RE.sub(f"Bearer {REDACTED}", out) + out = _SECRET_ASSIGN_RE.sub(REDACTED, out) + return out diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.js index 12d9dc3f..d7a80266 100644 --- a/meshchatx/src/frontend/js/DownloadUtils.js +++ b/meshchatx/src/frontend/js/DownloadUtils.js @@ -9,13 +9,18 @@ function isAndroidSaveBridge() { class DownloadUtils { static sanitizeDownloadFilename(filename, defaultFilename = "download") { let name = filename == null ? "" : String(filename); - // eslint-disable-next-line no-control-regex -- strip CR/LF/NUL from peer-provided names - name = name.replace(/[\r\n\x00]/g, "").trim(); + // eslint-disable-next-line no-control-regex -- strip CR/LF/NUL and bidi overrides from peer-provided names + name = name.replace(/[\r\n\x00\u202A-\u202E\u2066-\u2069]/g, "").trim(); // Drop path segments from naive Content-Disposition or peer-provided names. name = name.split(/[/\\]/).pop() || ""; + name = name.replace(/[. ]+$/g, ""); if (!name || name === "." || name === "..") { return defaultFilename; } + const stem = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name; + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) { + return defaultFilename; + } return name; } diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js index 86359869..cff72244 100644 --- a/meshchatx/src/frontend/js/MicronParser.js +++ b/meshchatx/src/frontend/js/MicronParser.js @@ -89,6 +89,8 @@ export default class MicronParser extends BaseMicronParser { } }); cleaned = cleaned.replace(/\\(.)/g, "$1"); + // Drop format/bidi noise so fi\u200Bxed / soft-hyphen cannot hide overlays. + cleaned = cleaned.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, ""); const declarations = cleaned.split(";").filter(Boolean); const safe = declarations.filter((decl) => { const colon = decl.indexOf(":"); @@ -101,6 +103,7 @@ export default class MicronParser extends BaseMicronParser { .trim() .toLowerCase() .replace(/!important/g, "") + .replace(/\s+/g, "") .trim(); if (prop === "position" && (/\bfixed\b/.test(val) || /\bsticky\b/.test(val))) { return false; diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js index 9e7a5a40..ebc7aba1 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.js +++ b/meshchatx/src/frontend/js/NomadPageRenderer.js @@ -81,8 +81,10 @@ export function stripOverlayFromCss(css) { } }); s = s.replace(/\\(.)/g, "$1"); + // Drop format/bidi noise so fi\u200Bxed cannot hide overlays. + s = s.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, ""); s = s.replace(/position\s*:\s*[^;{}]+/gi, (decl) => { - const lower = decl.toLowerCase(); + const lower = decl.toLowerCase().replace(/\s+/g, ""); if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower)) { return "position:static"; } diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js index 1fee38b8..2d37978f 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js @@ -54,16 +54,21 @@ out vec4 outColor; void main() { float d = length(v_uv); if (d > 1.0) discard; - float edge = smoothstep(1.0, 0.72, d); + float edge = smoothstep(1.0, 0.82, d); + float rim = smoothstep(0.92, 1.0, d); if (v_useTex > 0.5) { vec2 local = v_uv * 0.5 + 0.5; vec2 texUV = v_uvOrigin + local * u_cellUv; vec4 tex = texture(u_atlas, texUV); - float a = tex.a * edge * v_color.a; + // Opaque RGB icons (and RGB canvases) may report a=0 in some uploads. + float texA = max(tex.a, 0.001); + float a = texA * edge * v_color.a; if (a < 0.01) discard; - outColor = vec4(tex.rgb, a); + vec3 rgb = mix(tex.rgb, v_color.rgb, rim * 0.85); + outColor = vec4(rgb, a); } else { - outColor = vec4(v_color.rgb, v_color.a * edge); + vec3 rgb = mix(v_color.rgb * 0.92, v_color.rgb, 1.0 - rim); + outColor = vec4(rgb, v_color.a * edge); } } `; @@ -187,6 +192,28 @@ export function tryCreateWebGL2Context(canvas) { /** * @param {WebGL2RenderingContext} gl */ +/** + * Resolve a same-origin asset path against the Vite/app base URL. + * Absolute http(s)/blob/data URLs are returned unchanged. + * @param {string} url + * @returns {string} + */ +export function resolveVisualiserAssetUrl(url) { + if (!url || typeof url !== "string") return ""; + const trimmed = url.trim(); + if (!trimmed) return ""; + if (/^(?:blob:|data:|https?:|file:)/i.test(trimmed)) return trimmed; + if (typeof window !== "undefined" && window.location?.origin && trimmed.startsWith("/")) { + const base = typeof import.meta !== "undefined" && import.meta.env?.BASE_URL ? import.meta.env.BASE_URL : "/"; + const root = String(base || "/").replace(/\/?$/, "/"); + if (root !== "/" && !trimmed.startsWith(root)) { + return `${window.location.origin}${root.replace(/\/$/, "")}${trimmed}`; + } + return `${window.location.origin}${trimmed}`; + } + return trimmed; +} + function createIconAtlas(gl) { const width = ATLAS_COLS * ATLAS_CELL; const height = ATLAS_ROWS * ATLAS_CELL; @@ -207,7 +234,7 @@ function createIconAtlas(gl) { scratch.width = ATLAS_CELL; scratch.height = ATLAS_CELL; } - const scratchCtx = scratch?.getContext?.("2d") || null; + const scratchCtx = scratch?.getContext?.("2d", { willReadFrequently: true }) || null; function allocSlot() { if (freeSlots.length > 0) return freeSlots.pop(); @@ -216,31 +243,74 @@ function createIconAtlas(gl) { } function paintSlot(slot, source) { - if (!scratchCtx || !scratch) return; + if (!scratchCtx || !scratch) return false; + scratchCtx.save(); + scratchCtx.setTransform(1, 0, 0, 1, 0, 0); scratchCtx.clearRect(0, 0, ATLAS_CELL, ATLAS_CELL); const sw = source.width || source.videoWidth || ATLAS_CELL; const sh = source.height || source.videoHeight || ATLAS_CELL; + if (!(sw > 0 && sh > 0)) { + scratchCtx.restore(); + return false; + } const scale = Math.min(ATLAS_CELL / sw, ATLAS_CELL / sh); const dw = Math.max(1, Math.floor(sw * scale)); const dh = Math.max(1, Math.floor(sh * scale)); const dx = Math.floor((ATLAS_CELL - dw) / 2); const dy = Math.floor((ATLAS_CELL - dh) / 2); scratchCtx.drawImage(source, dx, dy, dw, dh); + // Force full opacity for RGB sources that leave alpha at 0 after upload. + const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL); + const data = pixels.data; + let painted = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] | data[i + 1] | data[i + 2] | data[i + 3]) { + data[i + 3] = 255; + painted += 1; + } + } + if (painted < 8) { + scratchCtx.restore(); + return false; + } + scratchCtx.putImageData(pixels, 0, 0); + scratchCtx.restore(); const col = slot % ATLAS_COLS; const row = Math.floor(slot / ATLAS_COLS); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); gl.texSubImage2D(gl.TEXTURE_2D, 0, col * ATLAS_CELL, row * ATLAS_CELL, gl.RGBA, gl.UNSIGNED_BYTE, scratch); + return true; } - function loadImage(url) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.decoding = "async"; - img.onload = () => resolve(img); - img.onerror = () => reject(new Error(`icon load failed: ${url}`)); - img.src = url; + async function loadImageSource(url) { + const resolved = resolveVisualiserAssetUrl(url); + if (typeof createImageBitmap === "function") { + try { + const res = await fetch(resolved); + if (!res.ok) throw new Error(`icon fetch ${res.status}`); + const blob = await res.blob(); + return await createImageBitmap(blob); + } catch { + // Fall through to HTMLImageElement. + } + } + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.decoding = "sync"; + el.onload = () => resolve(el); + el.onerror = () => reject(new Error(`icon load failed: ${resolved}`)); + el.src = resolved; }); + if (typeof img.decode === "function") { + try { + await img.decode(); + } catch { + // decode() can reject for already-decoded bitmaps + } + } + return img; } /** @@ -253,9 +323,21 @@ function createIconAtlas(gl) { if (pending.has(url)) return pending.get(url); const slot = allocSlot(); if (slot == null) return null; - const work = loadImage(url) + const work = loadImageSource(url) .then((img) => { - paintSlot(slot, img); + const ok = paintSlot(slot, img); + if (typeof img.close === "function") { + try { + img.close(); + } catch { + /* ignore */ + } + } + if (!ok) { + freeSlots.push(slot); + pending.delete(url); + return null; + } urlToSlot.set(url, slot); pending.delete(url); return slot; @@ -350,6 +432,19 @@ export function createNetworkVisualiserWebGL(canvas, gl) { let edgeVertexCount = 0; let edgeScratch = new Float32Array(0); + /** @type {HTMLCanvasElement|null} */ + let labelCanvas = null; + /** @type {CanvasRenderingContext2D|null} */ + let labelCtx = null; + if (typeof document !== "undefined" && canvas?.parentElement) { + labelCanvas = document.createElement("canvas"); + labelCanvas.className = "network-webgl-labels"; + labelCanvas.style.cssText = + "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;"; + canvas.parentElement.appendChild(labelCanvas); + labelCtx = labelCanvas.getContext("2d"); + } + function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const rect = canvas.getBoundingClientRect(); @@ -361,6 +456,10 @@ export function createNetworkVisualiserWebGL(canvas, gl) { canvas.width = bw; canvas.height = bh; } + if (labelCanvas && (labelCanvas.width !== bw || labelCanvas.height !== bh)) { + labelCanvas.width = bw; + labelCanvas.height = bh; + } gl.viewport(0, 0, canvas.width, canvas.height); return { width: cssW, height: cssH }; } @@ -370,12 +469,14 @@ export function createNetworkVisualiserWebGL(canvas, gl) { * @param {Float32Array} edges packed EDGE_STRIDE * @param {{x:number,y:number,zoom:number}} camera * @param {boolean} dark + * @param {{x:number,y:number,size:number,text:string}[]} [labels] */ - function draw(nodes, edges, camera, dark) { + function draw(nodes, edges, camera, dark, labels) { const size = resize(); const camX = camera?.x ?? 0; const camY = camera?.y ?? 0; const zoom = camera?.zoom > 0 ? camera.zoom : 1; + const dpr = Math.min(window.devicePixelRatio || 1, 2); if (dark) { gl.clearColor(0.035, 0.035, 0.04, 1); @@ -448,6 +549,32 @@ export function createNetworkVisualiserWebGL(canvas, gl) { gl.bindVertexArray(null); } + if (labelCtx && labelCanvas) { + labelCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + labelCtx.clearRect(0, 0, cssW, cssH); + if (zoom >= 0.45 && Array.isArray(labels) && labels.length > 0) { + labelCtx.textAlign = "center"; + labelCtx.textBaseline = "top"; + labelCtx.font = "600 11px ui-sans-serif, system-ui, sans-serif"; + const fill = dark ? "#f4f4f5" : "#18181b"; + const stroke = dark ? "rgba(9,9,11,0.85)" : "rgba(255,255,255,0.9)"; + for (const lab of labels) { + if (!lab?.text) continue; + const sx = (lab.x - camX) * zoom + cssW * 0.5; + const sy = (lab.y - camY) * zoom + cssH * 0.5; + if (sx < -40 || sy < -20 || sx > cssW + 40 || sy > cssH + 20) continue; + const r = Math.max(lab.size || 10, 6) * zoom; + const tx = sx; + const ty = sy + r + 3; + labelCtx.lineWidth = 3; + labelCtx.strokeStyle = stroke; + labelCtx.fillStyle = fill; + labelCtx.strokeText(lab.text, tx, ty); + labelCtx.fillText(lab.text, tx, ty); + } + } + } + return size; } @@ -460,6 +587,11 @@ export function createNetworkVisualiserWebGL(canvas, gl) { gl.deleteVertexArray(edgeVao); gl.deleteProgram(nodeProg); gl.deleteProgram(edgeProg); + if (labelCanvas?.parentElement) { + labelCanvas.parentElement.removeChild(labelCanvas); + } + labelCanvas = null; + labelCtx = null; } return { diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js index 113fec18..feae91ff 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js @@ -20,6 +20,14 @@ export const KIND_IFACE_OFF = 2; export const KIND_PEER = 3; export const KIND_DISCOVERED = 4; +const DEFAULT_ICON_BY_KIND = { + [KIND_ME]: "/assets/images/reticulum_logo_512.png", + [KIND_IFACE_ON]: "/assets/images/network-visualiser/interface_connected.png", + [KIND_IFACE_OFF]: "/assets/images/network-visualiser/interface_disconnected.png", + [KIND_PEER]: "/assets/images/network-visualiser/user.png", + [KIND_DISCOVERED]: "/assets/images/network-visualiser/interface_connected.png", +}; + /** * Distance between two CSS points. * @param {{x:number,y:number}} a @@ -113,12 +121,19 @@ function kindForNode(node) { function sizeForNode(node, kind) { const s = Number(node?.size); if (Number.isFinite(s) && s > 0) { - return Math.max(6, Math.min(28, s * 0.35)); + return Math.max(10, Math.min(34, s * 0.55)); } - if (kind === KIND_ME) return 18; - if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 12; - if (kind === KIND_DISCOVERED) return 9; - return 10; + if (kind === KIND_ME) return 26; + if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 18; + if (kind === KIND_DISCOVERED) return 14; + return 16; +} + +function imageForNode(node, kind) { + if (typeof node?.image === "string" && node.image) { + return node.image; + } + return DEFAULT_ICON_BY_KIND[kind] || null; } /** @@ -195,6 +210,8 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { const indexById = new Map(); /** @type {(string|null)[]} */ let imageByIndex = []; + /** @type {(string|null)[]} */ + let labelByIndex = []; /** @type {{useTex:number,u:number,v:number}[]} */ let texMeta = []; let drawNodeScratch = new Float32Array(0); @@ -256,10 +273,12 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { metaById.clear(); indexById.clear(); imageByIndex = []; + labelByIndex = []; let idx = 0; for (const n of graphNodes || []) { if (!n?.id) continue; const id = String(n.id); + const kind = kindForNode(n); metaById.set(id, { id, label: n.label || "", @@ -268,7 +287,8 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { announce: n._announce || null, }); indexById.set(id, idx); - imageByIndex[idx] = typeof n.image === "string" && n.image ? n.image : null; + imageByIndex[idx] = imageForNode(n, kind); + labelByIndex[idx] = typeof n.label === "string" && n.label ? n.label : null; idx += 1; } const size = renderer.resize(); @@ -347,7 +367,21 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { drawNodeScratch = new Float32Array(need); } const drawNodes = mergeSceneNodesWithTextures(buf.nodes, texMeta, drawNodeScratch); - const size = renderer.draw(drawNodes, buf.edges, camera, dark); + const labels = []; + if (camera.zoom >= 0.45) { + for (let i = 0; i < sceneCount; i++) { + const text = labelByIndex[i]; + if (!text) continue; + const o = i * SCENE_NODE_STRIDE; + labels.push({ + x: buf.nodes[o], + y: buf.nodes[o + 1], + size: buf.nodes[o + 2], + text, + }); + } + } + const size = renderer.draw(drawNodes, buf.edges, camera, dark, labels); callScene("meshchatxVisualiserSceneResize", size.width, size.height); nodeCount = buf.nodeCount || nodeCount; edgeCount = buf.edgeCount || edgeCount; @@ -506,6 +540,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { metaById.clear(); indexById.clear(); imageByIndex = []; + labelByIndex = []; texMeta = []; } diff --git a/pytest.ini b/pytest.ini index 252c03a2..94e140b2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,6 +7,8 @@ markers = integration: optional tests (live network, subprocess Reticulum, etc.) lxst_real: tests that require the real LXST Telephone class long_running: multi-minute soak tests (set MESHCHAT_LONG_TEST_SECONDS; see test_long_running_stress.py) + eect: Extended Edge Case Tester scenarios (seeded adversity packs) + live_validation: Live Validation ladder (L0 always; L2+ needs MESHCHAT_LIVE_VALIDATION=1) asyncio_mode = auto asyncio_default_fixture_loop_scope = function filterwarnings = diff --git a/tests/backend/eect/__init__.py b/tests/backend/eect/__init__.py new file mode 100644 index 00000000..efa0b1d2 --- /dev/null +++ b/tests/backend/eect/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: 0BSD +"""Extended Edge Case Tester (EECT) and Live Validation (LV). + +EECT packs force adversarial timing, identity, auth, hostility, and scarcity +under controlled seeds. LV proves this install is alive (ladder L0-L3). +""" diff --git a/tests/backend/eect/asserts.py b/tests/backend/eect/asserts.py new file mode 100644 index 00000000..95e96a9c --- /dev/null +++ b/tests/backend/eect/asserts.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: 0BSD +"""Shared oracles for EECT packs.""" + +from __future__ import annotations + +from typing import Any + + +def assert_no_unexpected_http_500(status: int, body: Any = None) -> None: + """EECT HTTP oracle: unexpected 500s fail; 4xx/503 are recoverable.""" + if status == 500: + raise AssertionError(f"unexpected HTTP 500 body={body!r}") + + +def assert_recoverable_missing_path(exc: BaseException) -> None: + """Direct send without path must surface TimeoutError, not an opaque crash.""" + assert isinstance(exc, TimeoutError), f"expected TimeoutError, got {type(exc)}" + msg = str(exc).lower() + assert "path" in msg, f"TimeoutError should mention path: {exc}" + + +def assert_identity_paths_isolated(path_a: str, path_b: str) -> None: + assert path_a != path_b + assert "identities" in path_a.replace("\\", "/") + assert "identities" in path_b.replace("\\", "/") + assert path_a.rstrip("/\\").endswith("database.db") or "database.db" in path_a + assert path_b.rstrip("/\\").endswith("database.db") or "database.db" in path_b + + +def assert_preview_capped(content: str | None, max_chars: int = 240) -> None: + if content is None: + return + assert len(content) <= max_chars, f"preview len {len(content)} > {max_chars}" + + +def assert_diagnostic_text_redacted(text: str) -> None: + """Oracle: diagnostic dumps must not keep raw absolute paths or full 32-byte hex hashes.""" + lower = text.lower() + assert "/tmp/" not in lower + assert "\\users\\" not in lower + assert "/home/" not in lower + assert "/users/" not in lower + # Full 32-byte hex destination/identity hash (64 hex chars) + import re + + full_hashes = re.findall(r"(? Scenario: + for scenario in SCENARIOS: + if scenario.id == scenario_id: + return scenario + raise KeyError(f"unknown EECT scenario: {scenario_id}") + + +def scenarios_for_pack(pack: str) -> tuple[Scenario, ...]: + return tuple(s for s in SCENARIOS if s.pack == pack) diff --git a/tests/backend/eect/harness.py b/tests/backend/eect/harness.py new file mode 100644 index 00000000..469a6b36 --- /dev/null +++ b/tests/backend/eect/harness.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: 0BSD +"""Seeded harness and failure banner for EECT scenarios.""" + +from __future__ import annotations + +import os +import random +from contextlib import contextmanager +from typing import Iterator + +from tests.backend.eect.catalog import Scenario, get_scenario + + +def resolve_seed(explicit: int | None = None) -> int: + """Return replay seed from arg, MESHCHAT_EECT_SEED, or a fresh random seed.""" + if explicit is not None: + return int(explicit) & 0xFFFFFFFF + raw = os.environ.get("MESHCHAT_EECT_SEED") + if raw is not None and str(raw).strip() != "": + return int(str(raw).strip(), 0) & 0xFFFFFFFF + return random.SystemRandom().randint(0, 0xFFFFFFFF) + + +def format_failure_banner(scenario: Scenario, seed: int, detail: str = "") -> str: + lines = [ + "EECT FAILURE", + f" scenario_id: {scenario.id}", + f" pack: {scenario.pack}", + f" gate: {scenario.gate}", + f" taxonomy: {scenario.taxonomy}", + f" seed: {seed}", + f" replay: MESHCHAT_EECT_SEED={seed}", + ] + if detail: + lines.append(f" detail: {detail}") + return "\n".join(lines) + + +@contextmanager +def eect_scenario( + scenario_id: str, seed: int | None = None +) -> Iterator[tuple[Scenario, int, random.Random]]: + """Bind scenario + seeded RNG. On assert failure, append banner to the message.""" + scenario = get_scenario(scenario_id) + resolved = resolve_seed(seed) + rng = random.Random(resolved) + try: + yield scenario, resolved, rng + except AssertionError as exc: + banner = format_failure_banner(scenario, resolved, detail=str(exc)) + raise AssertionError(f"{banner}\n\n{exc}") from None diff --git a/tests/backend/eect/live/__init__.py b/tests/backend/eect/live/__init__.py new file mode 100644 index 00000000..e45200eb --- /dev/null +++ b/tests/backend/eect/live/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +"""Live Validation ladder tests.""" diff --git a/tests/backend/eect/live/test_lv_ladder.py b/tests/backend/eect/live/test_lv_ladder.py new file mode 100644 index 00000000..117ff561 --- /dev/null +++ b/tests/backend/eect/live/test_lv_ladder.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: 0BSD +"""Live Validation ladder L0-L3 (acceptance for this install).""" + +from __future__ import annotations + +import os +import secrets +import subprocess +import sys + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from aiohttp_session import setup as setup_session + +from meshchatx.src.backend import self_check +from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers +from tests.backend.eect.harness import eect_scenario +from tests.backend.support.test_temp_dir import subprocess_test_env + +pytestmark = pytest.mark.live_validation + +_LIVE = ( + os.environ.get("MESHCHAT_LIVE_VALIDATION") == "1" + or os.environ.get( + "MESHCHAT_LIVE_RETICULUM", + ) + == "1" +) + + +def test_lv_l0_imports_sqlite_unicode(tmp_path): + with eect_scenario("lv.l0.imports_sqlite_unicode") as (_s, _seed, _rng): + assert self_check.check_critical_imports()["status"] == "ok" + assert self_check.check_python_runtime()["status"] == "ok" + assert self_check.check_sqlite_roundtrip(str(tmp_path))["status"] == "ok" + assert self_check.check_unicode_path(str(tmp_path))["status"] == "ok" + assert self_check.check_temp_filesystem()["status"] == "ok" + + +def _make_aio_app(mock_app, use_https: bool = False): + mock_app.session_secret_key = secrets.token_urlsafe(32) + mock_app.listen_host = "127.0.0.1" + mock_app.listen_port = 8000 + mock_app.use_https = use_https + mock_app.landlock_active = False + routes = web.RouteTableDef() + middlewares = mock_app._define_routes(routes) + aio_app = web.Application() + setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https)) + extend_meshchat_middlewares(aio_app, middlewares) + aio_app.add_routes(routes) + return aio_app + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") +async def test_lv_l1_status_and_csrf_reject(mock_app, monkeypatch): + with eect_scenario("lv.l1.status_and_csrf_reject") as (_s, _seed, _rng): + monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False) + aio_app = _make_aio_app(mock_app) + async with TestClient(TestServer(aio_app)) as client: + status = await client.get("/api/v1/status") + assert status.status == 200 + body = await status.json() + assert body.get("status") in ("ok", "starting", "failed") + + blocked = await client.patch( + "/api/v1/server/security", + json={"web_ui_ip_allowlist": ""}, + ) + assert blocked.status == 403 + + headers = await fetch_api_csrf_headers(client) + ok = await client.patch( + "/api/v1/server/security", + json={"web_ui_ip_allowlist": ""}, + headers=headers, + ) + assert ok.status == 200 + + +@pytest.mark.skipif(not _LIVE, reason="Set MESHCHAT_LIVE_VALIDATION=1 for LV L2+") +@pytest.mark.integration +def test_lv_l2_rns_subprocess(): + with eect_scenario("lv.l2.rns_subprocess") as (_s, _seed, _rng): + script = r""" +import tempfile +import RNS + +tmpdir = tempfile.mkdtemp(prefix="meshchat_lv_l2_") +try: + RNS.Reticulum(configdir=tmpdir, loglevel=RNS.LOG_ERROR) +finally: + RNS.exit(0) +""" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=120, + check=False, + env=subprocess_test_env(), + ) + assert result.returncode == 0, result.stderr + result.stdout + + +@pytest.mark.skipif(not _LIVE, reason="Set MESHCHAT_LIVE_VALIDATION=1 for LV L2+") +@pytest.mark.integration +@pytest.mark.usefixtures("require_loopback_tcp") +def test_lv_l3_loopback_tcp(): + with eect_scenario("lv.l3.loopback_tcp") as (_s, _seed, _rng): + result = self_check.check_loopback_tcp() + assert result["status"] == "ok", result.get("reason") diff --git a/tests/backend/eect/packs/__init__.py b/tests/backend/eect/packs/__init__.py new file mode 100644 index 00000000..33e5e3a4 --- /dev/null +++ b/tests/backend/eect/packs/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +"""EECT pack tests (pytest collects test_*.py under this tree).""" diff --git a/tests/backend/eect/packs/test_auth_surface_pack.py b/tests/backend/eect/packs/test_auth_surface_pack.py new file mode 100644 index 00000000..3e9188c3 --- /dev/null +++ b/tests/backend/eect/packs/test_auth_surface_pack.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: 0BSD +"""AuthSurfacePack: mutating HTTP without CSRF must die; with CSRF must pass.""" + +from __future__ import annotations + +import secrets + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from aiohttp_session import setup as setup_session + +from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers +from tests.backend.eect.asserts import assert_no_unexpected_http_500 +from tests.backend.eect.harness import eect_scenario + +pytestmark = [ + pytest.mark.eect, + pytest.mark.usefixtures("require_loopback_tcp"), +] + +# Curated mutating surfaces that accept empty/minimal bodies without deep state. +_MUTATING_SAMPLES = ( + ("PATCH", "/api/v1/server/security", {"web_ui_ip_allowlist": ""}), + ("POST", "/api/v1/app/tutorial/seen", {}), + ("POST", "/api/v1/app/changelog/seen", {"version": "999.999.999"}), +) + + +def _make_aio_app(mock_app, use_https: bool = False): + mock_app.session_secret_key = secrets.token_urlsafe(32) + mock_app.listen_host = "127.0.0.1" + mock_app.listen_port = 8000 + mock_app.use_https = use_https + mock_app.landlock_active = False + routes = web.RouteTableDef() + middlewares = mock_app._define_routes(routes) + aio_app = web.Application() + setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https)) + extend_meshchat_middlewares(aio_app, middlewares) + aio_app.add_routes(routes) + return aio_app + + +@pytest.mark.asyncio +async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch): + with eect_scenario("auth.csrf.mutating_without_token") as (_s, _seed, rng): + monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False) + aio_app = _make_aio_app(mock_app) + samples = list(_MUTATING_SAMPLES) + rng.shuffle(samples) + async with TestClient(TestServer(aio_app)) as client: + for method, path, body in samples: + if method == "PATCH": + resp = await client.patch(path, json=body) + else: + resp = await client.post(path, json=body) + assert_no_unexpected_http_500(resp.status, await resp.text()) + assert resp.status == 403, ( + f"{method} {path} expected 403 got {resp.status}" + ) + + +@pytest.mark.asyncio +async def test_eect_mutating_with_csrf_accepted(mock_app, monkeypatch): + with eect_scenario("auth.csrf.mutating_with_token") as (_s, _seed, rng): + monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False) + aio_app = _make_aio_app(mock_app) + samples = list(_MUTATING_SAMPLES) + rng.shuffle(samples) + async with TestClient(TestServer(aio_app)) as client: + headers = await fetch_api_csrf_headers(client) + for method, path, body in samples: + if method == "PATCH": + resp = await client.patch(path, json=body, headers=headers) + else: + resp = await client.post(path, json=body, headers=headers) + body_text = await resp.text() + assert_no_unexpected_http_500(resp.status, body_text) + assert resp.status != 403, f"{method} {path} still CSRF-blocked" + assert resp.status < 500 diff --git a/tests/backend/eect/packs/test_harness_acceptance.py b/tests/backend/eect/packs/test_harness_acceptance.py new file mode 100644 index 00000000..477a5ff5 --- /dev/null +++ b/tests/backend/eect/packs/test_harness_acceptance.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: 0BSD +"""Acceptance-style harness checks for EECT failure banners.""" + +from __future__ import annotations + +import pytest + +from tests.backend.eect.catalog import get_scenario +from tests.backend.eect.harness import ( + eect_scenario, + format_failure_banner, + resolve_seed, +) + +pytestmark = pytest.mark.eect + + +def test_failure_banner_includes_scenario_seed_gate(): + scenario = get_scenario("hostile.bug_report.redacts_secrets") + banner = format_failure_banner(scenario, 42, detail="boom") + assert "scenario_id: hostile.bug_report.redacts_secrets" in banner + assert "gate: gate3-hostile-medium" in banner + assert "seed: 42" in banner + assert "MESHCHAT_EECT_SEED=42" in banner + assert "taxonomy: security_surface" in banner + + +def test_eect_scenario_rewrites_assertion(monkeypatch): + monkeypatch.setenv("MESHCHAT_EECT_SEED", "7") + assert resolve_seed() == 7 + with pytest.raises(AssertionError) as caught: + with eect_scenario("path.direct.blocks_when_unavailable"): + assert False, "forced" + msg = str(caught.value) + assert "EECT FAILURE" in msg + assert "path.direct.blocks_when_unavailable" in msg + assert "MESHCHAT_EECT_SEED=7" in msg diff --git a/tests/backend/eect/packs/test_hostile_medium_pack.py b/tests/backend/eect/packs/test_hostile_medium_pack.py new file mode 100644 index 00000000..2acaec31 --- /dev/null +++ b/tests/backend/eect/packs/test_hostile_medium_pack.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: 0BSD +"""HostileMediumPack: hostile payloads and diagnostic redaction.""" + +from __future__ import annotations + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from meshchatx.src.backend.bug_report_manager import BugReportManager +from meshchatx.src.backend.favourites_layout import ( + MAX_SECTIONS, + normalize_favourites_layout, +) +from meshchatx.src.backend.log_redaction import REDACTED, redact_diagnostic_text +from tests.backend.eect.asserts import assert_diagnostic_text_redacted +from tests.backend.eect.harness import eect_scenario + +pytestmark = pytest.mark.eect + + +@settings(deadline=None, max_examples=40, suppress_health_check=[HealthCheck.too_slow]) +@given( + raw=st.one_of( + st.none(), + st.integers(), + st.text(max_size=80), + st.lists(st.integers(), max_size=20), + st.dictionaries( + keys=st.text(min_size=0, max_size=40), + values=st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.text(max_size=40), + st.lists(st.text(max_size=20), max_size=8), + ), + max_size=12, + ), + st.fixed_dictionaries( + { + "sections": st.lists( + st.fixed_dictionaries( + { + "id": st.one_of( + st.none(), + st.sampled_from( + ["__proto__", "constructor", "prototype", "ok", ""] + ), + st.text(min_size=0, max_size=80), + ), + "name": st.one_of(st.none(), st.text(max_size=200)), + "collapsed": st.one_of( + st.none(), st.booleans(), st.integers() + ), + }, + ), + max_size=MAX_SECTIONS + 5, + ), + "sectionOrder": st.lists(st.text(max_size=40), max_size=20), + "favouritesBySection": st.dictionaries( + keys=st.text(max_size=40), + values=st.lists(st.text(max_size=80), max_size=30), + max_size=10, + ), + }, + ), + ), +) +def test_eect_favourites_layout_fuzz_never_raises(raw): + with eect_scenario("hostile.favourites.layout_fuzz") as (_s, _seed, _rng): + out = normalize_favourites_layout(raw) + assert out is None or ( + isinstance(out, dict) + and isinstance(out.get("sections"), list) + and isinstance(out.get("sectionOrder"), list) + and isinstance(out.get("favouritesBySection"), dict) + ) + + +def test_eect_bug_report_redacts_secrets(tmp_path): + with eect_scenario("hostile.bug_report.redacts_secrets") as (_s, _seed, _rng): + full_hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + + class FakeLogs: + def get_logs(self, **_kwargs): + return [ + { + "timestamp": 1.0, + "level": "ERROR", + "module": "meshchat", + "message": ( + f"fail at /tmp/x for {full_hash} " + "user@example.com 203.0.113.9" + ), + }, + ] + + def get_total_count(self, **_kwargs): + return 1 + + class FakeDatabase: + debug_logs = FakeLogs() + + class FakeApp: + database = FakeDatabase() + storage_dir = str(tmp_path) + current_context = None + + manager = BugReportManager(FakeApp()) + preview = manager.preview_report({"limit": 5}) + assert_diagnostic_text_redacted(preview["log_text"]) + assert "/tmp/x" not in preview["log_text"] + assert full_hash not in preview["log_text"] + assert "user@example.com" not in preview["log_text"] + assert "203.0.113.9" not in preview["log_text"] + assert REDACTED in preview["log_text"] + + +def test_eect_redact_helper_preserves_short_hash_prefix(): + short = "aabbccddeeff00112233445566778899" + out = redact_diagnostic_text(f"peer {short} ok") + assert short in out + + +def test_eect_favourites_rejects_null_bytes(): + with eect_scenario("hostile.favourites.null_bytes") as (_s, _seed, _rng): + layout = normalize_favourites_layout( + { + "sections": [ + {"id": "bad\x00id", "name": "x"}, + {"id": "ok", "name": "y"}, + ], + "favouritesBySection": {"ok": ["a\x00b", "c" * 32]}, + }, + ) + assert layout is not None + assert [s["id"] for s in layout["sections"]] == ["ok"] + assert layout["favouritesBySection"]["ok"] == ["c" * 32] + + +def test_eect_rejects_decimal_hex_link_local_urls(): + from meshchatx.src.backend.http_url_guard import ( + UnsafeOutboundUrlError, + normalize_libretranslate_http_service_base, + ) + + with eect_scenario("hostile.url.decimal_link_local") as (_s, _seed, _rng): + for bad in ( + "http://2852039166/", + "http://0xa9fea9fe/", + "http://169.254.169.254/", + ): + with pytest.raises(UnsafeOutboundUrlError): + normalize_libretranslate_http_service_base(bad) diff --git a/tests/backend/eect/packs/test_identity_switch_pack.py b/tests/backend/eect/packs/test_identity_switch_pack.py new file mode 100644 index 00000000..5a4158ee --- /dev/null +++ b/tests/backend/eect/packs/test_identity_switch_pack.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: 0BSD +"""IdentitySwitchPack: teardown and path isolation under identity switch.""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import RNS + +from meshchatx.meshchat import ReticulumMeshChat +from meshchatx.src.backend.identity_context import IdentityContext +from tests.backend.eect.asserts import assert_identity_paths_isolated +from tests.backend.eect.harness import eect_scenario + +pytestmark = pytest.mark.eect + + +@pytest.fixture +def mock_rns(tmp_path): + real_identity_class = RNS.Identity + + class MockIdentityClass(real_identity_class): + def __init__(self, *args, **kwargs): + self.hash = b"initial_hash_32_bytes_long_01234" + self.hexhash = self.hash.hex() + + with ExitStack() as stack: + patches = [ + patch("RNS.Reticulum"), + patch("RNS.Transport"), + patch("RNS.Identity", MockIdentityClass), + patch("threading.Thread"), + patch("meshchatx.src.backend.identity_context.Database"), + patch("meshchatx.src.backend.identity_context.ConfigManager"), + patch("meshchatx.src.backend.identity_context.MessageHandler"), + patch("meshchatx.src.backend.identity_context.AnnounceManager"), + patch("meshchatx.src.backend.identity_context.ArchiverManager"), + patch("meshchatx.src.backend.identity_context.MapManager"), + patch("meshchatx.src.backend.identity_context.DocsManager"), + patch("meshchatx.src.backend.identity_context.NomadNetworkManager"), + patch("meshchatx.src.backend.identity_context.TelephoneManager"), + patch("meshchatx.src.backend.identity_context.VoicemailManager"), + patch("meshchatx.src.backend.identity_context.RingtoneManager"), + patch("meshchatx.src.backend.identity_context.RNCPHandler"), + patch("meshchatx.src.backend.identity_context.RNStatusHandler"), + patch("meshchatx.src.backend.identity_context.RNProbeHandler"), + patch("meshchatx.src.backend.identity_context.TranslatorHandler"), + patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"), + patch("LXMF.LXMRouter"), + patch("meshchatx.meshchat.IdentityContext"), + ] + mocks = {} + for p in patches: + attr_name = ( + p.attribute if hasattr(p, "attribute") else p.target.split(".")[-1] + ) + mocks[attr_name] = stack.enter_context(p) + + mock_id_instance = MockIdentityClass() + mock_id_instance.get_private_key = MagicMock( + return_value=b"initial_private_key", + ) + stack.enter_context( + patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance), + ) + stack.enter_context( + patch.object(MockIdentityClass, "recall", return_value=mock_id_instance), + ) + stack.enter_context( + patch.object( + MockIdentityClass, + "from_bytes", + return_value=mock_id_instance, + ), + ) + mock_config = mocks["ConfigManager"] + mock_config.return_value.display_name.get.return_value = "Test User" + yield { + "Identity": MockIdentityClass, + "id_instance": mock_id_instance, + "IdentityContext": mocks["IdentityContext"], + "tmp_path": tmp_path, + } + + +@pytest.mark.asyncio +async def test_eect_identity_switch_teardown_clears_context(mock_rns): + with eect_scenario("identity.switch.teardown_clears_context") as ( + _scenario, + _seed, + _rng, + ): + temp_dir = str(mock_rns["tmp_path"]) + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + old_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + old_ctx = MagicMock() + old_ctx.identity_hash = old_hash + app.current_context = old_ctx + app.contexts = {old_hash: old_ctx} + + new_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + identity_dir = os.path.join(temp_dir, "identities", new_hash) + os.makedirs(identity_dir) + with open(os.path.join(identity_dir, "identity"), "wb") as f: + f.write(b"new_private_key") + + new_id_instance = MagicMock() + new_id_instance.hash = bytes.fromhex(new_hash) + mock_rns["Identity"].from_file.return_value = new_id_instance + + new_ctx = mock_rns["IdentityContext"].return_value + new_ctx.config.display_name.get.return_value = "New User" + new_ctx.identity_hash = new_hash + + app.setup_identity = MagicMock( + side_effect=lambda _id: setattr(app, "current_context", new_ctx), + ) + app.websocket_broadcast = AsyncMock() + + with patch("meshchatx.meshchat.asyncio.sleep", new=AsyncMock()): + result = await app.hotswap_identity(new_hash) + + assert result is True + old_ctx.teardown.assert_called_once() + assert old_hash not in app.contexts + assert app.current_context is new_ctx + + +def test_eect_identity_storage_paths_isolated(tmp_path): + with eect_scenario("identity.switch.storage_paths_isolated") as ( + _scenario, + _seed, + _rng, + ): + app = MagicMock() + app.storage_dir = str(tmp_path) + hash_a = "a" * 32 + hash_b = "b" * 32 + + # Mirror IdentityContext path construction without full manager init. + path_a = os.path.join(app.storage_dir, "identities", hash_a, "database.db") + path_b = os.path.join(app.storage_dir, "identities", hash_b, "database.db") + assert_identity_paths_isolated(path_a, path_b) + + # Confirm the live class still builds the same layout for hash inputs. + id_a = MagicMock() + id_a.hash = bytes.fromhex(hash_a) + with ( + patch.object(IdentityContext, "__init__", lambda self, *_a, **_k: None), + ): + ctx = IdentityContext.__new__(IdentityContext) + ctx.app = app + ctx.identity = id_a + ctx.identity_hash = id_a.hash.hex() + ctx.storage_path = os.path.join( + app.storage_dir, + "identities", + ctx.identity_hash, + ) + ctx.database_path = os.path.join(ctx.storage_path, "database.db") + assert ctx.database_path == path_a + assert hash_b not in ctx.database_path diff --git a/tests/backend/eect/packs/test_missing_path_pack.py b/tests/backend/eect/packs/test_missing_path_pack.py new file mode 100644 index 00000000..310b627d --- /dev/null +++ b/tests/backend/eect/packs/test_missing_path_pack.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: 0BSD +"""MissingPathPack: recoverable path outcomes for direct vs propagated send.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from meshchatx.meshchat import ReticulumMeshChat +from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome +from tests.backend.eect.asserts import assert_recoverable_missing_path +from tests.backend.eect.harness import eect_scenario + +pytestmark = pytest.mark.eect + + +@pytest.fixture +def send_app(): + app = ReticulumMeshChat.__new__(ReticulumMeshChat) + app.current_context = MagicMock() + app.config = MagicMock() + app.database = MagicMock() + app.reticulum = MagicMock() + app.message_router = MagicMock() + app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(True, "reused_valid_path", False), + ) + app.get_current_icon_hash = MagicMock(return_value=None) + app.db_upsert_lxmf_message = MagicMock() + app.websocket_broadcast = AsyncMock() + app._is_contact = MagicMock(return_value=False) + app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b) + app.handle_lxmf_message_progress = AsyncMock() + ctx = app.current_context + ctx.message_router = app.message_router + ctx.database = app.database + ctx.config = app.config + ctx.local_lxmf_destination = MagicMock() + ctx.local_lxmf_destination.hexhash = "local_hash" + ctx.forwarding_manager = None + return app + + +@pytest.mark.asyncio +async def test_eect_direct_blocks_when_path_unavailable(send_app): + with eect_scenario("path.direct.blocks_when_unavailable") as (_s, _seed, _rng): + destination_hash = "aa" * 16 + send_app.recall_identity = MagicMock(return_value=MagicMock()) + send_app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(False, "new_path_requested", True), + ) + with pytest.raises(TimeoutError) as caught: + await send_app.send_message( + destination_hash=destination_hash, + content="hi", + delivery_method="direct", + ) + assert_recoverable_missing_path(caught.value) + send_app.message_router.handle_outbound.assert_not_called() + + +@pytest.mark.asyncio +async def test_eect_propagated_skips_path_await(send_app): + with eect_scenario("path.propagated.skips_await") as (_s, _seed, _rng): + destination_hash = "aa" * 16 + send_app.recall_identity = MagicMock(return_value=MagicMock()) + send_app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(False, "new_path_requested", True), + ) + send_app.config.auto_send_failed_messages_to_propagation_node.get.return_value = False + send_app.config.include_display_name_with_message.get.return_value = False + send_app.config.include_icon_with_message.get.return_value = False + send_app.config.include_signature_with_message.get.return_value = False + mock_msg = MagicMock() + mock_msg.hash = b"\x01" * 16 + mock_msg.fields = {} + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=MagicMock()), + patch("meshchatx.meshchat.LXMF.LXMessage", return_value=mock_msg), + patch( + "meshchatx.meshchat.RNS.Identity.current_ratchet_id", return_value=None + ), + patch( + "meshchatx.meshchat.convert_lxmf_message_to_dict", + return_value={"hash": "01" * 16, "state": "outbound"}, + ), + ): + result = await send_app.send_message( + destination_hash=destination_hash, + content="hi", + delivery_method="propagated", + ) + assert result is mock_msg + send_app.message_router.handle_outbound.assert_called_once() + send_app._await_transport_path.assert_not_called() diff --git a/tests/backend/eect/packs/test_scarcity_pack.py b/tests/backend/eect/packs/test_scarcity_pack.py new file mode 100644 index 00000000..559193a1 --- /dev/null +++ b/tests/backend/eect/packs/test_scarcity_pack.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: 0BSD +"""ScarcityPack: conversation list stays slim under large payloads.""" + +from __future__ import annotations + +import secrets +import tempfile +import time + +import pytest + +from meshchatx.src.backend.database import Database +from meshchatx.src.backend.database.provider import DatabaseProvider +from meshchatx.src.backend.message_handler import MessageHandler +from tests.backend.eect.asserts import assert_preview_capped +from tests.backend.eect.harness import eect_scenario + +pytestmark = pytest.mark.eect + + +def _message(peer_hash, i, *, fields="{}", content="hello"): + return { + "hash": secrets.token_hex(16), + "source_hash": peer_hash, + "destination_hash": "localhashlocalhashlocalhashlo12", + "peer_hash": peer_hash, + "state": "delivered", + "progress": 1.0, + "is_incoming": 1, + "method": "direct", + "delivery_attempts": 1, + "next_delivery_attempt_at": None, + "title": f"t{i}", + "content": content, + "fields": fields, + "timestamp": time.time() - i, + "rssi": -50, + "snr": 5.0, + "quality": 3, + "is_spam": 0, + "reply_to_hash": None, + } + + +@pytest.fixture +def handler_db(): + if DatabaseProvider._instance is not None: + DatabaseProvider._instance.close_all() + DatabaseProvider._instance = None + tmp = tempfile.TemporaryDirectory() + db = Database(f"{tmp.name}/database.db") + db.initialize() + handler = MessageHandler(db) + yield handler, db + db.close() + if DatabaseProvider._instance is not None: + DatabaseProvider._instance.close_all() + DatabaseProvider._instance = None + tmp.cleanup() + + +def test_eect_conversation_preview_capped(handler_db): + with eect_scenario("scarcity.conversation.preview_capped") as (_s, _seed, rng): + handler, db = handler_db + peer = secrets.token_hex(16) + long_content = "x" * (MessageHandler._CONVERSATION_CONTENT_PREVIEW_CHARS + 500) + # Sprinkle a few shorter messages so list still forms. + for i in range(3): + content = long_content if i == 0 else ("y" * (20 + rng.randint(0, 40))) + db.messages.upsert_lxmf_message(_message(peer, i, content=content)) + rows = handler.get_conversations("local", limit=10) + assert rows + for row in rows: + assert_preview_capped( + dict(row).get("content"), + MessageHandler._CONVERSATION_CONTENT_PREVIEW_CHARS, + ) + + +def test_eect_conversation_list_omits_fields(handler_db): + with eect_scenario("scarcity.conversation.fields_slim") as (_s, _seed, _rng): + handler, db = handler_db + peer = secrets.token_hex(16) + big_b64 = "C" * 90000 + db.messages.upsert_lxmf_message( + _message( + peer, + 0, + fields={ + "image": {"image_type": "png", "image_bytes": big_b64}, + }, + content="", + ), + ) + rows = handler.get_conversations("local", limit=10) + assert rows + row = dict(rows[0]) + assert "fields" not in row + assert row.get("has_image") == 1 diff --git a/tests/backend/test_bug_report_manager.py b/tests/backend/test_bug_report_manager.py index a24aa745..40dc639c 100644 --- a/tests/backend/test_bug_report_manager.py +++ b/tests/backend/test_bug_report_manager.py @@ -16,6 +16,8 @@ def _fake_app(tmp_path): def test_preview_report_uses_database_logs(tmp_path): + full_hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + class FakeLogs: def get_logs(self, **_kwargs): return [ @@ -23,7 +25,7 @@ def test_preview_report_uses_database_logs(tmp_path): "timestamp": 1.0, "level": "ERROR", "module": "meshchat", - "message": "fail at /tmp/x for aabbccddeeff00112233445566778899", + "message": f"fail at /tmp/x for {full_hash}", }, ] @@ -41,8 +43,9 @@ def test_preview_report_uses_database_logs(tmp_path): manager = BugReportManager(FakeApp()) preview = manager.preview_report({"limit": 5}) assert preview["line_count"] == 1 - assert "/tmp/x" in preview["log_text"] - assert "aabbccddeeff00112233445566778899" in preview["log_text"] + assert "/tmp/x" not in preview["log_text"] + assert full_hash not in preview["log_text"] + assert "[redacted]" in preview["log_text"] assert preview["chars"] > 0 diff --git a/tests/backend/test_favourites_layout.py b/tests/backend/test_favourites_layout.py index 21b732a0..bff9eaf3 100644 --- a/tests/backend/test_favourites_layout.py +++ b/tests/backend/test_favourites_layout.py @@ -134,6 +134,28 @@ def test_layout_payload_too_large(): assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES + 1) is True assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES) is False assert layout_payload_too_large("nope") is False + assert layout_payload_too_large(-1) is True + assert layout_payload_too_large("-5") is True + + +def test_normalize_rejects_null_bytes_in_ids_and_hashes(): + layout = normalize_favourites_layout( + { + "sections": [ + {"id": "ok\x00evil", "name": "bad"}, + {"id": "good", "name": "Good"}, + ], + "sectionOrder": ["ok\x00evil", "good"], + "favouritesBySection": { + "good": ["ab\x00cd", "aabbccddeeff00112233445566778899"], + }, + }, + ) + assert layout is not None + assert [s["id"] for s in layout["sections"]] == ["good"] + assert layout["favouritesBySection"]["good"] == [ + "aabbccddeeff00112233445566778899", + ] @given( diff --git a/tests/backend/test_http_url_guard.py b/tests/backend/test_http_url_guard.py index d53ca566..4ceded14 100644 --- a/tests/backend/test_http_url_guard.py +++ b/tests/backend/test_http_url_guard.py @@ -103,6 +103,10 @@ def test_normalize_libretranslate_private_and_loopback_ips(): "http://239.255.0.1:5000/", "http://0.0.0.0/", "http://240.0.0.1:1", + # Decimal / hex encodings of 169.254.169.254 (SSRF metadata bypass). + "http://2852039166/", + "http://0xa9fea9fe/", + "http://0xA9FEA9FE:80/", ], ) def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad): @@ -110,6 +114,16 @@ def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad): normalize_libretranslate_http_service_base(bad) +def test_normalize_libretranslate_allows_decimal_loopback(): + # Loopback encodings remain allowed for local LibreTranslate. + assert normalize_libretranslate_http_service_base("http://2130706433:5000/") == ( + "http://2130706433:5000" + ) + assert normalize_libretranslate_http_service_base("http://0x7f000001/") == ( + "http://0x7f000001" + ) + + @pytest.mark.parametrize( "bad", [ diff --git a/tests/backend/test_log_redaction.py b/tests/backend/test_log_redaction.py new file mode 100644 index 00000000..5d58c1d3 --- /dev/null +++ b/tests/backend/test_log_redaction.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: 0BSD +"""Unit tests for diagnostic log redaction.""" + +from meshchatx.src.backend.log_redaction import REDACTED, redact_diagnostic_text + + +def test_redact_paths_hashes_email_ip(): + full = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + text = f"fail at /tmp/secret/db for {full} user@example.com from 203.0.113.9" + out = redact_diagnostic_text(text) + assert "/tmp/" not in out.lower() + assert full not in out + assert "user@example.com" not in out + assert "203.0.113.9" not in out + assert out.count(REDACTED) >= 3 + + +def test_redact_run_media_and_windows_drives(): + out = redact_diagnostic_text( + "fail at /run/media/user1/projects/db and D:\\Users\\user1\\AppData\\id", + ) + assert "/run/media/" not in out.lower() + assert "D:\\Users" not in out + assert REDACTED in out + + +def test_redact_pem_bearer_and_secret_assigns(): + pem = "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END PRIVATE KEY-----" + out = redact_diagnostic_text( + f"{pem} Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb " + "alias_identity_private_key=YWJjZGVm", + ) + assert "BEGIN PRIVATE KEY" not in out + assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in out + assert "YWJjZGVm" not in out + assert "Bearer" in out + assert REDACTED in out + + +def test_redact_preserves_short_hash(): + short = "aabbccddeeff00112233445566778899" + out = redact_diagnostic_text(f"peer {short}") + assert short in out + + +def test_redact_empty(): + assert redact_diagnostic_text("") == "" + assert redact_diagnostic_text(None) is None # type: ignore[arg-type] diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py index c7fdbeeb..d1ae9d1e 100644 --- a/tests/backend/test_plugin_manager.py +++ b/tests/backend/test_plugin_manager.py @@ -80,7 +80,8 @@ class TestPluginManagerInstall: {"limit": 10}, ) assert preview["line_count"] == 1 - assert "/home/user1/secret" in preview["log_text"] + assert "/home/user1/secret" not in preview["log_text"] + assert "[redacted]" in preview["log_text"] def test_rns_link_capabilities_require_manifest_grant(self, tmp_path): class FakeLinkManager: diff --git a/tests/e2e/acceptance-settings-privacy.spec.js b/tests/e2e/acceptance-settings-privacy.spec.js new file mode 100644 index 00000000..62fc4fa2 --- /dev/null +++ b/tests/e2e/acceptance-settings-privacy.spec.js @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: 0BSD +/** + * Acceptance: Settings privacy surface is reachable and shows expected controls. + */ +const { test, expect } = require("@playwright/test"); +const { prepareE2eSession } = require("./helpers"); + +test.describe("Acceptance: Settings privacy", () => { + test.beforeEach(async ({ request }) => { + await prepareE2eSession(request); + }); + + test("privacy tab shows data controls and privacy mode", async ({ page }) => { + await page.goto("/#/settings"); + await expect(page).toHaveURL(/#\/settings/); + await expect(page.getByText("Profile", { exact: true }).first()).toBeVisible({ + timeout: 30000, + }); + + const privacyTab = page.getByRole("button", { name: /^Privacy$/i }).first(); + await expect(privacyTab).toBeVisible({ timeout: 20000 }); + await privacyTab.click(); + + await expect(page.getByText("Data & device", { exact: true }).first()).toBeVisible({ + timeout: 20000, + }); + await expect( + page.getByText("Privacy mode (block external HTTP/HTTPS)", { exact: true }).first(), + ).toBeVisible({ timeout: 20000 }); + }); +}); diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js index ec993c2f..c403a734 100644 --- a/tests/frontend/behaviorContracts.test.js +++ b/tests/frontend/behaviorContracts.test.js @@ -291,8 +291,11 @@ describe("behavior contracts: network visualiser performance", () => { expect(engine).toContain('pointerMode = "pinch"'); expect(engine).toContain("meshchatxVisualiserSceneZoomAt"); expect(engine).toContain("updateNodeImages"); + expect(engine).toContain("labelByIndex"); const webgl = readSource("meshchatx/src/frontend/js/networkVisualiserWebGL.js"); expect(webgl).toContain("u_atlas"); + expect(webgl).toContain("network-webgl-labels"); + expect(webgl).toContain("resolveVisualiserAssetUrl"); expect(webgl).toContain("mergeSceneNodesWithTextures"); const prefs = readSource("meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js"); expect(prefs).toContain("persistVisualiserRenderer"); diff --git a/tests/frontend/hotPathBugRegressions.test.js b/tests/frontend/hotPathBugRegressions.test.js index f6f24fd3..e7505287 100644 --- a/tests/frontend/hotPathBugRegressions.test.js +++ b/tests/frontend/hotPathBugRegressions.test.js @@ -43,4 +43,24 @@ describe("hot-path bug regressions", () => { expect(DownloadUtils.sanitizeDownloadFilename("a\r\nb.html", "x.bin")).toBe("ab.html"); expect(DownloadUtils.sanitizeDownloadFilename("a\x00b.bin", "x.bin")).toBe("ab.bin"); }); + + it("strips zero-width and soft-hyphen hidden fixed overlays", () => { + const zwsp = String.fromCharCode(0x200b); + const shyChar = String.fromCharCode(0x00ad); + const zw = MicronParser.stripOverlayStyles(`
x
`); + expect(zw.toLowerCase()).not.toMatch(/position\s*:\s*fi/); + expect(zw).toContain("color:red"); + const shy = MicronParser.stripOverlayStyles(`
x
`); + expect(shy.toLowerCase()).not.toMatch(/position\s*:/); + const cssZw = stripOverlayFromCss(`.x{position:fi${zwsp}xed;inset:0}`); + expect(cssZw.toLowerCase()).not.toMatch(/position\s*:\s*fi/); + expect(cssZw.toLowerCase()).toMatch(/position:static/); + }); + + it("download filenames strip bidi overrides and Windows reserved names", () => { + const rtl = String.fromCharCode(0x202e); + expect(DownloadUtils.sanitizeDownloadFilename(`${rtl}exe.txt`, "x.bin")).toBe("exe.txt"); + expect(DownloadUtils.sanitizeDownloadFilename("CON.txt", "x.bin")).toBe("x.bin"); + expect(DownloadUtils.sanitizeDownloadFilename("evil.txt...", "x.bin")).toBe("evil.txt"); + }); }); diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js index 1cf7e876..391b9765 100644 --- a/tests/frontend/networkVisualiserWebGLEngine.test.js +++ b/tests/frontend/networkVisualiserWebGLEngine.test.js @@ -15,6 +15,7 @@ import { import { atlasUvForSlot, mergeSceneNodesWithTextures, + resolveVisualiserAssetUrl, SCENE_NODE_STRIDE, NODE_STRIDE, ATLAS_COLS, @@ -109,6 +110,7 @@ function stubGl() { TEXTURE_WRAP_S: 0x2802, TEXTURE_WRAP_T: 0x2803, UNPACK_FLIP_Y_WEBGL: 0x9240, + UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241, COLOR_BUFFER_BIT: 0x4000, BLEND: 0x0be2, SRC_ALPHA: 0x0302, @@ -127,15 +129,35 @@ function stubGl() { } function makeCanvas(gl) { + const host = document.createElement("div"); + host.style.cssText = "position:relative;width:400px;height:300px;"; const canvas = document.createElement("canvas"); + host.appendChild(canvas); + document.body.appendChild(host); Object.defineProperty(canvas, "clientWidth", { value: 400 }); Object.defineProperty(canvas, "clientHeight", { value: 300 }); canvas.getBoundingClientRect = () => ({ left: 10, top: 20, width: 400, height: 300 }); canvas.setPointerCapture = vi.fn(); canvas.releasePointerCapture = vi.fn(); - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type) { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type, opts) { if (this === canvas && type === "webgl2") return gl; - if (type === "2d") return { clearRect: vi.fn(), drawImage: vi.fn() }; + if (type === "2d") { + return { + clearRect: vi.fn(), + drawImage: vi.fn(), + getImageData: () => ({ + data: new Uint8ClampedArray(64 * 64 * 4).fill(255), + width: 64, + height: 64, + }), + putImageData: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + setTransform: vi.fn(), + strokeText: vi.fn(), + fillText: vi.fn(), + }; + } return null; }); return canvas; @@ -251,6 +273,19 @@ describe("networkVisualiserWebGL textures", () => { expect(last.v).toBeCloseTo((ATLAS_ROWS - 1) / ATLAS_ROWS); }); + it("resolveVisualiserAssetUrl keeps blob/data/http URLs", () => { + expect(resolveVisualiserAssetUrl("blob:http://localhost/x")).toBe("blob:http://localhost/x"); + expect(resolveVisualiserAssetUrl("data:image/png;base64,xx")).toBe("data:image/png;base64,xx"); + expect(resolveVisualiserAssetUrl("https://example.com/a.png")).toBe("https://example.com/a.png"); + }); + + it("resolveVisualiserAssetUrl absolutizes root paths", () => { + const origin = window.location.origin; + expect(resolveVisualiserAssetUrl("/assets/images/reticulum_logo_512.png")).toBe( + `${origin}/assets/images/reticulum_logo_512.png` + ); + }); + it("mergeSceneNodesWithTextures attaches atlas UVs", () => { const scene = new Float32Array(SCENE_NODE_STRIDE); scene[0] = 1; @@ -345,6 +380,7 @@ describe("createVisualiserWebGLEngine interactions", () => { engine.destroy(); engine = null; } + canvas?.parentElement?.remove(); clearSceneGlobals(); vi.restoreAllMocks(); }); @@ -355,6 +391,7 @@ describe("createVisualiserWebGLEngine interactions", () => { clearSceneGlobals(); const c = makeCanvas(stubGl()); expect(() => createVisualiserWebGLEngine(c)).toThrow(/WASM scene unavailable/); + c.parentElement?.remove(); }); it("pinch gesture calls SceneZoomAt with distance ratio", () => { @@ -387,23 +424,25 @@ describe("createVisualiserWebGLEngine interactions", () => { }); it("setGraph and updateNodeImages upload icon textures", async () => { - const OriginalImage = globalThis.Image; - globalThis.Image = class MockImage { - constructor() { - this.width = 32; - this.height = 32; - queueMicrotask(() => this.onload?.()); - } - set src(_v) { - /* onload via microtask */ - } - }; + const bitmap = { width: 32, height: 32, close: vi.fn() }; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }), + })) + ); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => bitmap) + ); engine.setGraph( [ { id: "me", group: "me", + label: "Local", image: "/assets/images/reticulum_logo_512.png", x: 0, y: 0, @@ -411,6 +450,7 @@ describe("createVisualiserWebGLEngine interactions", () => { { id: "peer", group: "announce", + label: "Peer", image: "/assets/images/network-visualiser/user.png", x: 10, y: 10, @@ -431,8 +471,29 @@ describe("createVisualiserWebGLEngine interactions", () => { await vi.waitFor(() => { expect(gl.texSubImage2D.mock.calls.length).toBeGreaterThan(uploadsBefore); }); + }); - globalThis.Image = OriginalImage; + it("setGraph applies kind default icons when image missing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + blob: async () => new Blob([new Uint8Array([9])], { type: "image/png" }), + })) + ); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 16, height: 16, close: vi.fn() })) + ); + + engine.setGraph([{ id: "me", group: "me", label: "Me", x: 0, y: 0 }], [], { + preserveCamera: false, + zoom: 1, + }); + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalled(); + expect(String(globalThis.fetch.mock.calls[0][0])).toContain("reticulum_logo_512.png"); + }); }); it("sets touch-action none for mobile gestures", () => { diff --git a/tests/frontend/securityExploratoryOracle.test.js b/tests/frontend/securityExploratoryOracle.test.js index 8ad20d69..046ced49 100644 --- a/tests/frontend/securityExploratoryOracle.test.js +++ b/tests/frontend/securityExploratoryOracle.test.js @@ -37,6 +37,8 @@ const OVERLAY_STYLE_ATTACKS = [ 'style="position/**/:fixed; top:0; left:0"', 'style="position: sticky !important; transform: translateY(0)"', 'style="POSITION:FiXeD; width:100vw; height:100vh"', + `style="position:fi${String.fromCharCode(0x200b)}xed; inset:0"`, + `style="position:fi${String.fromCharCode(0x00ad)}xed"`, ]; function assertSafeHtmlOracle(html) {