diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
| commit | 6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch) | |
| tree | 859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli/tests/test_e2e_local.py | |
| parent | 16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff) | |
| download | meshbay-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.py | 107 |
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 |