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