summaryrefslogtreecommitdiffstats
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/fake_node.py29
-rw-r--r--packages/meshbay-cli/tests/test_account_flows.py285
-rw-r--r--packages/meshbay-cli/tests/test_account_keys.py135
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py78
-rw-r--r--packages/meshbay-cli/tests/test_completion.py92
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py262
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