aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_cli_actions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/tests/test_cli_actions.py
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/test_cli_actions.py')
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py238
1 files changed, 238 insertions, 0 deletions
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}"