aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/invite_link_probe.py
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/harness/invite_link_probe.py
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/harness/invite_link_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/invite_link_probe.py181
1 files changed, 181 insertions, 0 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())