From 2c6921aa2c35ffd41b6c453e6700574ef631ba2c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 12:57:58 +0200 Subject: fix(client): the page names node operations, and the app confirms what widens the node node:call is replaced by named operations with checked arguments; hosting a group, sharing an unpicked folder, key rotation, denylist clearing and a change of node account are confirmed by a native dialog. Every channel checks its sender, secrets:get/set/clear are gone, node:start writes the app's own hub. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-hub/tests/test_connect_never_hangs.py | 4 +- packages/meshbay-hub/tests/test_desktop_shell.py | 81 +++++++++++++++++++++- packages/meshbay-hub/tests/test_indexing_dock.py | 4 +- 3 files changed, 82 insertions(+), 7 deletions(-) (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py index 5680877..1fb4d3d 100644 --- a/packages/meshbay-hub/tests/test_connect_never_hangs.py +++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py @@ -69,7 +69,7 @@ def test_a_timed_out_gathering_still_sends_the_offer(): @pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") def test_every_hub_call_from_the_client_has_a_deadline(): source = MAIN.read_text(encoding="utf-8") - block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0] assert "AbortSignal.timeout" in block, ( "a hub that accepts the connection and says nothing holds this for " "as long as the OS allows") @@ -88,5 +88,5 @@ def test_the_deadline_outlasts_the_hubs_own_longest_call(): @pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") def test_a_timeout_says_so_rather_than_saying_fetch_failed(): source = MAIN.read_text(encoding="utf-8") - block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0] assert "TimeoutError" in block and "did not answer" in block diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index b9b3319..732ba07 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -18,7 +18,7 @@ import re from pathlib import Path import pytest -from spa_source import transport_files +from spa_source import STATIC, transport_files CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" MAIN = CLIENT / "src" / "main.js" @@ -378,6 +378,81 @@ def test_plain_http_is_refused_except_to_loopback(): assert "127\\." in source and "localhost" in source +def test_every_channel_answers_only_the_packaged_page(): + """ + A channel registered straight on `ipcMain` would answer any frame that got + hold of a bridge; `handle()` checks the sender first. So no channel may be + registered any other way, and every one the preload names is registered. + """ + source = _main() + wrapper = source.split("function handle(channel, fn) {", 1)[1].split("\n}\n", 1)[0] + assert "fromOurPage(event)" in wrapper + assert source.count("ipcMain.handle(") == 1, "a channel skips the sender check" + registered = set(re.findall(r"\n handle\('([\w:-]+)'", source)) + invoked = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", _preload())) + assert invoked <= registered, f"the preload names unregistered channels: {invoked - registered}" + + +def test_the_page_cannot_read_or_replace_the_secret_store(): + """It holds the device's hub key (§8.2), which the page is never handed.""" + for channel in ("secrets:get", "secrets:set", "secrets:clear"): + assert channel not in _main() and channel not in _preload(), channel + + +def _node_ops() -> dict[str, str]: + """Each operation of `NODE_OPS` in main.js, with its source.""" + block = _main().split("const NODE_OPS = {", 1)[1].split("\n };\n", 1)[0] + parts = re.split(r"\n (\w+):", "\n" + block) + return dict(zip(parts[1::2], parts[2::2])) + + +def test_the_page_names_node_operations_not_routes(): + """ + The page used to hand the main process a method and a path, which made the + whole loopback API the page's. Every operation the interface calls exists, + and none exists that it does not call — an unused one is surface. + """ + assert "node:call" not in _main() and "node:call" not in _preload() + used: set[str] = set() + for path in STATIC.glob("*.js"): + used |= set(re.findall(r"(?:node\.op|nodeOp)\('(\w+)'", path.read_text(encoding="utf-8"))) + defined = set(_node_ops()) + assert used, "no node operation found in the interface" + assert used <= defined, f"called but not defined: {used - defined}" + assert defined <= used, f"defined but never called: {defined - used}" + + +@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "initGek", "clearDenylist"]) +def test_what_widens_the_node_is_confirmed_natively(op): + """Sharing a folder, hosting a group, replacing the key, re-admitting a + revoked subject: asked by a dialog the main process draws, which a script in + the page cannot answer. A folder chosen in the native picker is its own + confirmation.""" + body = _node_ops()[op] + assert "confirmOrRefuse(" in body or "confirmFolder(" in body + + +def test_the_native_dialogs_are_worded_in_every_language(): + """The words come from the interface's catalogues; a key missing from one is + a dialog that shows its key.""" + keys = set(re.findall(r"(?:confirmOrRefuse|nativeText)\('([\w.]+)'", _main())) + assert "native.declined" in keys and "dialog.ok" in keys + for catalogue in (STATIC / "locales").glob("*.js"): + text = catalogue.read_text(encoding="utf-8") + missing = [k for k in keys if f"'{k}':" not in text] + assert not missing, f"{catalogue.name} lacks {missing}" + + +def test_the_node_is_never_pointed_at_a_hub_the_page_names(): + """`node:start` writes the hub this application is signed in to, and a + username that cannot break out of a TOML string.""" + source = _main() + assert "opts.hubUrl" not in source + provision = source.split("async function provisionFromRequest(opts) {", 1)[1] + provision = provision.split("\n }\n", 1)[0] + assert "config.hubBase" in provision and "USERNAME_RE.test(username)" in provision + + # ── One interface, one source ─────────────────────────────────────────────── def test_the_interface_is_copied_not_forked(): @@ -420,7 +495,7 @@ def test_automatic_saving_never_opens_a_dialog_for_want_of_a_folder(): system Downloads folder is the answer when there is no other. """ source = _main() - begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0] + begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0] assert "defaultDownloadDir()" in begin, ( "the automatic path has no destination when no folder was chosen") assert "app.getPath('downloads')" in source @@ -430,7 +505,7 @@ def test_a_chosen_folder_that_has_gone_is_not_silently_replaced(): """Someone who picked an external drive should be told it is not there, not find the film in their home directory a week later.""" source = _main() - begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0] + begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0] assert "config.downloadDir && !chosen" in begin, ( "a chosen-but-missing folder falls through to the default instead of asking") assert "showSaveDialog" in begin diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py index 93803a0..e960950 100644 --- a/packages/meshbay-hub/tests/test_indexing_dock.py +++ b/packages/meshbay-hub/tests/test_indexing_dock.py @@ -190,5 +190,5 @@ def test_the_transport_passes_the_new_counters_through(): def test_the_dock_polls_the_node_wide_route_not_one_group(): source = DOCK.read_text(encoding="utf-8") - assert "'/api/index-status'" in source - assert "/index-status`" not in source + assert "node.op('indexStatus')" in source + assert "groupIndexStatus" not in source -- cgit v1.2.3