mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: introduce Extended Edge Case Tester (EECT) and Live Validation (LV) with new testing scenarios and log redaction improvements
This commit is contained in:
parent
86a32dba94
commit
ae6829dd8f
40 changed files with 1589 additions and 55 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
Taskfile.yml
18
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
66
meshchatx/src/backend/log_redaction.py
Normal file
66
meshchatx/src/backend/log_redaction.py
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
6
tests/backend/eect/__init__.py
Normal file
6
tests/backend/eect/__init__.py
Normal 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).
|
||||
"""
|
||||
47
tests/backend/eect/asserts.py
Normal file
47
tests/backend/eect/asserts.py
Normal 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]}"
|
||||
164
tests/backend/eect/catalog.py
Normal file
164
tests/backend/eect/catalog.py
Normal 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)
|
||||
51
tests/backend/eect/harness.py
Normal file
51
tests/backend/eect/harness.py
Normal 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
|
||||
2
tests/backend/eect/live/__init__.py
Normal file
2
tests/backend/eect/live/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""Live Validation ladder tests."""
|
||||
114
tests/backend/eect/live/test_lv_ladder.py
Normal file
114
tests/backend/eect/live/test_lv_ladder.py
Normal 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")
|
||||
2
tests/backend/eect/packs/__init__.py
Normal file
2
tests/backend/eect/packs/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""EECT pack tests (pytest collects test_*.py under this tree)."""
|
||||
81
tests/backend/eect/packs/test_auth_surface_pack.py
Normal file
81
tests/backend/eect/packs/test_auth_surface_pack.py
Normal 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
|
||||
37
tests/backend/eect/packs/test_harness_acceptance.py
Normal file
37
tests/backend/eect/packs/test_harness_acceptance.py
Normal 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
|
||||
155
tests/backend/eect/packs/test_hostile_medium_pack.py
Normal file
155
tests/backend/eect/packs/test_hostile_medium_pack.py
Normal 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)
|
||||
169
tests/backend/eect/packs/test_identity_switch_pack.py
Normal file
169
tests/backend/eect/packs/test_identity_switch_pack.py
Normal 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
|
||||
96
tests/backend/eect/packs/test_missing_path_pack.py
Normal file
96
tests/backend/eect/packs/test_missing_path_pack.py
Normal 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()
|
||||
99
tests/backend/eect/packs/test_scarcity_pack.py
Normal file
99
tests/backend/eect/packs/test_scarcity_pack.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
48
tests/backend/test_log_redaction.py
Normal file
48
tests/backend/test_log_redaction.py
Normal 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]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
31
tests/e2e/acceptance-settings-privacy.spec.js
Normal file
31
tests/e2e/acceptance-settings-privacy.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue