228 lines
8.5 KiB
Python
228 lines
8.5 KiB
Python
"""Alpha issues 01/02/20 (ADR-0017): unified auth boundary.
|
|
|
|
Forfeit accepts only the validator service token or an admin session; client
|
|
API keys and garbage bearers are rejected. Financial reads require an admin
|
|
session. Hive gossip mutations require a fresh HMAC signature — unsigned
|
|
requests apply nothing.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
from meshnet_tracker.accounts import AccountStore
|
|
from meshnet_tracker.auth import sign_hive_request, verify_hive_request
|
|
from meshnet_tracker.billing import BillingLedger
|
|
from meshnet_tracker.server import TrackerServer
|
|
|
|
MODEL = "stub-model"
|
|
SERVICE_TOKEN = "svc-validator-secret-token"
|
|
HIVE_SECRET = "hive-shared-secret"
|
|
|
|
|
|
def _request(url, *, method="GET", payload=None, token=None, extra_headers=None):
|
|
headers = {"Content-Type": "application/json", **(extra_headers or {})}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload).encode() if payload is not None else None,
|
|
headers=headers,
|
|
method=method,
|
|
)
|
|
with urllib.request.urlopen(req) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def _status_of(callable_):
|
|
try:
|
|
callable_()
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code
|
|
return 200
|
|
|
|
|
|
@pytest.fixture
|
|
def secured_tracker():
|
|
accounts = AccountStore()
|
|
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
|
|
user = accounts.register(email="user@example.com", password="user-pass-1234")
|
|
admin_session = accounts.create_session(admin["account_id"])
|
|
user_session = accounts.create_session(user["account_id"])
|
|
client_api_key = accounts.create_api_key(user["account_id"])
|
|
|
|
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
|
|
|
|
tracker = TrackerServer(
|
|
billing=ledger,
|
|
accounts=accounts,
|
|
validator_service_token=SERVICE_TOKEN,
|
|
hive_secret=HIVE_SECRET,
|
|
)
|
|
port = tracker.start()
|
|
yield {
|
|
"url": f"http://127.0.0.1:{port}",
|
|
"ledger": ledger,
|
|
"admin_session": admin_session,
|
|
"user_session": user_session,
|
|
"client_api_key": client_api_key,
|
|
}
|
|
tracker.stop()
|
|
|
|
|
|
# ---------------------------------------------------------------- forfeit
|
|
|
|
|
|
def test_forfeit_rejects_missing_garbage_and_api_key(secured_tracker):
|
|
"Forfeit rejects missing garbage and api key\n\nTags: auth, http, security"
|
|
|
|
url = f"{secured_tracker['url']}/v1/billing/forfeit"
|
|
payload = {"wallet": "wallet-x", "reason": "fraud"}
|
|
|
|
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
|
|
assert _status_of(lambda: _request(url, method="POST", payload=payload, token="garbage")) == 403
|
|
# a *valid* client API key must still be rejected on operator endpoints
|
|
assert _status_of(lambda: _request(
|
|
url, method="POST", payload=payload, token=secured_tracker["client_api_key"],
|
|
)) == 403
|
|
# nothing was forfeited by any of the rejected attempts
|
|
assert secured_tracker["ledger"].get_node_pending("wallet-x") > 0
|
|
|
|
|
|
def test_forfeit_accepts_service_token_and_admin_session(secured_tracker):
|
|
"Forfeit accepts service token and admin session\n\nTags: auth, http, security"
|
|
|
|
url = f"{secured_tracker['url']}/v1/billing/forfeit"
|
|
result = _request(
|
|
url, method="POST", payload={"wallet": "wallet-x"}, token=SERVICE_TOKEN,
|
|
)
|
|
assert result["forfeited"] == pytest.approx(0.02 * 0.90)
|
|
assert secured_tracker["ledger"].get_node_pending("wallet-x") == pytest.approx(0.0)
|
|
|
|
# admin session is the operator override (idempotent second call: 0 pending)
|
|
result = _request(
|
|
url, method="POST", payload={"wallet": "wallet-x"},
|
|
token=secured_tracker["admin_session"],
|
|
)
|
|
assert result["forfeited"] == pytest.approx(0.0)
|
|
|
|
|
|
# ------------------------------------------------------- financial reads
|
|
|
|
|
|
@pytest.mark.parametrize("path", [
|
|
"/v1/billing/summary",
|
|
"/v1/billing/settlements",
|
|
"/v1/registry/wallets",
|
|
])
|
|
def test_financial_reads_require_admin_session(secured_tracker, path):
|
|
"Financial reads require admin session\n\nTags: auth, http, security"
|
|
|
|
url = f"{secured_tracker['url']}{path}"
|
|
assert _status_of(lambda: _request(url)) == 401
|
|
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
|
|
assert _status_of(lambda: _request(url, token=secured_tracker["client_api_key"])) == 403
|
|
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
|
|
|
|
|
|
def test_benchmark_endpoints_require_admin_or_service(secured_tracker):
|
|
"Benchmark endpoints require admin or service\n\nTags: auth, http, performance, security"
|
|
|
|
url = f"{secured_tracker['url']}/v1/benchmark/results"
|
|
assert _status_of(lambda: _request(url)) == 401
|
|
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
|
|
assert _status_of(lambda: _request(url, token=SERVICE_TOKEN)) == 200
|
|
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
|
|
|
|
|
|
def test_dashboard_stays_public(secured_tracker):
|
|
"Dashboard stays public\n\nTags: auth, http, security"
|
|
|
|
assert _status_of(lambda: urllib.request.urlopen(
|
|
f"{secured_tracker['url']}/dashboard"
|
|
)) == 200
|
|
|
|
|
|
# ---------------------------------------------------------------- gossip
|
|
|
|
|
|
def _gossip_events():
|
|
peer = BillingLedger(starting_credit=0.0)
|
|
peer.credit_client("injected-client", 999.0)
|
|
events, _ = peer.events_since(0)
|
|
return events
|
|
|
|
|
|
def test_unsigned_gossip_is_rejected_and_applies_nothing(secured_tracker):
|
|
"Unsigned gossip is rejected and applies nothing\n\nTags: auth, gossip, http, network, security"
|
|
|
|
url = f"{secured_tracker['url']}/v1/billing/gossip"
|
|
payload = {"events": _gossip_events()}
|
|
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
|
|
# a bearer token is not a hive signature either
|
|
assert _status_of(lambda: _request(
|
|
url, method="POST", payload=payload, token=secured_tracker["admin_session"],
|
|
)) == 401
|
|
assert secured_tracker["ledger"].get_client_balance("injected-client") == 0.0
|
|
|
|
|
|
def test_signed_gossip_applies_and_wrong_secret_rejected(secured_tracker):
|
|
"Signed gossip applies and wrong secret rejected\n\nTags: auth, gossip, http, network, security"
|
|
|
|
url = f"{secured_tracker['url']}/v1/billing/gossip"
|
|
body = json.dumps({"events": _gossip_events()}).encode()
|
|
|
|
bad = sign_hive_request("not-the-hive-secret", body)
|
|
assert _status_of(lambda: _request(
|
|
url, method="POST", payload=json.loads(body), extra_headers=bad,
|
|
)) == 401
|
|
|
|
good = sign_hive_request(HIVE_SECRET, body)
|
|
req = urllib.request.Request(
|
|
url, data=body,
|
|
headers={"Content-Type": "application/json", **good},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req) as r:
|
|
assert json.loads(r.read())["applied"] == 1
|
|
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)
|
|
|
|
|
|
def test_stale_signature_rejected():
|
|
"Stale signature rejected\n\nTags: auth, http, security"
|
|
|
|
body = b'{"events": []}'
|
|
stale = sign_hive_request(HIVE_SECRET, body, timestamp=time.time() - 3600)
|
|
assert not verify_hive_request(HIVE_SECRET, stale, body)
|
|
fresh = sign_hive_request(HIVE_SECRET, body)
|
|
assert verify_hive_request(HIVE_SECRET, fresh, body)
|
|
# signature must cover the body
|
|
assert not verify_hive_request(HIVE_SECRET, fresh, b'{"events": [1]}')
|
|
# fail closed without a configured secret
|
|
assert not verify_hive_request(None, fresh, body)
|
|
|
|
|
|
def test_accounts_and_stats_gossip_also_gated(secured_tracker):
|
|
"Accounts and stats gossip also gated\n\nTags: auth, gossip, http, network, security"
|
|
|
|
for path in ("/v1/accounts/gossip", "/v1/stats/gossip"):
|
|
url = f"{secured_tracker['url']}{path}"
|
|
assert _status_of(lambda: _request(url, method="POST", payload={})) == 401
|
|
|
|
|
|
def test_push_to_peers_signs_so_peers_accept(secured_tracker):
|
|
"Outgoing gossip from a tracker with the shared secret lands on a peer.\n\nTags: auth, http, security"
|
|
|
|
sender = TrackerServer(
|
|
billing=BillingLedger(starting_credit=0.0),
|
|
cluster_peers=[secured_tracker["url"]],
|
|
hive_secret=HIVE_SECRET,
|
|
)
|
|
body = json.dumps({"events": _gossip_events()}).encode()
|
|
assert sender._push_to_peers("/v1/billing/gossip", body) is True
|
|
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)
|