"""Tests for the Buzz WebSocket transport (NIP-42) and Nostr signing module. The signing module and WS transport were contributed in PR #73636 by @ScaleLeanChris and consolidated onto the merged poll-based adapter; these tests cover the crypto (against the official BIP-340 vector) and the WS lifecycle as wired into BuzzAdapter. """ import asyncio import json import time import pytest from tests.gateway._plugin_adapter_loader import load_plugin_adapter _buzz_mod = load_plugin_adapter("buzz") BuzzAdapter = _buzz_mod.BuzzAdapter import importlib.util as _ilu from pathlib import Path as _Path _auth_path = _Path(_buzz_mod.__file__).with_name("nostr_auth.py") _spec = _ilu.spec_from_file_location("plugin_adapter_buzz_nostr_auth", _auth_path) nostr_auth = _ilu.module_from_spec(_spec) _spec.loader.exec_module(nostr_auth) SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860" # BIP-340 test vector 0 private key TEST_PRIVATE_KEY = "00" * 31 + "03" CHANNEL = "ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd" def _make_adapter(extra=None): from gateway.config import PlatformConfig cfg = PlatformConfig(enabled=True, extra={"relay_url": "https://test.relay", **(extra or {})}) adapter = BuzzAdapter(cfg) adapter._self_pubkey = SELF_PUBKEY adapter._private_key = TEST_PRIVATE_KEY adapter._display_name = "Chip" return adapter # ── nostr_auth: BIP-340 / NIP-42 ────────────────────────────────────────── def test_schnorr_sign_matches_official_bip340_vector_zero(): signature = nostr_auth.schnorr_sign( bytes(32), TEST_PRIVATE_KEY, auxiliary_randomness=bytes(32) ) assert nostr_auth.public_key_hex(TEST_PRIVATE_KEY).upper() == ( "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9" ) assert signature.hex().upper() == ( "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215" "25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0" ) def test_decode_private_key_rejects_bad_input(): with pytest.raises(ValueError): nostr_auth.decode_private_key("not-a-key") with pytest.raises(ValueError): nostr_auth.decode_private_key("00" * 32) # zero — outside range with pytest.raises(ValueError): nostr_auth.decode_private_key("nsec1qqqqqqqq") # bad checksum/length def test_build_auth_event_shape_and_owner_tag(): tag = json.dumps(["auth", "b" * 64, "", "c" * 128]) event = nostr_auth.build_auth_event( private_key=TEST_PRIVATE_KEY, challenge="challenge-1", relay_url="wss://relay.example", auth_tag_json=tag, created_at=1_700_000_000, auxiliary_randomness=bytes(32), ) assert event["kind"] == 22242 assert ["relay", "wss://relay.example"] in event["tags"] assert ["challenge", "challenge-1"] in event["tags"] assert ["auth", "b" * 64, "", "c" * 128] in event["tags"] assert len(bytes.fromhex(event["sig"])) == 64 assert event["pubkey"] == nostr_auth.public_key_hex(TEST_PRIVATE_KEY) # ── Adapter WS wiring ───────────────────────────────────────────────────── class _FakeWebSocket: """Replays a NIP-42 handshake: AUTH challenge, then OK for the reply.""" def __init__(self): self.sent = [] async def recv(self): if self.sent: auth_event = self.sent[0][1] return json.dumps(["OK", auth_event["id"], True, "authenticated"]) return json.dumps(["AUTH", "relay-challenge"]) async def send(self, raw): self.sent.append(json.loads(raw)) # ── _websocket_loop: read-idle watchdog (#98097) ────────────────────────── class _ScriptedWebSocket(_FakeWebSocket): """A connect() target whose event frames come from a scripted behavior. The auth handshake is the relay's (inherited from _FakeWebSocket); after it, each ``__anext__`` delegates to ``anext_behavior`` — a coroutine function returning the next raw frame or raising StopAsyncIteration for a clean close. """ def __init__(self, anext_behavior): super().__init__() self._anext_behavior = anext_behavior self.exited = False async def __aenter__(self): return self async def __aexit__(self, *exc_info): self.exited = True def __aiter__(self): return self async def __anext__(self): return await self._anext_behavior() @pytest.mark.asyncio async def test_websocket_loop_reconnects_when_read_goes_silent(monkeypatch, caplog): """A relay close the transport never surfaces must not park the loop. Reproduces the #98097 shape: a socket stuck in CLOSE_WAIT yields no frame and no error, so without a read-side bound the loop would wait forever while the gateway keeps reporting "connected". """ import logging adapter = _make_adapter() monkeypatch.setattr(_buzz_mod, "_WS_READ_IDLE_TIMEOUT", 0.05) caplog.set_level(logging.WARNING) sockets = [] async def dead_anext(): await asyncio.Event().wait() # never yields, never raises def fake_connect(*args, **kwargs): ws = _ScriptedWebSocket(dead_anext) sockets.append(ws) return ws import websockets as _ws_mod monkeypatch.setattr(_ws_mod, "connect", fake_connect) task = asyncio.create_task(adapter._websocket_loop()) try: deadline = time.monotonic() + 5.0 while len(sockets) < 2 and time.monotonic() < deadline: await asyncio.sleep(0.02) finally: task.cancel() try: await asyncio.wait_for(task, 5.0) except (asyncio.CancelledError, asyncio.TimeoutError): pass assert len(sockets) >= 2, "idle read watchdog did not force a reconnect" assert sockets[0].exited, "the silent connection was not closed before reconnecting" assert any("went silent" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_websocket_loop_dispatches_frames_and_closes_cleanly(monkeypatch): """The watchdog refactor preserves the healthy path: frames dispatch to _handle_event and a server-side close (StopAsyncIteration) exits the connection cleanly before the loop reconnects.""" adapter = _make_adapter() adapter._channel_state = {CHANNEL: {"last_ts": 1, "seen": {}}} handled = [] async def record_handle_event(channel_id, state, event): handled.append((channel_id, event)) monkeypatch.setattr(adapter, "_handle_event", record_handle_event) frames = iter( [ json.dumps( ["EVENT", "hermes-buzz-0", {"id": "e1", "kind": 9, "created_at": 2, "content": "hi"}] ), ] ) async def scripted_anext(): try: return next(frames) except StopIteration: raise StopAsyncIteration from None sockets = [] def fake_connect(*args, **kwargs): # Second connect ends the loop: CancelledError re-raises out of the # loop's except-order, unlike a regular Exception which would retry. if len(sockets) == 1: raise asyncio.CancelledError() ws = _ScriptedWebSocket(scripted_anext) sockets.append(ws) return ws import websockets as _ws_mod monkeypatch.setattr(_ws_mod, "connect", fake_connect) task = asyncio.create_task(adapter._websocket_loop()) with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(task, 10.0) assert sockets[0].exited, "clean close did not exit the async-with block" assert handled and handled[0][0] == CHANNEL assert handled[0][1]["id"] == "e1" @pytest.mark.asyncio async def test_websocket_auth_raises_on_rejection(): adapter = _make_adapter() class RejectingWs(_FakeWebSocket): async def recv(self): if self.sent: auth_event = self.sent[0][1] return json.dumps(["OK", auth_event["id"], False, "denied"]) return json.dumps(["AUTH", "relay-challenge"]) with pytest.raises(ConnectionError): await adapter._authenticate_websocket(RejectingWs()) @pytest.mark.asyncio async def test_websocket_auth_uses_credentials_owner_tag(): adapter = _make_adapter() adapter._auth_tag = json.dumps(["auth", "b" * 64, "", "c" * 128]) websocket = _FakeWebSocket() await adapter._authenticate_websocket(websocket) assert ["auth", "b" * 64, "", "c" * 128] in websocket.sent[0][1]["tags"] # ── CLOSED frame handling ────────────────────────────────────────────────── @pytest.mark.asyncio async def test_websocket_loop_drops_restricted_channel_without_reconnect(): """A CLOSED frame with 'restricted: not a channel member' must silently drop the offending subscription and continue — not raise ConnectionError and trigger a reconnect loop. Regression test for the 1.6 s flood caused by the relay immediately rejecting a private-channel subscription. """ import sys from unittest.mock import patch, MagicMock from contextlib import asynccontextmanager adapter = _make_adapter(extra={"channels": [CHANNEL]}) adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} adapter._ws_ready = asyncio.Event() sub_id = "hermes-buzz-0" messages = [json.dumps(["CLOSED", sub_id, "restricted: not a channel member"])] idx = 0 class _FakeWs: sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) def __aiter__(self): return self async def __anext__(self): nonlocal idx if idx < len(messages): val = messages[idx] idx += 1 return val # Stall so the task stays alive for our assertions. await asyncio.sleep(10) raise StopAsyncIteration @asynccontextmanager async def _fake_connect(*_a, **_kw): yield _FakeWs() async def _noop_auth(self_inner, ws): pass async def _noop_subscribe(self_inner, ws): return {sub_id: CHANNEL} fake_ws_mod = MagicMock() fake_ws_mod.connect = _fake_connect with ( patch.dict(sys.modules, {"websockets": fake_ws_mod}), patch.object(type(adapter), "_authenticate_websocket", _noop_auth), patch.object(type(adapter), "_subscribe_websocket", _noop_subscribe), ): adapter._ws_ready = asyncio.Event() adapter._ws_ready.set() adapter._ws_active = True task = asyncio.create_task(adapter._websocket_loop()) await asyncio.sleep(0.1) assert CHANNEL in adapter._restricted_channels, ( "restricted channel should be recorded in _restricted_channels" ) assert CHANNEL not in adapter._channel_state, ( "channel_state entry should be removed for a restricted channel" ) assert not task.done(), "websocket_loop must not exit/reconnect on a restricted CLOSED" task.cancel() try: await task except (asyncio.CancelledError, Exception): pass @pytest.mark.asyncio async def test_websocket_loop_reconnects_on_non_restricted_closed(): """A CLOSED frame that is NOT 'restricted' must NOT add the channel to _restricted_channels — it is a transient error and the loop should reconnect. """ import sys from unittest.mock import patch, MagicMock from contextlib import asynccontextmanager adapter = _make_adapter(extra={"channels": [CHANNEL]}) adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} sub_id = "hermes-buzz-0" messages = [json.dumps(["CLOSED", sub_id, "error: server shutting down"])] idx = 0 class _FakeWs: sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) def __aiter__(self): return self async def __anext__(self): nonlocal idx if idx < len(messages): val = messages[idx] idx += 1 return val await asyncio.sleep(10) return json.dumps(["NOTICE", "stall"]) @asynccontextmanager async def _fake_connect(*_a, **_kw): yield _FakeWs() async def _noop_auth(self_inner, ws): pass async def _noop_subscribe(self_inner, ws): return {sub_id: CHANNEL} fake_ws_mod = MagicMock() fake_ws_mod.connect = _fake_connect with ( patch.dict(sys.modules, {"websockets": fake_ws_mod}), patch.object(type(adapter), "_authenticate_websocket", _noop_auth), patch.object(type(adapter), "_subscribe_websocket", _noop_subscribe), ): adapter._ws_ready = asyncio.Event() adapter._ws_ready.set() adapter._ws_active = True task = asyncio.create_task(adapter._websocket_loop()) await asyncio.sleep(0.1) assert CHANNEL not in adapter._restricted_channels, ( "non-restricted CLOSED must not add channel to _restricted_channels" ) task.cancel() try: await task except (asyncio.CancelledError, Exception): pass def test_restricted_channels_skipped_during_subscribe(): """Channels in _restricted_channels are not re-subscribed on reconnect.""" adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} adapter._restricted_channels.add(CHANNEL) subscriptions = {} class _CountingWs: sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) async def _run(): ws = _CountingWs() result = await adapter._subscribe_websocket(ws) return ws.sent, result sent, subs = asyncio.get_event_loop().run_until_complete(_run()) assert CHANNEL not in subs.values(), ( "restricted channel must not appear in subscriptions dict" ) req_channels = [ frame[2].get("#h", []) for frame in sent if isinstance(frame, list) and frame[0] == "REQ" ] assert all(CHANNEL not in ch_list for ch_list in req_channels), ( "restricted channel must not be sent in any REQ frame" ) # ── Fresh-conversation subscription window (#78429) ──────────────────────── @pytest.mark.asyncio async def test_new_subscription_without_high_water_mark_has_no_since_floor(): """A conversation adopted mid-run (last_ts == 0) must NOT subscribe with `since ≈ now` — that drops the message that created the conversation (#78429). It must request from the beginning with a bounded limit.""" adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "dm", "last_ts": 0, "seen": {}} class _Ws: def __init__(self): self.sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) ws = _Ws() await adapter._send_channel_subscription(ws, "hermes-buzz-dm-1", CHANNEL) assert len(ws.sent) == 1 req_filter = ws.sent[0][2] assert "since" not in req_filter, ( "fresh conversation must not have a since floor (drops the opening message)" ) assert req_filter.get("limit") == _buzz_mod._FETCH_LIMIT assert req_filter["#h"] == [CHANNEL] @pytest.mark.asyncio async def test_seeded_subscription_resumes_from_high_water_mark(): """A channel with a real high-water mark keeps the since-resume contract (last_ts - 1, same-second overlap de-duped by id).""" adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 1_700_000_000, "seen": {}} class _Ws: def __init__(self): self.sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) ws = _Ws() await adapter._send_channel_subscription(ws, "hermes-buzz-0", CHANNEL) req_filter = ws.sent[0][2] assert req_filter["since"] == 1_699_999_999 assert "limit" not in req_filter # ── Membership-rejection phrasing (#97502 composed into #76850) ──────────── @pytest.mark.asyncio @pytest.mark.parametrize( "detail", [ "restricted: not a channel member", "not a channel member", "auth-required: subscription needs auth", ], ) async def test_closed_membership_phrases_prune_without_reconnect(detail): """Every production-observed membership-rejection phrasing (#76850, #97502) prunes the subscription instead of tearing down the socket.""" import sys from unittest.mock import patch, MagicMock from contextlib import asynccontextmanager adapter = _make_adapter(extra={"channels": [CHANNEL]}) adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} sub_id = "hermes-buzz-0" messages = [json.dumps(["CLOSED", sub_id, detail])] idx = 0 class _FakeWs: async def send(self, raw): pass def __aiter__(self): return self async def __anext__(self): nonlocal idx if idx < len(messages): val = messages[idx] idx += 1 return val await asyncio.sleep(10) raise StopAsyncIteration @asynccontextmanager async def _fake_connect(*_a, **_kw): yield _FakeWs() async def _noop_auth(self_inner, ws): pass async def _noop_subscribe(self_inner, ws): return {sub_id: CHANNEL} fake_ws_mod = MagicMock() fake_ws_mod.connect = _fake_connect with ( patch.dict(sys.modules, {"websockets": fake_ws_mod}), patch.object(type(adapter), "_authenticate_websocket", _noop_auth), patch.object(type(adapter), "_subscribe_websocket", _noop_subscribe), ): adapter._ws_ready = asyncio.Event() adapter._ws_ready.set() task = asyncio.create_task(adapter._websocket_loop()) await asyncio.sleep(0.1) assert CHANNEL in adapter._restricted_channels assert CHANNEL not in adapter._channel_state assert not task.done(), "membership rejection must not reconnect the socket" task.cancel() try: await task except (asyncio.CancelledError, Exception): pass @pytest.mark.asyncio async def test_restricted_channel_not_readopted_by_discovery(): """The live-discovery paths must skip _restricted_channels; otherwise the next sweep silently re-adds the channel and re-triggers the rejection (the re-adoption hole flagged in the #76850 review).""" adapter = _make_adapter() adapter._restricted_channels.add(CHANNEL) calls = [] async def scripted_cli(args, *, input_text=None): calls.append(list(args)) if args[:2] == ["dms", "list"]: return 0, json.dumps([{"dm_id": CHANNEL}]), "" if args[:2] == ["channels", "list"]: return 0, json.dumps([{"channel_id": CHANNEL, "name": "DM", "description": ""}]), "" return 0, "[]", "" adapter._run_cli = scripted_cli await adapter._discover_dms(seed=False) assert CHANNEL not in adapter._channel_state, ( "restricted channel must not be re-adopted by discovery" ) # ── WS periodic discovery (#93557 / #75107) ──────────────────────────────── @pytest.mark.asyncio async def test_ws_discovery_loop_subscribes_newly_discovered_conversation(monkeypatch): """Without a kind-44100 membership event, the WS transport's periodic sweep must still find and subscribe a conversation opened mid-session (#93557).""" adapter = _make_adapter() adapter.poll_interval = 0.01 new_dm = "0f0e0d0c-0b0a-4123-8123-cafecafecafe" async def fake_discover(*, seed): adapter._channel_state.setdefault( new_dm, {"chat_type": "dm", "last_ts": 0, "seen": {}} ) monkeypatch.setattr(adapter, "_discover_dms", fake_discover) monkeypatch.setattr(_buzz_mod, "_MIN_POLL_INTERVAL", 0.01) class _Ws: def __init__(self): self.sent = [] async def send(self, raw): self.sent.append(json.loads(raw)) ws = _Ws() subscriptions = {"hermes-buzz-0": CHANNEL} task = asyncio.create_task(adapter._ws_discovery_loop(ws, subscriptions)) try: deadline = time.monotonic() + 5.0 while not ws.sent and time.monotonic() < deadline: await asyncio.sleep(0.02) finally: task.cancel() try: await task except asyncio.CancelledError: pass assert new_dm in subscriptions.values(), "sweep did not subscribe the new conversation" req = ws.sent[0] assert req[0] == "REQ" and req[2]["#h"] == [new_dm] # Fresh conversation: no since floor (#78429 applies here too). assert "since" not in req[2] @pytest.mark.asyncio async def test_ws_discovery_task_cancelled_when_connection_exits(monkeypatch): """The companion discovery task must not outlive its connection.""" adapter = _make_adapter() adapter.poll_interval = 10.0 # sweep never fires; we only test lifecycle adapter._channel_state = {CHANNEL: {"chat_type": "group", "last_ts": 1, "seen": {}}} started = [] real_create_task = asyncio.create_task def tracking_create_task(coro, **kw): t = real_create_task(coro, **kw) if "_ws_discovery_loop" in repr(coro): started.append(t) return t monkeypatch.setattr(asyncio, "create_task", tracking_create_task) async def closed_anext(): raise StopAsyncIteration sockets = [] def fake_connect(*args, **kwargs): if sockets: raise asyncio.CancelledError() ws = _ScriptedWebSocket(closed_anext) sockets.append(ws) return ws import websockets as _ws_mod monkeypatch.setattr(_ws_mod, "connect", fake_connect) task = real_create_task(adapter._websocket_loop()) with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(task, 10.0) assert started, "discovery task was never started with the connection" assert all(t.done() for t in started), "discovery task outlived its connection"