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
|
"""
Tests for meshbay_node.hub_client — uses httpx.MockTransport to avoid network.
"""
import time
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.keystore import NodeKeys
# ── Test fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
def hub_keys():
"""Fake hub Ed25519 keypair for signing test JWTs."""
sk = Ed25519PrivateKey.generate()
sk_pem = sk.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
pk_pem = sk.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
return sk, sk_pem, pk_pem
@pytest.fixture
def node_keys():
sk_ed = Ed25519PrivateKey.generate()
sk_x = X25519PrivateKey.generate()
return NodeKeys(sk_ed25519=sk_ed, sk_x25519=sk_x)
@pytest.fixture
def hub_config(tmp_path):
return HubConfig(
hub_url="http://fake-hub",
username="testuser",
cache_dir=tmp_path,
)
def make_node_token(sk_pem, user_id, pk_user_b64, hub_id="fake-hub", ttl=3600):
now = int(time.time())
return jwt.encode({
"iss": hub_id, "sub": user_id, "pk_user": pk_user_b64,
"hub_id": hub_id, "jti": "test-jti", "scope": "node",
"iat": now, "exp": now + ttl,
}, sk_pem, algorithm="EdDSA")
# ── Tests ─────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_login_verifies_jwt_offline(hub_keys, node_keys, hub_config):
sk_hub, sk_hub_pem, pk_hub_pem = hub_keys
user_id = "user-uuid-001"
token = make_node_token(sk_hub_pem, user_id, node_keys.pk_ed25519_b64)
def handler(request):
if request.url.path == "/v1/hub/pubkey":
return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()})
if request.url.path == "/v1/nodes/auth":
return httpx.Response(200, json={
"access_token": token, "token_type": "bearer", "expires_in": 3600})
return httpx.Response(404)
transport = httpx.MockTransport(handler)
client = HubClient(hub_config, node_keys)
client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub")
session = await client.login()
assert session.user_id == user_id
assert session.access_token == token
assert session.hub_pk_pem == pk_hub_pem
assert session.token_expires_in > 3500
assert not session.token_needs_refresh
@pytest.mark.asyncio
async def test_login_rejects_missing_jti(hub_keys, node_keys, hub_config):
sk_hub, sk_hub_pem, pk_hub_pem = hub_keys
bad_token = jwt.encode({
"iss": "fake-hub", "sub": "uid", "pk_user": node_keys.pk_ed25519_b64,
"hub_id": "fake-hub", "scope": "node",
"iat": int(time.time()), "exp": int(time.time()) + 3600,
}, sk_hub_pem, algorithm="EdDSA")
def handler(request):
if request.url.path == "/v1/hub/pubkey":
return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()})
if request.url.path == "/v1/nodes/auth":
return httpx.Response(200, json={
"access_token": bad_token, "token_type": "bearer", "expires_in": 3600})
return httpx.Response(404)
transport = httpx.MockTransport(handler)
client = HubClient(hub_config, node_keys)
client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub")
with pytest.raises(AssertionError, match="jti"):
await client.login()
@pytest.mark.asyncio
async def test_token_needs_refresh(hub_keys, node_keys, hub_config):
sk_hub, sk_hub_pem, pk_hub_pem = hub_keys
short_token = make_node_token(sk_hub_pem, "uid", node_keys.pk_ed25519_b64, ttl=60)
def handler(request):
if request.url.path == "/v1/hub/pubkey":
return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()})
if request.url.path == "/v1/nodes/auth":
return httpx.Response(200, json={
"access_token": short_token, "token_type": "bearer", "expires_in": 60})
return httpx.Response(404)
transport = httpx.MockTransport(handler)
client = HubClient(hub_config, node_keys)
client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub")
session = await client.login()
assert session.token_needs_refresh
@pytest.mark.asyncio
async def test_hub_pk_cached(hub_keys, node_keys, hub_config, tmp_path):
_, _, pk_hub_pem = hub_keys
call_count = {"n": 0}
def handler(request):
if request.url.path == "/v1/hub/pubkey":
call_count["n"] += 1
return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()})
return httpx.Response(404)
transport = httpx.MockTransport(handler)
client = HubClient(hub_config, node_keys)
client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub")
await client._fetch_hub_pk()
await client._fetch_hub_pk() # second call should use cache
assert call_count["n"] == 1, "Hub PK should be fetched only once"
|