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/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