aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 18:05:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 18:05:14 +0200
commit35a7764db3f58a93c32206cb3ce74bb2f03967e7 (patch)
treed2acb2a07ee6dc2f4241fcc785c2860d57263892 /packages/meshbay-hub/tests
parent998f9c69308ee88fac36cfb77dfb6d07c6fa926a (diff)
downloadmeshbay-35a7764db3f58a93c32206cb3ce74bb2f03967e7.tar.gz
feat(hub): open, create and join invitation links in the interface
#/invite takes the link out of the address on load and keeps it in the tab through registration and sign-in; joining is one click, only the ticket goes to the hub, and the code goes only to the node the link names once it has signed its challenge. Members tab gains "Invite by link" (shared e-mail box, pending list, cancel both halves); home page takes a pasted link. Browser probe drives the real app, signed out and in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/invite_link_probe.py181
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py194
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_flow.py61
-rw-r--r--packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py6
4 files changed, 441 insertions, 1 deletions
diff --git a/packages/meshbay-hub/tests/harness/invite_link_probe.py b/packages/meshbay-hub/tests/harness/invite_link_probe.py
new file mode 100644
index 0000000..97ee893
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/invite_link_probe.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""
+An invitation link, opened in the real application.
+
+`test_invite_link_client.py` runs the link's functions one by one. What only the
+running application can show is how they meet the router, the sign-in state and
+the hub calls: that the code is out of the address before anything routes on
+it, that a signed-out reader is sent to register with the invitation kept, and
+that a signed-in reader is shown the invitation, joins with one click and lands
+on the group — with the code never in a request to the hub.
+
+Loads the shipped `app.js` in a real browser with `fetch` stubbed, twice:
+
+ signed_out — a link, no session
+ signed_in — the same link, a session; then the Join button is clicked
+
+ invite_link_probe.py
+
+Prints JSON: one object per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8771
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+TICKET = "AbCdEfGhIjKlMnOpQr-_12"
+NODE = "A" * 43
+CODE = "K7P2-9WQX"
+LINK = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={NODE}&c={CODE}"
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script type="module">
+const CASE = new URLSearchParams(location.search).get('case');
+const realFetch = window.fetch.bind(window);
+const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) });
+const calls = [];
+const json = (body, status = 200) => ({
+ ok: status < 400, status, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body),
+});
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push({ url: u, body: init.body ? String(init.body) : '' });
+ if (u.includes('/v1/users/me/preferences')) return json({});
+ if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' });
+ if (u.includes('/v1/groups/mine')) return json({ groups: [] });
+ if (u.includes('/v1/invite-links/preview')) return json({
+ group_id: '__GROUP__', group_name: 'Some Group', inviter: 'the-owner',
+ expires_at: '2099-01-01T00:00:00+00:00', already_member: false });
+ if (u.includes('/v1/invite-links/redeem')) return json({
+ group_id: '__GROUP__', group_name: 'Some Group' });
+ if (u.includes('/nodes')) return json({ nodes: [] });
+ return json({});
+};
+if (CASE === 'signed_in') {
+ localStorage.setItem('mb_auth', JSON.stringify({
+ username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref',
+ role: 'user' }));
+} else {
+ localStorage.removeItem('mb_auth');
+}
+sessionStorage.clear();
+history.replaceState(null, '', '/?case=' + CASE + '__LINK__');
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const text = () => document.getElementById('app').innerText;
+(async () => {
+ const out = { case: CASE };
+ try {
+ await import('/app.js');
+ await wait(1500);
+ out.hash_after_load = location.hash;
+ out.pending = JSON.parse(sessionStorage.getItem('mb.pendingInvite') || 'null');
+ out.text_after_load = text().slice(0, 600);
+ if (CASE === 'signed_out') {
+ const reg = [...document.querySelectorAll('a')]
+ .find((a) => a.getAttribute('href') === '#/register');
+ out.register_link = Boolean(reg);
+ if (reg) { reg.click(); await wait(500); }
+ out.hash_after_click = location.hash;
+ out.pending_after_click = Boolean(sessionStorage.getItem('mb.pendingInvite'));
+ } else {
+ // By its role, not its label: the browser's language picks the label.
+ const join = document.querySelector('.login-card button.btn-primary');
+ out.join_button = Boolean(join);
+ if (join) { join.click(); await wait(1500); }
+ out.hash_after_click = location.hash;
+ out.redeem_bodies = calls.filter((c) => c.url.includes('/redeem')).map((c) => c.body);
+ }
+ out.code_in_a_hub_request = calls.some(
+ (c) => c.url.includes('__CODE__') || c.body.includes('__CODE__'));
+ out.hub_calls = calls.map((c) => c.url.replace(/^https?:\/\/[^/]+/, ''));
+ } catch (e) {
+ out.error = String(e && e.stack || e);
+ }
+ post(out);
+})();
+</script></body></html>
+""".replace("__GROUP__", GROUP).replace("__LINK__", LINK).replace("__CODE__", CODE)
+
+
+class H(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ body = self.rfile.read(length)
+ if self.path == "/log":
+ RECORDS.append(json.loads(body.decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ return
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else (
+ "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream")
+ self._send(asset.read_bytes(), ctype)
+
+
+def _run(case: str) -> dict | None:
+ before = len(RECORDS)
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/?case={case}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if len(RECORDS) > before:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ return RECORDS[before] if len(RECORDS) > before else None
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ results = [_run("signed_out"), _run("signed_in")]
+ if not all(results):
+ print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr)
+ return 1
+ print(json.dumps(results, indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
new file mode 100644
index 0000000..c5c0101
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -0,0 +1,194 @@
+"""
+The browser's half of an invitation link (docs/MESHBAY_DESIGN.md §3.4).
+
+Three properties, each run against the shipped code rather than restated:
+
+- **one shape.** The hub writes a link when it mails one (`invite_url`), the
+ page writes one when it shows one (`buildInviteLink`), and the page reads both
+ (`parseInvite`). A disagreement is a link that opens on nothing.
+- **the code leaves the address at once, and the tab keeps it.** Run in node
+ against a stand-in `window`: `captureFromLocation` rewrites the address and
+ stores what it read, and a malformed link is cleaned out without being kept.
+- **the code goes to the node the link names, and to no other.** The transport's
+ `_linkJoinRefusal` is what stops it; this runs it.
+
+The rest are read from the source, which is the evidence there is for them: the
+hub is never handed the code except when the inviter ticked the mail box, the
+capture is the first thing `app.js` loads, and signing out forgets the
+invitation.
+"""
+
+import base64
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from meshbay_hub import mail as mail_mod
+from meshbay_hub.api import invite_links
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+LINK_JS = STATIC / "invite-link.js"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+TICKET = "AbCdEfGhIjKlMnOpQr-_12"
+NODE_PK_STD = base64.b64encode(bytes(range(32))).decode() # has '+', '/', '='
+CODE = "K7P2-9WQX"
+
+
+def _module_body() -> str:
+ """invite-link.js with its import, its exports and its load-time capture
+ removed — the functions as shipped, runnable against a stand-in window."""
+ src = LINK_JS.read_text(encoding="utf-8")
+ src = re.sub(r"^import .*?;\n", "", src, flags=re.M)
+ src = src.replace("export function", "function")
+ tail = "\ncaptureFromLocation();\nwindow.addEventListener('hashchange', captureFromLocation);\n"
+ assert src.endswith(tail), "invite-link.js no longer ends with its load-time capture"
+ return src[: -len(tail)]
+
+
+def _run(tmp_path, script: str):
+ harness = tmp_path / "h.js"
+ harness.write_text(script)
+ out = subprocess.run(["node", str(harness)], capture_output=True, text=True, timeout=60)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+_WINDOW = r"""
+const store = new Map();
+globalThis.sessionStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+};
+const replaced = [];
+globalThis.window = {
+ location: { hash: '', pathname: '/', search: '' },
+ history: { replaceState: (_s, _t, url) => replaced.push(url) },
+ addEventListener() {},
+};
+const platform = { hubOrigin: () => 'https://hub.example' };
+"""
+
+
+def test_one_shape_between_the_hub_and_the_page(tmp_path, monkeypatch):
+ monkeypatch.setattr(mail_mod, "_hub_url", "https://hub.example")
+ n = NODE_PK_STD.replace("+", "-").replace("/", "_").rstrip("=")
+ from_hub = invite_links.invite_url(GROUP, TICKET, n, CODE)
+ got = _run(tmp_path, _WINDOW + _module_body() + f"""
+ const fields = {{ g: '{GROUP}', t: '{TICKET}',
+ n: nodePkForLink('{NODE_PK_STD}'), c: '{CODE}' }};
+ process.stdout.write(JSON.stringify({{
+ parsed: parseInvite({json.dumps(from_hub)}),
+ built: buildInviteLink('https://hub.example', fields),
+ back: nodePkFromLink(fields.n),
+ lower: parseInvite({json.dumps(from_hub.replace(CODE, CODE.lower()))}),
+ }}));
+ """)
+ assert got["parsed"] == {"g": GROUP, "t": TICKET, "n": n, "c": CODE}
+ assert got["built"] == from_hub
+ assert got["back"] == NODE_PK_STD, "the key the transport compares must come back exact"
+ assert got["lower"]["c"] == CODE
+
+
+@pytest.mark.parametrize("tamper", [
+ lambda u: u.replace("v=1", "v=2"),
+ lambda u: u.replace(CODE, "K7P2-9WQ"),
+ lambda u: u.replace(CODE, "K7P2-9WQX<script>"),
+ lambda u: u.replace(TICKET, TICKET + "x"),
+ lambda u: u.replace(GROUP, "../../admin"),
+ lambda u: u.replace("&n=", "&m="),
+])
+def test_anything_but_that_shape_is_not_an_invitation(tmp_path, tamper):
+ good = f"https://hub.example/#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}"
+ got = _run(tmp_path, _WINDOW + _module_body()
+ + f"process.stdout.write(JSON.stringify(parseInvite({json.dumps(tamper(good))})));")
+ assert got is None
+
+
+def test_the_code_leaves_the_address_and_stays_in_the_tab(tmp_path):
+ good = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}"
+ got = _run(tmp_path, _WINDOW + _module_body() + f"""
+ window.location.hash = {json.dumps(good)};
+ const first = captureFromLocation();
+ const kept = loadPending();
+ window.location.hash = '#/invite?v=1&g=nope';
+ captureFromLocation();
+ const afterBad = loadPending();
+ clearPending();
+ process.stdout.write(JSON.stringify({{
+ first: Boolean(first), replaced, kept, afterBad, cleared: loadPending(),
+ }}));
+ """)
+ assert got["first"] is True
+ assert got["replaced"] == ["/#/invite", "/#/invite"], (
+ "both the good link and the malformed one must be taken out of the address")
+ assert got["kept"]["c"] == CODE and got["kept"]["g"] == GROUP
+ assert got["afterBad"]["t"] == TICKET, "a malformed link must not replace a good one"
+ assert got["cleared"] is None
+
+
+def test_a_link_code_goes_to_the_node_the_link_names_and_no_other(tmp_path):
+ src = (STATIC / "transport.js").read_text(encoding="utf-8")
+ fn = re.search(r"^function _linkJoinRefusal\(.*?^\}", src, re.M | re.S)
+ assert fn, "transport.js no longer has _linkJoinRefusal"
+ got = _run(tmp_path, fn.group(0) + """
+ const r = (...a) => { const e = _linkJoinRefusal(...a); return e ? e.reason : null; };
+ process.stdout.write(JSON.stringify([
+ r('KEY', 'K7P2-9WQX', 'KEY', true),
+ r('KEY', 'K7P2-9WQX', 'OTHER', true),
+ r('KEY', 'K7P2-9WQX', 'KEY', false),
+ r(undefined, 'K7P2-9WQX', 'OTHER', false),
+ r('KEY', null, 'OTHER', false),
+ ]));
+ """)
+ assert got == [None, "link_other_node", "link_node_unproved", None, None]
+
+
+# ── Read from the source ─────────────────────────────────────────────────────
+
+def _code(name: str) -> str:
+ """The file without its comments — prose about the code is not the code."""
+ src = (STATIC / name).read_text(encoding="utf-8")
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return "\n".join(line for line in src.splitlines()
+ if not line.strip().startswith("//"))
+
+
+def test_the_capture_is_the_first_thing_the_app_loads():
+ imports = re.findall(r"^import .*? from '([^']+)';", _code("app.js"), re.M | re.S)
+ assert imports and imports[0] == "./invite-link.js"
+
+
+def test_the_invitation_page_never_sends_the_code_to_the_hub():
+ page = _code("invite-page.js")
+ assert "inv.t" in page, "the check below is looking at the wrong names"
+ assert not re.search(r"\binv\.c\b|\binv\[.c.\]", page), (
+ "invite-page.js reads the code; only the ticket is its to send")
+
+
+def test_the_members_tab_sends_the_code_only_for_the_mail():
+ settings = _code("group-settings.js")
+ sends = [m.start() for m in re.finditer(r"code: node\.code", settings)]
+ assert len(sends) == 1
+ before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]]
+ assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks"
+
+
+def test_signing_out_forgets_the_invitation():
+ app = _code("app.js")
+ logout = app[app.index("logout: () => {"):]
+ logout = logout[:logout.index("},")]
+ assert "clearPending()" in logout
+
+
+def test_the_group_page_moves_on_from_another_host():
+ page = _code("group-page.js")
+ loop = page[page.index("for (const n of nodesData.nodes)"):]
+ loop = loop[:loop.index("if (!transport)")]
+ assert "link_other_node" in loop
diff --git a/packages/meshbay-hub/tests/test_invite_link_flow.py b/packages/meshbay-hub/tests/test_invite_link_flow.py
new file mode 100644
index 0000000..42f461c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_link_flow.py
@@ -0,0 +1,61 @@
+"""
+An invitation link, opened in the real application (harness/invite_link_probe.py).
+
+The functions behind a link are tested one by one in
+`test_invite_link_client.py`; this is where they meet the router, the sign-in
+state and the hub. Two readers: one with no account, who must be sent to
+register with the invitation kept, and one signed in, who must be shown it,
+join with one click and land on the group. For both, the code never appears in
+a request to the hub — it is the node's, and the hub is only handed the ticket.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "invite_link_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = {c["case"]: c for c in json.loads(proc.stdout)}
+ for c in out.values():
+ assert "error" not in c, c["error"]
+ return out
+
+
+@pytest.mark.parametrize("case", ["signed_out", "signed_in"])
+def test_the_code_is_out_of_the_address_and_kept_in_the_tab(cases, case):
+ c = cases[case]
+ assert c["hash_after_load"] == "#/invite"
+ assert c["pending"] and c["pending"]["c"] == "K7P2-9WQX"
+
+
+@pytest.mark.parametrize("case", ["signed_out", "signed_in"])
+def test_the_code_never_reaches_the_hub(cases, case):
+ assert cases[case]["code_in_a_hub_request"] is False
+
+
+def test_a_reader_with_no_account_is_sent_to_register_with_the_invitation_kept(cases):
+ c = cases["signed_out"]
+ assert c["register_link"] and c["hash_after_click"] == "#/register"
+ assert c["pending_after_click"] is True
+ assert not any("/invite-links/" in u for u in c["hub_calls"]), (
+ "nothing about the invitation is asked of the hub before sign-in")
+
+
+def test_a_signed_in_reader_joins_with_one_click_and_lands_on_the_group(cases):
+ c = cases["signed_in"]
+ assert "the-owner" in c["text_after_load"] and "Some Group" in c["text_after_load"]
+ assert c["join_button"]
+ assert c["redeem_bodies"] == ['{"ticket":"AbCdEfGhIjKlMnOpQr-_12"}']
+ assert c["hash_after_click"] == "#/group/0f8fad5b-d9cb-469f-a165-70867728950e"
diff --git a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
index 547f7d4..0343ee2 100644
--- a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
+++ b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
@@ -56,5 +56,9 @@ def test_a_signed_in_person_on_the_form_is_sent_home_without_a_history_entry(app
assert "const onAuthForm = route === '/login' || route === '/register';" in app_body
effect = app_body[app_body.index("if (user && onAuthForm)"):]
effect = effect[:effect.index("\n")]
- assert "window.location.replace('#/')" in effect, (
+ # `replace`, whichever the destination: home, or the invitation that sent
+ # them to sign in (invite-link.js). Assigning the hash would leave the form
+ # one Back away.
+ assert "window.location.replace(" in effect and "'#/'" in effect, (
"Back must not lead to the form again")
+ assert "window.location.hash" not in effect