diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:06:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:06:46 +0200 |
| commit | b92b076bed49da15ce1ba96d80eb84db539a0778 (patch) | |
| tree | 28539f8fd9e7349a2f87dbe3aac6bc51598eedcd /packages/meshbay-node/tests/test_hub_client.py | |
| parent | 6b6b1a9d2febaa75da7609db671a82a458b40b4b (diff) | |
| download | meshbay-b92b076bed49da15ce1ba96d80eb84db539a0778.tar.gz | |
feat(node): add hub client with JWT offline verify and GEK fetch
Register/login/announce/token-refresh/GEK-unwrap. JWT jti and
pk_user verified at login. Hub PK cached after first fetch.
6/6 tests passing with httpx.MockTransport (no network).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_hub_client.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_hub_client.py | 207 |
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" |