From 1db49c37ea01db8498694613b3d0e2d54bd0d96d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 10:47:28 +0200 Subject: fix: the two ceilings §13.5b was still missing, as AV27 and AV28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A free-text TMDB search spends the operator's credential.** TMDB rates it, and the automatic matching every member sees runs on the same one, so a member holding down the search box — or a script doing it — degrades the library for everyone and empties a quota the operator pays for. The handler had no ceiling of any kind, where link previews beside it carry two. §6.5's standing rule is a bound and a named adversary in the same commit; this arrived with neither. Per member and not per connection, unlike link previews: three tabs is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the group context, so a reconnect does not reset it — a client that drops its channel between searches would otherwise have no ceiling at all. The node-wide window stays too, because the two answer different questions: one keeps a member from spending everyone's quota, the other keeps a roomful of them from doing it together. Ten a minute each, thirty for the node — a search every six seconds, sustained, is past what anyone types. The refusal is an error rather than an empty list. 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; `video-app.js` already puts `detail` on screen. **How many node keys one account may announce.** Each is a row in `nodes` plus a row in the IP log, and the IP log is kept for a year, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. M8 settled whose key it is and said nothing about how many. Ten: a node is a machine left running, and an account wanting an eleventh *identity* rather than an eleventh machine is the case this refuses. Counted only where a row is added. Applied to every announce it would freeze the address of every node an account already runs the moment it reached the limit, and a node that cannot re-announce is unreachable after its ISP renumbers it — an outage caused by the protection. There is a test for exactly that. Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for another is not a ceiling but a queue, and a ceiling shared between accounts would let one member stop every other from bringing a machine online. Checked by removing each ceiling: seven tests fail. `test_season_and_search_requests.py` built its session without a `_user_id`, which production guarantees — `_dispatch_message` refuses every message until the handshake settles it. The fixture was narrower than the node, so it could not exercise a per-member bound at all; it has one now. Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]` tests covering the same geometry pass. Co-Authored-By: Claude Opus 5 --- .../tests/test_season_and_search_requests.py | 6 + .../meshbay-node/tests/test_tmdb_search_bound.py | 186 +++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 packages/meshbay-node/tests/test_tmdb_search_bound.py (limited to 'packages/meshbay-node/tests') 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 9dcc6ce..4141d13 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -22,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_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 -- cgit v1.2.3