feat: introduce Extended Edge Case Tester (EECT) and Live Validation (LV) with new testing scenarios and log redaction improvements

This commit is contained in:
Ivan 2026-07-18 04:54:41 -05:00
parent 86a32dba94
commit ae6829dd8f
No known key found for this signature in database
40 changed files with 1589 additions and 55 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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)

View file

@ -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=<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.

View file

@ -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

View file

@ -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

View file

@ -22,6 +22,11 @@ pnpm exec vitest run tests/frontend/<Name>.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

Binary file not shown.

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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"(?<![0-9a-fA-F])([0-9a-fA-F]{64})(?![0-9a-fA-F])")
# Private-key-ish long hex blobs (>= 96 hex chars continuous).
_LONG_HEX_RE = re.compile(r"(?<![0-9a-fA-F])([0-9a-fA-F]{96,})(?![0-9a-fA-F])")
# Email-ish tokens.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
# IPv4 addresses (not link-local mesh addressing; UI logs should not leak host IPs by default).
_IPV4_RE = re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
)
# PEM private key / certificate blocks.
_PEM_RE = re.compile(
r"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----",
re.IGNORECASE,
)
# Bearer / JWT-ish tokens and common secret assignments.
_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._\-+=/]{8,}", re.IGNORECASE)
_SECRET_ASSIGN_RE = re.compile(
r"\b(?:alias_identity_private_key|private_key|session|password|passwd|token|"
r"api[_-]?key|csrf|authorization)\s*[:=]\s*\S+",
re.IGNORECASE,
)
REDACTED = "[redacted]"
def redact_diagnostic_text(text: str) -> 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

View file

@ -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;
}

View file

@ -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;

View file

@ -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";
}

View file

@ -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 {

View file

@ -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 = [];
}

View file

@ -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 =

View file

@ -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).
"""

View file

@ -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"(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])", lower)
assert not full_hashes, f"unredacted full hashes remain: {full_hashes[:3]}"

View file

@ -0,0 +1,164 @@
# SPDX-License-Identifier: 0BSD
"""Scenario registry for Extended Edge Case Tester packs."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Taxonomy = Literal[
"logic",
"timing",
"identity_leak",
"security_surface",
"resource",
"live_env",
]
@dataclass(frozen=True)
class Scenario:
"""One EECT scenario mapped to a Zen gate and failure taxonomy."""
id: str
pack: str
gate: str
taxonomy: Taxonomy
summary: str
ci: bool = True
SCENARIOS: tuple[Scenario, ...] = (
Scenario(
id="identity.switch.teardown_clears_context",
pack="IdentitySwitchPack",
gate="gate6-identity-context",
taxonomy="identity_leak",
summary="hotswap tears down prior context and drops it from contexts map",
),
Scenario(
id="identity.switch.storage_paths_isolated",
pack="IdentitySwitchPack",
gate="gate6-identity-context",
taxonomy="identity_leak",
summary="identity A and B use distinct database.db paths under identities/",
),
Scenario(
id="path.direct.blocks_when_unavailable",
pack="MissingPathPack",
gate="gate4-scarcity-async",
taxonomy="timing",
summary="direct send raises recoverable TimeoutError and never outbound",
),
Scenario(
id="path.propagated.skips_await",
pack="MissingPathPack",
gate="gate4-scarcity-async",
taxonomy="timing",
summary="propagated delivery does not await peer path",
),
Scenario(
id="hostile.favourites.layout_fuzz",
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
summary="normalize_favourites_layout never crashes on hostile blobs",
),
Scenario(
id="hostile.bug_report.redacts_secrets",
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
summary="bug report preview redacts paths and full destination hashes",
),
Scenario(
id="hostile.overlay.format_char_fixed",
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
summary="Micron/Nomad strip fixed even when hidden with ZWSP or soft hyphen",
),
Scenario(
id="hostile.favourites.null_bytes",
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
summary="favourites layout rejects NUL in section ids and hashes",
),
Scenario(
id="hostile.url.decimal_link_local",
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
summary="LibreTranslate URL guard rejects decimal/hex link-local SSRF forms",
),
Scenario(
id="scarcity.conversation.preview_capped",
pack="ScarcityPack",
gate="gate4-scarcity-async",
taxonomy="resource",
summary="conversation list content preview stays within 240 chars",
),
Scenario(
id="scarcity.conversation.fields_slim",
pack="ScarcityPack",
gate="gate4-scarcity-async",
taxonomy="resource",
summary="conversation list omits heavy fields blobs",
),
Scenario(
id="auth.csrf.mutating_without_token",
pack="AuthSurfacePack",
gate="gate6-auth-surface",
taxonomy="security_surface",
summary="sampled mutating HTTP routes reject missing CSRF",
),
Scenario(
id="auth.csrf.mutating_with_token",
pack="AuthSurfacePack",
gate="gate6-auth-surface",
taxonomy="security_surface",
summary="sampled mutating HTTP routes accept valid CSRF",
),
Scenario(
id="lv.l0.imports_sqlite_unicode",
pack="LiveValidation",
gate="gate0-intent",
taxonomy="live_env",
summary="L0 self-check imports, sqlite, unicode path",
),
Scenario(
id="lv.l1.status_and_csrf_reject",
pack="LiveValidation",
gate="gate6-auth-surface",
taxonomy="live_env",
summary="L1 status OK and unauth mutating POST rejected",
),
Scenario(
id="lv.l2.rns_subprocess",
pack="LiveValidation",
gate="gate0-intent",
taxonomy="live_env",
summary="L2 RNS start/exit in isolated subprocess",
ci=False,
),
Scenario(
id="lv.l3.loopback_tcp",
pack="LiveValidation",
gate="gate0-intent",
taxonomy="live_env",
summary="L3 loopback TCP bind prove-alive",
ci=False,
),
)
def get_scenario(scenario_id: str) -> 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)

View file

@ -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

View file

@ -0,0 +1,2 @@
# SPDX-License-Identifier: 0BSD
"""Live Validation ladder tests."""

View file

@ -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")

View file

@ -0,0 +1,2 @@
# SPDX-License-Identifier: 0BSD
"""EECT pack tests (pytest collects test_*.py under this tree)."""

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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",
[

View file

@ -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]

View file

@ -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:

View file

@ -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 });
});
});

View file

@ -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");

View file

@ -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(`<div style="position:fi${zwsp}xed; color:red">x</div>`);
expect(zw.toLowerCase()).not.toMatch(/position\s*:\s*fi/);
expect(zw).toContain("color:red");
const shy = MicronParser.stripOverlayStyles(`<div style="position:fi${shyChar}xed">x</div>`);
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");
});
});

View file

@ -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", () => {

View file

@ -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) {