aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_hub_client.py
blob: e733610fb464b71028ae263afc024c7e0ccbd137 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""
Tests for meshbay_node.hub_client — uses httpx.MockTransport to avoid network.
"""

import time

import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_node.hub_client import HubClient, HubConfig
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"