aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_client_version_gate.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_client_version_gate.py')
-rw-r--r--packages/meshbay-hub/tests/test_client_version_gate.py141
1 files changed, 141 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