summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 14:00:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 14:00:22 +0200
commit53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac (patch)
tree0d38403f86a0b96d5076c6404b471df7d1d41aad /packages/meshbay-hub/tests
parentdee57df42a525cead93fa30b4e7fa38a489d5b11 (diff)
downloadmeshbay-53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac.tar.gz
feat: MNP 3.0 — a transfer needs a lease
Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory and a 2.x peer is refused at the handshake. **The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the rest mean anything.** Browsing a group is never subject to a transfer slot — that is an operator decision and a requirement: a member must be able to browse a group at capacity exactly as they browse an idle one. But "not leased" cannot mean "unbounded", or a client that simply omits `tr` transfers outside every cap and the caps are decoration. A session may now read two distinct files at once without a lease: one because a viewer looks at one file, two so that prefetching the next photo stays possible. A count of files and not a byte budget, because a RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and no size threshold separates them. Thumbnails, posters and cover art never reach this check at all — they resolve out of the node's own cache. It is a fairness control among cooperating clients, in the company of `max_concurrent_streams`, and is not a defence against a member determined to saturate a node's disk. That member is a member, and the answer to them is `member revoke`. **MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The messages are additive; the requirement is not. An opt-in switch would leave a leaseless branch reachable on every node, which is finding C6's lesson — a transport that accepted a bare JWT — one feature later. **The desktop client now checks before it connects.** The SPA is served by the hub and picks up a new client on reload; the application ships its own interface, so an un-updated one would sign in, list groups, and fail every connection with `version_too_old` — a refusal in a protocol vocabulary with nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and says so plainly instead. An unreachable hub is deliberately *not* "too old": a captive portal or a closed laptop must not make starting the application impossible. **Every package is aligned on 0.13.0.** `meshbay-client/package.json` had drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until something compared those numbers, and then load-bearing: an installed client announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant to stop it. That is stated in the code rather than left to be rediscovered; it is acceptable exactly once, because the operator is updating every client, node and hub by hand for this flag day. A new test fails if two packages ever disagree again, and another fails if the hub would refuse the client the tree builds. Node suite 1209 passed, hub suite 861 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_client_version_gate.py141
-rw-r--r--packages/meshbay-hub/tests/test_versions_agree.py74
2 files changed, 215 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py
new file mode 100644
index 0000000..69ca062
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_client_version_gate.py
@@ -0,0 +1,141 @@
+"""
+The desktop client refuses to start when the hub will no longer talk to it.
+
+The SPA is served by the hub, so a browser picks up a new client on reload. The
+desktop application **ships its own interface**, so on a flag day an un-updated
+one can still sign in, still list groups, and then fail every connection with
+`version_too_old` — a refusal in a protocol vocabulary, surfacing as a node that
+will not talk, with nothing anyone can act on. §12.3 of
+~/next/improve-downloads.md named this as the thing that had to exist before
+MNP 3.0 could ship.
+
+`compareVersions` and `refuseIfTooOld` are lifted out of `main.js` **as text**
+and executed against a modelled environment, on the rule this repo follows
+elsewhere: model the environment, never the code under test. The rest of
+`test_desktop_shell.py` can only read the source, because there is no npm here
+to launch Electron with; these two are ordinary functions and can be run.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+MAIN = CLIENT / "src" / "main.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not MAIN.exists(),
+ reason="node or the desktop client sources are not available")
+
+
+def _lift(name: str) -> str:
+ src = MAIN.read_text()
+ cut = src[src.index(name):]
+ return cut[:cut.index("\n}\n") + 2]
+
+
+def _run(tmp_path, *, mine="1.1.0", hub_base="https://hub.example",
+ answer=None, status=200, throws=False):
+ """Drive the gate against one hub.
+
+ `answer` is what `/v1/hub/version` returns; None means the field is absent
+ entirely, which is what an older hub sends.
+ """
+ script = tmp_path / "gate.mjs"
+ script.write_text(f"""
+const out = {{ dialogs: 0, opened: null }};
+const config = {{ hubBase: {json.dumps(hub_base)} }};
+const app = {{ getVersion: () => {json.dumps(mine)} }};
+const dialog = {{
+ showMessageBox: async () => {{ out.dialogs += 1; return {{ response: 0 }}; }},
+}};
+const shell = {{ openExternal: async (u) => {{ out.opened = u; }} }};
+globalThis.fetch = async () => {{
+ if ({json.dumps(throws)}) throw new Error('unreachable');
+ return {{ ok: {json.dumps(status)} === 200,
+ json: async () => ({json.dumps(answer)}) }};
+}};
+""" + _lift("function compareVersions") + _lift("async function refuseIfTooOld") + """
+out.refused = await refuseIfTooOld();
+out.compare = [
+ compareVersions('1.0.0', '1.1.0'),
+ compareVersions('1.1.0', '1.1.0'),
+ compareVersions('1.2.0', '1.1.0'),
+ compareVersions('1.10.0', '1.9.0'),
+ compareVersions('1.1', '1.1.0'),
+ compareVersions('nonsense', '1.1.0'),
+];
+console.log(JSON.stringify(out));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+OK = {"client": {"minimum": "1.1.0", "recommended": "1.1.0"}}
+
+
+# ── the comparison ──────────────────────────────────────────────────────────
+
+def test_versions_compare_by_number_and_not_by_string(tmp_path):
+ """`1.10.0` is newer than `1.9.0`, which string comparison gets backwards —
+ and that mistake locks out exactly the people who did update."""
+ assert _run(tmp_path, answer=OK)["compare"] == [-1, 0, 1, 1, 0, 0]
+
+
+# ── the gate ────────────────────────────────────────────────────────────────
+
+def test_a_client_older_than_the_minimum_is_stopped(tmp_path):
+ out = _run(tmp_path, mine="1.0.0", answer=OK)
+ assert out["refused"] is True
+ assert out["dialogs"] == 1, "it stopped without saying why"
+ assert out["opened"] == "https://hub.example", (
+ "the offer to download the update led nowhere")
+
+
+def test_a_current_client_starts_normally(tmp_path):
+ out = _run(tmp_path, mine="1.1.0", answer=OK)
+ assert out["refused"] is False
+ assert out["dialogs"] == 0
+
+
+def test_a_newer_client_is_not_stopped(tmp_path):
+ """A development build ahead of the hub is not a reason to refuse to open
+ the application."""
+ assert _run(tmp_path, mine="2.0.0", answer=OK)["refused"] is False
+
+
+def test_an_unreachable_hub_is_not_too_old(tmp_path):
+ """A hub that is down, a laptop with no network, a captive portal. Treating
+ any of those as "you are out of date" would make an offline start
+ impossible for ever, and would do it at the worst moment."""
+ assert _run(tmp_path, mine="1.0.0", throws=True)["refused"] is False
+ assert _run(tmp_path, mine="1.0.0", status=503, answer=OK)["refused"] is False
+
+
+def test_a_hub_that_states_no_minimum_stops_nothing(tmp_path):
+ """An older hub answers without the field. Absent must read as "no opinion",
+ never as a refusal."""
+ assert _run(tmp_path, mine="0.0.1", answer={"hub": "1.2.3"})["refused"] is False
+
+
+def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path):
+ """There is nothing to ask, and the first-run screen is where the address
+ gets typed."""
+ assert _run(tmp_path, mine="0.0.1", hub_base="", answer=OK)["refused"] is False
+
+
+# ── where it is called ──────────────────────────────────────────────────────
+
+def test_the_gate_runs_before_the_window_is_built():
+ """A window that opens and then cannot connect is the failure this
+ replaces, so the order is the whole point."""
+ src = MAIN.read_text()
+ ready = src[src.index("app.whenReady().then("):]
+ ready = ready[:ready.index("createWindow();")]
+ assert "await refuseIfTooOld()" in ready, (
+ "the version check does not run before the window is created")
+ assert "app.quit()" in ready
diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py
new file mode 100644
index 0000000..4466c93
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_versions_agree.py
@@ -0,0 +1,74 @@
+"""
+Every package in this repository carries the same version.
+
+They are built, deployed and updated together — hub, node, common and the
+desktop client — so a version that differs is not a statement about that
+package, it is a mistake nobody has noticed yet.
+
+**Found on 2026-09-09, on the MNP 3.0 flag day.** `meshbay-client`'s
+`package.json` had drifted to `1.0.0` while every Python package was on
+`0.12.0`. That was invisible until the hub started publishing a minimum client
+version and the client started comparing itself against it — at which point an
+installed client announcing `1.0.0` sorted *above* a minimum of `0.13.0` and
+walked straight through the gate meant to stop it. A version nobody reads is
+free to be wrong; the moment something compares it, it is load-bearing.
+"""
+
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[3]
+PACKAGES = ROOT / "packages"
+
+
+def _python_versions() -> dict[str, str]:
+ found = {}
+ for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")):
+ m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M)
+ if m:
+ found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1)
+ for init in sorted(PACKAGES.glob("*/src/*/__init__.py")):
+ m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M)
+ if m:
+ found[f"{init.parent.name}/__init__.py"] = m.group(1)
+ return found
+
+
+def _client_version() -> str | None:
+ pkg = PACKAGES / "meshbay-client" / "package.json"
+ if not pkg.exists():
+ return None
+ return json.loads(pkg.read_text()).get("version")
+
+
+@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present")
+def test_every_package_carries_the_same_version():
+ versions = _python_versions()
+ assert versions, "no package versions found at all — has the layout moved?"
+ client = _client_version()
+ if client is not None:
+ versions["meshbay-client/package.json"] = client
+ distinct = sorted(set(versions.values()))
+ assert len(distinct) == 1, (
+ "packages disagree about the version: "
+ + ", ".join(f"{k}={v}" for k, v in sorted(versions.items())))
+
+
+@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present")
+def test_the_hub_will_not_refuse_the_client_it_ships_with():
+ """`MIN_CLIENT_VERSION` is compared against a client's own version, so a
+ minimum above the version being built would lock out the very build being
+ released — the one failure this field can cause that nobody would think to
+ test for by hand."""
+ from meshbay_hub.api.hub import MIN_CLIENT_VERSION
+
+ client = _client_version()
+ if client is None:
+ pytest.skip("desktop client sources not present")
+ as_numbers = lambda v: [int(n) for n in v.split(".")] # noqa: E731
+ assert as_numbers(MIN_CLIENT_VERSION) <= as_numbers(client), (
+ f"the hub requires client {MIN_CLIENT_VERSION} but this tree builds "
+ f"{client}")