From e9d5e979fdab9a1cc3c729d602e6f27207b9480c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 02:15:02 +0200 Subject: feat(node): several named roots per group, and one implementation per operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-node/tests/test_cli_dispatch.py | 129 +++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 packages/meshbay-node/tests/test_cli_dispatch.py (limited to 'packages/meshbay-node/tests/test_cli_dispatch.py') 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") -- cgit v1.2.3