aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_hub_client.py207
1 files changed, 207 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_hub_client.py b/packages/meshbay-node/tests/test_hub_client.py
new file mode 100644
index 0000000..fe8a2af
--- /dev/null
+++ b/packages/meshbay-node/tests/test_hub_client.py
@@ -0,0 +1,207 @@
+"""
+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"