diff options
17 files changed, 231 insertions, 125 deletions
diff --git a/packages/meshbay-hub/tests/node_tree.py b/packages/meshbay-hub/tests/node_tree.py new file mode 100644 index 0000000..8704492 --- /dev/null +++ b/packages/meshbay-hub/tests/node_tree.py @@ -0,0 +1,54 @@ +""" +The node's WebRTC transport, as source text, for the hub tests that check a +client contract against what the node actually sends. + +Read from the tree rather than imported, so these tests keep working in a +checkout without the node installed. The file set is derived — the transport +is `webrtc_server.py` and whatever has been split out of it under +`transport/webrtc/` — so that code moving between those files does not leave a +test reading a file its subject has left. The node suite's `node_source.py` +draws the same line and holds it with `test_node_source_scope.py`. +""" + +import ast +from pathlib import Path + +# parents[2] is `packages/`: the tests live at packages/meshbay-hub/tests/. A +# wrong index here does not fail anything — every test using it skips, which +# is worse than not having them: a green run that measured nothing. That +# happened once. +TRANSPORT = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" + / "meshbay_node" / "transport") +SERVER = TRANSPORT / "webrtc_server.py" + + +def available() -> bool: + return SERVER.exists() + + +def webrtc_files() -> list[Path]: + files = [SERVER] + package = TRANSPORT / "webrtc" + if package.is_dir(): + files += sorted(p for p in package.rglob("*.py") + if "__pycache__" not in p.parts) + return files + + +def webrtc_source() -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in webrtc_files()) + + +def method(name: str) -> str: + """The text of one method of a class in the transport, found by name.""" + found = [] + for path in webrtc_files(): + text = path.read_text(encoding="utf-8") + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.ClassDef): + for member in node.body: + if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) \ + and member.name == name: + found.append(ast.get_source_segment(text, member, padded=True)) + assert len(found) == 1, f"{name}: expected one definition, found {len(found)}" + return found[0] diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py index 027fee6..a1ecec7 100644 --- a/packages/meshbay-hub/tests/test_app_settings_plugin.py +++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py @@ -15,6 +15,7 @@ fault this refactor's new import graph could otherwise reintroduce. import re from pathlib import Path +import node_tree import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" @@ -24,12 +25,6 @@ GROUP_PAGE = STATIC / "group-page.js" SETTINGS_UI = STATIC / "settings-ui.js" FOLDER_TREE = STATIC / "folder-tree.js" TRANSPORT = STATIC / "transport.js" -# parents[2] is `packages/` — the tests live at -# packages/meshbay-hub/tests/, so [0] is tests, [1] the package, [2] packages. -# Got this wrong once and the two cross-package checks below skipped silently, -# which is worse than not having them: a green run that measured nothing. -NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" - / "meshbay_node" / "transport" / "webrtc_server.py") PANES = ["chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js"] @@ -89,11 +84,10 @@ def test_every_registered_app_has_a_key_the_node_would_accept(): key here that `ALLOWED_APPS` does not have is an app whose settings are refused by the node with no clue why. """ - node = NODE_SERVER - if not node.exists(): + if not node_tree.available(): pytest.skip("the node package is not in this checkout") m = re.search(r"ALLOWED_APPS = frozenset\(\{([^}]*)\}\)", - node.read_text(encoding="utf-8")) + node_tree.webrtc_source()) assert m, "ALLOWED_APPS moved" allowed = set(re.findall(r"'([^']+)'|\"([^\"]+)\"", m.group(1))) allowed = {a or b for a, b in allowed} @@ -207,9 +201,8 @@ def test_the_directory_op_is_signed_and_names_its_app(): assert "admin_challenge" in body and "_authorizeAdminOp" in body assert "${appKey}:${clean.join(',')}" in body - node = NODE_SERVER - if node.exists(): - assert 'f"{app}:{\',\'.join(clean)}"' in node.read_text(encoding="utf-8"), ( + if node_tree.available(): + assert 'f"{app}:{\',\'.join(clean)}"' in node_tree.webrtc_source(), ( "the node builds a different subject than the client signs") diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py index 451b57b..7fc9d8e 100644 --- a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py +++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py @@ -31,6 +31,7 @@ the code less app-specific rather than more: import re from pathlib import Path +import node_tree import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" @@ -88,8 +89,7 @@ def test_the_node_names_it_once_and_only_in_the_allow_list(): demonstrate anything. That entry plus the client's registry line is the whole cost. """ - source = (NODE_SRC / "transport" / "webrtc_server.py").read_text( - encoding="utf-8") + source = node_tree.webrtc_source() code = re.sub(r"^\s*#.*$", "", source, flags=re.M) hits = [ln for ln in code.splitlines() if "helloworld" in ln.lower()] assert len(hits) == 1, f"expected one mention, got: {hits}" diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py index 7dd9c81..a488976 100644 --- a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py +++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py @@ -54,12 +54,11 @@ import shutil import subprocess from pathlib import Path +import node_tree import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "video-player.js" -NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" - / "meshbay_node" / "transport" / "webrtc_server.py") pytestmark = pytest.mark.skipif( shutil.which("node") is None or not APP.exists(), @@ -390,20 +389,17 @@ def test_a_buffered_viewer_still_tells_the_node_it_is_there(app): "the node's stall timeout ends a film that is merely paused") -@pytest.mark.skipif(not NODE_SERVER.exists(), reason="node sources unavailable") +@pytest.mark.skipif(not node_tree.available(), reason="node sources unavailable") def test_the_node_ends_a_stream_on_silence_not_on_stinginess(): """The other half of the keepalive: the node has to honour it.""" - text = NODE_SERVER.read_text() - i = text.index("async def _await_stream_credit") - body = text[i:text.index("\n async def ", i + 1)] + body = node_tree.method("_await_stream_credit") assert "self._stream_heard_at" in body, ( "the stall budget still accumulates over the whole wait, so a keepalive " "that grants no credit cannot keep a paused film alive") assert "waited += STREAM_CREDIT_POLL" not in body, ( "the budget still accumulates over the whole wait rather than being " "measured from the last thing the peer said") - grant = text[text.index("def _grant_stream_credit"):] - grant = grant[:grant.index("\n def ", 1)] + grant = node_tree.method("_grant_stream_credit") assert "self._stream_heard_at = time.monotonic()" in grant, ( "n=0 does not refresh the timeout, so the keepalive is a no-op") diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py index 85e774d..665970d 100644 --- a/packages/meshbay-hub/tests/test_video_seek.py +++ b/packages/meshbay-hub/tests/test_video_seek.py @@ -33,13 +33,12 @@ import re import shutil from pathlib import Path +import node_tree import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "video-player.js" TRANSPORT = STATIC / "transport.js" -NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" - / "meshbay_node" / "transport" / "webrtc_server.py") pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") @@ -59,11 +58,9 @@ def _player(app: str) -> str: @pytest.fixture(scope="module") def stream_fn(): - if not NODE_SERVER.exists(): + if not node_tree.available(): pytest.skip("node sources unavailable") - text = NODE_SERVER.read_text() - i = text.index("async def _stream_video_inner") - return text[i:text.index("\n async def ", i + 1)] + return node_tree.method("_stream_video_inner") def test_the_seek_is_an_index_lookup_not_a_decode(stream_fn): diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py index 80d9421..7859057 100644 --- a/packages/meshbay-hub/tests/test_video_stream_switch.py +++ b/packages/meshbay-hub/tests/test_video_stream_switch.py @@ -32,6 +32,7 @@ import shutil import subprocess from pathlib import Path +import node_tree import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" @@ -149,11 +150,9 @@ def test_stream_messages_are_matched_to_the_file_they_belong_to(app, handler): def test_the_node_actually_stamps_those_messages(): """The guard above is worth nothing if the field is not sent.""" - server = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" - / "meshbay_node" / "transport" / "webrtc_server.py") - if not server.exists(): + if not node_tree.available(): pytest.skip("the node sources are not available") - text = server.read_text() + text = node_tree.webrtc_source() for const in ("STREAM_INIT", "STREAM_DATA", "STREAM_END"): i = text.index(f"MNP.{const}") block = text[i:i + 400] diff --git a/packages/meshbay-node/tests/node_source.py b/packages/meshbay-node/tests/node_source.py new file mode 100644 index 0000000..df63773 --- /dev/null +++ b/packages/meshbay-node/tests/node_source.py @@ -0,0 +1,52 @@ +""" +The node's source text, for the tests that have to read it. + +A test that reads source to prove something is *absent* — no bare +`ensure_future`, no exception text sent to a peer, no GEK unwrapped over MNP — +goes on passing when the code it guards moves to another file: it simply stops +looking at that code. So every such test takes its text from here, and the +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 inspect +import textwrap +from pathlib import Path + +SRC = Path(__file__).resolve().parents[1] / "src" / "meshbay_node" +TRANSPORT = SRC / "transport" + +# The WebRTC transport is `webrtc_server.py` and whatever has been split out of +# it into `transport/webrtc/`. The other modules beside them are not part of it +# and are not held to its rules by these tests: QUIC is a transport of its own +# and not at parity (docs/MESHBAY_DESIGN.md §15.3), and `wire.py`, `tls_cert.py` +# and the ICE helpers serve both or neither. +WEBRTC_PACKAGE = TRANSPORT / "webrtc" + + +def webrtc_files() -> list[Path]: + """Every module of the WebRTC transport, however it is split up.""" + files = [TRANSPORT / "webrtc_server.py"] + if WEBRTC_PACKAGE.is_dir(): + files += sorted(p for p in WEBRTC_PACKAGE.rglob("*.py") + if "__pycache__" not in p.parts) + return files + + +def webrtc_source() -> str: + return "\n".join(p.read_text(encoding="utf-8") for p in webrtc_files()) + + +def session_source() -> str: + """The body of WebRTCPeerSession, including every class it is built from.""" + from meshbay_node.transport.webrtc_server import WebRTCPeerSession + + return "\n".join(inspect.getsource(k) for k in WebRTCPeerSession.__mro__ + if k.__module__.startswith("meshbay_node.")) + + +def session_method(name: str) -> str: + """One method of WebRTCPeerSession, wherever it is defined.""" + from meshbay_node.transport.webrtc_server import WebRTCPeerSession + + return textwrap.dedent(inspect.getsource(getattr(WebRTCPeerSession, name))) diff --git a/packages/meshbay-node/tests/test_app_directories.py b/packages/meshbay-node/tests/test_app_directories.py index 955a769..1b8d1e0 100644 --- a/packages/meshbay-node/tests/test_app_directories.py +++ b/packages/meshbay-node/tests/test_app_directories.py @@ -32,6 +32,7 @@ from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet from meshbay_node.roster import Roster from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from node_source import session_method from conftest import one_root @@ -423,11 +424,7 @@ async def test_the_ack_has_no_list_of_applications_of_its_own(): """ from meshbay_node.daemon import NodeDaemon - source = Path(WebRTCPeerSession.__module__.replace(".", "/")) - text = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" - / f"{source}.py").read_text(encoding="utf-8") - body = text[text.index("def _app_directories_ack"):] - body = body[:body.index("\n def ", 1)] + body = session_method("_app_directories_ack") for app in NodeDaemon.APP_DIR_KEYS: assert app not in body, ( f"the ack names {app!r}; it must read the context's own keys") diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py index e286306..da8f5dd 100644 --- a/packages/meshbay-node/tests/test_chat_encryption.py +++ b/packages/meshbay-node/tests/test_chat_encryption.py @@ -30,6 +30,7 @@ from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roster import open_roster from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from node_source import webrtc_source from conftest import one_root @@ -258,8 +259,7 @@ async def test_there_is_no_setting_that_re_enables_plaintext(node): assert session.sent[-1]["type"] == "error" assert await node["chat"].message_count() == 0 - source = (Path(__file__).parent.parent / "src" / "meshbay_node" - / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + source = webrtc_source() assert 'get("chat_encrypted"' not in source, ( "nothing may read a chat_encrypted setting — there is no switch") diff --git a/packages/meshbay-node/tests/test_disk_io_off_loop.py b/packages/meshbay-node/tests/test_disk_io_off_loop.py index ceaf565..f98eceb 100644 --- a/packages/meshbay-node/tests/test_disk_io_off_loop.py +++ b/packages/meshbay-node/tests/test_disk_io_off_loop.py @@ -36,6 +36,7 @@ from meshbay_node.roots import Root, RootSet from meshbay_node.transfers import LeaselessReads from meshbay_node.transport import webrtc_server from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from node_source import webrtc_files from conftest import one_root, sealed_upload @@ -238,9 +239,10 @@ def test_no_handler_touches_the_disk_on_the_loop(): The whole class, not the calls that were fixed. Every measured test above exercises a handler that exists today; a new one - that stats a root inline would pass all of them. So this walks the module's - syntax tree instead and fails on any filesystem call outside the few - functions written to be run through `off_disk`. + that stats a root inline would pass all of them. So this walks the syntax + tree of every module of the WebRTC transport instead and fails on any + filesystem call outside the few functions written to be run through + `off_disk`. `entry_abs_path` and `safe_subdir` are in the list because both are `Path.resolve()` underneath, and a resolve is syscalls whatever it is @@ -274,14 +276,17 @@ def test_no_handler_touches_the_disk_on_the_loop(): found.append(f"{owner} calls {name}() at line {child.lineno}") visit(child, owner) - tree = ast.parse(Path(webrtc_server.__file__).read_text()) - for node in tree.body: - if isinstance(node, ast.ClassDef): - for member in node.body: - if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): - visit(member, member.name) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - visit(node, node.name) + for path in webrtc_files(): + tree = ast.parse(path.read_text(encoding="utf-8")) + before = len(found) + for node in tree.body: + if isinstance(node, ast.ClassDef): + for member in node.body: + if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): + visit(member, member.name) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + visit(node, node.name) + found[before:] = [f"{path.name}: {f}" for f in found[before:]] assert not found, ( "filesystem calls made from the event loop:\n " @@ -362,8 +367,8 @@ def test_the_scratch_read_is_only_ever_reached_on_a_thread(): handler. Passed to `asyncio.to_thread` it appears in the syntax tree as a name; called inline it appears as a call, which is what this refuses. """ - tree = ast.parse(Path(webrtc_server.__file__).read_text()) - direct = [n.lineno for n in ast.walk(tree) + direct = [f"{path.name}:{n.lineno}" for path in webrtc_files() + for n in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "_read_scratch_capped"] diff --git a/packages/meshbay-node/tests/test_hwaccel.py b/packages/meshbay-node/tests/test_hwaccel.py index a79d784..0e6c6ab 100644 --- a/packages/meshbay-node/tests/test_hwaccel.py +++ b/packages/meshbay-node/tests/test_hwaccel.py @@ -15,11 +15,10 @@ real file. import re import sys -from pathlib import Path import pytest from meshbay_node import hwaccel -from meshbay_node.transport import webrtc_server +from node_source import webrtc_source from conftest import needs_subprocess @@ -94,7 +93,7 @@ def test_every_encoder_produces_what_the_node_announces(): and every mode is checked against it. Changing the arguments of one encoder without the other, or changing either without the announced string, fails. """ - source = Path(webrtc_server.__file__).read_text(encoding="utf-8") + source = webrtc_source() announced = set(re.findall(r"avc1\.([0-9a-f]{6})", source)) assert announced == {"640029"}, ( "the streaming path announces a codec string this test does not know " diff --git a/packages/meshbay-node/tests/test_node_source_scope.py b/packages/meshbay-node/tests/test_node_source_scope.py new file mode 100644 index 0000000..e5a86ff --- /dev/null +++ b/packages/meshbay-node/tests/test_node_source_scope.py @@ -0,0 +1,35 @@ +""" +The file set `node_source` hands to the source-reading tests has to contain +the code those tests are about. + +It is derived from the tree — `webrtc_server.py` and everything under +`transport/webrtc/` — so that a module split out later is read without anybody +adding it to a list. That only holds while the code actually lives there: a +class the session is built from, put anywhere else, would leave every +"nothing here does X" test in this suite silently not reading it. +""" + +import inspect +from pathlib import Path + +from meshbay_node.transport.webrtc_server import WebRTCPeerSession, WebRTCTransport +from node_source import TRANSPORT, webrtc_files + + +def test_every_class_the_transport_is_built_from_is_in_scope(): + scope = {p.resolve() for p in webrtc_files()} + for cls in (WebRTCPeerSession, WebRTCTransport): + for klass in cls.__mro__: + if not klass.__module__.startswith("meshbay_node."): + continue + where = Path(inspect.getsourcefile(klass)).resolve() + assert where in scope, ( + f"{klass.__qualname__} ({cls.__name__}) is defined in " + f"{where.relative_to(TRANSPORT.parent)}, which the source-reading " + f"tests do not read — move it under transport/webrtc/") + + +def test_the_scope_is_not_empty(): + files = webrtc_files() + assert TRANSPORT / "webrtc_server.py" in files + assert all(p.exists() for p in files) diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 91191c1..038f12c 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -23,6 +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 conftest import one_root @@ -568,9 +569,7 @@ def test_challenge_carries_node_pk_in_source(): Belt and braces for the above: the field must be in the message the node builds, whatever the surrounding handshake does. """ - source = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "transport" - / "webrtc_server.py").read_text(encoding="utf-8") + source = webrtc_source() challenge = source[source.find("MNP.HANDSHAKE_CHALLENGE,"):] challenge = challenge[:challenge.find("})")] assert "node_pk" in challenge, ( @@ -899,9 +898,7 @@ def test_admin_authority_is_never_fetched_from_the_hub(): """ src = Path(__file__).parent.parent / "src" / "meshbay_node" - verifier = (src / "transport" / "webrtc_server.py").read_text(encoding="utf-8") - body = verifier[verifier.index("async def _verify_admin_sig"):] - body = body[:body.index("\n def ", 1)] + body = session_method("_verify_admin_sig") assert "operator_pks" in body, "the roster is where authority comes from" # Past the docstring: it names what was removed on purpose, so a reader knows # not to put it back. What must not reappear is code. diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 7010523..7ace21b 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -18,6 +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 conftest import one_root, opened_ack, sealed_upload @@ -461,9 +462,7 @@ def test_no_member_can_hand_the_node_key_material(tmp_path): "key material over MNP (C5b)" ) - source = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "transport" - / "webrtc_server.py").read_text(encoding="utf-8") + source = webrtc_source() assert "_do_gek_bundle_store" not in source assert "_admin_exec_bundle_store" not in source @@ -496,8 +495,7 @@ def test_gek_auto_activation_is_gone(): Since the operator's X25519 public key is public, any member could hand the node a GEK of their choosing. Nothing arriving over MNP may set a live GEK. """ - source = (Path(__file__).parent.parent / "src" / "meshbay_node" - / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + source = webrtc_source() assert "_try_activate_gek" not in source assert 'unwrap_gek_aes' not in source, ( "the MNP path must not unwrap a GEK — activation is local-admin only" @@ -709,8 +707,7 @@ def test_peer_errors_do_not_leak_internals(): client needs to know why it was refused, and those strings are authored for that purpose. The check targets the generic `except Exception as e` path. """ - source = (Path(__file__).parent.parent / "src" / "meshbay_node" - / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + source = webrtc_source() assert '"detail": str(e)' not in source, ( "generic exception text relayed to peer — use a fixed message" ) @@ -759,9 +756,10 @@ def test_no_transport_ships_media_outside_the_aead(): "the constant outliving the handlers is how a deleted endpoint keeps " "looking like part of the wire contract") - root = Path(__file__).parent.parent / "src" / "meshbay_node" / "transport" - for name in ("webrtc_server.py", "quic_server.py", "quic_client.py"): - source = (root / name).read_text(encoding="utf-8") + quic = [TRANSPORT / "quic_server.py", TRANSPORT / "quic_client.py"] + for path in webrtc_files() + quic: + name = path.name + source = path.read_text(encoding="utf-8") # Word boundaries: `_stream_segments` and `STREAM_SEGMENT_SIZE` belong # to the live `stream_data` path, which is encrypted and stays. assert not re.search(r"\bstream_seg\b", source), ( @@ -780,11 +778,10 @@ def test_ffmpeg_never_blocks_the_event_loop(): """ import ast - source = (Path(__file__).parent.parent / "src" / "meshbay_node" - / "transport" / "webrtc_server.py").read_text(encoding="utf-8") - tree = ast.parse(source) + source = webrtc_source() blocking = [ - node for node in ast.walk(tree) + node for path in webrtc_files() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "run" diff --git a/packages/meshbay-node/tests/test_task_lifetime.py b/packages/meshbay-node/tests/test_task_lifetime.py index 133c030..a8445fa 100644 --- a/packages/meshbay-node/tests/test_task_lifetime.py +++ b/packages/meshbay-node/tests/test_task_lifetime.py @@ -25,33 +25,28 @@ file. """ import re -from pathlib import Path import pytest - -SERVER = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" - / "transport" / "webrtc_server.py") +from node_source import TRANSPORT, session_method, session_source, webrtc_source pytestmark = pytest.mark.skipif( - not SERVER.exists(), reason="the node sources are not available") + not TRANSPORT.exists(), reason="the node sources are not available") @pytest.fixture(scope="module") def source(): - return SERVER.read_text(encoding="utf-8") + return webrtc_source() @pytest.fixture(scope="module") -def session(source): - """The body of WebRTCPeerSession.""" - i = source.index("class WebRTCPeerSession") - return source[i:source.index("\nclass WebRTCTransport")] +def session(): + """The body of WebRTCPeerSession, and of every class it is built from.""" + return session_source() def test_the_session_keeps_a_reference_to_what_it_starts(session): assert "self._tasks: set[asyncio.Task] = set()" in session - spawn = session[session.index("def _spawn("):] - spawn = spawn[:spawn.index("\n def ", 1)] + spawn = session_method("_spawn") assert "self._tasks.add(task)" in spawn, "the reference is what keeps it alive" assert "self._tasks.discard(" in spawn and "add_done_callback(" in spawn, ( "without this the set grows for the life of the session") @@ -72,8 +67,7 @@ def test_nothing_in_the_session_is_fired_and_forgotten(session): def test_the_stream_still_takes_a_slot_for_its_whole_life(session): """The leak is only interesting because the slot is held this way.""" - body = session[session.index("async def _stream_video(self"):] - body = body[:body.index("\n async def ", 1)] + body = session_method("_stream_video") assert "async with sem:" in body @@ -83,13 +77,11 @@ def test_closing_a_session_releases_its_tasks(session): The cancelling itself lives in shutdown_tasks, which the state handler also uses; close() is the variant that additionally shuts the peer connection. """ - close = session[session.index("async def close(self)"):] - close = close[:close.index("\n\n")] if "\n\n" in close else close + close = session_method("close") assert "shutdown_tasks()" in close assert "self._pc.close()" in close - fn = session[session.index("async def shutdown_tasks(self)"):] - fn = fn[:fn.index("\n async def ", 1)] + fn = session_method("shutdown_tasks") assert "_stop_stream()" in fn assert "task.cancel()" in fn assert "gather" in fn, "cancelling without awaiting does not run the exits" @@ -129,8 +121,7 @@ def test_a_new_request_retires_the_previous_stream(session): assert "self._spawn(self._replace_stream(msg))" in session, ( "the stream request must go through the path that retires the old one") - fn = session[session.index("async def _replace_stream(self"):] - fn = fn[:fn.index("\n async def ", 1)] + fn = session_method("_replace_stream") assert "self._stop_stream()" in fn assert "await asyncio.wait_for(asyncio.shield(prev)" in fn, ( "the slot comes back when the old task exits its `async with sem` — " @@ -173,8 +164,7 @@ def test_losing_the_peer_stops_its_work(source): def test_shutdown_is_separate_from_closing_the_connection(session): """The state handler runs while aiortc is already tearing pc down.""" - fn = session[session.index("async def shutdown_tasks(self)"):] - fn = fn[:fn.index("\n async def ", 1)] + fn = session_method("shutdown_tasks") assert "self._pc.close()" not in fn, ( "calling pc.close() from the state handler re-enters the teardown") assert "task.cancel()" in fn and "gather" in fn @@ -186,8 +176,7 @@ def test_a_dead_channel_is_noticed_while_waiting_not_after(source): Waiting the whole budget on a channel that is already shut is the slot being held for nothing, which is what the log above shows. """ - fn = source[source.index("async def _await_stream_credit"):] - fn = fn[:fn.index("\n async def ", 1)] + fn = session_method("_await_stream_credit") before = fn[:fn.index("wait_for")] assert 'readyState != "open"' in before, ( "the channel must be checked before the wait, not only after it") @@ -221,8 +210,7 @@ def test_the_pipes_are_drained_before_waiting_on_ffmpeg(source): Which is the reported failure exactly: one video fine, the next hanging, the one after refused. """ - fn = source[source.index("async def _stream_video_inner"):] - fn = fn[:fn.index("\n def _send(")] + fn = session_method("_stream_video_inner") finally_block = fn[fn.index("finally:"):] assert "proc.kill()" in finally_block @@ -234,8 +222,7 @@ def test_the_pipes_are_drained_before_waiting_on_ffmpeg(source): def test_releasing_the_slot_does_not_depend_on_ffmpeg_behaving(source): """SIGKILL has already been sent; the OS will reap it either way.""" - fn = source[source.index("async def _stream_video_inner"):] - fn = fn[:fn.index("\n def _send(")] + fn = session_method("_stream_video_inner") tail = fn[fn.index("wait_for(proc.wait()"):] assert "except Exception" in tail, ( "a timeout waiting for ffmpeg must not stop the slot coming back") @@ -253,13 +240,7 @@ def test_chunks_wait_for_room_on_the_channel(session): work through the rest — which is what "stuck at 1 MB" looks like, one chunk being exactly one megabyte. """ - start = session.index("async def _do_file_request") - # Up to whatever the next member is. This used to end at - # "\n def _do_stream_segment" — a neighbour removed in MNP 2.0 — and an - # `index()` on a name that no longer exists fails the test for a reason - # that has nothing to do with what it is about. - nxt = re.search(r"\n (?:@|(?:async )?def )", session[start:]) - fn = session[start:start + nxt.start()] if nxt else session[start:] + fn = session_method("_do_file_request") assert "DOWNLOAD_BUFFER_HIGH" in fn, "the send buffer has to be watched" assert "await asyncio.sleep" in fn, "waiting for room is the point" assert 'readyState != "open"' in fn, ( diff --git a/packages/meshbay-node/tests/test_transport_wire_parity.py b/packages/meshbay-node/tests/test_transport_wire_parity.py index 6e74b28..cd31fb4 100644 --- a/packages/meshbay-node/tests/test_transport_wire_parity.py +++ b/packages/meshbay-node/tests/test_transport_wire_parity.py @@ -23,6 +23,7 @@ from meshbay_common.protocol import MNP, file_chunk_plaintext, file_chunk_wire from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport import quic_server, webrtc_server from meshbay_node.transport.wire import index_sync_message +from node_source import webrtc_source from conftest import one_root @@ -41,23 +42,27 @@ def shared_dir(tmp_path): return d +def _transports(): + """Each transport's whole source: WebRTC is several modules, QUIC is one.""" + return [("the WebRTC transport", webrtc_source()), + (quic_server.__name__, inspect.getsource(quic_server))] + + def test_both_transports_use_the_one_chunk_encoder(): """Neither server may encrypt a chunk itself.""" - for module in (webrtc_server, quic_server): - source = inspect.getsource(module) - assert "file_chunk_wire" in source, f"{module.__name__} bypasses the shared encoder" + for name, source in _transports(): + assert "file_chunk_wire" in source, f"{name} bypasses the shared encoder" assert "encrypt_chunk_aes(" not in source, ( - f"{module.__name__} encrypts a chunk on its own — that is how the two " + f"{name} encrypts a chunk on its own — that is how the two " f"copies diverged the first time") assert "chunk_key_aes(" not in source, ( - f"{module.__name__} derives a chunk key on its own") + f"{name} derives a chunk key on its own") def test_both_transports_use_the_one_index_builder(): - for module in (webrtc_server, quic_server): - source = inspect.getsource(module) + for name, source in _transports(): assert "index_sync_message" in source, ( - f"{module.__name__} builds index_sync itself") + f"{name} builds index_sync itself") assert "index_b64" not in inspect.getsource(quic_server), ( "QUIC is serializing the index again — that was the fork") @@ -68,13 +73,12 @@ def test_neither_transport_seals_by_hand(): A server reaching for AESGCM or HKDF directly is a second envelope waiting to disagree with the first about a nonce length, an info string or an AAD. """ - for module in (webrtc_server, quic_server): - source = inspect.getsource(module) - assert "seal(" in source, f"{module.__name__} sends an unsealed ack" + for name, source in _transports(): + assert "seal(" in source, f"{name} sends an unsealed ack" assert "AESGCM(" not in source, ( - f"{module.__name__} builds its own AEAD instead of using groupbox") + f"{name} builds its own AEAD instead of using groupbox") assert "HKDF(" not in source, ( - f"{module.__name__} derives its own subkey instead of using groupbox") + f"{name} derives its own subkey instead of using groupbox") def test_the_daemon_does_not_build_an_index_message_itself(): diff --git a/packages/meshbay-node/tests/test_upload_size_cap.py b/packages/meshbay-node/tests/test_upload_size_cap.py index cc04eba..dbb14d9 100644 --- a/packages/meshbay-node/tests/test_upload_size_cap.py +++ b/packages/meshbay-node/tests/test_upload_size_cap.py @@ -25,6 +25,7 @@ from meshbay_node.transport.webrtc_server import ( WebRTCPeerSession, WebRTCTransport, ) +from node_source import webrtc_source def _cfg(tmp_path: Path, body: str): @@ -115,8 +116,7 @@ def test_the_handler_reads_it_rather_than_the_constant(): is already in flight — which it does only if the handler asks the context each time instead of closing over the constant. """ - src = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" - / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + src = webrtc_source() i = src.index("Upload exceeds size limit") check = src[src.rindex("if state.bytes", 0, i):i] assert "_max_upload_bytes()" in check, ( |