diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 02:15:02 +0200 |
| commit | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch) | |
| tree | b5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/tests/test_cli_dispatch.py | |
| parent | 50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff) | |
| download | meshbay-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.py | 129 |
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") |