aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_e2e_local.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 23:02:49 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 23:02:49 +0200
commit3131630d900aa4a72bf880bfa9c5232bff64d6d6 (patch)
tree5ef229f9a3f589fad6e7543f14b58ec7f9526758 /packages/meshbay-cli/tests/test_e2e_local.py
parent422647e7898b8df67cd11f8fb060aec23dec84aa (diff)
downloadmeshbay-3131630d900aa4a72bf880bfa9c5232bff64d6d6.tar.gz
fix(client): link a device from the application; approve one match only
"Get a linking code" called the connection the page had just closed on the node's unknown_device refusal, so it never filed anything. It now retries with the request filed on that connection, before the proof (MNP 6.3), and the page tries the group again after each refusal until a linked device approves the code. Offered only in the desktop application: a browser's minted identity is gone by its next connection. Approving refuses a code that matches more than one pending request: filing needs only the account's token, so somebody holding the passphrase who saw a code could file their own keys under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/test_e2e_local.py')
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py178
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()