aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
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}")