From 422647e7898b8df67cd11f8fb060aec23dec84aa Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 22:20:15 +0200 Subject: feat(cli): meshbay-cli, a command-line client for members Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-cli/tests/test_cli_actions.py | 238 +++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 packages/meshbay-cli/tests/test_cli_actions.py (limited to 'packages/meshbay-cli/tests/test_cli_actions.py') diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py new file mode 100644 index 0000000..b88d93c --- /dev/null +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -0,0 +1,238 @@ +""" +Every action, run through `main()` with the hub and the node stubbed. + +A branch nobody has run is covered by nothing — the node's `reload` once shipped +with a missing import. `test_every_action_is_run_here` refuses an action added +to the parser without a run below. +""" + +import io +import json + +import pytest +from fake_hub import PASSPHRASE, USERNAME, FakeHub +from meshbay_cli import __main__ as cli +from meshbay_cli.client import pool +from meshbay_cli.commands import context + +RUN: set[str] = set() + + +@pytest.fixture +def hub(monkeypatch): + fake = FakeHub() + monkeypatch.setattr(context, "Hub", lambda url: fake) + return fake + + +def main(*argv: str) -> int: + RUN.add(next(a for a in argv if a in cli.ACTIONS)) + return cli.main(list(argv)) + + +class FakeSession: + async def index(self): + return {"roots": [{"name": "share", "available": True, "writable": True}], + "dirs": ["share"], + "entries": [{"id": "a" * 64, "name": "notes.txt", "path": "share", + "size": 6, "type": "document", "added_at": 0}]} + + async def close(self): + pass + + +def test_login_ls_logout(hub, monkeypatch, capsys): + monkeypatch.setattr(pool, "open_group", lambda account, group: _session()) + + assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, "login") == 0 + assert "Signed in: alice-test" in capsys.readouterr().out + + assert main("ls") == 0 + assert capsys.readouterr().out.strip() == f"Family@{USERNAME}" + + assert main("ls", "--json", "Family/share") == 0 + [item] = json.loads(capsys.readouterr().out) + assert item["path"] == f"Family@{USERNAME}/share/notes.txt" and item["kind"] == "file" + + assert main("logout") == 0 + assert "Signed out." in capsys.readouterr().out + assert not hub.devices + + assert main("ls") == 2 + assert "Not signed in" in capsys.readouterr().err + + +def test_login_without_remembering_stores_nothing(hub, isolated_home, capsys): + assert main("--hub", hub.url, "-u", USERNAME, "login", "--no-remember", + "--password", PASSPHRASE) == 0 + assert "Credentials accepted" in capsys.readouterr().out + assert not hub.devices + assert not any(isolated_home.rglob("secrets.enc")) + + +def test_a_wrong_passphrase_exits_2(hub, capsys): + assert main("--hub", hub.url, "-u", USERNAME, "--password", "nope", "login") == 2 + assert "wrong username or passphrase" in capsys.readouterr().err + + +def test_no_passphrase_and_no_terminal_exits_2(hub, monkeypatch, capsys): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main("--hub", hub.url, "-u", USERNAME, "login") == 2 + assert "MESHBAY_PASSWORD" in capsys.readouterr().err + + +async def _session(): + return FakeSession() + + +@pytest.fixture +def node(hub, monkeypatch, capsys): + from fake_node import FakeNode + fake = FakeNode() + + async def open_group(account, group): + return fake.session() + monkeypatch.setattr(pool, "open_group", open_group) + assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, "login") == 0 + capsys.readouterr() + return fake + + +def test_get_files_folders_and_patterns(node, tmp_path, monkeypatch, capsys): + node.add("share", "notes.txt", b"hello") + node.add("share/2024", "a.jpg", b"A" * 3000) + node.add("share/2024", "b.jpg", b"B" * 10) + node.add("share/2024/deep", "c.txt", b"C") + node.dirs.add("share/2024/empty") + monkeypatch.chdir(tmp_path) + + assert main("get", "Family/share/notes.txt") == 0 + assert (tmp_path / "notes.txt").read_bytes() == b"hello" + assert "Downloaded 1 file" in capsys.readouterr().err + + # Already there: refused by default, the run fails at the end; -f overwrites. + (tmp_path / "notes.txt").write_bytes(b"mine") + assert main("get", "Family/share/notes.txt") == 4 + assert "already exists" in capsys.readouterr().err + assert (tmp_path / "notes.txt").read_bytes() == b"mine" + assert main("get", "-f", "Family/share/notes.txt") == 0 + assert (tmp_path / "notes.txt").read_bytes() == b"hello" + + assert main("get", "Family/share/2024/*.jpg", "-o", "pics/") == 0 + assert sorted(p.name for p in (tmp_path / "pics").iterdir()) == ["a.jpg", "b.jpg"] + + assert main("get", "Family/share/2024") == 1 # a folder needs -R + assert "use -R" in capsys.readouterr().err + assert main("get", "-R", "Family/share/2024", "-o", "tree/") == 0 + assert (tmp_path / "tree/2024/deep/c.txt").read_bytes() == b"C" + assert (tmp_path / "tree/2024/empty").is_dir() + + assert main("get", "Family/share/nope*") == 3 + + assert main("get", "Family/share/notes.txt", "-o", "-") == 0 + assert capsys.readouterr().out == "hello" + + +def test_put_files_folders_and_stdin(node, tmp_path, monkeypatch, capsys): + node.add("share", "taken.txt", b"theirs") + (tmp_path / "a.txt").write_bytes(b"one") + (tmp_path / "Taken.TXT").write_bytes(b"mine") + scans = tmp_path / "Scans" + (scans / "2024").mkdir(parents=True) + (scans / "2024" / "p1.pdf").write_bytes(b"%PDF" + b"x" * 100_000) + (scans / "empty").mkdir() + + assert main("put", str(tmp_path / "a.txt"), "Family/share") == 0 + assert node.stored("share/a.txt") == b"one" + + # A name already there, whatever its case: nothing is sent at all. + assert main("put", str(tmp_path / "a.txt"), str(tmp_path / "Taken.TXT"), + "Family/share") == 8 + assert "already there" in capsys.readouterr().err + assert node.stored("share/Taken.TXT") is None + # -n skips it and sends the rest. + assert main("put", "-n", str(tmp_path / "Taken.TXT"), "Family/share") == 0 + + assert main("put", str(scans), "Family/share") == 1 # a folder needs -R + assert main("put", "-R", str(scans), "Family/share") == 0 + assert node.stored("share/Scans/2024/p1.pdf").startswith(b"%PDF") + assert "share/Scans/empty" in node.dirs + + bad = tmp_path / ".hidden" + bad.write_bytes(b"x") + assert main("put", str(bad), "Family/share") == 8 + assert node.stored("share/.hidden") is None + + monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"piped bytes"))) + assert main("put", "-", "Family/share/from-stdin.txt") == 0 + assert node.stored("share/from-stdin.txt") == b"piped bytes" + + +def test_put_into_a_read_only_folder_sends_nothing(node, tmp_path, capsys): + node.roots[0]["writable"] = False + (tmp_path / "a.txt").write_bytes(b"one") + assert main("put", str(tmp_path / "a.txt"), "Family/share") == 8 + assert "read-only" in capsys.readouterr().err + + +def test_trust_lists_and_forgets_pins(hub, capsys): + assert main("trust", "list") == 0 # nothing stored: nothing pinned + assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, + "--json", "login") == 0 + assert json.loads(capsys.readouterr().out)["groups"] == 1 + from meshbay_cli.client.store import Store + store = Store() + store.load() + store.state["pins"] = {"node-1": "pk-1"} + store.save() + assert main("--json", "trust", "list") == 0 + assert json.loads(capsys.readouterr().out) == {"node-1": "pk-1"} + assert main("trust", "reset") == 1 + assert main("trust", "reset", "node-2") == 3 + assert main("trust", "reset", "node-1") == 0 + assert main("--json", "trust", "list") == 0 + capsys.readouterr() + assert main("--json", "logout") == 0 + assert json.loads(capsys.readouterr().out) == {"signed_out": True} + + +def test_a_usage_error_is_exit_1_not_the_sign_in_code(capsys): + """argparse's own 2 would read as a refused sign-in to a script.""" + assert cli.main(["ls", "--no-such-option"]) == 1 + assert cli.main(["no-such-action"]) == 1 + + + +def test_groups_lists_each_group_with_its_state(hub, capsys): + hub.groups.append({"id": "g2", "name": "Club/Photos", "owner_username": "bob-test", + "node_online": False, "visibility": "public", "is_admin": False, + "hosted": True, "last_activity_at": "2026-10-01T12:00:00+00:00"}) + hub.groups.append({"id": "g3", "name": "Draft\x1b[31m", "owner_username": USERNAME, + "visibility": "private", "is_admin": True, "hosted": False}) + assert main("groups") == 2 # signed in nowhere + capsys.readouterr() + assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, "login") == 0 + capsys.readouterr() + + assert main("groups") == 0 + header, *lines = capsys.readouterr().out.splitlines() + assert header.split() == ["NODE", "ROLE", "VISIBILITY", "LAST", "ACTIVITY", "GROUP"] + # In name order; a "/" in a name is encoded so the path can be typed back. + assert lines[0].split()[:3] == ["down", "member", "public"] + assert lines[0].endswith("Club%2FPhotos@bob-test") + assert "2026-10-0" in lines[0] + assert lines[1].split()[:3] == ["none", "owner", "private"] + assert "\x1b" not in lines[1] and lines[1].endswith(f"Draft\\x1b[31m@{USERNAME}") + assert lines[2].split()[:3] == ["up", "owner", "private"] + + assert main("--json", "groups") == 0 + rows = json.loads(capsys.readouterr().out) + assert [(r["group"], r["node"], r["role"]) for r in rows] == [ + ("Club/Photos", "down", "member"), ("Draft\x1b[31m", "none", "owner"), + ("Family", "up", "owner")] + assert rows[0]["path"] == "Club%2FPhotos@bob-test" + + +def test_every_action_is_run_here(): + """Runs last in this module: every action the parser knows was run above.""" + assert RUN == set(cli.ACTIONS), f"never run: {set(cli.ACTIONS) - RUN}" -- cgit v1.2.3