summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
commit30e855f55f1d920b25da0bdd8e538c249d3c0c26 (patch)
tree587dcad0beb8a120352613be8aa751a97040015c /packages/meshbay-hub/tests
parent768e07046368819b8a8f15c8b21e5a8bbfcdf282 (diff)
downloadmeshbay-30e855f55f1d920b25da0bdd8e538c249d3c0c26.tar.gz
feat(client): the platform seam, and an Electron shell that has never been run
Stage D, and the honest half of it. D1 — the seam (done, and verified) ---------------------------------- `static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is built with the same base, so one address has one source. In a browser it returns '' and every path stays relative to the origin that served the page — the acceptance criterion for this split was "the browser SPA behaves identically", and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the content hash and a cached browser would never ask for it. D2 — the shell (written, never launched) ----------------------------------------- **There is no npm on this machine. Electron was never installed and `packages/meshbay-client/` has not been run once.** That is stated here rather than discovered later. What is there: a main process serving the packaged interface over a privileged `app://` scheme (`secure` and `standard` are not cosmetic — without them the service worker refuses to register and streamed downloads break silently), a preload exposing an enumerated bridge that never passes a filesystem path, a window with `sandbox`, `contextIsolation` and no node integration, navigation away from the package refused, and a CSP where the hub is reachable over connect-src and is not a script source. The hub address arrives as a process argument because `platform.hubBase()` runs before anything can await. `test_desktop_shell.py` pins each of those by reading the source — the treatment `test_downloads.py` already gives the three browser save paths. It catches a property being removed and proves nothing about the application running. Two were checked by breaking them. The interface is *copied* into the package by `build/sync-ui.js` from the hub's static directory, and `ui/` is gitignored: a silent fork is the only real way to end up maintaining the interface twice. D3 — partial ------------ The bridge, and the part worth having now: safeStorage's backend is reported rather than assumed. On Linux it falls back to a fixed key when no keyring is running, silently — someone who believes the OS is holding their keys is told when it is not. The native key lifecycle belongs with D4 and needs a running application to mean anything. D8 — partial, and a real defect found -------------------------------------- `meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` — into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry `User=`; systemd refuses the file, so the packaged unit could never have started. Nothing noticed because nobody had built and installed the RPM. Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service` that a person enables themselves without a password — which is what lets the desktop client install a node without asking for one. It carries ExecReload, so `meshbay-node reload` does not have to stop a service somebody is streaming from, and documents the drop-in for a drive outside the home, RequiresMountsFor included. 798 tests pass; e2e.py still passes end to end. Nothing here was built or launched: no npm, no rpmbuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py218
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py12
2 files changed, 228 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
new file mode 100644
index 0000000..13d194e
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -0,0 +1,218 @@
+"""
+The desktop shell's security contract, pinned by reading its source.
+
+There is no npm on the development machine, so the Electron application cannot
+be installed or launched here. That is stated plainly rather than worked around:
+**nothing below proves the app runs.** What it does prove is that the properties
+the design depends on are present in the source, and it fails if one is removed
+— which is the same treatment `test_downloads.py` gives the three
+browser-specific save paths, for the same reason.
+
+Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md`
+§3. Weak evidence, and the only evidence available without a packaged build; a
+person with an installed client is what confirms the rest.
+"""
+
+from pathlib import Path
+
+import pytest
+
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+MAIN = CLIENT / "src" / "main.js"
+PRELOAD = CLIENT / "src" / "preload.js"
+INDEX = CLIENT / "build" / "index.html"
+
+pytestmark = pytest.mark.skipif(
+ not MAIN.exists(), reason="desktop client sources not present")
+
+
+def _main() -> str:
+ return MAIN.read_text(encoding="utf-8")
+
+
+def _preload() -> str:
+ return PRELOAD.read_text(encoding="utf-8")
+
+
+# ── The renderer is confined ────────────────────────────────────────────────
+
+@pytest.mark.parametrize("setting", [
+ "sandbox: true",
+ "contextIsolation: true",
+ "nodeIntegration: false",
+])
+def test_the_renderer_keeps_its_sandbox(setting):
+ """
+ Electron with these keeps the Chromium renderer sandbox — the strongest
+ available, and the reason "native costs the browser sandbox" is false for
+ this shell. Without contextIsolation the preload's objects are reachable and
+ mutable from page script, which would make the bridge decorative.
+ """
+ assert setting in _main(), f"{setting} is missing from the window"
+
+
+def test_the_interface_is_never_loaded_from_the_hub():
+ """
+ The whole reason this application exists. A shell pointing a WebView at the
+ hub's /app/ is a browser with a different icon and fixes nothing (T3).
+ """
+ source = _main()
+ assert "loadURL(`${SCHEME}://" in source or "loadURL('app://" in source
+ assert "loadURL('http" not in source and 'loadURL("http' not in source
+ assert "loadURL(`http" not in source
+
+
+def test_navigation_away_from_the_package_is_refused():
+ source = _main()
+ assert "will-navigate" in source
+ assert "setWindowOpenHandler" in source
+ assert "event.preventDefault()" in source
+
+
+def test_no_permission_is_granted_to_the_page():
+ """Nothing here needs a camera, a microphone or a location."""
+ assert "setPermissionRequestHandler" in _main()
+ assert "callback(false)" in _main()
+
+
+# ── The custom scheme ───────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("privilege", [
+ "standard: true",
+ "secure: true",
+ "supportFetchAPI: true",
+ "stream: true",
+])
+def test_the_scheme_is_privileged(privilege):
+ """
+ Without `secure` the scheme is not a secure context, the service worker
+ silently refuses to register, and streamed downloads break with no error —
+ the same failure mode as an uncontrolled page, which this codebase has
+ already learned once. `standard` gives a real origin, so IndexedDB survives
+ an update instead of being keyed to something that moves.
+ """
+ assert privilege in _main(), f"{privilege} missing from the scheme privileges"
+
+
+def test_the_protocol_handler_cannot_be_walked_out_of():
+ """
+ The renderer parses decrypted content from nodes, which is
+ attacker-controlled input. A traversal here would hand it the filesystem.
+ """
+ source = _main()
+ assert "path.resolve(UI_DIR" in source
+ assert "startsWith(root + path.sep)" in source
+ assert "status: 404" in source
+
+
+# ── Content Security Policy ─────────────────────────────────────────────────
+
+def test_the_policy_keeps_wasm_unsafe_eval():
+ """
+ The bundle key is Argon2id in WebAssembly. A policy that forbids it does not
+ degrade anything — it locks every user out of their keys.
+ """
+ assert "'wasm-unsafe-eval'" in _directive("script-src")
+
+
+def _policy() -> str:
+ """The meta tag's content, not the file — the comment above it names the
+ same directives and would satisfy a naive search."""
+ import re
+ page = INDEX.read_text(encoding="utf-8")
+ match = re.search(
+ r'http-equiv="Content-Security-Policy"\s+content="([^"]*)"', page)
+ assert match, "no Content-Security-Policy meta tag"
+ return match.group(1)
+
+
+def _directive(name: str) -> str:
+ for part in _policy().split(";"):
+ part = part.strip()
+ if part.startswith(name + " "):
+ return part
+ return ""
+
+
+def test_the_hub_is_reachable_but_never_executable():
+ """
+ connect-src allows the hub's API and its signaling socket. script-src does
+ not include it: nothing the hub returns is ever executed.
+ """
+ connect = _directive("connect-src")
+ assert "https:" in connect and "wss:" in connect
+
+ script = _directive("script-src")
+ assert script, "no script-src directive"
+ assert "https:" not in script, "the hub can serve script under this policy"
+ assert "'unsafe-eval'" not in script.replace("'wasm-unsafe-eval'", "")
+ assert "default-src 'none'" in _policy()
+
+
+# ── The bridge ──────────────────────────────────────────────────────────────
+
+def test_the_bridge_is_the_only_way_in():
+ source = _preload()
+ assert "contextBridge.exposeInMainWorld" in source
+ # Handing the raw ipcRenderer to the page would expose every channel in the
+ # main process, named or not.
+ assert "exposeInMainWorld('meshbay', ipcRenderer" not in source
+ assert "ipcRenderer)" not in source.replace("require('electron');", "")
+
+
+def test_the_renderer_never_names_a_path():
+ """
+ It asks for a dialog and receives an opaque id; the main process holds the
+ handle. A channel that took a path from the renderer and wrote to it would
+ be the whole confinement undone.
+ """
+ source = _preload()
+ assert "save:begin" in source
+ assert "handle.id" in source
+ assert "filePath" not in source, "the preload passes a filesystem path around"
+
+
+def test_the_hub_address_is_not_fetched_synchronously_over_ipc():
+ """
+ `platform.hubBase()` runs while the module graph is loading, before anything
+ can await. Synchronous IPC would block the renderer on every call for a
+ value that cannot change within a run.
+ """
+ source = _preload()
+ assert "--meshbay-hub=" in source
+ assert "sendSync" not in source
+
+
+def test_plain_http_is_refused_except_to_loopback():
+ """Anywhere else it would put the session token on the wire in clear."""
+ source = _main()
+ assert "must be https" in source
+ # The guard is a regular expression, so the dot is escaped in the source.
+ assert "127\\." in source and "localhost" in source
+
+
+# ── One interface, one source ───────────────────────────────────────────────
+
+def test_the_interface_is_copied_not_forked():
+ """
+ §2.7: the hub's static directory is the single source. A silent fork is the
+ only real way to end up maintaining the interface twice, so the copy is
+ generated and the generated tree is not committed.
+ """
+ sync = (CLIENT / "build" / "sync-ui.js").read_text(encoding="utf-8")
+ assert "meshbay-hub" in sync and "static" in sync
+ assert "rmSync" in sync, "a stale file could survive a rebuild"
+
+ gitignore = (CLIENT.parents[1] / ".gitignore").read_text(encoding="utf-8")
+ assert "meshbay-client/ui/" in gitignore, (
+ "the generated copy is committed, which is how a fork begins")
+
+
+def test_the_packaged_page_loads_the_shared_modules():
+ """The app's index.html is its own — the hub's carries a /a/<hash>/ prefix
+ that would point back at the hub — but it must load the same files."""
+ page = INDEX.read_text(encoding="utf-8")
+ for module in ("keyderive.js", "crypto.js", "transport.js", "app.js",
+ "style.css", "argon2.min.js"):
+ assert module in page, f"{module} is not loaded by the packaged page"
+ assert "/a/" not in page, "the packaged page points at the hub's asset prefix"
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
index 38db67f..6c47f7a 100644
--- a/packages/meshbay-hub/tests/test_session_renewal.py
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -28,6 +28,7 @@ broken client.
import json
import shutil
import subprocess
+import re
from pathlib import Path
import pytest
@@ -223,5 +224,12 @@ def test_the_connection_signs_its_offer_with_a_live_token():
assert "await ensureFreshToken()" in connect, (
"the offer is signed with whatever token the effect captured, which is "
"no longer refreshed by a re-run")
- assert "new window.MeshBayTransport('', live)" in connect, (
- "the transport is built with the captured token rather than the live one")
+ # Asserted on the argument, not on the whole call: the first argument is
+ # the hub's base URL and became configurable when the interface started
+ # shipping in a package. Pinning the literal made this fail for a change
+ # that had nothing to do with tokens.
+ built = re.search(r"new window\.MeshBayTransport\(([^)]*)\)", connect)
+ assert built, "the transport is not built in connect()"
+ args = [a.strip() for a in built.group(1).split(",")]
+ assert args[-1] == "live", (
+ f"the transport is built with {args[-1]!r} rather than the live token")