diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_season_and_search_requests.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_tmdb_search_bound.py | 186 |
2 files changed, 192 insertions, 0 deletions
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 |