summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_e2e_local.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
commit6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch)
tree859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli/tests/test_e2e_local.py
parent16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff)
downloadmeshbay-6f68bb6c1970e26de1f66c073ea5d7fecd21840d.tar.gz
feat(cli): invitations, join, decline, leave, devices, browser access
join accepts an invitation or redeems a link pasted as sent, then has the node admit a key pair made here, with the invitation's code (a link's code goes only to the node it names; one for a node that is away is kept for the next connection). decline, leave and devices revoke ask first, and refuse without --yes where there is no terminal. devices approve countersigns the one waiting request a code matches, none when several do. browser-access is the application's setting, kept per machine and never enabled from the hub's mirror: on leaves a copy sealed under M on each node, off withdraws it, unset touches nothing. A key pair made for a first contact is now kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/test_e2e_local.py')
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py107
1 files changed, 106 insertions, 1 deletions
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index e2306d2..9cac392 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -185,7 +185,7 @@ def _build(root: Path, procs: list) -> dict:
app = App(hub_url, group_id)
asyncio.run(app.first_contact(code))
return {"root": root, "hub_url": hub_url, "group_id": group_id, "db": db,
- "share": share, "app": app}
+ "share": share, "app": app, "run_node": run_node}
async def _signed_in(account) -> None:
@@ -743,3 +743,108 @@ def test_the_application_links_itself_from_its_group_page(world):
assert s["minted"] == 1, "the identity linked is not the one kept"
finally:
chrome.close()
+
+
+
+# ── A second account: invited, joined, its devices, browser access, leaving ──
+
+BOB = "bob-e2e-cli"
+BOB_PASSPHRASE = "another long test passphrase, bob's"
+
+
+def _register(world: dict, username: str, passphrase: str) -> None:
+ from meshbay_cli.client.keys import auth_key
+ r = httpx.post(f"{world['hub_url']}/v1/users/register", json={
+ "username": username, "email": f"{username}@example.invalid",
+ "auth_key": auth_key(username, passphrase)})
+ assert r.status_code == 201, r.text
+ with sqlite3.connect(world["db"]) as conn:
+ conn.execute("UPDATE users SET status='active' WHERE username=?", (username,))
+
+
+def bob(world: dict, home: str, *args: str) -> subprocess.CompletedProcess:
+ """`meshbay-cli` as bob, on the machine `home` names; no terminal."""
+ return cli(world, *args, home=home)
+
+
+def _bob_login(world: dict, home: str) -> None:
+ deadline = time.monotonic() + 90
+ while True:
+ r = cli(world, "--hub", world["hub_url"], "-u", BOB, "login", "--password-file",
+ "-", stdin=BOB_PASSPHRASE + "\n", home=home)
+ if r.returncode == 0:
+ return
+ assert "Too many attempts" in r.stderr and time.monotonic() < deadline, r.stderr
+ time.sleep(5) # the hub's 10 sign-ins a minute
+
+
+def test_a_member_joins_links_devices_shares_with_browsers_and_leaves(world):
+ _register(world, BOB, BOB_PASSPHRASE)
+ group = _handle()
+ # The owner invites bob on the node, which also tells the hub.
+ code = re.search(r"INVITATION CODE\s+(\S+)",
+ world["run_node"]("member", "invite", BOB, "--group",
+ world["group_id"])).group(1)
+
+ _bob_login(world, "bob1")
+ r = bob(world, "bob1", "groups")
+ assert r.returncode == 0 and "1 invitation waiting" in r.stderr, r.stderr
+ r = bob(world, "bob1", "invitations")
+ assert r.returncode == 0 and r.stdout.splitlines()[1].endswith(group), r.stdout
+
+ # Accepted on the hub; the node wants the code, and there is no terminal.
+ r = bob(world, "bob1", "join", group)
+ assert r.returncode == 5 and "--code CODE" in r.stderr, r.stderr
+ r = bob(world, "bob1", "join", group, "--code", code)
+ assert r.returncode == 0, r.stderr
+ assert "Joined" in r.stdout and "browser-access on" in r.stderr
+ r = bob(world, "bob1", "ls", f"{group}/share")
+ assert r.returncode == 0 and "notes.txt" in r.stdout, r.stderr
+
+ # Browser access on: the next connection leaves a copy, which a machine
+ # with nothing of its own then opens — the same identity, no approval.
+ assert bob(world, "bob1", "browser-access", "on").returncode == 0
+ assert bob(world, "bob1", "ls", group).returncode == 0
+ _bob_login(world, "bob2")
+ r = bob(world, "bob2", "ls", group)
+ assert r.returncode == 0 and "share/" in r.stdout, r.stderr
+ r = bob(world, "bob1", "--json", "devices", "list", group)
+ assert r.returncode == 0, r.stderr
+ assert len(json.loads(r.stdout)["devices"]) == 1
+
+ # Off: the copy is withdrawn, so a third machine is a new device, approved
+ # from the first by its code, and revoked from it.
+ assert bob(world, "bob1", "browser-access", "off").returncode == 0
+ assert bob(world, "bob1", "ls", group).returncode == 0
+ _bob_login(world, "bob3")
+ r = bob(world, "bob3", "ls", group)
+ assert r.returncode == 5, r.stderr
+ link_code = re.search(r"enter\s+([0-9A-Z]{4}-[0-9A-Z]{4})", r.stderr).group(1)
+ assert f"meshbay-cli devices approve {group} {link_code}" in r.stderr
+ r = bob(world, "bob1", "devices", "approve", group, link_code)
+ assert r.returncode == 0, r.stderr
+ assert bob(world, "bob3", "ls", group).returncode == 0
+ listed = json.loads(bob(world, "bob1", "--json", "devices", "list", group).stdout)
+ new = next(d for d in listed["devices"] if not d["is_this_one"])
+ assert len(listed["devices"]) == 2
+ r = bob(world, "bob1", "devices", "revoke", group, new["pk_ed25519"][:12])
+ assert r.returncode == 1 and "--yes" in r.stderr # no terminal to ask on
+ r = bob(world, "bob1", "devices", "revoke", group, new["pk_ed25519"][:12], "--yes")
+ assert r.returncode == 0, r.stderr
+ assert bob(world, "bob3", "ls", group).returncode == 5 # a stranger again
+
+ # Leaving: asked first, then the group is gone from the account.
+ assert bob(world, "bob1", "leave", group).returncode == 1
+ r = bob(world, "bob1", "leave", "--yes", group)
+ assert r.returncode == 0, r.stderr
+ assert group not in bob(world, "bob1", "groups").stdout
+ assert bob(world, "bob1", "ls", group).returncode == 3
+
+ # Back through an invitation link, pasted as it was sent.
+ out = world["run_node"]("member", "invite", "--link", "--group", world["group_id"])
+ link = re.search(r"INVITATION LINK\s+(\S+)", out).group(1)
+ r = bob(world, "bob1", "join", link)
+ assert r.returncode == 0, r.stderr
+ assert bob(world, "bob1", "ls", f"{group}/share").returncode == 0
+ for home in ("bob1", "bob2", "bob3"):
+ assert bob(world, home, "logout").returncode == 0