""" Tests for meshbay_node.hub_client — uses httpx.MockTransport to avoid network. """ import json import os import time import pytest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import httpx import jwt from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives import serialization from meshbay_node.hub_client import HubClient, HubConfig, HubSession 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"