aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/tests')
-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()