diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:27:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:27:38 +0200 |
| commit | 005f3cf83559eaf84fd307584477c40676be1dd3 (patch) | |
| tree | ad1668e1e99f3e0cb03f559cfdec6d27cf742d5b /packages/meshbay-hub/tests | |
| parent | 36ef1b76dce56a40650f890156eb137536ef015d (diff) | |
| download | meshbay-005f3cf83559eaf84fd307584477c40676be1dd3.tar.gz | |
fix(client): degrade against a node still speaking MNP 1.0
The SPA is served by the hub, so deploying the hub puts this client in front of
every node — including the ones not updated yet. That window is the normal
state for as long as an operator takes, and for a node someone else runs it may
be indefinite. Three controls were broken across it, and the failure mode is
quiet: an unknown message type is logged by the node and never answered, so the
click produces a thirty-second wait ending in a timeout with nothing on screen
to say the node simply cannot do this.
Files' Upload button read `root.writable`, which a 1.0 node does not send — it
says `upload`, the same answer under the older name. The button disappeared on
every un-upgraded node. It reads both now, and still respects an explicit
`writable: false` rather than falling through to the legacy flag.
The per-app folder pickers spoke `app_directories`. Videos, Music and Photos
each had their own message before that and those still work, so the page
chooses by version: an operator on an older node keeps the ability they had.
`video_root` and `audio_root` hold one folder, so several are refused with a
reason rather than stored as the first and silently truncated.
Root management — writable, removable, eject, plug — has no older equivalent to
route to, so the table goes read-only with a line saying why and pointing at
the `meshbay-node root` commands. Chat's two settings are new with nothing
before them and are hidden the same way.
None of this was inferred from a payload's shape: `_checkNodeVersion` already
parsed the node's version and threw it away, and it is kept now. Coupling a
capability to whether some field happens to be present is how a flag flips
because an unrelated payload changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_app_settings_plugin.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py | 175 |
2 files changed, 180 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py index 00a07b0..1837c28 100644 --- a/packages/meshbay-hub/tests/test_app_settings_plugin.py +++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py @@ -226,9 +226,12 @@ def test_the_page_performs_exactly_one_app_specific_operation(): # The page's own settings, which belong to no app: which apps are enabled # at all, and how hard the node works watching its disk. page_level = {"setAppsEnabled", "setScanSettings"} - assert calls - page_level == {"setAppDirectories"}, ( + # Both are the same generic operation; the second is what a node too old + # for it understands, chosen by version rather than by app. + generic = {"setAppDirectories", "setAppDirectoriesLegacy"} + assert calls - page_level == generic, ( f"the settings page performs app-specific operations: " - f"{sorted(calls - page_level - {'setAppDirectories'})}") + f"{sorted(calls - page_level - generic)}") # ── The apps read a list ──────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py new file mode 100644 index 0000000..63390af --- /dev/null +++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py @@ -0,0 +1,175 @@ +""" +The page ships before the nodes do. + +The SPA is served by the hub, so deploying the hub puts this version of the +client in front of *every* node, including the ones still running MNP 1.0. That +window is not a corner case — it is the normal state for as long as it takes an +operator to update, and for a node someone else runs it may be indefinite. + +The failure mode is specific and quiet: a node logs an unknown message type and +sends **nothing back**, so a control that speaks MNP 1.1 to it produces a +thirty-second wait ending in a timeout, with nothing on screen to say the node +simply cannot do this. Three of them were like that before these tests: + +* Files' Upload button read `root.writable`, which a 1.0 node does not send — + it says `upload`. The button disappeared on every un-upgraded node. +* The shared-directories toggles, eject and plug have no older equivalent at + all. +* The per-app folder pickers spoke `app_directories`, where a 1.0 node + understands `video_root` / `audio_root` / `photo_roots`. + +Source-reading, like the other SPA guards. What it cannot check is that the +degraded path is pleasant; what it does check is that each of the three exists. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +TRANSPORT = STATIC / "transport.js" +FILES_APP = STATIC / "files-app.js" +GROUP_PAGE = STATIC / "group-page.js" +GROUP_SETTINGS = STATIC / "group-settings.js" + +pytestmark = pytest.mark.skipif(not TRANSPORT.exists(), + reason="SPA sources unavailable") + + +def _component(source: str, name: str) -> str: + start = source.index(f"\nfunction {name}(") + end = source.find("\nfunction ", start + 1) + return source[start:end if end != -1 else len(source)] + + +# ── Knowing which node you are talking to ─────────────────────────────────── + +def test_the_client_keeps_the_version_it_checked(): + """ + `_checkNodeVersion` parsed the node's version and threw it away, so nothing + downstream could ask. Refusing to connect is not the only thing a version + is good for. + """ + source = TRANSPORT.read_text(encoding="utf-8") + assert "this._nodeVersion = String(reply.v" in source + assert "get supportsAppOps()" in source + + +def test_the_capability_reads_the_version_rather_than_guessing(): + """ + Inferring it from whether some field happens to be present is how two + unrelated things end up coupled — the flag would flip because a payload + changed shape for another reason entirely. + """ + source = TRANSPORT.read_text(encoding="utf-8") + getter = source[source.index("get supportsAppOps()"):] + getter = getter[:getter.index("\n }") + 4] + assert "_nodeVersion" in getter + assert "1" in getter, "no version comparison in the capability check" + + +# ── The three degraded paths ──────────────────────────────────────────────── + +def test_the_upload_button_reads_the_older_flag_too(): + """ + A 1.0 node's roots carry `upload`; `writable` is the same answer renamed. + Reading only the new name hides the Upload button on every node that has + not been updated, which on the day the page ships is all of them. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + decl = page[page.index("const currentRootWritable"):] + decl = decl[:decl.index(";") + 1] + assert "currentRoot.upload" in decl, ( + "the Upload button ignores the flag an older node actually sends") + assert "writable !== undefined" in decl, ( + "a root that is explicitly writable=false must stay read-only — " + "falling through to `upload` there would reopen it") + + +def test_app_directories_fall_back_to_the_three_older_messages(): + """ + Videos, Music and Photos each had their own message before the generic op, + and those still work — so an operator on an un-upgraded node keeps the + ability they had rather than being handed a control that times out. + """ + source = TRANSPORT.read_text(encoding="utf-8") + legacy = source[source.index("async setAppDirectoriesLegacy("):] + legacy = legacy[:legacy.index("\n async ", 1)] + for call in ("setPhotoRoots", "setVideoRoot", "setAudioRoot"): + assert call in legacy, f"{call} is not reachable on the older path" + + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + assert "transport.supportsAppOps" in panel, ( + "the settings page sends the 1.1 message unconditionally") + + +def test_the_older_path_refuses_what_it_cannot_carry(): + """ + `video_root` and `audio_root` hold one folder. Sending several would store + the first and drop the rest silently, which is worse than refusing — the + operator would see a saved setting that is not what they chose. + """ + source = TRANSPORT.read_text(encoding="utf-8") + legacy = source[source.index("async setAppDirectoriesLegacy("):] + legacy = legacy[:legacy.index("\n async ", 1)] + assert "clean.length > 1" in legacy + assert "throw new Error" in legacy + + +def test_root_management_is_read_only_against_an_older_node(): + """ + Unlike the app directories, `writable`, `removable`, eject and plug have no + older equivalent to route to. The controls are shown without being + offered, with the reason, rather than accepting a click that goes nowhere. + """ + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + table_call = panel[panel.index("<${SharedDirectoriesTable}"):] + table_call = table_call[:table_call.index("/>")] + assert "readOnly=" in table_call + assert "nodeSupportsAppOps" in table_call + assert "settings_node.roots_node_too_old" in panel, ( + "nothing says why the controls are inert") + + +def test_chat_settings_are_hidden_rather_than_routed(): + """ + Chat's directory and link-preview switch are new in 1.1 with nothing + before them, so there is no older message to fall back to. + """ + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + assert "settings_node.app_node_too_old" in panel + + +# ── Reading an older node's handshake ─────────────────────────────────────── + +def test_the_ack_is_read_in_both_shapes(app=None): + """ + A 1.0 ack has `video_root` and no `video_directories`, and no + `chat_link_preview` at all. Reading a missing plural as "nothing + configured" empties a working Videos tab; reading a missing switch as off + silently changes what a group's chat does. + """ + page = GROUP_PAGE.read_text(encoding="utf-8") + block = page[page.index("setAppDirectories({"):] + block = block[:block.index("setNodeSupportsAppOps")] + for legacy in ("ack.video_root", "ack.audio_root", "ack.photo_roots"): + assert legacy in block, f"{legacy} is not read as a fallback" + assert "ack.chat_link_preview !== false" in page, ( + "an absent link-preview switch must read as on, not off") + + +def test_the_attachment_root_falls_back_to_the_older_answer(): + """ + A 1.0 node's roots carry no `writable`, so nothing looks writable and the + paperclip would vanish. The group-wide `member_upload` flag is the only + answer such a node gives, and it is what gets used. + """ + page = GROUP_PAGE.read_text(encoding="utf-8") + block = page[page.index("const writableRoots"):] + block = block[:block.index("const commonProps")] + assert "legacyNode" in block and "memberUpload" in block + assert "writable === undefined" in block |