diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
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 |