diff options
Diffstat (limited to 'packages/meshbay-node/tests')
32 files changed, 364 insertions, 84 deletions
diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py index 692a118..2ca0c8a 100644 --- a/packages/meshbay-node/tests/conftest.py +++ b/packages/meshbay-node/tests/conftest.py @@ -8,7 +8,7 @@ from meshbay_node.roots import RootSet # ffmpeg / ffprobe run via asyncio.create_subprocess_exec, which needs the # ProactorEventLoop — but the repo-root conftest forces the SelectorEventLoop -# on win32 so aiortc's ICE stack works there (see devel/windows-devel.md §5). +# on win32 so aiortc's ICE stack works there (see docs/MESHBAY_DESIGN.md §11.2). # The two are mutually exclusive on one Windows asyncio loop; until the media # path gets a thread-based subprocess runner, these tests can't run on win32. needs_subprocess = pytest.mark.skipif( @@ -39,7 +39,7 @@ def _restore_media_tool_paths(): _plat._ffmpeg_path, _plat._ffprobe_path = before -# Windows-only gaps still to close (see devel/windows-devel.md §5/§6). +# Windows-only gaps still to close (see docs/MESHBAY_DESIGN.md §11.2). win32_todo = pytest.mark.skipif( sys.platform == "win32", reason="Windows behaviour not implemented yet (W3 / platform specifics)", diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py index d353d27..251617c 100644 --- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py +++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py @@ -1,12 +1,11 @@ """ -Music-app enrichment (tag/cover extraction, docs/musicbay.md §2.1/§6) only +Music-app enrichment (tag/cover extraction, docs/MESHBAY_DESIGN.md §9.8) only ever runs for a group that has an audio_root configured, and only for files under it — see daemon.py's _enrich_new_audio_entries. Same reasoning as Videos' video_root gate (test_video_root_gates_enrichment.py), added later: -musicbay.md's original "no root, whole shared tree" call turned out wrong -against a real messy library, where everything under every shared folder -got mixed together with no way to scope Music down to just the actual -music library. +the original "no root, whole shared tree" call turned out wrong against a +real messy library, where everything under every shared folder got mixed +together with no way to scope Music down to just the actual music library. Setting or changing the folder (ops.set_app_directory) fires a one-off sweep (_enrich_audio_root_now) of whatever it already contains — same shape as diff --git a/packages/meshbay-node/tests/test_audio_transcode.py b/packages/meshbay-node/tests/test_audio_transcode.py index c2f7f64..a6557f0 100644 --- a/packages/meshbay-node/tests/test_audio_transcode.py +++ b/packages/meshbay-node/tests/test_audio_transcode.py @@ -1,6 +1,6 @@ """ Tests for the Music app's one exception to "no node-side transcode pool" -(docs/musicbay.md §2.2): WMA and Musepack tag/cover fine (enrich_audio.py) +(docs/MESHBAY_DESIGN.md §9.8): WMA and Musepack tag/cover fine (enrich_audio.py) but decode in no mainstream browser's <audio> element at all, so `_do_audio_transcode_request` converts to AAC/M4A on request and caches the result — served back through the ordinary file_req/chunk path, generalized @@ -191,3 +191,39 @@ async def test_multi_chunk_cached_blob_reassembles_correctly(tmp_path, media_cac assert len(chunk_msgs) == total_chunks reassembled = _reassemble_file_chunks(session.sent, gek, blob_hash) assert reassembled == blob + + +async def test_only_the_two_formats_that_need_it_are_transcoded(tmp_path, media_cache): + """ + The gate that was written down and never applied. + + `BROWSER_INCOMPATIBLE_AUDIO_EXTS` was read by nobody: the player asked only + for `.wma` and `.mpc`, and the node converted whatever file id it was given. + A member's own message is not the player, and this conversion is whole-file + while holding a transcode slot shared with video streaming — so one message + naming a two-hour film spends minutes of the operator's CPU and a slot every + other viewer is queued behind. The size cap catches the result; only this + catches the work. + """ + clip = tmp_path / "feature.mkv" + clip.write_bytes(b"not really a film, and never opened") + session, file_id = _session(tmp_path, clip, generate_gek(), media_cache) + + await session._do_audio_transcode_request({"file_id": file_id}) + + (msg,) = session.sent + assert msg["type"] == "error" + assert msg["code"] == "transcode_not_applicable" + + +@pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") +async def test_the_two_formats_that_do_need_it_still_pass(tmp_path, media_cache): + """The gate must admit what it exists for; a refusal of everything is not a gate.""" + clip = tmp_path / "clip.wma" + _make_wma_clip(clip) + session, file_id = _session(tmp_path, clip, generate_gek(), media_cache) + + await session._do_audio_transcode_request({"file_id": file_id}) + + assert [m for m in session.sent if m.get("type") == MNP.AUDIO_TRANSCODE_RESP], ( + f"a WMA file was refused: {session.sent}") diff --git a/packages/meshbay-node/tests/test_bundle_store_recovery.py b/packages/meshbay-node/tests/test_bundle_store_recovery.py index 10a3400..d649716 100644 --- a/packages/meshbay-node/tests/test_bundle_store_recovery.py +++ b/packages/meshbay-node/tests/test_bundle_store_recovery.py @@ -1,5 +1,5 @@ """ -The recovery-wrapped keypair copy (docs/auth-confirm.md §4.3, MNP 0.14). +The recovery-wrapped keypair copy (docs/MESHBAY_DESIGN.md §3.6, MNP 0.14). `bundle_enc_recovery` is a second copy of the identity bundle wrapped under the account's recovery key. The store has to add the column to a database that diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py index 0401d27..e286306 100644 --- a/packages/meshbay-node/tests/test_chat_encryption.py +++ b/packages/meshbay-node/tests/test_chat_encryption.py @@ -1,7 +1,7 @@ """ Chat encryption: what the node stores, what it refuses, and what survives. -Design A of `docs/chat-sender-keys.md`. Every test here is written as "this +Design A of `docs/MESHBAY_DESIGN.md` §4.5. Every test here is written as "this does not work" or "this still works after X" — the regressions the plan's register names, in the order they would bite. diff --git a/packages/meshbay-node/tests/test_chat_history_binary.py b/packages/meshbay-node/tests/test_chat_history_binary.py index 18efbf5..e2397a0 100644 --- a/packages/meshbay-node/tests/test_chat_history_binary.py +++ b/packages/meshbay-node/tests/test_chat_history_binary.py @@ -10,7 +10,7 @@ possible place to look for a wire-format error. The fix keeps plaintext exactly where it has always been (a string in `payload`, which older clients read) and gives ciphertext its own `ct` field. -That way this is not a compatibility break either — `docs/chat-sender-keys.md` +That way this is not a compatibility break either — `docs/MESHBAY_DESIGN.md` §4.5 R3. """ diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py index d718b2a..bb61285 100644 --- a/packages/meshbay-node/tests/test_chat_multidevice.py +++ b/packages/meshbay-node/tests/test_chat_multidevice.py @@ -11,8 +11,9 @@ what they said. Neither shows up as an error anywhere. The first is a message that silently reaches nobody after a second device connects and disconnects; the second is a phone that never shows what was typed on the laptop. Both are -`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it -should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE. +finding F7 (`docs/MESHBAY_DESIGN.md` §13.6), and both are the same "keyed by +account where it should be keyed by connection" mistake as `pin_identity`'s +old INSERT OR REPLACE. """ from pathlib import Path diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index ce661b5..ad60b91 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -173,7 +173,7 @@ def test_the_verb_list_here_matches_the_parser(): f"VERBS above") # The server-rendered admin UI (and its `ui` verb) were removed in - # docs/refactor-node-ui.md phase 5. The control API stays; the browser + # docs/MESHBAY_DESIGN.md §6.7. The control API stays; the browser # page does not. assert "ui" not in declared, "the `ui` verb came back" diff --git a/packages/meshbay-node/tests/test_device_on_connection.py b/packages/meshbay-node/tests/test_device_on_connection.py index 3da8a8c..03ed9ca 100644 --- a/packages/meshbay-node/tests/test_device_on_connection.py +++ b/packages/meshbay-node/tests/test_device_on_connection.py @@ -12,7 +12,7 @@ oldest live device" and calling it the answer: * `_admin_exec_file_delete`, which authorized deletion against **that exact key** — so a person could not delete their own file from their other device, and the only symptom was "Signature verification failed" on their own upload - (`docs/desktop-client-v1.md` §4.8 A). + (`docs/MESHBAY_DESIGN.md` §3.3). `device_hello` closes the first: additive, signed, refused unless the key is a live device *of this account in the node's own roster*. The second is closed by @@ -224,8 +224,9 @@ class _Entry: async def test_a_second_device_can_delete_the_first_devices_upload( tmp_path, roster): """ - §4.8 A. Alice uploads from her phone and deletes from her desktop. Before - the fix this failed with "Signature verification failed" on her own file. + docs/MESHBAY_DESIGN.md §3.3. Alice uploads from her phone and deletes + from her desktop. Before the fix this failed with "Signature verification + failed" on her own file. """ sk_phone, pk_phone, pk_x_phone = _keys() sk_desk, pk_desk, pk_x_desk = _keys() 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 2179e66..ceaf565 100644 --- a/packages/meshbay-node/tests/test_disk_io_off_loop.py +++ b/packages/meshbay-node/tests/test_disk_io_off_loop.py @@ -25,6 +25,7 @@ import threading import time from pathlib import Path +import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.crypto import generate_gek from meshbay_common.protocol import MNP @@ -252,13 +253,11 @@ def test_no_handler_touches_the_disk_on_the_loop(): on_the_disk_thread = {"_locate", "_append_chunk", "_read_and_encrypt", "_mkdir_if_absent", "_is_empty_dir", "_rmdir_if_empty", "safe_subdir"} - # ffmpeg's own output, under `tempfile.mkstemp` on the system disk — not a - # group root, so not what spins down. Listed rather than silently allowed: - # these still read a whole transcode into memory from the loop, and the day - # that matters it is a different measurement from this one. - ffmpeg_scratch = {"_transcode_audio_to_aac", "_seek_lands_at", - "_extract_subtitle_to_webvtt"} - allowed = on_the_disk_thread | ffmpeg_scratch + # ffmpeg's own output goes through `_read_scratch_capped` and + # `_discard_scratch` on a worker thread — `asyncio.to_thread` and not + # `off_disk`, because a temp file is not a group root and has no platter to + # serialise against. Nothing is exempt here any more. + allowed = on_the_disk_thread | {"_read_scratch_capped"} found = [] @@ -354,3 +353,52 @@ async def test_chunks_of_one_upload_keep_their_order_under_a_slow_disk(tmp_path, refusals = [m for m in session.sent if m.get("type") == "error"] assert not refusals, f"a chunk was refused: {refusals}" assert (shared / "clip.bin").read_bytes() == b"".join(pieces) + + +def test_the_scratch_read_is_only_ever_reached_on_a_thread(): + """ + `_read_scratch_capped` blocks by design, so the guard above allows it — and + that allowance is worth nothing if somebody calls it straight from a + 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) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Name) + and n.func.id == "_read_scratch_capped"] + assert not direct, ( + f"_read_scratch_capped is called directly at line(s) {direct} — hand it " + "to `asyncio.to_thread` instead, or the cap is paid for on the loop") + + +async def test_ffmpeg_output_over_the_cap_is_refused_before_it_is_read(tmp_path): + """ + The stat comes first, so an oversized result costs a stat rather than the + read and the memory. The number in the message is the one that was measured, + not the cap, because an operator reading a log wants to know by how much. + """ + scratch = tmp_path / "out.m4a" + scratch.write_bytes(b"x" * 5000) + + with pytest.raises(RuntimeError, match=r"5000 bytes, over the 1024 cap"): + webrtc_server._read_scratch_capped(scratch, 1024, "transcoded audio") + + # And under the cap it simply reads. + assert webrtc_server._read_scratch_capped(scratch, 8192, "x") == b"x" * 5000 + + +async def test_a_slow_scratch_read_does_not_stop_the_loop(tmp_path, monkeypatch): + """Measured like the others: the loop keeps its wake-ups during the read.""" + scratch = tmp_path / "out.vtt" + scratch.write_bytes(CONTENT) + monkeypatch.setattr(webrtc_server, "_read_scratch_capped", + _slow(webrtc_server._read_scratch_capped)) + + with _Ticker() as ticker: + blob = await asyncio.to_thread( + webrtc_server._read_scratch_capped, scratch, 1 << 20, "subtitle track") + + assert blob == CONTENT + assert ticker.ticks > SLOW_S / TICK_S / 2, ( + f"the loop was blocked: {ticker.ticks} wake-ups during a {SLOW_S}s read") diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py index 75cf0ee..e059bbe 100644 --- a/packages/meshbay-node/tests/test_enrich.py +++ b/packages/meshbay-node/tests/test_enrich.py @@ -134,7 +134,7 @@ def test_synthetic_episode_number_does_not_collide_across_per_season_bonus_folde # ── end-to-end against a real (tiny, synthetic) video file ────────────────── # ffprobe runs via asyncio subprocess, which the win32 selector loop (forced -# for aiortc, see devel/windows-devel.md §5) cannot spawn. +# for aiortc, see docs/MESHBAY_DESIGN.md §11.2) cannot spawn. pytestmark_ffmpeg = pytest.mark.skipif( not _HAVE_FFMPEG or sys.platform == "win32", reason="needs ffprobe installed and a ProactorEventLoop", diff --git a/packages/meshbay-node/tests/test_enrich_photo.py b/packages/meshbay-node/tests/test_enrich_photo.py index 7684f40..81367eb 100644 --- a/packages/meshbay-node/tests/test_enrich_photo.py +++ b/packages/meshbay-node/tests/test_enrich_photo.py @@ -172,7 +172,7 @@ async def test_enricher_corrects_orientation(tmp_path, media_cache): def test_gps_is_never_read_by_this_module(): """ - docs/photos.md §2.4/§11: GPS must never be extracted, cached, or handed + docs/MESHBAY_DESIGN.md §9.9: GPS must never be extracted, cached, or handed to a caller — a location disclosure the instant it is surfaced to every group member. Grep-based, the same discipline test_hub_address_seam.py/ test_task_lifetime.py already apply elsewhere in this codebase to a diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py index cb9828c..a41cce2 100644 --- a/packages/meshbay-node/tests/test_group_roster.py +++ b/packages/meshbay-node/tests/test_group_roster.py @@ -1,9 +1,9 @@ """ Tier 2: a member verifies another member's device for themselves. -`docs/desktop-client-v1.md` §4.8, and `docs/chat-sender-keys.md` §13, which -recorded why it could not ship with the encryption: **the evidence was not being -kept.** `_do_device_add` verified the countersignature and stored only +`docs/MESHBAY_DESIGN.md` §3.3, which records why it could not ship with the +encryption: **the evidence was not being kept.** `_do_device_add` verified the +countersignature and stored only `added_by_pk` — *which* key approved, never the proof — and the transcript binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who was not on that connection. diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py index f12a366..dc78d8d 100644 --- a/packages/meshbay-node/tests/test_media_cache.py +++ b/packages/meshbay-node/tests/test_media_cache.py @@ -133,7 +133,7 @@ async def test_prune_file_also_clears_the_override_marker(cache): assert await cache.clear_tmdb_matches(["gone"]) == 1 -# ── Music app (docs/musicbay.md §6) — file_mbid/mbid_meta ──────────────────── +# ── Music app (docs/MESHBAY_DESIGN.md §9.8) — file_mbid/mbid_meta ──────────── @pytest.mark.asyncio async def test_file_mbid_round_trip(cache): diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py index cff7fea..ccf7597 100644 --- a/packages/meshbay-node/tests/test_musicbrainz.py +++ b/packages/meshbay-node/tests/test_musicbrainz.py @@ -156,7 +156,8 @@ async def test_no_contact_configured_makes_no_request(): result, ratio = await client.search_release("Anyone", "Anything") assert result is None - assert calls == [], "an unidentified client must never be sent — see musicbay.md §3.1" + assert calls == [], ("an unidentified client must never be sent " + "— see docs/MESHBAY_DESIGN.md §9.8") await client.close() @@ -227,7 +228,7 @@ async def test_cover_art_found_returns_bytes(): @pytest.mark.asyncio async def test_calls_are_paced_at_least_min_interval_apart(): """ - docs/musicbay.md §3.2: the ~1 req/s courtesy limit is this node's own + docs/MESHBAY_DESIGN.md §9.8: the ~1 req/s courtesy limit is this node's own job, not something the server hands out — verified by timing two calls back to back rather than mocking the clock, so a change to the pacing implementation that still meets the contract doesn't break this test. diff --git a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py index e86a3f3..76b687e 100644 --- a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py +++ b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py @@ -1,7 +1,8 @@ """ -Whether MusicBrainz lookups run *at all* for a group — docs/musicbay.md -§3.2/§6. Per-group from the start (unlike tmdb_enabled, which started -node-wide and moved per-group later once the lesson was already learned). +Whether MusicBrainz lookups run *at all* for a group — +docs/MESHBAY_DESIGN.md §9.8. Per-group from the start (unlike tmdb_enabled, +which started node-wide and moved per-group later once the lesson was already +learned). Same shape as test_tmdb_enabled_policy.py: a signed operator instruction, scoped to self._group_id (not passed explicitly on the wire), stored via roster.py's group_settings table under the real group_id. diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 877994d..7b44bbe 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -24,16 +24,14 @@ NSH = CLIENT / "build" / "installer.nsh" MAIN_JS = CLIENT / "src" / "main.js" PRELOAD_JS = CLIENT / "src" / "preload.js" -# The "Light" target: Electron client + UI, no bundled node. See -# C:\Users\admin\devel\light-client.md for the evaluation this implements. +# The "Light" target: Electron client + UI, no bundled node. LIGHT_NSH = CLIENT / "build" / "installer-light.nsh" LIGHT_YML = WIN / "electron-builder.light.yml" BUILD_WIN_LIGHT = WIN / "build-win-light.ps1" BUILD_WIN_COMMON = WIN / "build-win-common.ps1" # The "MSIX" target: same feature set as Full, packaged for Microsoft Store -# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for -# the plan this implements. +# submission instead of NSIS. MSIX_YML = WIN / "electron-builder.msix.yml" BUILD_WIN_MSIX = WIN / "build-win-msix.ps1" MSIX_EXTENSIONS_XML = CLIENT / "build" / "appx-extensions.xml" @@ -683,8 +681,7 @@ def test_ffmpeg_bundling_is_the_default_not_opt_in(): # ------------------------------------------------------------------------ -# The "Light" target: Electron client + UI, no bundled node. See -# C:\Users\admin\devel\light-client.md for the evaluation. Weak, text- +# The "Light" target: Electron client + UI, no bundled node. Weak, text- # reading evidence throughout, same reasoning as the rest of this file: # there is no electron-builder/PowerShell/NSIS runner here, and it is the # right kind of evidence for what these guard against -- a config drifting @@ -921,9 +918,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): # ------------------------------------------------------------------------ # The "MSIX" target: same feature set as Full, packaged for Microsoft Store -# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for -# the plan. Unlike Light, this target keeps the node runtime and both -# service scripts -- what changes is packaging format, not what ships. +# submission instead of NSIS. Unlike Light, this target keeps the node +# runtime and both service scripts -- what changes is packaging format, not +# what ships. # Weak, text-reading evidence throughout, same reasoning as the rest of # this file: there is no electron-builder/appx runner here either. # ------------------------------------------------------------------------ @@ -931,9 +928,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): def test_msix_config_is_standalone_and_keeps_the_full_bundle(): """ Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as - Full -- an AppX install never elevating (msix-installer.md 4) is not a - reason to drop the daemon, only to change how its two elevated - operations get triggered (see the two tests below). --config still + Full -- an AppX install never elevating is not a reason to drop the + daemon, only to change how its two elevated operations get triggered + (see the two tests below). --config still means this file is read alone (app-builder-lib's getConfig), so it cannot silently inherit Full's package.json build.nsis or any signing config meant for NSIS. @@ -979,11 +976,11 @@ def test_msix_declares_no_csc_on_purpose(): No certificateFile/certificateSubjectName/certificateSha1 anywhere in this config -- per app-builder-lib's own windowsSignToolManager.js, an AppX target built with no certificate configured is logged as "Windows - Store only build" and left unsigned; Microsoft signs it at publish time - (msix-installer.md 3). Configuring a cert here would be wasted work, not - extra safety, and would risk this target picking up whatever might one - day be configured for Full's NSIS signing if it were ever added to this - file instead of package.json's own build.win. + Store only build" and left unsigned; Microsoft signs it at publish + time. Configuring a cert here would be wasted work, not extra safety, + and would risk this target picking up whatever might one day be + configured for Full's NSIS signing if it were ever added to this file + instead of package.json's own build.win. """ yml = MSIX_YML.read_text(encoding="utf-8") for forbidden in ("certificateFile", "certificateSubjectName", "certificateSha1"): @@ -993,10 +990,10 @@ def test_msix_declares_no_csc_on_purpose(): def test_msix_declares_the_network_capabilities_firewall_ps1_would_add(): """ Matches firewall.ps1's own rules, which are `-Profile Any` (private AND - public network) -- msix-installer.md 8's #1 open item: whether Windows - actually auto-exempts a full-trust packaged app on the strength of - these declarations is unverified until sideloaded, but the declaration - itself must at least match what the elevated NSIS path grants today, or + public network). Whether Windows actually auto-exempts a full-trust + packaged app on the strength of these declarations is unverified until + sideloaded, but the declaration itself must at least match what the + elevated NSIS path grants today, or an MSIX install would be silently narrower than Full/Light. """ yml = MSIX_YML.read_text(encoding="utf-8") diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 14285e7..2e8c183 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -7,7 +7,7 @@ or a mistake that must not work. The one to keep an eye on is sovereignty inert as shipped, and it fails closed, so nothing else in the suite notices if it comes back. -See `docs/invite-pairing-v1.md`. +See `docs/MESHBAY_DESIGN.md` §3.4. """ import base64 diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py index 44d7da6..4141d13 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -1,6 +1,7 @@ """ `_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the -season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose +season-tab view — docs/MESHBAY_DESIGN.md §9.7's per-season text, found live +against a 3-season show whose overview read as season-3-specific for every season) and `_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a wrong automatic match). Neither is a signed admin op — see each handler's own @@ -21,6 +22,12 @@ def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession: session = WebRTCPeerSession.__new__(WebRTCPeerSession) session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client} session._group_id = None + # Set because production always has one: `_dispatch_message` refuses every + # message until the handshake settles `_user_id`, so a session reaching any + # of these handlers without it does not exist. Left out, this fixture was + # narrower than the node and the per-member search ceiling could not be + # exercised by it at all. + session._user_id = "u1" session.sent = [] session._send = session.sent.append return session diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index a229153..d6ecb71 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -1,10 +1,10 @@ """ Phase 11.5 security regression tests. -Each test here encodes a finding from `second-review.md`. They are negative tests: -they assert that an attack does NOT work. The pre-11.5 code passed 209 feature -tests while every one of these attacks succeeded — the suite only ever exercised -happy paths, never an authorization boundary. +Each test here encodes a finding from `docs/MESHBAY_DESIGN.md` §13.3. They are +negative tests: they assert that an attack does NOT work. The pre-11.5 code +passed 209 feature tests while every one of these attacks succeeded — the suite +only ever exercised happy paths, never an authorization boundary. If one of these starts failing, a fix has been reverted. Do not "fix" the test. """ @@ -447,7 +447,7 @@ def test_daemon_sets_no_global_chat_store(tmp_path): def test_no_member_can_hand_the_node_key_material(tmp_path): """ - C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md). + C5b, strengthened by the invite redesign (docs/MESHBAY_DESIGN.md §3.4). This test used to assert that `gek_bundle_store` answered with an admin challenge and stored nothing without an operator signature. The message is now @@ -818,7 +818,7 @@ def test_node_control_api_serves_no_html(): H2 was stored XSS in the server-rendered admin dashboard: a member-chosen filename, or a hub-supplied username, landed in an HTML page on the operator's machine unescaped. That dashboard is gone - (docs/refactor-node-ui.md phase 5) — the control API is JSON only, so there + (docs/MESHBAY_DESIGN.md §6.7) — the control API is JSON only, so there is no server-side template to inject into. The Node page that replaced it ships in the desktop client and escapes by default (Preact). diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py index d2cbc3e..f43c6e9 100644 --- a/packages/meshbay-node/tests/test_startup_scan_enrichment.py +++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py @@ -12,8 +12,8 @@ is already running (seen by the watchdog), would trigger it. Found live against a real library after the first restart with this feature enabled. Enrichment only runs once a group has a video_root configured (a group -with none set gets no TMDB/thumbnail work at all, docs/mediacenter.md -§5.2/§10) — this test's fake roster reports the shared root itself as the +with none set gets no TMDB/thumbnail work at all, docs/MESHBAY_DESIGN.md +§6.5) — this test's fake roster reports the shared root itself as the configured video_root, so the enrichment-scheduling behaviour under test is exercised the same way a real operator's group would be. """ diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py index 0fd12c6..316fdce 100644 --- a/packages/meshbay-node/tests/test_stream_video_transcode.py +++ b/packages/meshbay-node/tests/test_stream_video_transcode.py @@ -19,8 +19,8 @@ None — there is nothing to put in `stream_init` for the client to check. Until live against an episode rip in a `.avi` — mpeg4 video, mp3 audio, 720x404 — which the reporting machine re-encodes at about six times playback speed. The setting that governs it, `transcode_incompatible_video`, was documented from the start -as covering "HEVC *and other browser-incompatible video codecs*" (draft-v6 -§2.11); only HEVC was ever wired up. +as covering "HEVC *and other browser-incompatible video codecs*" +(docs/MESHBAY_DESIGN.md §6.8); only HEVC was ever wired up. These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi test sources, ~1s), the same style as test_stream_audio_transcode.py. diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py index bbe9afa..2c4573a 100644 --- a/packages/meshbay-node/tests/test_title_parse.py +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -1,6 +1,6 @@ """ Tests for indexer/title_parse.py — synthetic filenames only, one per -docs/mediacenter.md §3.3/§3.4 rule. The real ~1950-file library validation +docs/MESHBAY_DESIGN.md §9.7 rule. The real ~1950-file library validation is a manual acceptance step (§11), not something this repo's corpus holds. """ @@ -72,7 +72,7 @@ def test_sequel_variants_empty_when_no_trailing_digit(): assert sequel_variants("Some Movie") == [] -# ── §10.1/V10: wider sequel-index handling ────────────────────────────────── +# ── V10: wider sequel-index handling ──────────────────────────────────────── def test_sequel_variants_roman_numeral_offers_the_digit_form(): v = sequel_variants("Old Frontier III") @@ -81,7 +81,7 @@ def test_sequel_variants_roman_numeral_offers_the_digit_form(): def test_sequel_variants_rewrites_a_part_keyword_index_but_keeps_the_name(): - # §10.1/V14: with a "Part"/"Episode"/… keyword the bare base is + # V14: with a "Part"/"Episode"/… keyword the bare base is # withheld — "Some Saga" alone collides with a franchise-origin film. v = sequel_variants("Some Saga Part 2") assert "Some Saga II" in v @@ -97,7 +97,7 @@ def test_sequel_variants_reads_a_spelled_out_index(): def test_sequel_variants_saga_shape_does_not_offer_the_bare_franchise(): # Every "<Saga> Chapter <N>" was matching the franchise's first entry - # because the bare "<Saga>" variant hit it at ratio 1.0 (§10.1/V14). + # because the bare "<Saga>" variant hit it at ratio 1.0 (V14). v = sequel_variants("Some Saga Chapter III") assert "Some Saga" not in v assert "Some Saga 3" in v @@ -125,7 +125,7 @@ def test_clean_query_despaces_a_folder_name_without_eating_the_last_word(): assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids -# ── §10.1/V14: telling a real episode marker from a mangled number ────────── +# ── V14: telling a real episode marker from a mangled number ──────────────── def test_has_episode_marker_accepts_real_markers(): for name in ["Some.Show.S01E08.mkv", "some.show.s1.e8.mkv", @@ -244,7 +244,7 @@ def test_non_season_folder_name_returns_none(): assert season_from_folder_name("Some Show Name") is None -# ── §3.4c: a bare leading episode number, guessit's 3-digit blind spot ─────── +# ── V-findings: a bare leading episode number, guessit's 3-digit blind spot ─ def test_leading_episode_number_reads_the_whole_number(): assert leading_episode_number("001 Episode's Own Title.mkv") == 1 diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py index ab452ce..299ff2a 100644 --- a/packages/meshbay-node/tests/test_tmdb.py +++ b/packages/meshbay-node/tests/test_tmdb.py @@ -40,7 +40,7 @@ async def test_search_movie_returns_top_result_and_confidence(): await client.close() -# ── §10.1/V9: year-exact preference, only when the top hit is weak ────────── +# ── V9: year-exact preference, only when the top hit is weak ──────────────── @pytest.mark.asyncio async def test_year_exact_result_wins_when_the_top_hit_is_low_confidence(): diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py index c1781e3..cef4fda 100644 --- a/packages/meshbay-node/tests/test_tmdb_config_policy.py +++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py @@ -1,6 +1,7 @@ """ -The operator's custom TMDB API token and query language — docs/mediacenter.md -§5.5. Same shape as test_apps_enabled_policy.py/test_scan_settings_policy.py: +The operator's custom TMDB API token and query language — +docs/MESHBAY_DESIGN.md §9.7. Same shape as +test_apps_enabled_policy.py/test_scan_settings_policy.py: a signed operator instruction, node-wide (group_id="") rather than per-group, stored via roster.py's group_settings table. diff --git a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py index 8e945ad..0a748cd 100644 --- a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py +++ b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py @@ -1,5 +1,5 @@ """ -Whether TMDB lookups run *at all* for a group — docs/mediacenter.md §5.5. +Whether TMDB lookups run *at all* for a group — docs/MESHBAY_DESIGN.md §9.7. Per-group (2026-08-24 — used to be node-wide, folded into tmdb_config): a real media-library group and a test/demo group on the same node need not share the decision to spend TMDB quota and make outbound requests. Same diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py index ff259c1..198d75b 100644 --- a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py +++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py @@ -1,5 +1,5 @@ """ -`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB +`tmdb_rematch` (V13) — an operator dropping one file's cached TMDB match so it re-resolves with the current matcher. Signed like `tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb` it forgets a manual override marker too, since the operator is explicitly diff --git a/packages/meshbay-node/tests/test_tmdb_search_bound.py b/packages/meshbay-node/tests/test_tmdb_search_bound.py new file mode 100644 index 0000000..486a2c2 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_search_bound.py @@ -0,0 +1,186 @@ +""" +One member's typing must not spend what the whole group depends on. + +`tmdb_search_req` takes a member's free text and calls TMDB with the +**operator's** credential. That credential is rated by TMDB and shared: the +automatic matching every other member sees runs on it too. So a member holding +down a search box — or a script doing it — degrades the library for everyone and +costs the operator their quota, and the node had no ceiling of any kind on it. +§6.5's standing rule is a bound and a named adversary in the same commit; this +handler shipped with neither. + +Two members in every test here, which is the point: a ceiling that one person +can exhaust for another is not a ceiling, it is a queue. The per-member window +is what keeps them apart, and the node-wide one is what keeps them together +from emptying the operator's quota — they answer different questions and both +are checked. + +The refusal is an error rather than an empty result. An empty list is what "no +such film" looks like, and telling somebody their film is unknown when the node +simply declined to ask is a worse answer than the truth. +""" + +import pytest +from meshbay_node.transport import webrtc_server +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +GROUP = "g" * 32 + + +class _FakeTmdb: + """Counts what would have been spent.""" + + def __init__(self): + self.calls = 0 + + async def search_movie_results(self, query): + self.calls += 1 + return [{"id": 1, "title": "Some Saga", "release_date": "1999-01-01", + "poster_path": None}] + + async def search_tv_results(self, query): + self.calls += 1 + return [] + + +class _FakeMediaCache: + async def get_thumb_hash_by_file_id(self, _file_id): + return None + + +@pytest.fixture +def group(): + """One group's context, shared by every session in it, as a node has.""" + return { + "gek": b"k" * 32, + "tmdb_enabled": True, + } + + +@pytest.fixture +def node(group): + tmdb = _FakeTmdb() + ctx = { + "groups": {GROUP: group}, + "media_cache": _FakeMediaCache(), + "tmdb_client": tmdb, + } + return ctx, tmdb + + +def _member(ctx, user_id: str) -> WebRTCPeerSession: + s = WebRTCPeerSession.__new__(WebRTCPeerSession) + s._ctx = ctx + s._group_id = GROUP + s._user_id = user_id + s._peer_id = user_id + s.sent = [] + s._send = s.sent.append + s._audit = lambda *a, **k: None + return s + + +async def _search(session, query="a film"): + await session._do_tmdb_search_request( + {"query": query, "media_type": "movie"}) + + +def _refusals(session): + return [m for m in session.sent + if m.get("code") == "tmdb_search_rate_limited"] + + +async def test_a_member_at_the_ceiling_does_not_stop_another_one(node, monkeypatch): + """ + The property a one-member test cannot state. + + Alice exhausts her own window; Bob, who has typed nothing, must be served + exactly as if she had not been there. + """ + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 3) + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100) + ctx, tmdb = node + + alice = _member(ctx, "alice") + for i in range(4): + await _search(alice, f"film {i}") + assert tmdb.calls == 3, "the ceiling did not stop the fourth search" + assert len(_refusals(alice)) == 1 + + bob = _member(ctx, "bob") + await _search(bob, "something else") + assert tmdb.calls == 4 + assert _refusals(bob) == [] + + +async def test_one_member_cannot_spend_the_whole_node_quota(node, monkeypatch): + """ + And the other half: two members together still meet a node-wide ceiling, + because the operator's credential is one credential however many people + hold the search box down. + """ + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 100) + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 2) + ctx, tmdb = node + + alice, bob = _member(ctx, "alice"), _member(ctx, "bob") + await _search(alice) + await _search(bob) + await _search(bob) + + assert tmdb.calls == 2 + assert len(_refusals(bob)) == 1 + + +async def test_a_members_count_survives_their_reconnection(node, monkeypatch): + """ + Kept in the group context, not on the session: otherwise the ceiling is one + reconnect wide, and a client that drops its DataChannel between searches has + no ceiling at all. + """ + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 2) + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100) + ctx, tmdb = node + + first = _member(ctx, "alice") + await _search(first, "one") + await _search(first, "two") + + reconnected = _member(ctx, "alice") # same person, new connection + await _search(reconnected, "three") + + assert tmdb.calls == 2, "a reconnect reset the member's window" + assert len(_refusals(reconnected)) == 1 + + +async def test_a_refusal_is_said_out_loud_and_not_drawn_as_no_matches(node, monkeypatch): + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 0) + ctx, _ = node + + alice = _member(ctx, "alice") + await _search(alice) + + (msg,) = alice.sent + assert msg["type"] == "error" + assert msg["code"] == "tmdb_search_rate_limited" + assert msg.get("results") is None, ( + "a refusal that carries an empty result list reads as 'no such film'") + + +async def test_the_windows_do_not_grow_without_bound(node, monkeypatch): + """ + The lists are trimmed on every call, so the thing that bounds a member also + bounds what remembering them costs. + """ + monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_WINDOW", 0.0) + ctx, tmdb = node + + alice = _member(ctx, "alice") + for i in range(12): + await _search(alice, f"film {i}") + + # Every entry ages out before the next call, so nothing is refused, and what + # is kept is the one just recorded rather than one per search ever made. + assert tmdb.calls == 12 + assert len(ctx["groups"][GROUP]["tmdb_search_hits"]["alice"]) == 1 + assert len(ctx["tmdb_search_hits_node"]) == 1 diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py index 9865c77..dc83733 100644 --- a/packages/meshbay-node/tests/test_tmdb_search_ladder.py +++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py @@ -208,7 +208,7 @@ async def test_strong_direct_match_costs_a_single_request(): assert client.calls == [("A Quiet Film", 2010)] -# ── §10.1/V11: a decent primary hit with nothing more specific to try ────── +# ── V11: a decent primary hit with nothing more specific to try ──────────── async def test_decent_primary_with_no_stronger_candidate_costs_one_call(): result, _, client = await _run( @@ -222,7 +222,7 @@ async def test_decent_primary_with_no_stronger_candidate_costs_one_call(): "is not worth a second request once the primary hit is decent") -# ── §10.1/V8: the show branch uses the same scored ladder ───────────────── +# ── V8: the show branch uses the same scored ladder ─────────────────────── async def test_show_scored_ladder_beats_a_weak_primary_hit(): result, _, _ = await _run_show( diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py index 7f3719d..bcfa6a8 100644 --- a/packages/meshbay-node/tests/test_transfer_settings.py +++ b/packages/meshbay-node/tests/test_transfer_settings.py @@ -4,7 +4,8 @@ of setting. The **pools** are the machine's: how many transfers this node runs at once, across every group, from `[node]` in node.toml with a roster override — the -§2.11 pattern, changed from the Node page or the CLI, applied live. +docs/MESHBAY_DESIGN.md §6.8 pattern, changed from the Node page or the CLI, +applied live. The **per-member cap** is a group's: how many one member may run at once here. It lives on the node like every other group setting (not the hub, which would diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py index fd0e040..d06b3f4 100644 --- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py +++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py @@ -1,5 +1,5 @@ """ -Videos-app enrichment (ffprobe/thumbnailing/TMDB, mediacenter.md §5.2/§10) +Videos-app enrichment (ffprobe/thumbnailing/TMDB, docs/MESHBAY_DESIGN.md §6.5) only ever runs for a group that has a video_root configured, and only for files under it — see daemon.py's _enrich_new_video_entries. Burning TMDB's rate limit and the node's CPU on an operator's whole shared index before diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 3010fe9..0839d42 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -768,7 +768,8 @@ async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir): browser_pc, channel, received = await _setup_peer( transport, sk_hub, gek, "peer-cleanup") - # Keyed per connection, not per account (docs/chat-sender-keys.md F7), so + # Keyed per connection, not per account (finding F7, + # docs/MESHBAY_DESIGN.md §13.6), so # membership is asserted by the session object rather than by user_id — # one account may hold several entries here. peers = transport._ctx["_peers"] |