diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
| commit | fb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch) | |
| tree | eb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/tests | |
| parent | 77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff) | |
| download | meshbay-fb91c4545c757711e1b5fd354ca4b311c89fd2c0.tar.gz | |
feat(hub): add production hub — config, auth, API routers, tests
config.py: TOML + env var priority. auth.py: Argon2id passwords,
JWT EdDSA with jti, refresh token hashed (blake3). Routers:
hub (info/pubkey), users (register/login/refresh/pubkeys),
nodes (announce/get), groups (create/gek-bundle/gek-retrieve).
Rate limiting via slowapi. app.py factory with lifespan.
All 40 tests pass (SQLite in-memory, no PostgreSQL required).
Fix: remove tests/__init__.py to resolve namespace conflicts.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/__init__.py | 0 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/conftest.py | 64 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 263 |
3 files changed, 327 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/__init__.py b/packages/meshbay-hub/tests/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/packages/meshbay-hub/tests/__init__.py +++ /dev/null diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py new file mode 100644 index 0000000..b42aba1 --- /dev/null +++ b/packages/meshbay-hub/tests/conftest.py @@ -0,0 +1,64 @@ +"""Shared pytest fixtures for hub tests.""" + +import os +import pytest +import pytest_asyncio +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +# Force SQLite in-memory for all hub tests +os.environ.setdefault("MESHBAY_DATABASE_URL", "sqlite+aiosqlite:///:memory:") + + +@pytest.fixture(scope="session") +def hub_key_path(tmp_path_factory) -> Path: + """Generate a hub keypair PEM for the test session.""" + d = tmp_path_factory.mktemp("hub_keys") + path = d / "hub_private.pem" + sk = Ed25519PrivateKey.generate() + path.write_bytes(sk.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + return path + + +@pytest.fixture +def hub_config(hub_key_path, tmp_path): + from meshbay_hub.config import HubConfig, DatabaseConfig, ServerConfig, HubIdentityConfig, JWTConfig + cfg = HubConfig( + db=DatabaseConfig(url="sqlite+aiosqlite:///:memory:"), + server=ServerConfig(host="127.0.0.1", port=8000), + identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path), + jwt=JWTConfig(access_token_ttl=3600, refresh_token_ttl=86400), + ) + return cfg + + +@pytest_asyncio.fixture +async def app(hub_config): + """Create a fresh FastAPI app with in-memory DB for each test.""" + # Reset module-level engine state + from meshbay_hub.db import engine as eng_mod + eng_mod._engine = None + eng_mod._session_factory = None + + from meshbay_hub.app import create_app + application = create_app(hub_config) + + # Run lifespan startup manually + async with application.router.lifespan_context(application): + yield application + + +@pytest_asyncio.fixture +async def client(app): + """httpx.AsyncClient pointing at the test app — no network.""" + import httpx + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as c: + yield c diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py new file mode 100644 index 0000000..ea59f17 --- /dev/null +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -0,0 +1,263 @@ +""" +Integration tests for the Hub API. +Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network. +""" + +import base64 +import pytest +import pytest_asyncio +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 + + +def _gen_user_keys(): + sk_ed = Ed25519PrivateKey.generate() + sk_x = X25519PrivateKey.generate() + return ( + pk_to_b64(sk_ed.public_key()), + pk_to_b64(sk_x.public_key()), + sk_x, + ) + + +# ── Hub info ────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_hub_info(client): + r = await client.get("/v1/hub/info") + assert r.status_code == 200 + data = r.json() + assert "mnp_version" in data + assert "mhp_version" in data + + +@pytest.mark.asyncio +async def test_hub_pubkey(client): + r = await client.get("/v1/hub/pubkey") + assert r.status_code == 200 + pem = r.json()["pk_hub_pem"] + assert pem.startswith("-----BEGIN PUBLIC KEY-----") + + +# ── Users ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_register_and_login(client): + pk_ed, pk_x, _ = _gen_user_keys() + r = await client.post("/v1/users/register", json={ + "username": "alice", "email": "alice@example.com", + "password": "alicepass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x, + }) + assert r.status_code == 201 + assert "user_id" in r.json() + + r = await client.post("/v1/users/login", json={ + "username": "alice", "password": "alicepass99"}) + assert r.status_code == 200 + data = r.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_register_duplicate_rejected(client): + pk_ed, pk_x, _ = _gen_user_keys() + body = {"username": "bob", "email": "bob@example.com", + "password": "bobpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x} + await client.post("/v1/users/register", json=body) + r = await client.post("/v1/users/register", json=body) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_wrong_password_rejected(client): + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "carol", "email": "carol@example.com", + "password": "carolpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "carol", "password": "wrongpass"}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_jwt_offline_verify(client, hub_key_path): + """JWT returned by login must be verifiable offline with hub's public key.""" + import jwt as pyjwt + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "dave", "email": "dave@example.com", + "password": "davepass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "dave", "password": "davepass99"}) + token = r.json()["access_token"] + + r_pk = await client.get("/v1/hub/pubkey") + hub_pk_pem = r_pk.json()["pk_hub_pem"].encode() + + decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) + assert decoded["pk_user"] == pk_ed + assert "jti" in decoded # mandatory + + +@pytest.mark.asyncio +async def test_token_refresh(client): + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "eve", "email": "eve@example.com", + "password": "evepass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "eve", "password": "evepass99"}) + rt = r.json()["refresh_token"] + at = r.json()["access_token"] + + r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt}) + assert r2.status_code == 200 + assert r2.json()["access_token"] != at # new token (different jti) + + +@pytest.mark.asyncio +async def test_get_user_pubkeys(client): + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "frank", "email": "frank@example.com", + "password": "frankpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + login = await client.post("/v1/users/login", json={ + "username": "frank", "password": "frankpass99"}) + token = login.json()["access_token"] + + r = await client.get("/v1/users/frank/pubkeys", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["pk_ed25519"] == pk_ed + assert r.json()["pk_x25519"] == pk_x + + +# ── Nodes ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_announce_and_get_node(client): + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "node1", "email": "n@example.com", + "password": "nodepass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + login = await client.post("/v1/users/login", json={ + "username": "node1", "password": "nodepass99"}) + token = login.json()["access_token"] + hdrs = {"Authorization": f"Bearer {token}"} + + r = await client.post("/v1/nodes/announce", + json={"pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"}, + headers=hdrs) + assert r.status_code == 201 + node_id = r.json()["node_id"] + + r2 = await client.get(f"/v1/nodes/{node_id}", headers=hdrs) + assert r2.status_code == 200 + assert r2.json()["pk_node"] == pk_ed + assert r2.json()["endpoint_hint"] == "1.2.3.4:19000" + + +# ── Groups + GEK bundles ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_group_gek_roundtrip(client): + """Admin creates group, wraps GEK for member, member retrieves and can unwrap.""" + import jwt as pyjwt + from meshbay_common.crypto import unwrap_gek + + # Register admin (alice2) and member (bob2) + pk_ed_a, pk_x_a, _ = _gen_user_keys() + pk_ed_b, pk_x_b, sk_x_b = _gen_user_keys() + + for uname, email, pwd, pk_ed, pk_x in [ + ("alice2", "a2@x.com", "alicepass99", pk_ed_a, pk_x_a), + ("bob2", "b2@x.com", "bobpass99", pk_ed_b, pk_x_b), + ]: + await client.post("/v1/users/register", json={ + "username": uname, "email": email, "password": pwd, + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + + def _token(uname, pwd): + async def _inner(): + r = await client.post("/v1/users/login", + json={"username": uname, "password": pwd}) + return r.json()["access_token"] + return _inner + + alice_token = (await client.post("/v1/users/login", + json={"username": "alice2", "password": "alicepass99"})).json()["access_token"] + bob_token = (await client.post("/v1/users/login", + json={"username": "bob2", "password": "bobpass99"})).json()["access_token"] + + a_hdrs = {"Authorization": f"Bearer {alice_token}"} + b_hdrs = {"Authorization": f"Bearer {bob_token}"} + + # Alice creates group + r = await client.post("/v1/groups", json={"name": "mygroup"}, headers=a_hdrs) + assert r.status_code == 201 + group_id = r.json()["group_id"] + + # Alice generates GEK and wraps it for bob + gek = generate_gek() + pk_bob_raw = base64.b64decode(pk_x_b) + bundle = wrap_gek(gek, pk_bob_raw) + + r = await client.post(f"/v1/groups/{group_id}/members/bob2/gek", + json=bundle, headers=a_hdrs) + assert r.status_code == 201 + + # Bob retrieves his bundle + r = await client.get(f"/v1/groups/{group_id}/gek", headers=b_hdrs) + assert r.status_code == 200 + retrieved = r.json() + + # Bob unwraps — must recover original GEK + sk_b_raw = sk_x_b.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + recovered = unwrap_gek(retrieved, sk_b_raw, pk_bob_raw) + assert recovered == gek + + +@pytest.mark.asyncio +async def test_non_admin_cannot_add_member(client): + pk_ed_a, pk_x_a, _ = _gen_user_keys() + pk_ed_b, pk_x_b, _ = _gen_user_keys() + + for uname, email, pwd, pk_ed, pk_x in [ + ("charlie", "c@x.com", "charliepass", pk_ed_a, pk_x_a), + ("dan", "d@x.com", "danpass1234", pk_ed_b, pk_x_b), + ]: + await client.post("/v1/users/register", json={ + "username": uname, "email": email, "password": pwd, + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + + charlie_token = (await client.post("/v1/users/login", + json={"username": "charlie", "password": "charliepass"})).json()["access_token"] + dan_token = (await client.post("/v1/users/login", + json={"username": "dan", "password": "danpass1234"})).json()["access_token"] + + r = await client.post("/v1/groups", json={"name": "charlies-group"}, + headers={"Authorization": f"Bearer {charlie_token}"}) + group_id = r.json()["group_id"] + + gek = generate_gek() + bundle = wrap_gek(gek, base64.b64decode(pk_x_b)) + + # Dan (non-admin) tries to add a member → 403 + r = await client.post(f"/v1/groups/{group_id}/members/charlie/gek", + json=bundle, + headers={"Authorization": f"Bearer {dan_token}"}) + assert r.status_code == 403 |