aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_node_key_unlinked.py
blob: 85674e19ff39766e4fbcc00d0a5836af1d47a98c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""
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()