From 9bd15de65bbd7e1c84b47b8cb91c27cba5c6fbe2 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 4 Jul 2026 23:03:03 +0200 Subject: [PATCH] validator edits --- .claude/memory/MEMORY.md | 1 + packages/tracker/meshnet_tracker/auth.py | 75 +++++++ packages/tracker/meshnet_tracker/cli.py | 18 ++ .../tracker/meshnet_tracker/dashboard.html | 5 +- packages/tracker/meshnet_tracker/server.py | 171 +++++++++----- tests/test_auth_boundary.py | 208 ++++++++++++++++++ 6 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 packages/tracker/meshnet_tracker/auth.py create mode 100644 tests/test_auth_boundary.py diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 2202801..287b18a 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -4,3 +4,4 @@ - [User profile](user-profile.md) — who Dobromir is and how to work with him - [Project status](project-status.md) — 35/35 stories done; alpha hardening next - **Alpha hardening** — `.scratch/alpha-hardening/` (22 issues, ADRs 0016–0019, [README](../.scratch/alpha-hardening/README.md), [handoff](../.scratch/alpha-hardening/handoff.md)) +- [Alpha hardening navigation](alpha-hardening-navigation.md) — locked fraud/auth decisions, Bucket-1 order, handoff pointers diff --git a/packages/tracker/meshnet_tracker/auth.py b/packages/tracker/meshnet_tracker/auth.py new file mode 100644 index 0000000..558c987 --- /dev/null +++ b/packages/tracker/meshnet_tracker/auth.py @@ -0,0 +1,75 @@ +"""Unified tracker auth primitives (ADR-0017, alpha issues 01/02/20). + +Two mechanisms live here: + +- **Hive peer HMAC** — gossip mutation endpoints require a signature computed + as HMAC-SHA256(hive_secret, "." + raw_body). All trackers in a + hive share one secret (``--hive-secret`` / MESHNET_HIVE_SECRET). Timestamps + outside the skew window are rejected to blunt replay. +- **Validator service token** — a dedicated bearer credential distinct from + client API keys and admin sessions (``--validator-service-token`` / + MESHNET_VALIDATOR_SERVICE_TOKEN), resolved to the ``validator`` role. + +Role resolution itself happens in the request handler (it needs the account +store); these are the shared, dependency-free pieces. +""" + +from __future__ import annotations + +import hmac +import hashlib +import time + +HIVE_SIGNATURE_HEADER = "X-Meshnet-Hive-Signature" +HIVE_TIMESTAMP_HEADER = "X-Meshnet-Hive-Timestamp" +HIVE_MAX_CLOCK_SKEW_SECONDS = 300.0 + + +def _hive_digest(secret: str, timestamp: str, body: bytes) -> str: + return hmac.new( + secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256 + ).hexdigest() + + +def sign_hive_request(secret: str, body: bytes, *, timestamp: float | None = None) -> dict[str, str]: + """Headers a tracker attaches when pushing gossip to a hive peer.""" + ts = f"{timestamp if timestamp is not None else time.time():.3f}" + return { + HIVE_SIGNATURE_HEADER: _hive_digest(secret, ts, body), + HIVE_TIMESTAMP_HEADER: ts, + } + + +def verify_hive_request( + secret: str | None, + headers, + body: bytes, + *, + now: float | None = None, +) -> bool: + """True only when the request carries a fresh, valid hive signature. + + Fails closed: no configured secret means no gossip is accepted. + """ + if not secret: + return False + signature = headers.get(HIVE_SIGNATURE_HEADER) + timestamp = headers.get(HIVE_TIMESTAMP_HEADER) + if not signature or not timestamp: + return False + try: + ts_value = float(timestamp) + except ValueError: + return False + current = now if now is not None else time.time() + if abs(current - ts_value) > HIVE_MAX_CLOCK_SKEW_SECONDS: + return False + return hmac.compare_digest(signature, _hive_digest(secret, timestamp, body)) + + +def is_validator_token(token: str | None, configured: str | None) -> bool: + """Constant-time check of a presented bearer token against the configured + validator service token.""" + if not token or not configured: + return False + return hmac.compare_digest(token, configured) diff --git a/packages/tracker/meshnet_tracker/cli.py b/packages/tracker/meshnet_tracker/cli.py index c7983d4..1da39e1 100644 --- a/packages/tracker/meshnet_tracker/cli.py +++ b/packages/tracker/meshnet_tracker/cli.py @@ -102,6 +102,22 @@ def main() -> None: default=0.01, help="Never pay out less than this many USDT", ) + common.add_argument( + "--validator-service-token", + default=None, + help=( + "Service token the validator uses on POST /v1/billing/forfeit " + "(default: MESHNET_VALIDATOR_SERVICE_TOKEN env; ADR-0017)" + ), + ) + common.add_argument( + "--hive-secret", + default=None, + help=( + "Shared secret authenticating gossip between tracker peers " + "(default: MESHNET_HIVE_SECRET env; required for multi-tracker replication)" + ), + ) parser = argparse.ArgumentParser( prog="meshnet-tracker", @@ -139,6 +155,8 @@ def main() -> None: settle_period=args.settle_period, payout_threshold=args.payout_threshold, payout_dust_floor=args.payout_dust_floor, + validator_service_token=args.validator_service_token, + hive_secret=args.hive_secret, ) port = server.start() print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True) diff --git a/packages/tracker/meshnet_tracker/dashboard.html b/packages/tracker/meshnet_tracker/dashboard.html index b7476dd..874cc35 100644 --- a/packages/tracker/meshnet_tracker/dashboard.html +++ b/packages/tracker/meshnet_tracker/dashboard.html @@ -75,7 +75,10 @@ const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) async function fetchJson(path) { try { - const r = await fetch(path); + const headers = {}; + const token = localStorage.getItem("meshnet_session"); + if (token) headers["Authorization"] = "Bearer " + token; + const r = await fetch(path, { headers }); if (!r.ok) return null; return await r.json(); } catch { return null; } diff --git a/packages/tracker/meshnet_tracker/server.py b/packages/tracker/meshnet_tracker/server.py index d1c7e9d..9c32d1d 100644 --- a/packages/tracker/meshnet_tracker/server.py +++ b/packages/tracker/meshnet_tracker/server.py @@ -37,6 +37,7 @@ from importlib.resources import files from typing import Any from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore +from .auth import is_validator_token, sign_hive_request, verify_hive_request from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger from .gossip import NodeGossip from .raft import RaftNode @@ -1264,6 +1265,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): billing: "BillingLedger | None" = None, accounts: "AccountStore | None" = None, benchmark_results_path: str | None = None, + validator_service_token: str | None = None, + hive_secret: str | None = None, ) -> None: super().__init__(addr, handler) self.registry = registry @@ -1282,6 +1285,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): os.getcwd(), "benchmark_results.json" ) self.benchmark_lock = threading.Lock() + self.validator_service_token = validator_service_token + self.hive_secret = hive_secret class _TrackerHandler(http.server.BaseHTTPRequestHandler): @@ -1299,6 +1304,65 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): except BrokenPipeError: pass + # ---- unified auth boundary (ADR-0017) ---- + + def _resolve_identity(self) -> tuple[str | None, dict | None]: + """Resolve the caller to (role, account). + + Roles: "validator" (service token), "admin"/"user" (session token). + Client API keys resolve to no privileged role — they authorize + inference and wallet binding only, never operator endpoints. + """ + server: _TrackerHTTPServer = self.server # type: ignore[assignment] + token = _api_key_from_headers(self.headers) + if not token: + return None, None + if is_validator_token(token, server.validator_service_token): + return "validator", None + if server.accounts is not None: + account = server.accounts.session_account(token) + if account is not None: + return account.get("role", "user"), account + return None, None + + def _require_role(self, *allowed: str) -> bool: + """Gate a privileged handler; sends 401/403 and returns False on failure. + + 401 when no credential was presented; 403 when a credential was + presented but does not resolve to an allowed role — this covers + client API keys and garbage bearer strings on operator endpoints. + """ + role, _account = self._resolve_identity() + if role in allowed: + return True + if _api_key_from_headers(self.headers) is None: + self._send_json(401, {"error": "authentication required (admin session or service token)"}) + else: + self._send_json(403, {"error": "this endpoint requires an admin session or service token"}) + return False + + def _read_hive_authenticated_body(self) -> dict | None: + """Read + verify a hive gossip body (HMAC per ADR-0017 §3). + + Fails closed: without a configured --hive-secret no gossip is + accepted. Sends the error response itself and returns None on failure. + """ + server: _TrackerHTTPServer = self.server # type: ignore[assignment] + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length else b"{}" + if not verify_hive_request(server.hive_secret, self.headers, raw): + self._send_json(401, {"error": "valid hive signature required"}) + return None + try: + body = json.loads(raw or b"{}") + except json.JSONDecodeError: + self._send_json(400, {"error": "invalid JSON body"}) + return None + if not isinstance(body, dict): + self._send_json(400, {"error": "JSON body must be an object"}) + return None + return body + def _read_json_body(self) -> dict | None: length = int(self.headers.get("Content-Length", 0)) try: @@ -2354,7 +2418,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_stats_gossip(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] - body = self._read_json_body() + body = self._read_hive_authenticated_body() if body is None: return tracker_url = body.get("tracker_url", "") @@ -2365,6 +2429,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_billing_summary(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] + if not self._require_role("admin"): + return if server.billing is None: self._send_json(404, {"error": "billing is not enabled on this tracker"}) return @@ -2390,6 +2456,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_registry_wallets(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] + if not self._require_role("admin"): + return if server.contracts is None: self._send_json(200, {"wallets": {}}) return @@ -2406,6 +2474,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_billing_settlements(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] + if not self._require_role("admin"): + return if server.billing is None: self._send_json(404, {"error": "billing is not enabled on this tracker"}) return @@ -2413,7 +2483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_billing_gossip(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] - body = self._read_json_body() + body = self._read_hive_authenticated_body() if body is None: return if server.billing is None: @@ -2429,12 +2499,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_billing_forfeit(self): """Privileged: forfeit a node's pending balance + record a strike (US-034). - Auth is a header-presence stub (non-empty Authorization), matching the - benchmark endpoints — real validator auth arrives with on-chain keys. + ADR-0017 §4: validator service token or admin session only. Client + API keys — valid or not — are rejected. """ server: _TrackerHTTPServer = self.server # type: ignore[assignment] - if not self.headers.get("Authorization"): - self._send_json(401, {"error": "Authorization header required"}) + if not self._require_role("validator", "admin"): return if server.billing is None: self._send_json(404, {"error": "billing is not enabled on this tracker"}) @@ -2609,7 +2678,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_accounts_gossip(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] - body = self._read_json_body() + body = self._read_hive_authenticated_body() if body is None: return if server.accounts is None: @@ -2655,10 +2724,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): is its total minus the (k-1)-node route's total. """ server: _TrackerHTTPServer = self.server # type: ignore[assignment] - auth = self.headers.get("Authorization") - if not auth: - self._send_json(401, {"error": "Authorization header required"}) + if not self._require_role("admin", "validator"): return + auth = self.headers.get("Authorization") body = self._read_json_body() if body is None: return @@ -2744,8 +2812,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler): def _handle_benchmark_results(self): server: _TrackerHTTPServer = self.server # type: ignore[assignment] - if not self.headers.get("Authorization"): - self._send_json(401, {"error": "Authorization header required"}) + if not self._require_role("admin", "validator"): return results: list = [] with server.benchmark_lock: @@ -3198,6 +3265,8 @@ class TrackerServer: payout_threshold: float = 5.0, payout_dust_floor: float = 0.01, settlement_check_interval: float | None = None, + validator_service_token: str | None = None, + hive_secret: str | None = None, ) -> None: self._host = host self._requested_port = port @@ -3256,6 +3325,16 @@ class TrackerServer: ) self._settlement_stop = threading.Event() self._settlement_thread: threading.Thread | None = None + self._validator_service_token = ( + validator_service_token + if validator_service_token is not None + else os.environ.get("MESHNET_VALIDATOR_SERVICE_TOKEN") or None + ) + self._hive_secret = ( + hive_secret + if hive_secret is not None + else os.environ.get("MESHNET_HIVE_SECRET") or None + ) self.port: int | None = None def start(self) -> int: @@ -3282,6 +3361,8 @@ class TrackerServer: billing=self._billing, accounts=self._accounts, benchmark_results_path=self._benchmark_results_path, + validator_service_token=self._validator_service_token, + hive_secret=self._hive_secret, ) self.port = self._server.server_address[1] @@ -3450,6 +3531,23 @@ class TrackerServer: _purge_expired_nodes_locked(server) _rebalance_all_locked(server) + def _push_to_peers(self, path: str, body: bytes) -> bool: + """POST a hive-signed payload to every cluster peer; True if all succeeded.""" + headers = {"Content-Type": "application/json"} + if self._hive_secret: + headers.update(sign_hive_request(self._hive_secret, body)) + delivered_all = True + for peer in self._cluster_peers: + try: + req = urllib.request.Request( + f"{peer}{path}", data=body, headers=headers, method="POST", + ) + with urllib.request.urlopen(req, timeout=2.0) as r: + r.read() + except Exception: + delivered_all = False + return delivered_all + def _stats_loop(self) -> None: """Periodically save stats/billing to DB and push local slices to cluster peers.""" while not self._stats_stop.wait(_StatsCollector.SAVE_INTERVAL): @@ -3459,62 +3557,31 @@ class TrackerServer: self._billing.save_to_db() if self._accounts is not None: self._accounts.save_to_db() + if self._cluster_peers and not self._hive_secret: + print( + "[tracker] WARNING: cluster peers configured without --hive-secret — " + "gossip pushes will be rejected (ADR-0017)", + flush=True, + ) if self._stats is not None and self._cluster_peers: self_url = self._cluster_self_url or f"http://{self._host}:{self.port}" local_rpms = self._stats.get_local_rpms() body = json.dumps({"tracker_url": self_url, "stats": local_rpms}).encode() - for peer in self._cluster_peers: - try: - req = urllib.request.Request( - f"{peer}/v1/stats/gossip", - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=2.0) as r: - r.read() - except Exception: - pass + self._push_to_peers("/v1/stats/gossip", body) if self._billing is not None and self._cluster_peers: events, cursor = self._billing.events_since(self._billing_gossip_cursor) if events: - delivered_all = True body = json.dumps({"events": events}).encode() - for peer in self._cluster_peers: - try: - req = urllib.request.Request( - f"{peer}/v1/billing/gossip", - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=2.0) as r: - r.read() - except Exception: - delivered_all = False # Only advance past events every peer has seen; unreachable # peers get the full backlog on the next tick (idempotent # via event-id dedupe on the receiving side). - if delivered_all: + if self._push_to_peers("/v1/billing/gossip", body): self._billing_gossip_cursor = cursor if self._accounts is not None and self._cluster_peers: events, cursor = self._accounts.events_since(self._accounts_gossip_cursor) if events: - delivered_all = True body = json.dumps({"events": events}).encode() - for peer in self._cluster_peers: - try: - req = urllib.request.Request( - f"{peer}/v1/accounts/gossip", - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=2.0) as r: - r.read() - except Exception: - delivered_all = False - if delivered_all: + if self._push_to_peers("/v1/accounts/gossip", body): self._accounts_gossip_cursor = cursor def stop(self) -> None: diff --git a/tests/test_auth_boundary.py b/tests/test_auth_boundary.py new file mode 100644 index 0000000..2f7422f --- /dev/null +++ b/tests/test_auth_boundary.py @@ -0,0 +1,208 @@ +"""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): + 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): + 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): + 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): + 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): + 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): + 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): + 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(): + 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): + 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.""" + 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)