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