diff options
9 files changed, 138 insertions, 59 deletions
diff --git a/packages/meshbay-node/tests/node_source.py b/packages/meshbay-node/tests/node_source.py index df63773..918cf05 100644 --- a/packages/meshbay-node/tests/node_source.py +++ b/packages/meshbay-node/tests/node_source.py @@ -9,6 +9,7 @@ file sets below are derived from the tree rather than listed, so that a module added later is read without anybody having to remember it. """ +import ast import inspect import textwrap from pathlib import Path @@ -50,3 +51,61 @@ def session_method(name: str) -> str: from meshbay_node.transport.webrtc_server import WebRTCPeerSession return textwrap.dedent(inspect.getsource(getattr(WebRTCPeerSession, name))) + + +# The daemon is `daemon.py`, every class NodeDaemon is built from, and the CLI +# once `main()` is split out into a package of its own. +CLI_PACKAGE = SRC / "cli" + + +def daemon_files() -> list[Path]: + """Every module of the daemon and its CLI, however they are split up.""" + from meshbay_node.daemon import NodeDaemon + + files = [SRC / "daemon.py"] + for klass in NodeDaemon.__mro__: + if klass.__module__.startswith("meshbay_node."): + where = Path(inspect.getsourcefile(klass)).resolve() + if where not in files: + files.append(where) + if CLI_PACKAGE.is_dir(): + files += sorted(p for p in CLI_PACKAGE.rglob("*.py") + if "__pycache__" not in p.parts) + return files + + +def daemon_source() -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in daemon_files()) + + +def daemon_class_source() -> str: + """The body of NodeDaemon, including every class it is built from.""" + from meshbay_node.daemon import NodeDaemon + + return "\n".join(inspect.getsource(k) for k in NodeDaemon.__mro__ + if k.__module__.startswith("meshbay_node.")) + + +def cli_source() -> str: + """`meshbay-node`'s entry point and whatever it hands the work to.""" + from meshbay_node import daemon + + files = [] + if CLI_PACKAGE.is_dir(): + files = sorted(p for p in CLI_PACKAGE.rglob("*.py") + if "__pycache__" not in p.parts) + return "\n".join([inspect.getsource(daemon.main)] + + [p.read_text(encoding="utf-8") for p in files]) + + +def daemon_call(name: str) -> str: + """The one call to `name(...)` in the daemon, found by name, not position.""" + found = [] + for path in daemon_files(): + text = path.read_text(encoding="utf-8") + for node in ast.walk(ast.parse(text)): + if (isinstance(node, ast.Call) + and getattr(node.func, "id", getattr(node.func, "attr", None)) == name): + found.append(ast.get_source_segment(text, node)) + assert len(found) == 1, f"{len(found)} calls to {name} in the daemon — re-read this test" + return found[0] diff --git a/packages/meshbay-node/tests/test_audit.py b/packages/meshbay-node/tests/test_audit.py index b5b98b5..1a685ff 100644 --- a/packages/meshbay-node/tests/test_audit.py +++ b/packages/meshbay-node/tests/test_audit.py @@ -1,13 +1,13 @@ """Tests for the node audit store (legal compliance IP/action logging).""" import asyncio -import inspect import time import pytest from meshbay_node.audit import AuditStore from meshbay_node.config import Config, HubConfig, KeystoreConfig, NodeConfig from meshbay_node.daemon import NodeDaemon +from node_source import daemon_class_source @pytest.fixture @@ -132,5 +132,5 @@ async def test_daemon_purges_the_audit_log(audit, tmp_path): def test_daemon_starts_the_purge(): - source = inspect.getsource(NodeDaemon) + source = daemon_class_source() assert "create_task(self._purge_audit_log())" in source diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index a0b2a64..d73dfcf 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -13,10 +13,12 @@ is tested in `test_ops.py` and `test_roster_pairing.py`. What this catches is a branch nobody ever ran. """ +import re import sys import pytest from meshbay_node import daemon as daemon_mod +from node_source import cli_source # 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. @@ -151,18 +153,22 @@ def test_every_verb_reaches_its_branch(argv, stub_daemon, monkeypatch, capsys): assert out.out or out.err, f"{' '.join(argv)} printed nothing at all" -def test_the_verb_list_here_matches_the_parser(): +def test_the_verb_list_here_matches_the_parser(monkeypatch, capsys): """ A verb added to the parser and not to this file would go untested, which is exactly how `reload` shipped broken. - """ - 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()} + The verbs are read from what `--help` prints, not from the source, so this + holds wherever the parser is built. + """ + monkeypatch.setattr(sys, "argv", ["meshbay-node", "--help"]) + with pytest.raises(SystemExit): + daemon_mod.main() + usage = capsys.readouterr().out + groups = [g for g in re.findall(r"\{([\w,-]+)\}", usage) + if "status" in g.split(",")] + assert groups, "--help no longer lists the verbs as {a,b,…}" + declared = set(groups[0].split(",")) exercised = {argv[0] for argv in VERBS} # `init` writes a config file and `calibrate-argon2` burns CPU for seconds; @@ -262,7 +268,7 @@ def test_a_bare_invocation_with_no_config_yet_exits_cleanly(monkeypatch, tmp_pat "a fresh, unprovisioned start must not create anything on disk") -def test_a_removed_verb_says_what_replaced_it(): +def test_a_removed_verb_says_what_replaced_it(stub_daemon, monkeypatch, capsys): """ `member upload` used to set a group-wide switch that no longer exists. It reached the usage line for the *other* member verbs — "usage: meshbay-node @@ -272,20 +278,20 @@ def test_a_removed_verb_says_what_replaced_it(): Naming it costs three lines and is the difference between an operator finding `root set --writable` and concluding the CLI is broken. """ - import inspect - source = inspect.getsource(daemon_mod.main) - start = source.index('if args.command == "member":') - block = source[start:source.index('if args.command == "group":', start)] + monkeypatch.setattr(sys, "argv", ["meshbay-node", "member", "upload"]) + with pytest.raises(SystemExit) as exc: + daemon_mod.main() + assert exc.value.code == 1 + guidance = capsys.readouterr().out - assert 'sub == "upload"' in block, ( + assert "usage: meshbay-node member upload" not in guidance, ( "`member upload` falls through to the generic usage line") - guidance = block[block.index('sub == "upload"'):] - guidance = guidance[:guidance.index("sys.exit")] assert "root set" in guidance and "--writable" in guidance, ( "the message does not name what replaced it") -def test_there_is_no_way_to_create_a_group_in_the_old_shape(): +def test_there_is_no_way_to_create_a_group_in_the_old_shape( + stub_daemon, monkeypatch, capsys): """ `--upload-dir` is gone, and documenting it as deprecated was the wrong answer — which is what it got at first. @@ -301,9 +307,16 @@ def test_there_is_no_way_to_create_a_group_in_the_old_shape(): only legitimate use. Writing it does not. """ import inspect - source = inspect.getsource(daemon_mod.main) - assert "--upload-dir" not in source, ( + assert "--upload-dir" not in cli_source(), ( "the CLI can still create a group in the pre-RO/RW shape") + # With the daemon stubbed: a parser that accepted the flag would otherwise + # go on and add the group on whatever node answers the loopback API. + monkeypatch.setattr(sys, "argv", ["meshbay-node", "group", "add", "g", + "--dir", "/tmp/a", "--upload-dir", "/tmp/b"]) + with pytest.raises(SystemExit) as exc: + daemon_mod.main() + assert exc.value.code == 2, "the parser accepts --upload-dir" + assert "--upload-dir" in capsys.readouterr().err from meshbay_node import ops params = inspect.signature(ops.attach_group).parameters diff --git a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py index 0e89afa..a18e9fa 100644 --- a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py +++ b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py @@ -19,6 +19,7 @@ them in the member payload too. import inspect import re +import sys from pathlib import Path from meshbay_node import daemon as daemon_mod @@ -85,21 +86,16 @@ def test_the_loopback_api_asks_for_paths(): "list_groups uses the member form, so every path it reports is missing") -def test_the_cli_only_reads_fields_the_payload_carries(): +def test_the_cli_only_reads_fields_the_payload_carries(monkeypatch, tmp_path, capsys): """ The gap this whole file exists for. The CLI reads a dict and the API returns a dict; nothing between them says which keys are owed, so a name that is simply absent prints as a placeholder and looks like a node problem. - """ - source = inspect.getsource(daemon_mod.main) - start = source.index('if args.command == "root":') - block = source[start:source.index('if args.command == "operator":', start)] - - read = set(re.findall(r"r\.get\(['\"](\w+)['\"]", block)) - read |= set(re.findall(r"r\[['\"](\w+)['\"]\]", block)) - assert read, "the root CLI no longer reads the payload this way" + `root list` is run against a payload that records every key it is asked + for, so this holds wherever the CLI's code lives. + """ class _Any: path = Path("/tmp/x") name = "x" @@ -107,7 +103,31 @@ def test_the_cli_only_reads_fields_the_payload_carries(): writable = removable = ejected = False available = True - offered = set(RootSet(roots=[_Any()]).describe(with_paths=True)[0]) - assert read <= offered, ( + offered = RootSet(roots=[_Any()]).describe(with_paths=True)[0] + read: set[str] = set() + + class _Recording(dict): + def __getitem__(self, key): + read.add(key) + return super().__getitem__(key) + + def get(self, key, default=None): + read.add(key) + return super().get(key, default) + + gid = "g" * 32 + monkeypatch.setattr(daemon_mod, "_daemon_api", lambda cfg, path, **kw: { + "groups": [{"id": gid, "roots": [_Recording(offered)]}]}) + monkeypatch.setattr(daemon_mod, "_resolve_group", lambda cfg, g: gid) + conf = tmp_path / "node.toml" + conf.write_text('[hub]\nurl = "https://example.invalid"\n') + monkeypatch.setattr(daemon_mod, "DEFAULT_CONFIG_PATH", conf) + monkeypatch.setattr(sys, "argv", ["meshbay-node", "root", "list"]) + + daemon_mod.main() + + assert read, "the root CLI no longer reads the payload this way" + assert "/tmp/x" in capsys.readouterr().out + assert read <= set(offered), ( f"the `root` CLI reads keys the loopback payload does not carry: " - f"{sorted(read - offered)}") + f"{sorted(read - set(offered))}") diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 038f12c..ba16317 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -23,7 +23,7 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roster import Roster, hash_code, normalize_code from meshbay_node.transport.webrtc_server import WebRTCPeerSession -from node_source import session_method, webrtc_source +from node_source import daemon_source, session_method, webrtc_source from conftest import one_root @@ -528,9 +528,7 @@ def test_join_policy_is_carried_from_node_config(): The policy reaches the transport from node.toml. If it ever came from the hub instead, a hub could declare any group open and be handed its key. """ - daemon_src = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "daemon.py").read_text(encoding="utf-8") - assert '"join_policy": group_cfg.join_policy' in daemon_src + assert '"join_policy": group_cfg.join_policy' in daemon_source() config_src = (Path(__file__).parent.parent / "src" / "meshbay_node" / "config.py").read_text(encoding="utf-8") @@ -883,8 +881,7 @@ def test_daemon_does_not_auto_pin_keystore_key(): Authority now comes from the roster, or from an explicit node.toml value. """ - source = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "daemon.py").read_text(encoding="utf-8") + source = daemon_source() assert "Auto-pinning admin key" not in source assert "_resolve_admin_pk" not in source, ( "the auto-pin resolver is back — node authority must be established " @@ -896,7 +893,6 @@ def test_admin_authority_is_never_fetched_from_the_hub(): The fix M3 invites: ask the hub which key belongs to the operator. That would hand a malicious hub the node — the same substitution as H3, one level deeper. """ - src = Path(__file__).parent.parent / "src" / "meshbay_node" body = session_method("_verify_admin_sig") assert "operator_pks" in body, "the roster is where authority comes from" @@ -908,7 +904,7 @@ def test_admin_authority_is_never_fetched_from_the_hub(): f"_verify_admin_sig mentions {forbidden!r} — authority must come from " "the local roster and nothing else") - daemon = (src / "daemon.py").read_text(encoding="utf-8") + daemon = daemon_source() assert "has_operator()" in daemon, "the daemon reads authority from the roster" assert "admin_pk_ed25519" not in daemon, ( "the node.toml operator key is gone; it must not come back as a second " diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 5e8badf..8e90332 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -18,7 +18,7 @@ from meshbay_common.crypto import generate_gek from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet from meshbay_node.transport.webrtc_server import WebRTCPeerSession -from node_source import TRANSPORT, webrtc_files, webrtc_source +from node_source import TRANSPORT, daemon_source, webrtc_files, webrtc_source from conftest import one_root, opened_ack, sealed_upload @@ -434,8 +434,7 @@ def test_chat_store_and_peers_are_per_group(tmp_path): def test_daemon_sets_no_global_chat_store(tmp_path): """H1: the daemon must not hoist one group's chat store onto the transport.""" - source = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "daemon.py").read_text(encoding="utf-8") + source = daemon_source() assert '_ctx["chat_store"]' not in source, ( "daemon must not assign a transport-wide chat_store — it leaks chat " "across groups (H1)" @@ -610,8 +609,7 @@ def test_swarm_registration_skips_private_groups(): mis-mounted route, so fixing the route without this filter would have turned a dormant leak into a live one. """ - source = (Path(__file__).parent.parent / "src" / "meshbay_node" - / "daemon.py").read_text(encoding="utf-8") + source = daemon_source() assert 'visibility' in source and '_register_swarm' in source # Both registration sites must gate on public visibility. for marker in ['gctx.get("visibility") == "public"', diff --git a/packages/meshbay-node/tests/test_stream_capacity_config.py b/packages/meshbay-node/tests/test_stream_capacity_config.py index 5514712..a40cbc0 100644 --- a/packages/meshbay-node/tests/test_stream_capacity_config.py +++ b/packages/meshbay-node/tests/test_stream_capacity_config.py @@ -27,6 +27,7 @@ from meshbay_node.config import load_config from meshbay_node.roots import RootSet from meshbay_node.transport.webrtc.apps.streaming import MAX_CONCURRENT_TRANSCODES from meshbay_node.transport.webrtc_server import WebRTCPeerSession, WebRTCTransport +from node_source import daemon_call def _cfg(tmp_path: Path, body: str): @@ -133,10 +134,7 @@ def test_the_daemon_passes_it( ): `self.cfg` parses and imports perfectly well; it raises AttributeError the first time somebody plays a video, which is not where anyone would look. """ - daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" - / "daemon.py").read_text(encoding="utf-8") - i = daemon.index("WebRTCTransport(") - call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))] + call = daemon_call("WebRTCTransport") assert "max_concurrent_streams=" in call, ( "the daemon builds the transport without the operator's setting, so " "node.toml is read and then ignored") diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py index 3250f7f..c7008c8 100644 --- a/packages/meshbay-node/tests/test_transport_wire_parity.py +++ b/packages/meshbay-node/tests/test_transport_wire_parity.py @@ -24,7 +24,7 @@ from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport import quic_server from meshbay_node.transport.webrtc import disk from meshbay_node.transport.wire import index_sync_message -from node_source import webrtc_source +from node_source import daemon_source, webrtc_source from conftest import one_root @@ -88,9 +88,7 @@ def test_the_daemon_does_not_build_an_index_message_itself(): site for an index message, and the one that would have kept sending cleartext while the other two were sealed. """ - from meshbay_node import daemon - - source = inspect.getsource(daemon) + source = daemon_source() assert "index_delta_message" in source and "index_sync_message" in source assert '"type": MNP.INDEX_DELTA' not in source, ( "the daemon builds index_delta by hand again") diff --git a/packages/meshbay-node/tests/test_upload_size_cap.py b/packages/meshbay-node/tests/test_upload_size_cap.py index 7675f32..d2deb03 100644 --- a/packages/meshbay-node/tests/test_upload_size_cap.py +++ b/packages/meshbay-node/tests/test_upload_size_cap.py @@ -21,7 +21,7 @@ from meshbay_node.config import load_config from meshbay_node.roots import RootSet from meshbay_node.transport.webrtc.upload_handlers import GB_BYTES, MAX_UPLOAD_BYTES from meshbay_node.transport.webrtc_server import WebRTCPeerSession, WebRTCTransport -from node_source import webrtc_source +from node_source import daemon_call, webrtc_source def _cfg(tmp_path: Path, body: str): @@ -138,10 +138,7 @@ def test_zero_is_refused_at_the_transport_too(): def test_the_daemon_passes_it(): """The join that syntax checking cannot see.""" - daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" - / "daemon.py").read_text(encoding="utf-8") - i = daemon.index("WebRTCTransport(") - call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))] + call = daemon_call("WebRTCTransport") assert "max_upload_gb=self._config.node.max_upload_gb" in call, ( "the daemon builds the transport without the operator's ceiling, so " "node.toml is read and then ignored") |