summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_cli_dispatch.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
commite9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch)
treeb5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/tests/test_cli_dispatch.py
parent50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff)
downloadmeshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_cli_dispatch.py')
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py129
1 files changed, 129 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
new file mode 100644
index 0000000..faca0ce
--- /dev/null
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -0,0 +1,129 @@
+"""
+Every CLI verb reaches its own code without falling over on a name.
+
+`meshbay-node reload` shipped with `subprocess` unimported and crashed with a
+NameError the first time it was typed. Python compiles that file happily —
+`node --check validates syntax, not names` is already in CLAUDE.md about the
+SPA, and it is the same class here: nothing in the module is wrong until the
+branch runs.
+
+So this walks every documented verb with the daemon stubbed out, and asserts
+that the branch executes. It is deliberately shallow — what each command *does*
+is tested in `test_ops.py` and `test_roster_pairing.py`. What this catches is a
+branch nobody ever ran.
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+from meshbay_node import daemon as daemon_mod
+
+# Each verb, with the arguments that reach its branch. `--yes` where the command
+# would otherwise stop for a confirmation nobody can type in a test.
+VERBS = [
+ ["status"],
+ ["ui"],
+ ["group", "list"],
+ ["group", "add"], # missing --dir: usage, then exit
+ ["gek", "init"],
+ ["gek", "rotate", "--yes"],
+ ["gek-init"],
+ ["member", "list"],
+ ["member", "invite", "bob"],
+ ["member", "revoke", "bob"],
+ ["member", "unpin", "bob"],
+ ["operator", "pair"],
+ ["file", "list"],
+ ["file", "rm", "abc", "--yes"],
+ ["denylist", "show"],
+ ["denylist", "clear", "--yes"],
+ ["reload"],
+]
+
+
+@pytest.fixture
+def stub_daemon(monkeypatch, tmp_path):
+ """
+ Answer every loopback call with an empty-ish payload.
+
+ The point is to reach the branch, not to exercise the daemon: a command that
+ only crashes when the node is running is still a command that crashes.
+ """
+ calls: list[tuple] = []
+
+ def fake_api(cfg, path, method="GET", timeout=30, body=None):
+ calls.append((method, path))
+ return {
+ "groups": [], "files": [], "identities": [], "members": [],
+ "invites": [], "users": [], "jtis": [], "count": 0, "removed": 0,
+ "subject": "all", "code": "TEST-CODE", "expires_at": "",
+ "user_id": "u", "authorized_members": 0, "errors": [],
+ "name": "g", "group_id": "g", "shared_dir": str(tmp_path),
+ "config": str(tmp_path / "node.toml"),
+ }
+
+ monkeypatch.setattr(daemon_mod, "_daemon_api", fake_api)
+ monkeypatch.setattr(daemon_mod, "_resolve_group", lambda cfg, g: "g" * 32)
+
+ conf = tmp_path / "node.toml"
+ conf.write_text('[hub]\nurl = "https://example.invalid"\n')
+ monkeypatch.setattr(daemon_mod, "DEFAULT_CONFIG_PATH", conf)
+
+ # No interactive prompt left to hang on.
+ monkeypatch.setattr("builtins.input", lambda *a: "n")
+
+ # `reload` looks for a real daemon and signals it. Without this the test
+ # SIGHUPs whatever node happens to be running on the machine — which it did,
+ # once, before this was added. A test must not reach outside itself.
+ import os
+ import subprocess
+
+ signalled: list[int] = []
+ monkeypatch.setattr(
+ subprocess, "run",
+ lambda *a, **k: subprocess.CompletedProcess(a[0], 0, stdout="4242\n",
+ stderr=""))
+ monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append(pid))
+ calls.append(("_signalled", signalled))
+ return calls
+
+
+@pytest.mark.parametrize("argv", VERBS, ids=lambda a: "-".join(a))
+def test_every_verb_reaches_its_branch(argv, stub_daemon, monkeypatch, capsys):
+ monkeypatch.setattr(sys, "argv", ["meshbay-node", *argv])
+ try:
+ daemon_mod.main()
+ except SystemExit:
+ # A usage message and exit(1) is a branch that ran, which is what this
+ # asserts. A NameError or AttributeError is not.
+ pass
+ except (NameError, AttributeError) as e: # pragma: no cover
+ pytest.fail(f"{' '.join(argv)} crashed on a name: {e}")
+
+ out = capsys.readouterr()
+ assert out.out or out.err, f"{' '.join(argv)} printed nothing at all"
+
+
+def test_the_verb_list_here_matches_the_parser():
+ """
+ A verb added to the parser and not to this file would go untested, which is
+ exactly how `reload` shipped broken.
+ """
+ import argparse
+ import inspect
+
+ source = inspect.getsource(daemon_mod.main)
+ start = source.index('choices=[') + len('choices=[')
+ end = source.index(']', start)
+ declared = {c.strip().strip('"\'') for c in source[start:end].split(',')
+ if c.strip()}
+
+ exercised = {argv[0] for argv in VERBS}
+ # `init` writes a config file and `calibrate-argon2` burns CPU for seconds;
+ # both are excluded on purpose rather than by omission.
+ untested = declared - exercised - {"init", "calibrate-argon2"}
+ assert not untested, (
+ f"CLI verbs with no dispatch test: {sorted(untested)} — add them to "
+ f"VERBS above")