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 | |
| 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')
| -rw-r--r-- | packages/meshbay-cli/tests/fake_node.py | 29 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_account_flows.py | 285 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_account_keys.py | 135 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_cli_actions.py | 78 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_completion.py | 92 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 262 |
6 files changed, 876 insertions, 5 deletions
diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py index f9c552c..552bd39 100644 --- a/packages/meshbay-cli/tests/fake_node.py +++ b/packages/meshbay-cli/tests/fake_node.py @@ -23,7 +23,7 @@ from fake_hub import USER_ID from meshbay_cli.client.keys import Identity from meshbay_cli.client.session import GroupSession, NodeRefusal from meshbay_common import chatbox -from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript +from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript from meshbay_common.device import ( device_add_transcript, device_code_hash, @@ -322,11 +322,38 @@ class FakeChannel: "subject": msg["file_id"], "nonce": base64.b64encode(nonce).decode(), "ts": 1_700_000_000, "node_pk": NODE_PK, "group_id": GROUP_ID} + def _dir_delete(self, msg): + """Empty folders only, the operator's only, never a root — the node's rule.""" + n, path = self.node, msg["dir"].strip("/") + if path not in n.dirs or "/" not in path: + return {"type": "error", "detail": "Invalid directory"} + if any(d.startswith(path + "/") for d in n.dirs) or \ + any(e["path"] == path or e["path"].startswith(path + "/") + for e in n.files.values()): + return {"type": "error", "detail": "Directory is not empty"} + if n.role != "operator": + return {"type": "error", "detail": "No authorized key for deletion"} + op_id, nonce = secrets.token_hex(8), secrets.token_bytes(32) + n.admin_ops[op_id] = {"op": OP_DIR_DELETE, "subject": path, "nonce": nonce, + "ts": 1_700_000_000} + return {"type": "admin_challenge", "op_id": op_id, "op": OP_DIR_DELETE, + "subject": path, "nonce": base64.b64encode(nonce).decode(), + "ts": 1_700_000_000, "node_pk": NODE_PK, "group_id": GROUP_ID} + def _admin_response(self, msg): n = self.node op = n.admin_ops.pop(msg["op_id"], None) if op is None: return {"type": "error", "detail": "No pending admin operation"} + if op.get("op") == OP_DIR_DELETE: + transcript = admin_transcript(op=OP_DIR_DELETE, node_pk_b64=NODE_PK, + group_id=GROUP_ID, subject=op["subject"], + nonce=op["nonce"], ts=op["ts"]) + if not (_signed_by_a_device(n, transcript, msg["signature"]) + and n.role == "operator"): + return {"type": "error", "detail": "Signature verification failed"} + n.dirs.discard(op["subject"]) + return {"type": "dir_delete_ack", "dir": op["subject"]} entry = n.files.get(op["subject"]) transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=NODE_PK, group_id=GROUP_ID, subject=op["subject"], diff --git a/packages/meshbay-cli/tests/test_account_flows.py b/packages/meshbay-cli/tests/test_account_flows.py new file mode 100644 index 0000000..464b0d6 --- /dev/null +++ b/packages/meshbay-cli/tests/test_account_flows.py @@ -0,0 +1,285 @@ +""" +The account's keys on the nodes across a new passphrase, and what `account +passphrase` does in which order: the copies first, the hub only then, the +copies set back if the hub refuses — never an account whose nodes and hub +disagree on the passphrase. +""" + +import asyncio +import base64 +import types + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from fake_hub import PASSPHRASE, USER_ID, USERNAME, FakeHub +from meshbay_cli import __main__ as cli +from meshbay_cli.client import rewrap, session +from meshbay_cli.client.account import Account +from meshbay_cli.client.hub import HubError +from meshbay_cli.client.keys import ( + Identity, + b64, + bundle_master_key, + opens_under, + passphrase_half, + seal_bundle, + seal_recovery, + unb64, +) +from meshbay_cli.client.store import Store +from meshbay_cli.commands import account as account_cmd +from meshbay_cli.commands import context + +NODE = base64.b64encode(b"\x05" * 32).decode() +M_OLD, M_NEW, M_ELSEWHERE = b"\x01" * 32, b"\x02" * 32, b"\x03" * 32 + + +def _keys() -> dict: + return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()).to_pkcs8() + + +def _sealed(keys: dict, master: bytes) -> str: + return seal_bundle(keys, master=master, pepper_version=1, user_id=USER_ID, node_pk=NODE) + + +class Ch: + def __init__(self): + self.sent: list[dict] = [] + + async def request(self, msg, *expect, **kw): + self.sent.append(msg) + return {"type": "ack"} + + +def _account(master: bytes, previous=(), setting=None) -> Account: + acc = Account(None, USERNAME, lambda: PASSPHRASE) + acc.state = {"user_id": USER_ID, **({"browser_access": setting} if setting else {})} + acc.secrets = {"master": b64(master), "previous_masters": [b64(m) for m in previous]} + return acc + + +# ── What a connection leaves on the node ───────────────────────────────────── + +async def test_a_copy_under_a_passphrase_changed_here_is_carried_same_keys(): + browsers, mine = _keys(), Identity(Ed25519PrivateKey.generate(), + X25519PrivateKey.generate()) + ch = Ch() + await session._settle_bundle(_account(M_NEW, [M_OLD]), ch, mine, NODE, + _sealed(browsers, M_OLD)) + assert len(ch.sent) == 1 + assert opens_under(ch.sent[0]["bundle_enc"], M_NEW, user_id=USER_ID, + node_pk=NODE) == browsers + + +async def test_a_copy_under_a_passphrase_set_elsewhere_is_left_alone(caplog): + """Rewriting it under this machine's older `M` would lock the browsers out.""" + ch = Ch() + mine = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) + await session._settle_bundle(_account(M_OLD, setting="on"), ch, mine, NODE, + _sealed(_keys(), M_ELSEWHERE)) + assert ch.sent == [] + assert "does not know" in caplog.text + + +@pytest.mark.parametrize("setting, bundle, expect", [ + ("on", "", "keypair_bundle_store"), + ("off", "x", "keypair_bundle_delete"), + (None, "", None), +]) +async def test_browser_access_still_decides_the_rest(setting, bundle, expect): + ch = Ch() + mine = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()) + if bundle: + bundle = _sealed(_keys(), M_NEW) + await session._settle_bundle(_account(M_NEW, setting=setting), ch, mine, NODE, bundle) + assert [m["type"] for m in ch.sent] == ([expect] if expect else []) + + +# ── Carrying the copies ────────────────────────────────────────────────────── + +class FakeSession: + def __init__(self, held: dict, identity: Identity | None = None): + self.held, self.node_pk, self.ch = held, NODE, Ch() + self.identity = identity or Identity(Ed25519PrivateKey.generate(), + X25519PrivateKey.generate()) + + async def close(self): + pass + + +def _hub(nodes_by_group: dict): + async def group_nodes(token, gid): + return nodes_by_group[gid] + return types.SimpleNamespace(group_nodes=group_nodes) + + +def _carry_world(monkeypatch, acc: Account, nodes_by_group: dict, sessions: dict): + acc.hub = _hub(nodes_by_group) + + async def call(fn, *args): + return await fn("t", *args) + monkeypatch.setattr(acc, "call", call) + opened = [] + + async def connect(account, gid, node, join_code=None, *, rewrap=None): + opened.append(node["pk_node"]) + s = sessions[node["pk_node"]] + if isinstance(s, Exception): + raise s + return s + monkeypatch.setattr(rewrap, "connect", connect) + return opened + + +async def test_each_copy_is_sealed_again_once_per_node(monkeypatch): + browsers = _keys() + acc = _account(M_OLD) + s = FakeSession({"bundle_enc": _sealed(browsers, M_OLD)}) + opened = _carry_world(monkeypatch, acc, { + "g1": [{"pk_node": "n1"}], "g2": [{"pk_node": "n1"}], "g3": [], + "g4": [{"pk_node": "n2"}]}, {"n1": s, "n2": session.NothingHere()}) + groups = [{"id": g, "name": g, "owner_username": "bob"} for g in ("g1", "g2", "g3", "g4")] + out = await rewrap.carry(acc, groups, master=M_NEW, pepper_version=1) + assert opened == ["n1", "n2"] # n1 once, for two groups + assert out.done == ["n1"] and out.unreachable == ["g3@bob"] and out.nothing == ["g4@bob"] + sent = s.ch.sent[0] + assert opens_under(sent["bundle_enc"], M_NEW, user_id=USER_ID, node_pk=NODE) == browsers + assert "bundle_enc_recovery" not in sent # the node keeps the one it has + + +async def test_after_a_reset_the_recovery_copy_gives_the_keys_back(monkeypatch): + browsers, rkey = _keys(), b"\x09" * 32 + acc = _account(M_NEW) # the reset's: opens nothing old + s = FakeSession({"bundle_enc": _sealed(browsers, M_OLD), + "bundle_enc_recovery": seal_recovery(browsers, rkey=rkey, + user_id=USER_ID, node_pk=NODE)}) + _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s}) + out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW, + pepper_version=1, recovery=rkey) + assert out.done == ["n1"] + sent = s.ch.sent[0] + assert opens_under(sent["bundle_enc"], M_NEW, user_id=USER_ID, node_pk=NODE) == browsers + assert sent["bundle_enc_recovery"] + + +async def test_a_copy_nothing_opens_is_left_as_it_is_unless_this_machine_leaves_its_own( + monkeypatch): + acc = _account(M_NEW) + held = {"bundle_enc": _sealed(_keys(), M_OLD)} + s = FakeSession(dict(held)) + _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s}) + out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW, + pepper_version=1) + assert out.done == [] and "left as it is" in out.failed[0] and s.ch.sent == [] + + s = FakeSession(dict(held)) + _carry_world(monkeypatch, acc, {"g1": [{"pk_node": "n1"}]}, {"n1": s}) + out = await rewrap.carry(acc, [{"id": "g1", "name": "g1"}], master=M_NEW, + pepper_version=1, own_keys=True) + assert out.done == ["n1"] + assert opens_under(s.ch.sent[0]["bundle_enc"], M_NEW, user_id=USER_ID, + node_pk=NODE) == s.identity.to_pkcs8() + + +# ── `account passphrase`: the order of things ──────────────────────────────── + +NEW = "a new passphrase that is long enough" + + +@pytest.fixture +def hub(monkeypatch): + fake = FakeHub() + calls: list[str] = [] + fake.calls = calls + fake.refuse_change = False + + async def bundle_pepper(token, ak): + fake._auth(token) + if ak != fake.ak: + raise HubError(403, "Passphrase does not match") + calls.append("pepper") + return {"bundle_pepper": base64.b64encode(b"\x07" * 32).decode(), + "bundle_pepper_version": 1} + + async def change_passphrase(token, old, new): + fake._auth(token) + calls.append("hub") + if fake.refuse_change: + raise HubError(429, "Too many requests") + fake.ak = new + return fake._session() + + async def my_groups(token): + return [{"id": "g1", "name": "Family", "owner_username": USERNAME}] + fake.bundle_pepper, fake.change_passphrase, fake.my_groups = ( + bundle_pepper, change_passphrase, my_groups) + monkeypatch.setattr(context, "Hub", lambda url: fake) + return fake + + +def _master(passphrase: str) -> bytes: + return bundle_master_key(passphrase_half(USERNAME, passphrase), b"\x07" * 32, USER_ID) + + +@pytest.fixture +def carried(monkeypatch, hub): + seen = [] + + async def carry(account, groups, *, master, pepper_version, only=None, **kw): + hub.calls.append("carry") + seen.append({"opens_with": account.master, "seals_with": master, "only": only}) + return rewrap.Carried(done=["n1"]) + monkeypatch.setattr(account_cmd, "carry", carry) + return seen + + +def _login(): + assert cli.main(["--hub", "https://hub.test", "-u", USERNAME, "--password", + PASSPHRASE, "login"]) == 0 + + +async def _stored_secrets() -> dict: + store = Store() + store.load() + return store.secrets + + +def test_the_copies_move_first_and_the_hub_only_then(hub, carried, monkeypatch, capsys): + _login() + monkeypatch.setenv("MESHBAY_NEW_PASSWORD", NEW) + assert cli.main(["--password", PASSPHRASE, "account", "passphrase"]) == 0 + assert hub.calls == ["pepper", "carry", "hub"] + assert carried[0]["opens_with"] == _master(PASSPHRASE) + assert carried[0]["seals_with"] == _master(NEW) + secrets = asyncio.run(_stored_secrets()) + assert unb64(secrets["master"]) == _master(NEW) + assert [unb64(m) for m in secrets["previous_masters"]] == [_master(PASSPHRASE)] + assert "sealed again on 1 node" in capsys.readouterr().out + + +def test_a_refused_change_sets_the_copies_back(hub, carried, monkeypatch, capsys): + _login() + hub.refuse_change = True + monkeypatch.setenv("MESHBAY_NEW_PASSWORD", NEW) + assert cli.main(["--password", PASSPHRASE, "account", "passphrase"]) == 8 + assert hub.calls == ["pepper", "carry", "hub", "carry"] + back = carried[1] + assert back["opens_with"] == _master(NEW) and back["seals_with"] == _master(PASSPHRASE) + assert back["only"] == {"n1"} + assert unb64(asyncio.run(_stored_secrets())["master"]) == _master(PASSPHRASE) + assert "nothing was changed" in capsys.readouterr().err + + +@pytest.mark.parametrize("old, new, says", [ + ("wrong passphrase entirely", NEW, "does not match"), + (PASSPHRASE, "too short", "12 characters"), + (PASSPHRASE, PASSPHRASE, "is the current one"), +]) +def test_nothing_moves_on_a_wrong_or_weak_passphrase(hub, carried, monkeypatch, capsys, + old, new, says): + _login() + monkeypatch.setenv("MESHBAY_NEW_PASSWORD", new) + assert cli.main(["--password", old, "account", "passphrase"]) != 0 + assert "carry" not in hub.calls and "hub" not in hub.calls + assert says in capsys.readouterr().err diff --git a/packages/meshbay-cli/tests/test_account_keys.py b/packages/meshbay-cli/tests/test_account_keys.py new file mode 100644 index 0000000..e14f631 --- /dev/null +++ b/packages/meshbay-cli/tests/test_account_keys.py @@ -0,0 +1,135 @@ +""" +What `account` computes on this side, held to what the page computes. + +The recovery key and the passphrase floor are run against the page's own +`keyderive.js` and `auth-page.js` under node: a recovery copy sealed here must +open in a browser and the other way round, or a reset would restore nothing; +and a passphrase the page refuses must be refused here. The proof of work is +solved against the hub's own issuer. +""" + +import base64 +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from meshbay_cli.client.keys import ( + Identity, + generate_recovery, + open_recovery, + recovery_bytes, + recovery_key, + recovery_mnemonic, + seal_recovery, +) +from meshbay_cli.client.passphrase import ( + passphrase_bits, + passphrase_problem, + solve_captcha, +) + +STATIC = (Path(__file__).resolve().parents[2] / "meshbay-hub" / "src" / "meshbay_hub" + / "static") +needs_node = pytest.mark.skipif(shutil.which("node") is None or not STATIC.exists(), + reason="node or the page's scripts are unavailable") + +USER, UID, NODE = "alice-test", "u-1", base64.b64encode(b"\x05" * 32).decode() +PASSPHRASES = ["", "short", "correct horse battery staple", "aaaaaaaaaaaaaaaa", + "123456789012345678", "MyPassword is long enough!", "Zé ünïcødé ✓ 🔐 2026", + "azerty-azerty-azerty", "Tr0ub4dor&3xyzzy", "x" * 40] + +_HARNESS = r""" +const fs = require('fs'), webcrypto = require('crypto').webcrypto; +global.self = global; global.window = global; global.crypto = webcrypto; +eval(fs.readFileSync(process.argv[2], 'utf8')); +const K = window.MeshBayKeys; +const page = fs.readFileSync(process.argv[3], 'utf8'); +eval(page.match(/function passwordBits[\s\S]*?\n}\n/)[0]); +const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); +const b = (s) => Uint8Array.from(Buffer.from(s, 'base64')); +(async () => { + const out = {bits: input.passphrases.map(passwordBits)}; + const key = await K.deriveRecoveryKey(input.mnemonic, input.user); + out.opened = await K.decryptBundle(input.sealed, key, {userId: input.uid, nodePk: input.node}); + const fresh = K.generateRecoveryKey(); + const fkey = await K.deriveRecoveryKey(fresh.mnemonic, input.user); + out.js_mnemonic = fresh.mnemonic; + out.js_raw = fresh.rawB64; + out.js_sealed = await K.encryptBundle(b(input.keys.skEd), b(input.keys.skX), fkey, + {userId: input.uid, nodePk: input.node, pepperVersion: 0}); + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def page(tmp_path_factory): + d = tmp_path_factory.mktemp("parity") + keys = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()).to_pkcs8() + raw, mnemonic = generate_recovery() + sealed = seal_recovery(keys, rkey=recovery_key(raw, USER), user_id=UID, node_pk=NODE) + (d / "h.cjs").write_text(_HARNESS, encoding="utf-8") + (d / "in.json").write_text(json.dumps({ + "passphrases": PASSPHRASES, "mnemonic": mnemonic, "user": USER, "uid": UID, + "node": NODE, "sealed": sealed, "keys": keys}), encoding="utf-8") + r = subprocess.run(["node", str(d / "h.cjs"), str(STATIC / "keyderive.js"), + str(STATIC / "auth-page.js"), str(d / "in.json")], + capture_output=True, text=True, timeout=60) + assert r.returncode == 0, r.stderr + return {"keys": keys, **json.loads(r.stdout)} + + +@needs_node +def test_a_recovery_copy_sealed_here_opens_in_a_browser(page): + assert page["opened"] == page["keys"] + + +@needs_node +def test_a_recovery_copy_sealed_in_a_browser_opens_here(page): + raw = recovery_bytes(page["js_mnemonic"]) + assert raw == base64.b64decode(page["js_raw"]) + assert recovery_mnemonic(raw) == page["js_mnemonic"] + assert open_recovery(page["js_sealed"], rkey=recovery_key(raw, USER), user_id=UID, + node_pk=NODE) == page["keys"] + + +@needs_node +def test_the_passphrase_estimate_is_the_pages(page): + assert [passphrase_bits(p) for p in PASSPHRASES] == page["bits"] + + +def test_the_floor_is_twelve_characters_and_sixty_bits(): + assert "12 characters" in passphrase_problem("Short1!") + assert "too easy" in passphrase_problem("aaaaaaaaaaaaaaaa") + assert "too easy" in passphrase_problem("password-password") + assert passphrase_problem("correct horse battery staple") is None + + +def test_a_recovery_key_is_read_however_it_is_typed(): + raw, mnemonic = generate_recovery() + assert re.fullmatch(r"(?:[A-Z2-7]{4} ){12}[A-Z2-7]{4}", mnemonic) + assert recovery_bytes(mnemonic.lower().replace(" ", "")) == raw + assert recovery_bytes(" " + mnemonic.replace(" ", "-") + "\n") == raw + with pytest.raises(ValueError): + recovery_bytes(mnemonic[:20]) + with pytest.raises(ValueError): + recovery_bytes(mnemonic.replace(mnemonic[0], "1", 1)) + assert recovery_key(raw, "alice") != recovery_key(raw, "bob") + + +def test_the_proof_of_work_is_solved_as_the_hub_checks_it(monkeypatch): + captcha = pytest.importorskip("meshbay_hub.captcha") + from cryptography.hazmat.primitives import serialization + from meshbay_hub import auth + monkeypatch.setattr(auth, "_hub_sk_pem", Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption())) + challenge = captcha.issue(5000) + token = solve_captcha(challenge) + assert token and captcha.verify(token) + assert solve_captcha({**challenge, "challenge": "00" * 32}) is None diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index bcbe0c4..006c183 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -205,6 +205,25 @@ def test_a_usage_error_is_exit_1_not_the_sign_in_code(capsys): +def test_account_shows_the_account(hub, capsys): + assert main("--hub", "https://hub.test", "-u", USERNAME, "--password", PASSPHRASE, + "login") == 0 + capsys.readouterr() + assert main("account") == 0 + out = capsys.readouterr().out + assert f"Account: {USERNAME}" in out and "https://hub.test" in out + assert main("--json", "account", "show") == 0 + assert json.loads(capsys.readouterr().out)["username"] == USERNAME + + +def test_completion_prints_each_shells_script(capsys): + assert main("completion", "bash") == 0 + assert "complete -F _meshbay_cli meshbay-cli" in capsys.readouterr().out + assert main("completion", "powershell") == 0 + assert "Register-ArgumentCompleter -Native" in capsys.readouterr().out + assert main("completion", "fish") == 1 + + def test_help_prints_what_help_prints(capsys): assert cli.main(["--help"]) == 0 whole = capsys.readouterr().out @@ -472,6 +491,65 @@ def test_rm_deletes_only_what_this_account_may_and_asks_first(node, monkeypatch, assert not node.files +def test_rm_r_removes_a_folder_and_all_it_holds_from_the_operator_key(node, capsys): + from fake_hub import USER_ID + node.add("share/trip/day 1", "a.jpg", b"A" * 2000, uploader_id=USER_ID) + node.add("share/trip/day 2", "b.jpg", b"B" * 1000, uploader_id="someone-else") + node.add("share/trip", "notes.txt", b"n", uploader_id=USER_ID) + node.dirs |= {"share/trip/empty", "share/kept"} + kept = node.add("share/kept", "c.txt", b"c", uploader_id=USER_ID) + + # Not the operator: refused whole, nothing deleted, not even its own files. + assert main("rm", "-r", "--yes", "Family/share/trip") == 8 + assert "operator" in capsys.readouterr().err + assert len(node.files) == 4 + # A root is never removed. + node.role = "operator" + assert main("rm", "-r", "--yes", "Family/share") == 8 + assert "root" in capsys.readouterr().err + + assert main("--json", "rm", "-r", "--yes", "Family/share/trip") == 0 + out = json.loads(capsys.readouterr().out) + assert len(out["deleted"]) == 3 and out["failed"] == [] + assert out["folders_removed"][-1] == "Family@alice-test/share/trip" # deepest first + assert {"Family@alice-test/share/trip/empty", "Family@alice-test/share/trip/day 1", + "Family@alice-test/share/trip/day 2"} <= set(out["folders_removed"]) + assert set(node.files) == {kept["id"]} + assert not any(d == "share/trip" or d.startswith("share/trip/") for d in node.dirs) + + +def test_rm_r_says_what_it_will_delete_before_it_asks(node, monkeypatch, capsys): + node.role = "operator" + for i in range(12): + node.add("share/big", f"f{i}.bin", b"x" * 1024) + monkeypatch.setattr("sys.stdin", _Terminal("n\n")) + assert main("rm", "-r", "Family/share/big") == 1 + asked = capsys.readouterr().err + assert "Delete 12 files (12.0 KB) and 1 folder" in asked + assert "Family@alice-test/share/big/" in asked + assert len(node.files) == 12 + + +def test_mkdir_makes_folders_where_the_root_accepts_them(node, capsys): + assert main("mkdir", "Family/share/2026") == 0 + assert "share/2026" in node.dirs + assert main("mkdir", "Family/share/2026") == 8 # already there + assert main("mkdir", "Family/share/a/b/c") == 3 # no parent + assert "--parents makes it too" in capsys.readouterr().err + assert main("--json", "mkdir", "--parents", "Family/share/a/b/c", "Family/share/2026") == 0 + assert json.loads(capsys.readouterr().out)["made"] == [ + "Family@alice-test/share/a", "Family@alice-test/share/a/b", + "Family@alice-test/share/a/b/c"] + node.add("share", "Notes.txt", b"n") + assert main("mkdir", "Family/share/notes.TXT") == 8 # a file, without case + assert "(a file)" in capsys.readouterr().err + assert main("mkdir", "Family/share/bad\x01name") == 8 + assert main("mkdir", "Family/share") == 1 # a root is not made here + node.roots[0]["writable"] = False + assert main("mkdir", "Family/share/x") == 8 + assert "read-only" in capsys.readouterr().err + + def test_search_finds_across_groups_once_per_file(hub, monkeypatch, capsys): from fake_node import FakeNode from meshbay_cli.client.session import DeviceLinkPending diff --git a/packages/meshbay-cli/tests/test_completion.py b/packages/meshbay-cli/tests/test_completion.py new file mode 100644 index 0000000..1222dc2 --- /dev/null +++ b/packages/meshbay-cli/tests/test_completion.py @@ -0,0 +1,92 @@ +""" +Tab completion's candidates: from the parser for actions, verbs and options, +from the hub and the node for remote paths — never a question, never an error. +""" + +import pytest +from fake_hub import PASSPHRASE, USERNAME, FakeHub +from fake_node import FakeNode +from meshbay_cli import __main__ as cli +from meshbay_cli import complete +from meshbay_cli.client import hub as hub_module +from meshbay_cli.client import session as session_module +from meshbay_cli.commands import context + + +def _c(*words, shell="bash"): + return complete.candidates(shell, len(words), ["meshbay-cli", *words]) + + +def test_actions_verbs_options_and_choices_come_from_the_parser(): + assert "account" in _c("") and "mkdir" in _c("") and "completion" in _c("") + assert _c("ac") == ["account"] + assert _c("account", "") == ["show", "register", "verify", "passphrase", "reset", + "email", "sign-out-everywhere", "delete"] + assert _c("account", "pa") == ["passphrase"] + assert _c("devices", "a") == ["approve"] + assert _c("browser-access", "") == ["on", "off"] + assert _c("completion", "") == ["bash", "powershell"] + assert _c("help", "mk") == ["mkdir"] + assert "--parents" in _c("mkdir", "--") and "--yes" in _c("rm", "--") + assert _c("ls", "--sort", "") == ["name", "size", "date"] + # A value, then the slots go on: `get -o DIR PATH`. + assert _c("get", "-o", "") == [complete.FILES] + assert _c("--hub", "https://x", "ac") == ["account"] + + +@pytest.mark.parametrize("typed, shell, plain", [ + ("Some\\ Saga", "bash", "Some Saga"), + ("'Some Saga", "bash", "Some Saga"), + ('"Some Saga"', "bash", "Some Saga"), + ("'it''s'", "powershell", "it's"), + ("C:\\Users\\x", "powershell", "C:\\Users\\x"), +]) +def test_quoting_is_taken_off_as_each_shell_wrote_it(typed, shell, plain): + assert complete.unquote(typed, shell) == plain + + +def test_nothing_signed_in_gives_no_candidate_and_no_error(capsys): + assert cli.main(["__complete", "bash", "2", "meshbay-cli", "ls", "Fam"]) == 0 + out = capsys.readouterr() + assert out.out == "" and out.err == "" + assert cli.main(["__complete", "garbage"]) == 0 + + +@pytest.fixture +def world(monkeypatch): + hub, node = FakeHub(), FakeNode() + node.add("share/2024/été", "plage.jpg", b"x") + node.add("share", "notes.txt", b"n") + monkeypatch.setattr(context, "Hub", lambda url: hub) + monkeypatch.setattr(hub_module, "Hub", lambda url: hub) + + async def group_nodes(token, gid): + return [{"node_id": "node-1", "pk_node": "pk"}] + hub.group_nodes = group_nodes + opened = [] + + async def connect(account, gid, node_row, join_code=None, *, rewrap=None): + assert rewrap is not None, "completion must never make keys" + opened.append(gid) + return node.session() + monkeypatch.setattr(session_module, "connect", connect) + assert cli.main(["--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, + "login"]) == 0 + return opened + + +def test_remote_paths_from_the_hub_and_the_node(world, capsys): + capsys.readouterr() + assert _c("ls", "fa") == ["Family@alice-test/"] + assert _c("ls", "Family@alice-test/") == ["Family@alice-test/share/"] + assert _c("get", "Family@alice-test/share/") == [ + "Family@alice-test/share/2024/", "Family@alice-test/share/notes.txt"] + assert _c("get", "Family@alice-test/share/2024/é") == [ + "Family@alice-test/share/2024/été/"] + assert _c("mkdir", "Family@alice-test/share/") == ["Family@alice-test/share/2024/"] + assert _c("chat", "F") == ["Family@alice-test"] + assert _c("put", "x", "Family@alice-test/sh") == [complete.FILES, + "Family@alice-test/share/"] + assert _c("ls", "Nope/") == [] + # The node was asked once; the next Tabs read what it said. + assert world == ["g1"] 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 |