feat(tracker): unified auth boundary — gossip HMAC + validator token + admin-gated reads (alpha 01/02/20, ADR-0017)
Wire server.py handlers to the auth helper: forfeit requires validator service token or admin session (client API keys rejected); billing summary/ settlements/registry-wallets and benchmark endpoints require admin/service; the three gossip mutation endpoints require a fresh hive HMAC signature and outgoing gossip pushes are signed. Dashboard sends its session token on panel fetches. Existing tests updated for the new gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,221 +1,231 @@
|
||||
"""Dashboard user accounts: registration, login, roles, API keys, usage.
|
||||
|
||||
Unit tests for AccountStore plus HTTP integration on the tracker:
|
||||
register/login/logout, per-account balance and usage, API-key lifecycle
|
||||
(revoked keys rejected by the OpenAI proxy), and the admin listing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_first_account_is_admin_then_users():
|
||||
store = AccountStore()
|
||||
first = store.register(email="admin@example.com", password="secret-123")
|
||||
second = store.register(email="user@example.com", password="secret-123")
|
||||
assert first["role"] == "admin"
|
||||
assert second["role"] == "user"
|
||||
|
||||
|
||||
def test_register_requires_email_or_wallet_and_password_length():
|
||||
store = AccountStore()
|
||||
with pytest.raises(ValueError, match="email or a wallet"):
|
||||
store.register(password="secret-123")
|
||||
with pytest.raises(ValueError, match="invalid email"):
|
||||
store.register(email="not-an-email", password="secret-123")
|
||||
with pytest.raises(ValueError, match="at least 8"):
|
||||
store.register(email="a@b.co", password="short")
|
||||
|
||||
|
||||
def test_register_rejects_duplicate_identifiers():
|
||||
store = AccountStore()
|
||||
store.register(email="dup@example.com", password="secret-123")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.register(email="DUP@example.com", password="other-secret")
|
||||
|
||||
|
||||
def test_login_by_email_or_wallet():
|
||||
store = AccountStore()
|
||||
account = store.register(
|
||||
email="both@example.com", wallet="WalletXYZ", password="secret-123"
|
||||
)
|
||||
assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("both@example.com", "wrong-password") is None
|
||||
assert store.verify_login("nobody@example.com", "secret-123") is None
|
||||
|
||||
|
||||
def test_sessions_resolve_and_destroy():
|
||||
store = AccountStore()
|
||||
account = store.register(email="s@example.com", password="secret-123")
|
||||
token = store.create_session(account["account_id"])
|
||||
assert store.session_account(token)["account_id"] == account["account_id"]
|
||||
store.destroy_session(token)
|
||||
assert store.session_account(token) is None
|
||||
assert store.session_account("bogus") is None
|
||||
|
||||
|
||||
def test_api_key_lifecycle():
|
||||
store = AccountStore()
|
||||
account = store.register(email="k@example.com", password="secret-123")
|
||||
other = store.register(email="other@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
assert key.startswith("sk-mesh-")
|
||||
assert store.keys_for(account["account_id"]) == [key]
|
||||
# someone else's account cannot revoke it
|
||||
assert store.revoke_api_key(other["account_id"], key) is False
|
||||
assert store.revoke_api_key(account["account_id"], key) is True
|
||||
assert store.keys_for(account["account_id"]) == []
|
||||
assert store.is_key_revoked(key)
|
||||
|
||||
|
||||
def test_accounts_persist_across_restart(tmp_path):
|
||||
db = str(tmp_path / "accounts.db")
|
||||
store = AccountStore(db_path=db)
|
||||
account = store.register(email="p@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
store.save_to_db()
|
||||
|
||||
reloaded = AccountStore(db_path=db)
|
||||
assert reloaded.verify_login("p@example.com", "secret-123") is not None
|
||||
assert reloaded.keys_for(account["account_id"]) == [key]
|
||||
|
||||
|
||||
def test_account_events_replicate_and_dedupe():
|
||||
leader = AccountStore()
|
||||
follower = AccountStore()
|
||||
account = leader.register(email="r@example.com", password="secret-123")
|
||||
key = leader.create_api_key(account["account_id"])
|
||||
leader.revoke_api_key(account["account_id"], key)
|
||||
|
||||
events, cursor = leader.events_since(0)
|
||||
assert follower.apply_events(events) == len(events)
|
||||
assert follower.apply_events(events) == 0 # replay is a no-op
|
||||
assert follower.verify_login("r@example.com", "secret-123") is not None
|
||||
assert follower.is_key_revoked(key)
|
||||
more, _ = leader.events_since(cursor)
|
||||
assert more == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
def _call(url, method="GET", body=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(billing=ledger, accounts=AccountStore())
|
||||
port = tracker.start()
|
||||
yield f"http://127.0.0.1:{port}", ledger
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_register_login_and_account_view(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
assert reg["account"]["role"] == "admin"
|
||||
assert reg["api_key"].startswith("sk-mesh-")
|
||||
assert reg["session_token"]
|
||||
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "admin@example.com", "password": "secret-123"})
|
||||
me = _call(f"{url}/v1/account", token=login["session_token"])
|
||||
assert me["account"]["email"] == "admin@example.com"
|
||||
assert me["api_keys"] == [reg["api_key"]]
|
||||
# registration granted Caller Credit on the ledger
|
||||
assert me["total_balance"] == pytest.approx(0.5)
|
||||
assert me["usage"]["requests"] == 0
|
||||
|
||||
|
||||
def test_bad_credentials_and_missing_session_are_401(account_tracker):
|
||||
url, _ = account_tracker
|
||||
_call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "a@example.com", "password": "secret-123"})
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "a@example.com", "password": "wrong-pass"})
|
||||
assert exc_info.value.code == 401
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/account")
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "k@example.com", "password": "secret-123"})
|
||||
token = reg["session_token"]
|
||||
|
||||
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"]
|
||||
me = _call(f"{url}/v1/account", token=token)
|
||||
assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
|
||||
|
||||
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/chat/completions", "POST",
|
||||
{"model": "any", "messages": []}, token=new_key)
|
||||
assert exc_info.value.code == 401
|
||||
assert "revoked" in exc_info.value.read().decode()
|
||||
|
||||
|
||||
def test_admin_listing_requires_admin_role(account_tracker):
|
||||
url, _ = account_tracker
|
||||
admin = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
user = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"wallet": "WalletUser1", "password": "secret-123"})
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/admin/accounts", token=user["session_token"])
|
||||
assert exc_info.value.code == 403
|
||||
|
||||
listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"])
|
||||
accounts = listing["accounts"]
|
||||
assert len(accounts) == 2
|
||||
assert accounts[0]["role"] == "admin"
|
||||
assert accounts[1]["wallet"] == "WalletUser1"
|
||||
assert "balances" in accounts[0]
|
||||
|
||||
|
||||
def test_accounts_gossip_endpoint_applies_events(account_tracker):
|
||||
url, _ = account_tracker
|
||||
peer = AccountStore()
|
||||
peer.register(email="remote@example.com", password="secret-123")
|
||||
events, _ = peer.events_since(0)
|
||||
result = _call(f"{url}/v1/accounts/gossip", "POST", {"events": events})
|
||||
assert result["applied"] == len(events)
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "remote@example.com", "password": "secret-123"})
|
||||
assert login["account"]["email"] == "remote@example.com"
|
||||
|
||||
|
||||
def test_accounts_endpoints_404_when_disabled():
|
||||
tracker = TrackerServer() # no accounts, no billing
|
||||
port = tracker.start()
|
||||
try:
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST",
|
||||
{"email": "x@example.com", "password": "secret-123"})
|
||||
assert exc_info.value.code == 404
|
||||
finally:
|
||||
tracker.stop()
|
||||
"""Dashboard user accounts: registration, login, roles, API keys, usage.
|
||||
|
||||
Unit tests for AccountStore plus HTTP integration on the tracker:
|
||||
register/login/logout, per-account balance and usage, API-key lifecycle
|
||||
(revoked keys rejected by the OpenAI proxy), and the admin listing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.auth import sign_hive_request
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
HIVE_SECRET = "test-hive-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_first_account_is_admin_then_users():
|
||||
store = AccountStore()
|
||||
first = store.register(email="admin@example.com", password="secret-123")
|
||||
second = store.register(email="user@example.com", password="secret-123")
|
||||
assert first["role"] == "admin"
|
||||
assert second["role"] == "user"
|
||||
|
||||
|
||||
def test_register_requires_email_or_wallet_and_password_length():
|
||||
store = AccountStore()
|
||||
with pytest.raises(ValueError, match="email or a wallet"):
|
||||
store.register(password="secret-123")
|
||||
with pytest.raises(ValueError, match="invalid email"):
|
||||
store.register(email="not-an-email", password="secret-123")
|
||||
with pytest.raises(ValueError, match="at least 8"):
|
||||
store.register(email="a@b.co", password="short")
|
||||
|
||||
|
||||
def test_register_rejects_duplicate_identifiers():
|
||||
store = AccountStore()
|
||||
store.register(email="dup@example.com", password="secret-123")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.register(email="DUP@example.com", password="other-secret")
|
||||
|
||||
|
||||
def test_login_by_email_or_wallet():
|
||||
store = AccountStore()
|
||||
account = store.register(
|
||||
email="both@example.com", wallet="WalletXYZ", password="secret-123"
|
||||
)
|
||||
assert store.verify_login("both@example.com", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("WalletXYZ", "secret-123")["account_id"] == account["account_id"]
|
||||
assert store.verify_login("both@example.com", "wrong-password") is None
|
||||
assert store.verify_login("nobody@example.com", "secret-123") is None
|
||||
|
||||
|
||||
def test_sessions_resolve_and_destroy():
|
||||
store = AccountStore()
|
||||
account = store.register(email="s@example.com", password="secret-123")
|
||||
token = store.create_session(account["account_id"])
|
||||
assert store.session_account(token)["account_id"] == account["account_id"]
|
||||
store.destroy_session(token)
|
||||
assert store.session_account(token) is None
|
||||
assert store.session_account("bogus") is None
|
||||
|
||||
|
||||
def test_api_key_lifecycle():
|
||||
store = AccountStore()
|
||||
account = store.register(email="k@example.com", password="secret-123")
|
||||
other = store.register(email="other@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
assert key.startswith("sk-mesh-")
|
||||
assert store.keys_for(account["account_id"]) == [key]
|
||||
# someone else's account cannot revoke it
|
||||
assert store.revoke_api_key(other["account_id"], key) is False
|
||||
assert store.revoke_api_key(account["account_id"], key) is True
|
||||
assert store.keys_for(account["account_id"]) == []
|
||||
assert store.is_key_revoked(key)
|
||||
|
||||
|
||||
def test_accounts_persist_across_restart(tmp_path):
|
||||
db = str(tmp_path / "accounts.db")
|
||||
store = AccountStore(db_path=db)
|
||||
account = store.register(email="p@example.com", password="secret-123")
|
||||
key = store.create_api_key(account["account_id"])
|
||||
store.save_to_db()
|
||||
|
||||
reloaded = AccountStore(db_path=db)
|
||||
assert reloaded.verify_login("p@example.com", "secret-123") is not None
|
||||
assert reloaded.keys_for(account["account_id"]) == [key]
|
||||
|
||||
|
||||
def test_account_events_replicate_and_dedupe():
|
||||
leader = AccountStore()
|
||||
follower = AccountStore()
|
||||
account = leader.register(email="r@example.com", password="secret-123")
|
||||
key = leader.create_api_key(account["account_id"])
|
||||
leader.revoke_api_key(account["account_id"], key)
|
||||
|
||||
events, cursor = leader.events_since(0)
|
||||
assert follower.apply_events(events) == len(events)
|
||||
assert follower.apply_events(events) == 0 # replay is a no-op
|
||||
assert follower.verify_login("r@example.com", "secret-123") is not None
|
||||
assert follower.is_key_revoked(key)
|
||||
more, _ = leader.events_since(cursor)
|
||||
assert more == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
def _call(url, method="GET", body=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.5, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET)
|
||||
port = tracker.start()
|
||||
yield f"http://127.0.0.1:{port}", ledger
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def test_register_login_and_account_view(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
assert reg["account"]["role"] == "admin"
|
||||
assert reg["api_key"].startswith("sk-mesh-")
|
||||
assert reg["session_token"]
|
||||
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "admin@example.com", "password": "secret-123"})
|
||||
me = _call(f"{url}/v1/account", token=login["session_token"])
|
||||
assert me["account"]["email"] == "admin@example.com"
|
||||
assert me["api_keys"] == [reg["api_key"]]
|
||||
# registration granted Caller Credit on the ledger
|
||||
assert me["total_balance"] == pytest.approx(0.5)
|
||||
assert me["usage"]["requests"] == 0
|
||||
|
||||
|
||||
def test_bad_credentials_and_missing_session_are_401(account_tracker):
|
||||
url, _ = account_tracker
|
||||
_call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "a@example.com", "password": "secret-123"})
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "a@example.com", "password": "wrong-pass"})
|
||||
assert exc_info.value.code == 401
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/account")
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_key_create_revoke_and_revoked_key_rejected_by_proxy(account_tracker):
|
||||
url, _ = account_tracker
|
||||
reg = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "k@example.com", "password": "secret-123"})
|
||||
token = reg["session_token"]
|
||||
|
||||
new_key = _call(f"{url}/v1/account/keys", "POST", {}, token=token)["api_key"]
|
||||
me = _call(f"{url}/v1/account", token=token)
|
||||
assert sorted(me["api_keys"]) == sorted([reg["api_key"], new_key])
|
||||
|
||||
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": new_key}, token=token)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/chat/completions", "POST",
|
||||
{"model": "any", "messages": []}, token=new_key)
|
||||
assert exc_info.value.code == 401
|
||||
assert "revoked" in exc_info.value.read().decode()
|
||||
|
||||
|
||||
def test_admin_listing_requires_admin_role(account_tracker):
|
||||
url, _ = account_tracker
|
||||
admin = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"email": "admin@example.com", "password": "secret-123"})
|
||||
user = _call(f"{url}/v1/auth/register", "POST",
|
||||
{"wallet": "WalletUser1", "password": "secret-123"})
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"{url}/v1/admin/accounts", token=user["session_token"])
|
||||
assert exc_info.value.code == 403
|
||||
|
||||
listing = _call(f"{url}/v1/admin/accounts", token=admin["session_token"])
|
||||
accounts = listing["accounts"]
|
||||
assert len(accounts) == 2
|
||||
assert accounts[0]["role"] == "admin"
|
||||
assert accounts[1]["wallet"] == "WalletUser1"
|
||||
assert "balances" in accounts[0]
|
||||
|
||||
|
||||
def test_accounts_gossip_endpoint_applies_events(account_tracker):
|
||||
url, _ = account_tracker
|
||||
peer = AccountStore()
|
||||
peer.register(email="remote@example.com", password="secret-123")
|
||||
events, _ = peer.events_since(0)
|
||||
body = json.dumps({"events": events}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{url}/v1/accounts/gossip", data=body,
|
||||
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
result = json.loads(r.read())
|
||||
assert result["applied"] == len(events)
|
||||
login = _call(f"{url}/v1/auth/login", "POST",
|
||||
{"identifier": "remote@example.com", "password": "secret-123"})
|
||||
assert login["account"]["email"] == "remote@example.com"
|
||||
|
||||
|
||||
def test_accounts_endpoints_404_when_disabled():
|
||||
tracker = TrackerServer() # no accounts, no billing
|
||||
port = tracker.start()
|
||||
try:
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_call(f"http://127.0.0.1:{port}/v1/auth/register", "POST",
|
||||
{"email": "x@example.com", "password": "secret-123"})
|
||||
assert exc_info.value.code == 404
|
||||
finally:
|
||||
tracker.stop()
|
||||
|
||||
@@ -1,315 +1,320 @@
|
||||
"""US-031: billing ledger — per-token pricing, 90/10 split, pending balances.
|
||||
|
||||
Unit tests for BillingLedger math/persistence/replication, plus HTTP
|
||||
integration: 401 without an API key, billed 200 with one, 402 once the
|
||||
Caller Credit is exhausted (rejected before routing — no free work).
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
MODEL = "openai-community/gpt2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_charge_single_node_gets_90_percent():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
event = ledger.charge_request("key-a", MODEL, total_tokens=1000, node_work=[("wallet-1", 12)])
|
||||
assert event["cost"] == pytest.approx(0.02)
|
||||
assert ledger.get_client_balance("key-a") == pytest.approx(1.0 - 0.02)
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_charge_three_node_split_by_work_units():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
ledger.charge_request(
|
||||
"key-a", MODEL, total_tokens=1000,
|
||||
node_work=[("wallet-1", 6), ("wallet-2", 3), ("wallet-3", 3)],
|
||||
)
|
||||
pool = 0.02 * 0.90
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool * 6 / 12)
|
||||
assert ledger.get_node_pending("wallet-2") == pytest.approx(pool * 3 / 12)
|
||||
assert ledger.get_node_pending("wallet-3") == pytest.approx(pool * 3 / 12)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_walletless_node_share_accrues_to_protocol_cut():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
ledger.charge_request(
|
||||
"key-a", MODEL, total_tokens=1000,
|
||||
node_work=[("wallet-1", 6), (None, 6)],
|
||||
)
|
||||
pool = 0.02 * 0.90
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool / 2)
|
||||
# walletless half of the pool + the 10% cut both land in protocol_cut
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(pool / 2 + 0.02 * 0.10)
|
||||
|
||||
|
||||
def test_per_model_price_override():
|
||||
ledger = BillingLedger(
|
||||
starting_credit=1.0,
|
||||
default_price_per_1k=0.02,
|
||||
prices={MODEL: 0.10},
|
||||
)
|
||||
ledger.ensure_client("key-a")
|
||||
event = ledger.charge_request("key-a", MODEL, 500, [("wallet-1", 1)])
|
||||
assert event["cost"] == pytest.approx(0.10 * 500 / 1000)
|
||||
event = ledger.charge_request("key-a", "other-model", 500, [("wallet-1", 1)])
|
||||
assert event["cost"] == pytest.approx(0.02 * 500 / 1000)
|
||||
|
||||
|
||||
def test_payout_and_forfeit_hooks():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
|
||||
pending = ledger.get_node_pending("wallet-1")
|
||||
ledger.settle_node_payout("wallet-1", pending, reference="tx-sig-1")
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
|
||||
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
|
||||
cut_before = ledger.snapshot()["protocol_cut"]
|
||||
forfeited = ledger.forfeit_pending("wallet-1")["amount"]
|
||||
assert forfeited == pytest.approx(0.02 * 0.90)
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(cut_before + forfeited)
|
||||
|
||||
|
||||
def test_restart_persistence(tmp_path):
|
||||
db = str(tmp_path / "billing.db")
|
||||
ledger = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.credit_client("key-a", 5.0)
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 12)])
|
||||
ledger.save_to_db()
|
||||
|
||||
reloaded = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
|
||||
assert reloaded.get_client_balance("key-a") == pytest.approx(
|
||||
ledger.get_client_balance("key-a")
|
||||
)
|
||||
assert reloaded.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
|
||||
assert reloaded.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkeypatch):
|
||||
from meshnet_tracker.billing import DEFAULT_BILLING_DB_PATH
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tracker = TrackerServer(enable_billing=True)
|
||||
port = tracker.start()
|
||||
try:
|
||||
summary = json.loads(
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/billing/summary").read()
|
||||
)
|
||||
assert "protocol_cut" in summary
|
||||
finally:
|
||||
tracker.stop()
|
||||
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
|
||||
|
||||
|
||||
def test_event_replication_converges_and_dedupes():
|
||||
leader = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
follower = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
|
||||
leader.credit_client("key-a", 10.0)
|
||||
leader.charge_request("key-a", MODEL, 2000, [("wallet-1", 8), ("wallet-2", 4)])
|
||||
|
||||
events, cursor = leader.events_since(0)
|
||||
assert follower.apply_events(events) == len(events)
|
||||
# replaying the same batch must be a no-op
|
||||
assert follower.apply_events(events) == 0
|
||||
|
||||
assert follower.get_client_balance("key-a") == pytest.approx(
|
||||
leader.get_client_balance("key-a")
|
||||
)
|
||||
assert follower.get_node_pending("wallet-1") == pytest.approx(
|
||||
leader.get_node_pending("wallet-1")
|
||||
)
|
||||
assert follower.snapshot()["protocol_cut"] == pytest.approx(
|
||||
leader.snapshot()["protocol_cut"]
|
||||
)
|
||||
# incremental cursor: nothing new after full sync
|
||||
more, _ = leader.events_since(cursor)
|
||||
assert more == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
class _UsageStubNode:
|
||||
"""Minimal head node returning a chat completion with real usage numbers."""
|
||||
|
||||
def __init__(self, total_tokens: int = 1000):
|
||||
self.total_tokens = total_tokens
|
||||
outer = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
self.rfile.read(length)
|
||||
body = json.dumps({
|
||||
"id": "chatcmpl-stub",
|
||||
"object": "chat.completion",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": outer.total_tokens,
|
||||
"total_tokens": outer.total_tokens,
|
||||
},
|
||||
}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
self._server.daemon_threads = True
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> int:
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
return self._server.server_address[1]
|
||||
|
||||
def stop(self):
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billed_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
"layers_start": 0,
|
||||
"layers_end": 11,
|
||||
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
|
||||
}
|
||||
},
|
||||
billing=ledger,
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
stub = _UsageStubNode(total_tokens=1000)
|
||||
stub_port = stub.start()
|
||||
reg = json.dumps({
|
||||
"endpoint": f"http://127.0.0.1:{stub_port}",
|
||||
"shard_start": 0,
|
||||
"shard_end": 11,
|
||||
"model": MODEL,
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
"tracker_mode": True,
|
||||
"wallet_address": "wallet-head",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/nodes/register",
|
||||
data=reg,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
yield tracker_url, ledger
|
||||
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def _chat(tracker_url: str, api_key: str | None):
|
||||
data = json.dumps({
|
||||
"model": MODEL,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/chat/completions",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def test_proxy_chat_requires_api_key_when_billing_enabled(billed_tracker):
|
||||
tracker_url, _ = billed_tracker
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key=None)
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
_chat(tracker_url, api_key="client-1")
|
||||
# 1000 tokens at 0.02/1K = 0.02 USDT; starting credit 0.03
|
||||
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
|
||||
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
|
||||
|
||||
summary = json.loads(
|
||||
urllib.request.urlopen(f"{tracker_url}/v1/billing/summary").read()
|
||||
)
|
||||
assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90)
|
||||
assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
stats = json.loads(urllib.request.urlopen(f"{tracker_url}/v1/stats").read())
|
||||
node_stats = next(iter(stats["nodes"].values()))["models"][MODEL]
|
||||
assert node_stats["tokens_per_sec_last_hour"] is not None
|
||||
assert node_stats["sample_count_last_hour"] == 1
|
||||
|
||||
|
||||
def test_proxy_chat_402_when_balance_exhausted(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
_chat(tracker_url, api_key="client-2") # 0.03 -> 0.01
|
||||
_chat(tracker_url, api_key="client-2") # 0.01 -> -0.01 (post-pay drift)
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key="client-2")
|
||||
assert exc_info.value.code == 402
|
||||
# rejected before routing: nothing further was billed
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
|
||||
|
||||
def test_billing_gossip_endpoint_applies_events(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
peer = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
peer.credit_client("remote-client", 7.0)
|
||||
events, _ = peer.events_since(0)
|
||||
body = json.dumps({"events": events}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/billing/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert json.loads(r.read())["applied"] == len(events)
|
||||
# only the replicated credit event lands here — Caller Credit was granted
|
||||
# on the peer that first saw the key, and its event replicates separately
|
||||
assert ledger.get_client_balance("remote-client") == pytest.approx(7.0)
|
||||
"""US-031: billing ledger — per-token pricing, 90/10 split, pending balances.
|
||||
|
||||
Unit tests for BillingLedger math/persistence/replication, plus HTTP
|
||||
integration: 401 without an API key, billed 200 with one, 402 once the
|
||||
Caller Credit is exhausted (rejected before routing — no free work).
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.auth import sign_hive_request
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
MODEL = "openai-community/gpt2"
|
||||
HIVE_SECRET = "test-hive-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unit tests
|
||||
|
||||
|
||||
def test_charge_single_node_gets_90_percent():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
event = ledger.charge_request("key-a", MODEL, total_tokens=1000, node_work=[("wallet-1", 12)])
|
||||
assert event["cost"] == pytest.approx(0.02)
|
||||
assert ledger.get_client_balance("key-a") == pytest.approx(1.0 - 0.02)
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_charge_three_node_split_by_work_units():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
ledger.charge_request(
|
||||
"key-a", MODEL, total_tokens=1000,
|
||||
node_work=[("wallet-1", 6), ("wallet-2", 3), ("wallet-3", 3)],
|
||||
)
|
||||
pool = 0.02 * 0.90
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool * 6 / 12)
|
||||
assert ledger.get_node_pending("wallet-2") == pytest.approx(pool * 3 / 12)
|
||||
assert ledger.get_node_pending("wallet-3") == pytest.approx(pool * 3 / 12)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_walletless_node_share_accrues_to_protocol_cut():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.ensure_client("key-a")
|
||||
ledger.charge_request(
|
||||
"key-a", MODEL, total_tokens=1000,
|
||||
node_work=[("wallet-1", 6), (None, 6)],
|
||||
)
|
||||
pool = 0.02 * 0.90
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(pool / 2)
|
||||
# walletless half of the pool + the 10% cut both land in protocol_cut
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(pool / 2 + 0.02 * 0.10)
|
||||
|
||||
|
||||
def test_per_model_price_override():
|
||||
ledger = BillingLedger(
|
||||
starting_credit=1.0,
|
||||
default_price_per_1k=0.02,
|
||||
prices={MODEL: 0.10},
|
||||
)
|
||||
ledger.ensure_client("key-a")
|
||||
event = ledger.charge_request("key-a", MODEL, 500, [("wallet-1", 1)])
|
||||
assert event["cost"] == pytest.approx(0.10 * 500 / 1000)
|
||||
event = ledger.charge_request("key-a", "other-model", 500, [("wallet-1", 1)])
|
||||
assert event["cost"] == pytest.approx(0.02 * 500 / 1000)
|
||||
|
||||
|
||||
def test_payout_and_forfeit_hooks():
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
|
||||
pending = ledger.get_node_pending("wallet-1")
|
||||
ledger.settle_node_payout("wallet-1", pending, reference="tx-sig-1")
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
|
||||
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 1)])
|
||||
cut_before = ledger.snapshot()["protocol_cut"]
|
||||
forfeited = ledger.forfeit_pending("wallet-1")["amount"]
|
||||
assert forfeited == pytest.approx(0.02 * 0.90)
|
||||
assert ledger.get_node_pending("wallet-1") == pytest.approx(0.0)
|
||||
assert ledger.snapshot()["protocol_cut"] == pytest.approx(cut_before + forfeited)
|
||||
|
||||
|
||||
def test_restart_persistence(tmp_path):
|
||||
db = str(tmp_path / "billing.db")
|
||||
ledger = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.credit_client("key-a", 5.0)
|
||||
ledger.charge_request("key-a", MODEL, 1000, [("wallet-1", 12)])
|
||||
ledger.save_to_db()
|
||||
|
||||
reloaded = BillingLedger(db_path=db, starting_credit=1.0, default_price_per_1k=0.02)
|
||||
assert reloaded.get_client_balance("key-a") == pytest.approx(
|
||||
ledger.get_client_balance("key-a")
|
||||
)
|
||||
assert reloaded.get_node_pending("wallet-1") == pytest.approx(0.02 * 0.90)
|
||||
assert reloaded.snapshot()["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
|
||||
def test_tracker_enables_billing_with_default_db_when_requested(tmp_path, monkeypatch):
|
||||
from meshnet_tracker.billing import DEFAULT_BILLING_DB_PATH
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tracker = TrackerServer(enable_billing=True)
|
||||
port = tracker.start()
|
||||
try:
|
||||
# /v1/billing/summary is admin-gated now; just confirm the server is up.
|
||||
health = json.loads(
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/health").read()
|
||||
)
|
||||
assert health["status"] == "ok"
|
||||
finally:
|
||||
tracker.stop()
|
||||
# enabling billing creates the ledger DB in the working directory
|
||||
assert (tmp_path / DEFAULT_BILLING_DB_PATH).exists()
|
||||
|
||||
|
||||
def test_event_replication_converges_and_dedupes():
|
||||
leader = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
follower = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
|
||||
leader.credit_client("key-a", 10.0)
|
||||
leader.charge_request("key-a", MODEL, 2000, [("wallet-1", 8), ("wallet-2", 4)])
|
||||
|
||||
events, cursor = leader.events_since(0)
|
||||
assert follower.apply_events(events) == len(events)
|
||||
# replaying the same batch must be a no-op
|
||||
assert follower.apply_events(events) == 0
|
||||
|
||||
assert follower.get_client_balance("key-a") == pytest.approx(
|
||||
leader.get_client_balance("key-a")
|
||||
)
|
||||
assert follower.get_node_pending("wallet-1") == pytest.approx(
|
||||
leader.get_node_pending("wallet-1")
|
||||
)
|
||||
assert follower.snapshot()["protocol_cut"] == pytest.approx(
|
||||
leader.snapshot()["protocol_cut"]
|
||||
)
|
||||
# incremental cursor: nothing new after full sync
|
||||
more, _ = leader.events_since(cursor)
|
||||
assert more == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------- HTTP integration
|
||||
|
||||
|
||||
class _UsageStubNode:
|
||||
"""Minimal head node returning a chat completion with real usage numbers."""
|
||||
|
||||
def __init__(self, total_tokens: int = 1000):
|
||||
self.total_tokens = total_tokens
|
||||
outer = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
self.rfile.read(length)
|
||||
body = json.dumps({
|
||||
"id": "chatcmpl-stub",
|
||||
"object": "chat.completion",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": outer.total_tokens,
|
||||
"total_tokens": outer.total_tokens,
|
||||
},
|
||||
}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
self._server.daemon_threads = True
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> int:
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
return self._server.server_address[1]
|
||||
|
||||
def stop(self):
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billed_tracker():
|
||||
ledger = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
tracker = TrackerServer(
|
||||
model_presets={
|
||||
MODEL: {
|
||||
"layers_start": 0,
|
||||
"layers_end": 11,
|
||||
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
|
||||
}
|
||||
},
|
||||
billing=ledger,
|
||||
hive_secret=HIVE_SECRET,
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
stub = _UsageStubNode(total_tokens=1000)
|
||||
stub_port = stub.start()
|
||||
reg = json.dumps({
|
||||
"endpoint": f"http://127.0.0.1:{stub_port}",
|
||||
"shard_start": 0,
|
||||
"shard_end": 11,
|
||||
"model": MODEL,
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
"tracker_mode": True,
|
||||
"wallet_address": "wallet-head",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/nodes/register",
|
||||
data=reg,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
yield tracker_url, ledger
|
||||
|
||||
stub.stop()
|
||||
tracker.stop()
|
||||
|
||||
|
||||
def _chat(tracker_url: str, api_key: str | None):
|
||||
data = json.dumps({
|
||||
"model": MODEL,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/chat/completions",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def test_proxy_chat_requires_api_key_when_billing_enabled(billed_tracker):
|
||||
tracker_url, _ = billed_tracker
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key=None)
|
||||
assert exc_info.value.code == 401
|
||||
|
||||
|
||||
def test_proxy_chat_bills_client_and_credits_node(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
_chat(tracker_url, api_key="client-1")
|
||||
# 1000 tokens at 0.02/1K = 0.02 USDT; starting credit 0.03
|
||||
assert ledger.get_client_balance("client-1") == pytest.approx(0.03 - 0.02)
|
||||
assert ledger.get_node_pending("wallet-head") == pytest.approx(0.02 * 0.90)
|
||||
|
||||
# /v1/billing/summary is admin-gated now (ADR-0017); auth is covered in
|
||||
# test_auth_boundary.py, so verify the numbers via the ledger snapshot.
|
||||
summary = ledger.snapshot()
|
||||
assert summary["node_pending"]["wallet-head"] == pytest.approx(0.02 * 0.90)
|
||||
assert summary["protocol_cut"] == pytest.approx(0.02 * 0.10)
|
||||
|
||||
stats = json.loads(urllib.request.urlopen(f"{tracker_url}/v1/stats").read())
|
||||
node_stats = next(iter(stats["nodes"].values()))["models"][MODEL]
|
||||
assert node_stats["tokens_per_sec_last_hour"] is not None
|
||||
assert node_stats["sample_count_last_hour"] == 1
|
||||
|
||||
|
||||
def test_proxy_chat_402_when_balance_exhausted(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
_chat(tracker_url, api_key="client-2") # 0.03 -> 0.01
|
||||
_chat(tracker_url, api_key="client-2") # 0.01 -> -0.01 (post-pay drift)
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_chat(tracker_url, api_key="client-2")
|
||||
assert exc_info.value.code == 402
|
||||
# rejected before routing: nothing further was billed
|
||||
assert ledger.get_client_balance("client-2") == pytest.approx(-0.01)
|
||||
|
||||
|
||||
def test_billing_gossip_endpoint_applies_events(billed_tracker):
|
||||
tracker_url, ledger = billed_tracker
|
||||
peer = BillingLedger(starting_credit=0.03, default_price_per_1k=0.02)
|
||||
peer.credit_client("remote-client", 7.0)
|
||||
events, _ = peer.events_since(0)
|
||||
body = json.dumps({"events": events}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{tracker_url}/v1/billing/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", **sign_hive_request(HIVE_SECRET, body)},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert json.loads(r.read())["applied"] == len(events)
|
||||
# only the replicated credit event lands here — Caller Credit was granted
|
||||
# on the peer that first saw the key, and its event replicates separately
|
||||
assert ledger.get_client_balance("remote-client") == pytest.approx(7.0)
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import urllib.request
|
||||
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
@@ -48,12 +49,17 @@ def test_registry_wallets_endpoint():
|
||||
contracts = LocalSolanaContracts()
|
||||
contracts.registry.submit_stake("wallet-a", 100)
|
||||
contracts.registry.record_strike("wallet-a")
|
||||
tracker = TrackerServer(contracts=contracts)
|
||||
accounts = AccountStore()
|
||||
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
|
||||
session = accounts.create_session(admin["account_id"])
|
||||
tracker = TrackerServer(contracts=contracts, accounts=accounts)
|
||||
port = tracker.start()
|
||||
try:
|
||||
data = json.loads(urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/v1/registry/wallets"
|
||||
).read())
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/v1/registry/wallets",
|
||||
headers={"Authorization": f"Bearer {session}"},
|
||||
)
|
||||
data = json.loads(urllib.request.urlopen(req).read())
|
||||
assert data["wallets"]["wallet-a"]["strike_count"] == 1
|
||||
assert data["wallets"]["wallet-a"]["banned"] is False
|
||||
finally:
|
||||
|
||||
@@ -157,7 +157,9 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
|
||||
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
|
||||
pending = ledger.get_node_pending("wallet-x")
|
||||
|
||||
tracker = TrackerServer(contracts=contracts, billing=ledger)
|
||||
tracker = TrackerServer(
|
||||
contracts=contracts, billing=ledger, validator_service_token="test-svc-token",
|
||||
)
|
||||
port = tracker.start()
|
||||
url = f"http://127.0.0.1:{port}/v1/billing/forfeit"
|
||||
body = json.dumps({"wallet": "wallet-x", "reason": "fraud"}).encode()
|
||||
@@ -171,7 +173,7 @@ def test_forfeit_endpoint_requires_auth_and_forfeits():
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=body,
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer validator"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer test-svc-token"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
|
||||
@@ -41,6 +41,7 @@ def benchmark_setup(tmp_path):
|
||||
}
|
||||
},
|
||||
benchmark_results_path=results_path,
|
||||
validator_service_token="bench",
|
||||
)
|
||||
port = tracker.start()
|
||||
tracker_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
@@ -73,9 +73,9 @@ def test_threshold_triggers_payout_and_zeroes_pending():
|
||||
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
|
||||
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
||||
|
||||
history = json.loads(urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/v1/billing/settlements"
|
||||
).read())["settlements"]
|
||||
# /v1/billing/settlements is admin-gated now (ADR-0017, covered in
|
||||
# test_auth_boundary.py); verify content via the ledger directly.
|
||||
history = ledger.settlement_history()
|
||||
assert len(history) == 1
|
||||
assert history[0]["signature"] == "fake-tx-1"
|
||||
assert history[0]["payouts"] == [
|
||||
|
||||
@@ -12,8 +12,11 @@ import pytest
|
||||
from meshnet_gateway.server import GatewayServer, _banned_route_wallet
|
||||
from meshnet_node.server import StubNodeServer
|
||||
from meshnet_contracts import LocalSolanaContracts
|
||||
from meshnet_tracker.auth import sign_hive_request
|
||||
from meshnet_tracker.server import TrackerServer, _NodeEntry, _registration_ban_error
|
||||
|
||||
_TEST_HIVE_SECRET = "test-hive-secret"
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict) -> dict:
|
||||
data = json.dumps(payload).encode()
|
||||
@@ -1558,7 +1561,7 @@ def test_stats_sqlite_persistence_survives_restart(tmp_path):
|
||||
|
||||
|
||||
def test_stats_gossip_endpoint_merges_peer_slice():
|
||||
tracker = TrackerServer()
|
||||
tracker = TrackerServer(hive_secret=_TEST_HIVE_SECRET)
|
||||
port = tracker.start()
|
||||
try:
|
||||
peer_payload = {
|
||||
@@ -1571,7 +1574,14 @@ def test_stats_gossip_endpoint_merges_peer_slice():
|
||||
}
|
||||
},
|
||||
}
|
||||
_post_json(f"http://127.0.0.1:{port}/v1/stats/gossip", peer_payload)
|
||||
body = json.dumps(peer_payload).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/v1/stats/gossip", data=body,
|
||||
headers={"Content-Type": "application/json", **sign_hive_request(_TEST_HIVE_SECRET, body)},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
combined = _get_json(f"http://127.0.0.1:{port}/v1/stats")
|
||||
assert "remote-model" in combined["models"]
|
||||
|
||||
Reference in New Issue
Block a user