diff options
Diffstat (limited to 'packages/meshbay-cli/tests/test_e2e_local.py')
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 178 |
1 files changed, 176 insertions, 2 deletions
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index 4283819..d6f6d52 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -17,7 +17,8 @@ What it sets up, as an operator and a browser would: because the CLI does not join groups. Then the CLI: login, ls of groups, of a root, recursive, JSON, a second run -from the identity it kept, and logout. +from the identity it kept, and logout. And, where google-chrome is installed, +the application's group page linking itself as a device against the same node. MESHBAY_CLI_E2E=1 pytest packages/meshbay-cli/tests/test_e2e_local.py -v @@ -31,6 +32,7 @@ import json import os import re import secrets +import shutil import socket import sqlite3 import subprocess @@ -186,6 +188,20 @@ def _build(root: Path, procs: list) -> dict: "share": share, "app": app} +async def _signed_in(account) -> None: + """Signed in, waiting out the hub's limit of 10 sign-ins a minute: this + module signs in more often than that, as the application and as the CLI.""" + from meshbay_cli.errors import CliError + deadline = time.monotonic() + 90 + while True: + try: + return await account.sign_in(remember=False) + except CliError as e: + if "Too many attempts" not in str(e) or time.monotonic() > deadline: + raise + await asyncio.sleep(5) + + def _try(fn, *args) -> str: try: return fn(*args) @@ -223,7 +239,7 @@ class App: hub = Hub(self.hub_url) account = Account(hub, USERNAME, lambda: PASSPHRASE) - await account.sign_in(remember=False) + await _signed_in(account) token = await account.token() node = (await hub.group_nodes(token, self.group_id))[0] mnp = await hub.mnp_token(token, node["pk_node"], self.group_id) @@ -568,3 +584,161 @@ async def test_the_interactive_mode_against_the_real_node(world, monkeypatch, tm await pilot.press("f5") await until(pilot, lambda: (tmp_path / "notes.txt").exists() and (tmp_path / "notes.txt").read_text() == "hello\n") + + +# ── The application's half of linking, in a real Chrome ────────────────────── +# +# Here because this is where a real hub and node stand: the group page, served +# by the hub, linking a device in the desktop application — the request filed +# before the proof (MNP 6.3) on the connection the node refused, then the group +# opening by itself once a linked device approves. The application's key store +# (keyring.js, in its main process) is stood in for by a page script that keeps +# the identity in memory and signs through the page's own transcripts. + +_KEYS_STUB = r""" +(() => { + const ids = {}; + const b64 = (b) => btoa(String.fromCharCode(...new Uint8Array(b))); + const k = (u, n) => `${u}|${n}`; + window.__minted = 0; + const keys = { + available: async () => true, + hasSession: async () => true, + identity: async (u, n) => (ids[k(u, n)] ? { ...ids[k(u, n)].pub, sealedWith: null } : null), + async mint(u, n) { + window.__minted += 1; + const ed = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + const x = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); + const pub = { pkEdB64: b64(await crypto.subtle.exportKey('raw', ed.publicKey)), + pkXB64: b64(await crypto.subtle.exportKey('raw', x.publicKey)) }; + ids[k(u, n)] = { skEd: b64(await crypto.subtle.exportKey('pkcs8', ed.privateKey)), + skX: x.privateKey, pub }; + return pub; + }, + async sign(u, n, kind, fields) { + const id = ids[k(u, n)]; + return window.MeshBayKeys.signBytes( + id.skEd, window.MeshBayCrypto.transcriptFor(kind, fields, id.pub)); + }, + async shared(u, n, peerB64) { + const raw = Uint8Array.from(atob(peerB64), (c) => c.charCodeAt(0)); + const pk = await crypto.subtle.importKey('raw', raw, { name: 'X25519' }, false, []); + return b64(await crypto.subtle.deriveBits( + { name: 'X25519', public: pk }, ids[k(u, n)].skX, 256)); + }, + browserAccess: async () => false, + fingerprint: async () => 'fp', + openBundle: async () => { throw new Error('the node keeps no bundle here'); }, + commitPending: async () => true, dropPending: async () => true, + forgetSession: async () => true, createdHere: async () => true, + }; + window.meshbay = { hubBase: () => location.origin, setHubBase: async () => true, keys }; + localStorage.setItem('mb_auth', JSON.stringify(__AUTH__)); +})(); +""" + +_LINK_FORM = r"""(() => { + const page = document.body ? document.body.innerText : ''; // null mid-navigation + const form = [...document.querySelectorAll('.invite-form')] + .find((f) => f.querySelector('button, p') && /linked/i.test(f.innerText)); + const code = form && [...form.querySelectorAll('p')].map((p) => p.innerText.trim()) + .find((t) => /^[0-9A-Z]{4}-[0-9A-Z]{4}$/.test(t)); + return { form: Boolean(form), button: Boolean(form && form.querySelector('button')), + code: code || '', minted: window.__minted, + error: (document.querySelector('.error-msg') || {}).innerText || '', + opened: /No messages yet/.test(page), page: page.slice(-400) }; +})()""" + + +class _Chrome: + """Chrome, headless, driven over the DevTools protocol.""" + + def __init__(self, init_script: str): + import tempfile + + from websockets.sync.client import connect + self.profile = tempfile.TemporaryDirectory(prefix="meshbay-e2e-chrome-") + port = _free_port() + self.proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--remote-debugging-port={port}", f"--user-data-dir={self.profile.name}", + "--lang=en-US", "about:blank"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + 30 + while True: + try: + pages = httpx.get(f"http://127.0.0.1:{port}/json/list").json() + target = next(p for p in pages if p["type"] == "page") + break + except (httpx.HTTPError, StopIteration): + assert time.monotonic() < deadline, "Chrome did not come up" + time.sleep(0.2) + self.ws = connect(target["webSocketDebuggerUrl"], max_size=None) + self.n = 0 + self.call("Page.enable") + self.call("Page.addScriptToEvaluateOnNewDocument", source=init_script) + + def call(self, method: str, **params): + self.n += 1 + self.ws.send(json.dumps({"id": self.n, "method": method, "params": params})) + while True: + msg = json.loads(self.ws.recv()) + if msg.get("id") == self.n: + assert "error" not in msg, msg + return msg.get("result", {}) + + def js(self, expr: str): + r = self.call("Runtime.evaluate", expression=expr, awaitPromise=True, + returnByValue=True) + assert "exceptionDetails" not in r, r["exceptionDetails"] + return r["result"].get("value") + + def until(self, expr: str, pred, timeout: float = 90): + deadline = time.monotonic() + timeout + while not pred(state := self.js(expr)): + assert time.monotonic() < deadline, f"timed out: {state}" + time.sleep(0.5) + return state + + def close(self): + self.ws.close() + self.proc.terminate() + self.proc.wait(10) + self.profile.cleanup() + + +@pytest.mark.skipif(not shutil.which("google-chrome"), + reason="google-chrome is not available") +def test_the_application_links_itself_from_its_group_page(world): + from meshbay_cli.client.keys import auth_key + app, hub_url = world["app"], world["hub_url"] + asyncio.run(app.withdraw_bundle()) + deadline = time.monotonic() + 90 + while (r := httpx.post(f"{hub_url}/v1/users/login", json={ + "username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)}) + ).status_code == 429 and time.monotonic() < deadline: + time.sleep(5) # the hub's 10 sign-ins a minute (see _signed_in) + assert r.status_code == 200, r.text + tokens = r.json() + me = httpx.get(f"{hub_url}/v1/users/me", + headers={"Authorization": f"Bearer {tokens['access_token']}"}).json() + auth = {"username": USERNAME, "userId": me["user_id"], "role": "user", + "token": tokens["access_token"], "refreshToken": tokens.get("refresh_token")} + + chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(auth))) + try: + chrome.call("Page.navigate", url=f"{hub_url}/#/group/{world['group_id']}") + # Refused as an unknown device: the form, and nothing shown as an error. + s = chrome.until(_LINK_FORM, lambda s: s["button"]) + assert not s["error"], s + chrome.js("""[...document.querySelectorAll('.invite-form button')] + .find((b) => /linking code/i.test(b.innerText)).click()""") + s = chrome.until(_LINK_FORM, lambda s: s["code"] or s["error"]) + assert s["code"] and not s["error"], s + asyncio.run(app.approve(s["code"])) + # Nobody clicks anything: the page tries again and the group opens. + s = chrome.until(_LINK_FORM, lambda s: s["opened"] and not s["form"], timeout=60) + assert not s["error"], s + assert s["minted"] == 1, "the identity linked is not the one kept" + finally: + chrome.close() |