diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
| commit | 988a0eecd522431980174910db28e45a63ff2710 (patch) | |
| tree | 80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/tests/test_e2e_local.py | |
| parent | e0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff) | |
| download | meshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz | |
feat(cli): account, rm -r and mkdir, Tab completion
account shows the account and does what the sign-up form and the Profile
page do: register (the passphrase floor and the hub's proof of work checked
here, the recovery key printed once), verify, change the passphrase, reset a
forgotten one, change the e-mail address, sign out everywhere, delete. A new
passphrase carries the copy of the account's keys each node keeps for
browsers first — opened under the old M, the same keys sealed under the new
one — and only then tells the hub; a refusal sets the copies back. A reset
restores them from the recovery copy. The connections that do it never make
keys nor ask for a device approval. This machine keeps the M it changed away
from, so a node out of reach is carried at its next connection; a copy under
an M it does not know is no longer rewritten, which locked the browsers out
after a passphrase changed elsewhere.
rm -r removes a folder and all it holds, from the node's operator key: the
files, then the folders deepest first, one confirmation that counts them.
mkdir makes folders, --parents the missing ones (not -p, the passphrase).
completion bash|powershell prints the script that makes Tab complete actions,
verbs, options and the paths of the groups this machine is signed in to,
cached two minutes; the deb and rpm packages install the bash one.
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.py | 262 |
1 files changed, 258 insertions, 4 deletions
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index c385bb6..6b7869b 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -215,9 +215,12 @@ class App: def __init__(self, hub_url: str, group_id: str): self.hub_url, self.group_id = hub_url, group_id self.ident = None + self.passphrase = PASSPHRASE - async def session(self, code: str = ""): - """Signed in, handshake done, joined: (channel, close, node_pk, nonce_s, user_id).""" + async def session(self, code: str = "", *, fetch: bool = False): + """Signed in, handshake done, joined: (channel, close, node_pk, nonce_s, user_id). + `fetch`: the node's copy of the account's keys, asked for in the handshake + window as a browser asks, is left in `self.fetched`.""" import os as _os from aiortc import ( @@ -238,7 +241,7 @@ class App: from meshbay_common.join import join_transcript hub = Hub(self.hub_url) - account = Account(hub, USERNAME, lambda: PASSPHRASE) + account = Account(hub, USERNAME, lambda: self.passphrase) await _signed_in(account) token = await account.token() node = (await hub.group_nodes(token, self.group_id))[0] @@ -261,6 +264,9 @@ class App: "group_id": self.group_id, "nonce": b64(nonce_c)}, "handshake_challenge") nonce_s, node_pk = unb64(chal["nonce"]), chal["node_pk"] + if fetch: + self.fetched = await ch.request({"type": "keypair_bundle_fetch", + "v": MNP_VERSION}, "keypair_bundle_resp") if self.ident is None: self.ident = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) ident, ts = self.ident, int(time.time()) @@ -304,6 +310,32 @@ class App: assert stored.get("detail") == "keypair_bundle_stored", stored await close() + async def leave_bundle(self, recovery: bytes | None = None) -> None: + """Browser access: this device's keys left on the node under `M`, and a + recovery copy beside it when the recovery key is held.""" + from meshbay_cli.client.keys import seal_bundle, seal_recovery + from meshbay_common import MNP_VERSION + ch, close, node_pk, _, account = await self.session() + keys = self.ident.to_pkcs8() + msg = {"type": "keypair_bundle_store", "v": MNP_VERSION, + "bundle_enc": seal_bundle(keys, master=account.master, pepper_version=1, + user_id=account.user_id, node_pk=node_pk)} + if recovery is not None: + msg["bundle_enc_recovery"] = seal_recovery(keys, rkey=recovery, + user_id=account.user_id, + node_pk=node_pk) + assert (await ch.request(msg, "ack")).get("detail") == "keypair_bundle_stored" + await close() + + async def held(self) -> dict: + """What the node keeps of the account's keys, as a browser fetches it.""" + ch, close, node_pk, _, account = await self.session(fetch=True) + found = self.fetched + out = {"node_pk": node_pk, "user_id": account.user_id, "master": account.master, + **{k: found.get(k) for k in ("bundle_enc", "bundle_enc_recovery")}} + await close() + return out + async def withdraw_bundle(self) -> None: """Browser access turned off: the application withdraws the node's copy.""" from meshbay_common import MNP_VERSION @@ -359,10 +391,11 @@ class App: def cli(world: dict, *args: str, stdin: str | None = None, - home: str = "cli") -> subprocess.CompletedProcess: + home: str = "cli", env_extra: dict | None = None) -> subprocess.CompletedProcess: env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / home), "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"} env.pop("MESHBAY_PASSWORD", None) + env.update(env_extra or {}) exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli") return subprocess.run([exe, *args], env=env, input=stdin, capture_output=True, text=True, timeout=180) @@ -1250,3 +1283,224 @@ def test_stream_a_film_to_real_players(world, tmp_path): assert r.returncode == 0, r.stderr + log.read_text() assert "The player was closed" in r.stderr assert time.monotonic() - started > 10, "VLC played the film, in real time" + + +def test_mkdir_and_rm_r_against_the_real_node(world, tmp_path): + """mkdir --parents, a file put in the new folder, then rm -r of it all from + the operator's key — paired as an operator pairs a browser.""" + group = _handle() + r = cli(world, "rm", "-r", "--yes", f"{group}/share/2024", home="cli-chat") + assert r.returncode == 8 and "operator" in r.stderr, r.stderr # not yet + + code = re.search(r"PAIRING CODE\s+(\S+)", world["run_node"]("operator", "pair")).group(1) + r = cli(world, "join", group, "--code", code, home="cli-chat") + assert r.returncode == 0, r.stderr + + r = cli(world, "mkdir", "--parents", f"{group}/share/Album/Disc 1", + home="cli-chat") + assert r.returncode == 0, r.stderr + assert (world["share"] / "Album" / "Disc 1").is_dir() + (tmp_path / "track notes.txt").write_text("liner notes\n") + r = cli(world, "put", str(tmp_path / "track notes.txt"), f"{group}/share/Album/Disc 1", + home="cli-chat") + assert r.returncode == 0, r.stderr + assert (world["share"] / "Album" / "Disc 1" / "track notes.txt").is_file() + r = cli(world, "mkdir", f"{group}/share/Album/Empty", home="cli-chat") + assert r.returncode == 0, r.stderr + deadline = time.monotonic() + 30 + while "track notes.txt" not in (r := cli(world, "ls", "-R", f"{group}/share/Album", + home="cli-chat")).stdout: + assert time.monotonic() < deadline, r.stdout # the node indexes it first + time.sleep(1) + + r = cli(world, "--json", "rm", "-r", "--yes", f"{group}/share/Album", home="cli-chat") + assert r.returncode == 0, r.stderr + out = json.loads(r.stdout) + assert len(out["deleted"]) == 1 and len(out["folders_removed"]) == 3, out + assert not (world["share"] / "Album").exists() + + +def test_tab_completes_remote_paths_from_the_real_node(world): + """The candidates come from the hub and the node, for the machine signed in; + bash's own completion function, and PowerShell's when pwsh is installed.""" + group = _handle() + + def complete(*words: str) -> list[str]: + r = cli(world, "__complete", "bash", str(len(words)), "meshbay-cli", *words, + home="cli-chat") + assert r.returncode == 0 and not r.stderr, r.stderr + return r.stdout.splitlines() + + assert complete("ls", group[:4]) == [group + "/"] + assert complete("ls", f"{group}/sh") == [f"{group}/share/"] + assert complete("get", f"{group}/share/20") == [f"{group}/share/2024/"] + inside = complete("get", f"{group}/share/2024/") + assert f"{group}/share/2024/été à la mer/" in inside + assert f"{group}/share/2024/A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac" \ + in inside + folders = complete("mkdir", f"{group}/share/2024/") # a destination: folders only + assert f"{group}/share/2024/été à la mer/" in folders + assert all(f.endswith("/") for f in folders), folders + assert complete("chat", group[:4]) == [group] + assert complete("put", "nothing-here", f"{group}/sh")[0] == "__files__" + assert complete("ls", "No such group/") == [] # nothing, quietly + + # bash itself: the function the script installs, on a word typed with its + # spaces escaped, as bash hands it over. + script = cli(world, "completion", "bash").stdout + escaped = group[:10].replace(" ", "\\ ") + bash = subprocess.run( + ["bash", "-c", script + "\nCOMP_WORDS=(meshbay-cli ls " + escaped + ")\n" + "COMP_CWORD=2\n_meshbay_cli\nprintf '%s\\n' \"${COMPREPLY[@]}\"\n"], + env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), + "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", + "PATH": f"{BIN}:{os.environ['PATH']}"}, + capture_output=True, text=True, timeout=60) + assert bash.stdout.splitlines() == [group + "/"], (bash.stdout, bash.stderr) + + pwsh = shutil.which("pwsh") + if pwsh: + ps = cli(world, "completion", "powershell").stdout + line = f"meshbay-cli ls '{group}/sh" + r = subprocess.run( + [pwsh, "-NoProfile", "-Command", + ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}" + "\n$r.CompletionMatches | ForEach-Object { $_.CompletionText }"], + env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), + "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", + "PATH": f"{BIN}:{os.environ['PATH']}"}, + capture_output=True, text=True, timeout=120) + assert r.stdout.splitlines() == [f"'{group}/share/'"], (r.stdout, r.stderr) + + +def _cli(world: dict, *args: str, new: str | None = None, **kw): + """`cli`, on this world's hub even from a machine that never signed in.""" + extra = {"MESHBAY_HUB": world["hub_url"]} + if new is not None: + extra["MESHBAY_NEW_PASSWORD"] = new + return cli(world, *args, env_extra=extra, **kw) + + +def _code(world: dict, purpose: str) -> str: + """The code the hub would have mailed: this throwaway hub mails nothing.""" + with sqlite3.connect(world["db"]) as conn: + row = conn.execute("SELECT code FROM email_verifications WHERE purpose = ? " + "AND verified_at IS NULL ORDER BY created_at DESC LIMIT 1", + (purpose,)).fetchone() + assert row, f"no {purpose} code" + return row[0] + + +def test_an_account_registered_verified_changed_and_erased_from_the_cli(world): + """register → verify → sign in → show → new address → sign out everywhere → + delete, each against the real hub; the e-mailed codes read from its database.""" + name, email, new_email = "carol-e2e-cli", "carol@example.invalid", "carol2@example.invalid" + phrase = "carol's own long passphrase, 2026" + r = _cli(world, "account", "register", "-u", name, "--email", email, + "--password-file", "-", stdin=phrase + "\n", home="cli-carol") + assert r.returncode == 0, r.stderr + mnemonic = re.search(r"^ {4}((?:[A-Z2-7]{4} ){12}[A-Z2-7]{4})$", r.stdout, re.M) + assert mnemonic, r.stdout + assert "meshbay-cli account verify CODE" in r.stderr + + # Not usable before the address is verified. + r = _cli(world, "login", "-u", name, "--password-file", "-", stdin=phrase + "\n", + home="cli-carol") + assert r.returncode == 2 and "not verified" in r.stderr, r.stderr + r = _cli(world, "account", "verify", _code(world, "registration"), "--email", email, + home="cli-carol") + assert r.returncode == 0 and "Verified" in r.stdout, r.stderr + r = _cli(world, "login", "-u", name, "--password-file", "-", stdin=phrase + "\n", + home="cli-carol") + assert r.returncode == 0, r.stderr + + r = _cli(world, "--json", "account", home="cli-carol") + assert json.loads(r.stdout)["email"] == email, r.stdout + r = _cli(world, "account", "email", new_email, "--password-file", "-", + stdin=phrase + "\n", home="cli-carol") + assert r.returncode == 0 and "--code CODE" in r.stdout, (r.stdout, r.stderr) + r = _cli(world, "account", "email", "--code", _code(world, "email_change"), + home="cli-carol") + assert r.returncode == 0, r.stderr + r = _cli(world, "--json", "account", home="cli-carol") + assert json.loads(r.stdout)["email"] == new_email, r.stdout + + r = _cli(world, "--json", "account", "sign-out-everywhere", home="cli-carol") + assert r.returncode == 0 and json.loads(r.stdout)["revoked"] >= 1, r.stderr + # This machine signs back in with its device key, without the passphrase. + r = _cli(world, "--json", "account", home="cli-carol") + assert r.returncode == 0, r.stderr + + r = _cli(world, "account", "delete", home="cli-carol") + assert r.returncode == 1 and "--yes" in r.stderr, r.stderr # never unasked + r = _cli(world, "account", "delete", "--yes", "--password-file", "-", + stdin=phrase + "\n", home="cli-carol") + assert r.returncode == 0 and "erased" in r.stdout, r.stderr + r = _cli(world, "login", "-u", name, "--no-remember", "--password-file", "-", + stdin=phrase + "\n", home="cli-carol-2") + assert r.returncode == 2, r.stderr + + +def test_a_new_passphrase_carries_the_browsers_keys_and_a_reset_restores_them(world): + """ + The browsers' copy of the account's keys on the node — another device's + keys than this machine's — sealed again under the new passphrase, the same + keys; then, the passphrase forgotten, a machine that keeps nothing restores + them from the recovery copy. Last: it changes the account's passphrase. + """ + from meshbay_cli.client.keys import ( + generate_recovery, + open_recovery, + opens_under, + recovery_key, + ) + app = world["app"] + raw, mnemonic = generate_recovery() + rkey = recovery_key(raw, USERNAME) + asyncio.run(app.leave_bundle(recovery=rkey)) + browsers = app.ident.to_pkcs8() + + new = "a brand new passphrase for alice, 2026" + r = _cli(world, "chat", "-1", _handle(), home="cli-chat") + assert r.returncode == 0, "the chat tests above did not leave this machine linked" + r = _cli(world, "account", "passphrase", "--password-file", "-", + stdin=PASSPHRASE + "\n", home="cli-chat", new=new) + assert r.returncode == 0, r.stderr + assert "sealed again on 1 node" in r.stdout, r.stdout + + app.passphrase = new + held = asyncio.run(app.held()) + keys = opens_under(held["bundle_enc"], held["master"], user_id=held["user_id"], + node_pk=held["node_pk"]) + assert keys == browsers, "the browsers' keys were replaced, not carried" + # The recovery copy, left as it was, still opens. + assert open_recovery(held["bundle_enc_recovery"], rkey=rkey, user_id=held["user_id"], + node_pk=held["node_pk"]) == browsers + r = _cli(world, "login", "--no-remember", "-u", USERNAME, "--password-file", "-", + stdin=PASSPHRASE + "\n", home="cli-old") + assert r.returncode == 2, "the old passphrase still signs in" + # This machine goes on with the new one, without being asked for it. + r = _cli(world, "ls", _handle(), home="cli-chat") + assert r.returncode == 0, r.stderr + + # Forgotten: a code, then a machine that keeps nothing and the recovery key. + reset = "the passphrase after the reset, 2026" + r = _cli(world, "account", "reset", "-u", USERNAME, "--email", "alice@example.invalid", + home="cli-reset") + assert r.returncode == 0 and "--code CODE" in r.stderr, r.stderr + key_file = world["root"] / "recovery.txt" + key_file.write_text(mnemonic + "\n") + r = _cli(world, "account", "reset", "-u", USERNAME, "--code", + _code(world, "password_reset"), "--recovery-key-file", str(key_file), + "--password-file", "-", stdin=reset + "\n", home="cli-reset") + assert r.returncode == 0, r.stderr + assert "sealed again on 1 node" in r.stdout, (r.stdout, r.stderr) + + app.passphrase = reset + held = asyncio.run(app.held()) + assert opens_under(held["bundle_enc"], held["master"], user_id=held["user_id"], + node_pk=held["node_pk"]) == browsers + assert open_recovery(held["bundle_enc_recovery"], rkey=rkey, user_id=held["user_id"], + node_pk=held["node_pk"]) == browsers + r = _cli(world, "ls", _handle(), home="cli-reset") + assert r.returncode == 0, r.stderr |