""" Tests for meshbay_node.hub_client — uses httpx.MockTransport to avoid network. """ import base64 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_common.crypto import generate_gek, pk_to_b64, wrap_gek 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", password="testpass99", cache_dir=tmp_path, ) def make_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", "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_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/users/login": return httpx.Response(200, json={ "access_token": token, "refresh_token": "rt-abc", "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 # Token without jti bad_token = jwt.encode({ "iss": "fake-hub", "sub": "uid", "pk_user": node_keys.pk_ed25519_b64, "hub_id": "fake-hub", "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/users/login": return httpx.Response(200, json={ "access_token": bad_token, "refresh_token": "rt", "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_register_idempotent(hub_keys, node_keys, hub_config): def handler(request): if request.url.path == "/v1/users/register": return httpx.Response(409, json={"detail": "Username already taken"}) 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") # Should not raise on 409 result = await client.register() assert result == "" @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 # Token expiring in 60s (< TOKEN_REFRESH_MARGIN of 300s) short_token = make_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/users/login": return httpx.Response(200, json={ "access_token": short_token, "refresh_token": "rt", "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_fetch_gek(hub_keys, node_keys, hub_config): """Admin wraps GEK for this node; client fetches and unwraps.""" sk_hub, sk_hub_pem, pk_hub_pem = hub_keys gek = generate_gek() # Simulate admin wrapping GEK for this node pk_x_raw = base64.b64decode(node_keys.pk_x25519_b64) bundle = wrap_gek(gek, pk_x_raw) token = make_token(sk_hub_pem, "uid", 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/users/login": return httpx.Response(200, json={ "access_token": token, "refresh_token": "rt", "expires_in": 3600}) if "/v1/groups/" in request.url.path and request.url.path.endswith("/gek"): return httpx.Response(200, json=bundle) 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.login() recovered = await client.fetch_gek("group-abc") assert recovered == gek @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"