""" A node whose key was unlinked from its account notices, and comes back when it is linked again. The hub ends an unlinked node's tokens at once and refuses its socket with "Node key no longer linked". The node then signs in afresh instead of waiting for its token to near expiry — so a key linked again is back within seconds — and while the hub answers "No node key", it reports `waiting_for_node_key` rather than `running`. """ import asyncio import json import httpx import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_node import hub_client as hc from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.keystore import NodeKeys def _client(tmp_path): keys = NodeKeys(sk_ed25519=Ed25519PrivateKey.generate(), sk_x25519=X25519PrivateKey.generate()) return HubClient(HubConfig(hub_url="http://fake-hub", username="testuser", cache_dir=tmp_path), keys) def _status_error(status, detail): req = httpx.Request("POST", "http://fake-hub/v1/nodes/auth") resp = httpx.Response(status, json={"detail": detail}, request=req) return httpx.HTTPStatusError(detail, request=req, response=resp) @pytest.fixture(autouse=True) def _no_wait(monkeypatch): async def _instant(_s): return None monkeypatch.setattr(hc.asyncio, "sleep", _instant) @pytest.mark.asyncio async def test_an_unlinked_key_is_reported(tmp_path, monkeypatch): hub = _client(tmp_path) async def login(): raise _status_error(401, "No node key registered for this node — link it") monkeypatch.setattr(hub, "login", login) told = [] await hub._sign_in_again(lambda: told.append(True)) assert told == [True] @pytest.mark.asyncio async def test_a_key_linked_again_signs_in_without_reporting(tmp_path, monkeypatch): hub = _client(tmp_path) calls = [] async def login(): calls.append(True) monkeypatch.setattr(hub, "login", login) told = [] await hub._sign_in_again(lambda: told.append(True)) assert calls == [True] assert told == [] @pytest.mark.asyncio async def test_other_failures_are_not_mistaken_for_an_unlink(tmp_path, monkeypatch): hub = _client(tmp_path) async def login(): raise _status_error(429, "Too many requests") monkeypatch.setattr(hub, "login", login) told = [] with pytest.raises(httpx.HTTPStatusError): await hub._sign_in_again(lambda: told.append(True)) assert told == [] class _RefusingSocket: """A hub that refuses the node's socket once, with `detail`.""" def __init__(self, detail, stop): self.detail, self.stop, self.sent = detail, stop, [] async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def send(self, data): self.sent.append(data) async def recv(self): self.stop.set() return json.dumps({"type": "error", "detail": self.detail}) @pytest.mark.parametrize("detail, signs_in_again", [ ("Node key no longer linked to this account", True), ("Node already connected", False), ]) @pytest.mark.asyncio async def test_only_an_unlink_refusal_signs_in_again(tmp_path, monkeypatch, detail, signs_in_again): """Any other refusal keeps the old pace, so `/auth`'s rate limit is not spent on it.""" import types import websockets hub = _client(tmp_path) hub._session = types.SimpleNamespace(hub_url="http://fake-hub", access_token="t", node_id="n1") stop = asyncio.Event() monkeypatch.setattr(websockets, "connect", lambda *a, **k: _RefusingSocket(detail, stop)) signed_in, refreshed = [], [] async def sign_in_again(_cb): signed_in.append(True) raise asyncio.CancelledError async def ensure_fresh(): refreshed.append(True) raise asyncio.CancelledError monkeypatch.setattr(hub, "_sign_in_again", sign_in_again) monkeypatch.setattr(hub, "ensure_fresh_token", ensure_fresh) with pytest.raises(asyncio.CancelledError): await hub.maintain_ws(group_ids=[]) assert bool(signed_in) is signs_in_again assert bool(refreshed) is not signs_in_again @pytest.mark.asyncio async def test_unlinking_itself_signs_in_first_and_names_its_key(tmp_path, monkeypatch): """A token from before the hub named keys cannot unlink; a fresh one names this node's.""" import types hub = _client(tmp_path) hub._session = types.SimpleNamespace(auth_headers={"Authorization": "Bearer fresh"}) order, sent = [], {} async def login(): order.append("login") monkeypatch.setattr(hub, "login", login) async def request(method, url, json=None, headers=None): order.append(method) sent.update(json or {}) return httpx.Response(200, json={}, request=httpx.Request(method, "http://h" + url)) monkeypatch.setattr(hub._http, "request", request) await hub.unlink_node_key() assert order == ["login", "DELETE"] assert sent == {"pk_node_ed25519": hub._keys.pk_ed25519_b64} @pytest.mark.asyncio async def test_unlinking_a_key_already_unlinked_is_not_an_error(tmp_path, monkeypatch): import types hub = _client(tmp_path) hub._session = types.SimpleNamespace(auth_headers={}) async def login(): raise _status_error(401, "No node key registered for this node — link it") monkeypatch.setattr(hub, "login", login) async def request(*a, **k): raise AssertionError("nothing to unlink, nothing should be sent") monkeypatch.setattr(hub._http, "request", request) await hub.unlink_node_key()