mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-28 12:32:28 +02:00
3810e029ce
Bundles four independent improvements to API caching and route evaluator
accuracy that all touch the same files in the cache/routes stack.
## HTTP Cache-Control headers (api_cache_control_enabled)
The API now emits HTTP Cache-Control on every /api/v1/* response and
handles ETag / If-None-Match -> 304 Not Modified on @cached endpoints.
- @cached GETs: private, max-age=<redis_ttl> + strong SHA-256 ETag.
Matching If-None-Match returns 304 with empty body.
- Uncached GETs under /api/v1/*: private, max-age=0, must-revalidate.
- POST/PUT/DELETE/PATCH and /health*: no-store.
- All private (several @cached endpoints are role-aware).
- @cached decorator stores new envelope {body, etag} in Redis; legacy
bare-JSON entries still read (hashed on the fly) and auto-migrate on
the next miss.
- X-Cache: HIT|MISS observability header is always emitted.
- Kill switch: API_CACHE_CONTROL_ENABLED (default true).
## Dashboard cache TTL bump (30s -> 300s)
REDIS_CACHE_TTL_DASHBOARD default raised from 30s to 300s. Covers all
/dashboard/* endpoints and /api/v1/routes/{id}/history. These return
trend/aggregation data where minute-level staleness is invisible but
every MISS costs seconds of SQL/aggregation. Also resolves reported
short-TTL behaviour on the per-route healthchart (which shares this
setting). Operators with explicit env var: unchanged. Old Redis entries
expire naturally within <=30s; no flush needed.
## Route recent-packets hop truncation
In the routes page expanded card, path hops are now truncated to
2 + ellipsis + 2 when length > 5, matching the packet-detail page
styling. Reuses the existing packets.hops_hidden i18n key as the
tooltip on the ellipsis badge.
## Route event-hash deduplication
A single advert/message/telemetry/trace retransmitted or flooded
through the mesh produces one RawPacket per on-air copy, each with a
fresh wire packet_hash. Counting those as distinct matches let a single
underlying event satisfy packet_count_threshold within seconds and
biased route health.
- Add nullable event_hash column to raw_packets and packet_path_hops
(alembic 6b3430fd84f4). No backfill; legacy rows keep NULL and the
evaluator falls back to wire packet_hash until they age out of
window_hours.
- Subscriber denormalizes the structured event's event_hash onto the
captured raw packet after handler dispatch.
- Route evaluator prefers event_hash when set, collapsing all
receptions of the same underlying event into one match.
796 lines
26 KiB
Python
796 lines
26 KiB
Python
"""Tests for route API endpoints."""
|
|
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
|
|
from meshcore_hub.collector.routes import derive_expected_hash
|
|
from meshcore_hub.common.models import (
|
|
Node,
|
|
PacketPathHop,
|
|
RawPacket,
|
|
Route,
|
|
RouteNode,
|
|
)
|
|
|
|
|
|
def _make_node(session, public_key: str, name: str | None = None) -> Node:
|
|
node = Node(public_key=public_key, name=name, first_seen=datetime.now(timezone.utc))
|
|
session.add(node)
|
|
session.flush()
|
|
return node
|
|
|
|
|
|
def _sample_nodes(session, count: int = 2) -> list[Node]:
|
|
keys = [f"{chr(97 + i)}" * 64 for i in range(count)]
|
|
return [_make_node(session, k, f"Node-{i}") for i, k in enumerate(keys)]
|
|
|
|
|
|
def _make_reception(
|
|
session,
|
|
observer_node_id: str | None,
|
|
packet_hash: str,
|
|
path_hashes: list[str],
|
|
received_at: datetime | None = None,
|
|
) -> str:
|
|
"""Insert a RawPacket + PacketPathHop rows for a test reception."""
|
|
ts = received_at or datetime.now(timezone.utc)
|
|
rp_id = str(uuid4())
|
|
session.add(
|
|
RawPacket(
|
|
id=rp_id,
|
|
observer_node_id=observer_node_id,
|
|
packet_hash=packet_hash,
|
|
received_at=ts,
|
|
)
|
|
)
|
|
session.flush()
|
|
for pos, nh in enumerate(path_hashes):
|
|
session.add(
|
|
PacketPathHop(
|
|
raw_packet_id=rp_id,
|
|
position=pos,
|
|
node_hash=nh,
|
|
packet_hash=packet_hash,
|
|
received_at=ts,
|
|
observer_node_id=observer_node_id,
|
|
)
|
|
)
|
|
session.flush()
|
|
return rp_id
|
|
|
|
|
|
class TestListRoutes:
|
|
def test_empty(self, client_no_auth):
|
|
resp = client_no_auth.get("/api/v1/routes")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|
|
|
|
def test_visibility_filter(self, client_no_auth, api_db_session):
|
|
api_db_session.add(
|
|
Route(from_label="Public", to_label="Endpoint", visibility="community")
|
|
)
|
|
api_db_session.add(
|
|
Route(from_label="Secret", to_label="Endpoint", visibility="admin")
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get("/api/v1/routes")
|
|
assert resp.status_code == 200
|
|
labels = [r["from_label"] for r in resp.json()["items"]]
|
|
assert "Public" in labels
|
|
assert "Secret" not in labels
|
|
|
|
def test_admin_sees_all(self, client_no_auth, api_db_session):
|
|
api_db_session.add(
|
|
Route(from_label="Public", to_label="Endpoint", visibility="community")
|
|
)
|
|
api_db_session.add(
|
|
Route(from_label="Secret", to_label="Endpoint", visibility="admin")
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get("/api/v1/routes", headers={"X-User-Roles": "admin"})
|
|
assert resp.status_code == 200
|
|
labels = [r["from_label"] for r in resp.json()["items"]]
|
|
assert "Public" in labels
|
|
assert "Secret" in labels
|
|
|
|
|
|
class TestCreateRoute:
|
|
def test_create_success(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "Alpha",
|
|
"to_label": "Beta",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"match_width": 1,
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["from_label"] == "Alpha"
|
|
assert data["to_label"] == "Beta"
|
|
assert len(data["route_nodes"]) == 2
|
|
assert data["route_nodes"][0]["expected_hash"] is not None
|
|
assert data["reversible"] is True
|
|
|
|
def test_create_non_reversible(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "Alpha",
|
|
"to_label": "Beta",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"reversible": False,
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["reversible"] is False
|
|
|
|
def test_duplicate_from_to_rejected(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.add(Route(from_label="Dup", to_label="End"))
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "Dup",
|
|
"to_label": "End",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
def test_min_two_nodes(self, client_no_auth, api_db_session):
|
|
node = _make_node(api_db_session, "a" * 64)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "A",
|
|
"to_label": "B",
|
|
"node_public_keys": [node.public_key],
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_distinct_nodes(self, client_no_auth, api_db_session):
|
|
node = _make_node(api_db_session, "a" * 64)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "A",
|
|
"to_label": "B",
|
|
"node_public_keys": [node.public_key, node.public_key],
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_clear_threshold_validation(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "A",
|
|
"to_label": "B",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"packet_count_threshold": 5,
|
|
"clear_threshold": 3,
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_non_admin_rejected(self, client_with_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_with_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "A",
|
|
"to_label": "B",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
},
|
|
headers={"Authorization": "Bearer test-read-key"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestGetRouteDetail:
|
|
def test_detail_shape(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session, 3)
|
|
route = Route(from_label="Alpha", to_label="Beta")
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["from_label"] == "Alpha"
|
|
assert data["to_label"] == "Beta"
|
|
assert len(data["route_nodes"]) == 3
|
|
assert "contributing_observers" in data
|
|
assert "recent_matches" in data
|
|
|
|
def test_not_found(self, client_no_auth):
|
|
resp = client_no_auth.get("/api/v1/routes/nonexistent")
|
|
assert resp.status_code == 404
|
|
|
|
def test_detail_response_is_cached(self, client_no_auth, api_db_session):
|
|
"""Detail endpoint writes its response to the cache after a miss."""
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
nodes = _sample_nodes(api_db_session, 2)
|
|
route = Route(from_label="Alpha", to_label="Beta")
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
api_db_session.commit()
|
|
|
|
mock_cache = MagicMock()
|
|
mock_cache.get.return_value = None
|
|
client_no_auth.app.state.redis_cache = mock_cache
|
|
client_no_auth.app.state.redis_cache_ttl_route_detail = 90
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert resp.status_code == 200
|
|
assert resp.headers.get("x-cache") == "MISS"
|
|
|
|
mock_cache.set.assert_called_once()
|
|
cache_key, serialized, ttl = mock_cache.set.call_args[0]
|
|
assert cache_key.startswith(f"/api/v1/routes/{route.id}:")
|
|
assert "role=anonymous" in cache_key
|
|
assert ttl == 90
|
|
envelope = json.loads(serialized)
|
|
assert envelope["body"]["from_label"] == "Alpha"
|
|
assert isinstance(envelope["etag"], str)
|
|
|
|
def test_detail_serves_from_cache_on_hit(self, client_no_auth, api_db_session):
|
|
"""A second call within the TTL window is served from the cache."""
|
|
nodes = _sample_nodes(api_db_session, 2)
|
|
route = Route(from_label="Alpha", to_label="Beta")
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
api_db_session.commit()
|
|
|
|
store: dict[str, str] = {}
|
|
|
|
class _FakeCache:
|
|
def get(self, key):
|
|
return store.get(key)
|
|
|
|
def set(self, key, value, ttl):
|
|
store[key] = value
|
|
|
|
def ping(self):
|
|
return True
|
|
|
|
client_no_auth.app.state.redis_cache = _FakeCache()
|
|
client_no_auth.app.state.redis_cache_ttl_route_detail = 60
|
|
|
|
first = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert first.status_code == 200
|
|
assert first.headers.get("x-cache") == "MISS"
|
|
first_body = first.json()
|
|
|
|
second = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert second.status_code == 200
|
|
assert second.headers.get("x-cache") == "HIT"
|
|
assert second.json() == first_body
|
|
|
|
|
|
class TestUpdateRoute:
|
|
def test_update_from_to(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
route = Route(from_label="OldFrom", to_label="OldTo")
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{route.id}",
|
|
json={"from_label": "NewFrom", "to_label": "NewTo"},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["from_label"] == "NewFrom"
|
|
assert data["to_label"] == "NewTo"
|
|
|
|
def test_update_path_nodes(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session, 2)
|
|
route = Route(from_label="A", to_label="B")
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
api_db_session.commit()
|
|
|
|
new_node = _make_node(api_db_session, "z" * 64)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{route.id}",
|
|
json={"node_public_keys": [nodes[0].public_key, new_node.public_key]},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
public_keys = [rn["public_key"] for rn in data["route_nodes"]]
|
|
assert new_node.public_key in public_keys
|
|
|
|
|
|
class TestDeleteRoute:
|
|
def test_delete_success(self, client_no_auth, api_db_session):
|
|
route = Route(from_label="Bye", to_label="Gone")
|
|
api_db_session.add(route)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.delete(
|
|
f"/api/v1/routes/{route.id}",
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
def test_not_found(self, client_no_auth):
|
|
resp = client_no_auth.delete(
|
|
"/api/v1/routes/nonexistent",
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestPreview:
|
|
def test_preview_no_match(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes/preview",
|
|
json={
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"match_width": 1,
|
|
"window_hours": 24,
|
|
"packet_count_threshold": 3,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["truncated"] is False
|
|
assert data["matched_count"] == 0
|
|
|
|
def test_preview_validation_min_nodes(self, client_no_auth, api_db_session):
|
|
node = _make_node(api_db_session, "a" * 64)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes/preview",
|
|
json={"node_public_keys": [node.public_key]},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def _make_route_with_nodes(
|
|
session,
|
|
from_label: str,
|
|
to_label: str,
|
|
pubkeys: list[str],
|
|
visibility: str = "community",
|
|
enabled: bool = True,
|
|
match_width: int = 1,
|
|
) -> Route:
|
|
nodes = [_make_node(session, pk) for pk in pubkeys]
|
|
route = Route(
|
|
from_label=from_label,
|
|
to_label=to_label,
|
|
visibility=visibility,
|
|
enabled=enabled,
|
|
match_width=match_width,
|
|
)
|
|
session.add(route)
|
|
session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[: 2 * match_width].upper(),
|
|
)
|
|
)
|
|
return route
|
|
|
|
|
|
class TestRouteHistory:
|
|
def test_response_shape(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=3")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["route_id"] == route.id
|
|
assert "days" in data
|
|
assert "data" in data
|
|
assert isinstance(data["data"], list)
|
|
for entry in data["data"]:
|
|
assert "date" in entry
|
|
assert "quality" in entry
|
|
assert "state" in entry
|
|
assert "matched_count" in entry
|
|
|
|
def test_includes_today(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=7")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# days=7 + include_today=True → 8 entries
|
|
assert len(data["data"]) == 8
|
|
|
|
def test_not_found(self, client_no_auth):
|
|
resp = client_no_auth.get("/api/v1/routes/nonexistent/history")
|
|
assert resp.status_code == 404
|
|
|
|
def test_hidden_route_404(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session,
|
|
"Secret",
|
|
"EP",
|
|
["aa" + "0" * 62, "bb" + "0" * 62],
|
|
visibility="admin",
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history")
|
|
assert resp.status_code == 404
|
|
|
|
def test_admin_sees_hidden_route(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session,
|
|
"Secret",
|
|
"EP",
|
|
["aa" + "0" * 62, "bb" + "0" * 62],
|
|
visibility="admin",
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(
|
|
f"/api/v1/routes/{route.id}/history",
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_disabled_route_history(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session,
|
|
"Disabled",
|
|
"Route",
|
|
["aa" + "0" * 62, "bb" + "0" * 62],
|
|
enabled=False,
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=3")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
for entry in data["data"]:
|
|
assert entry["quality"] == "unknown"
|
|
assert entry["state"] == "no_coverage"
|
|
assert entry["matched_count"] == 0
|
|
|
|
def test_retention_clamp(self, client_no_auth, api_db_session, monkeypatch):
|
|
route = _make_route_with_nodes(
|
|
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
|
|
)
|
|
api_db_session.commit()
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_settings = MagicMock(effective_raw_packet_retention_days=2)
|
|
monkeypatch.setattr(
|
|
"meshcore_hub.api.routes.routes.get_collector_settings",
|
|
lambda: mock_settings,
|
|
)
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=7")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# retention=2 → days clamped to 2 → 3 entries (days + today)
|
|
assert len(data["data"]) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional coverage: update fields, observers, hidden detail, preview guards
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateRouteFields:
|
|
"""Cover the per-field update branches and edge cases of update_route."""
|
|
|
|
def _make_route(self, session) -> Route:
|
|
nodes = _sample_nodes(session)
|
|
route = Route(from_label="OldFrom", to_label="OldTo")
|
|
session.add(route)
|
|
session.flush()
|
|
for pos, n in enumerate(nodes):
|
|
session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=n.public_key[:2].upper(),
|
|
)
|
|
)
|
|
session.commit()
|
|
return route
|
|
|
|
def test_update_all_scalar_fields(self, client_no_auth, api_db_session):
|
|
route = self._make_route(api_db_session)
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{route.id}",
|
|
json={
|
|
"description": "updated desc",
|
|
"visibility": "member",
|
|
"match_width": 2,
|
|
"window_hours": 48,
|
|
"packet_count_threshold": 5,
|
|
"clear_threshold": 8,
|
|
"max_hop_span": 4,
|
|
"enabled": False,
|
|
"reversible": False,
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["description"] == "updated desc"
|
|
assert data["visibility"] == "member"
|
|
assert data["match_width"] == 2
|
|
assert data["window_hours"] == 48
|
|
assert data["packet_count_threshold"] == 5
|
|
assert data["clear_threshold"] == 8
|
|
assert data["max_hop_span"] == 4
|
|
assert data["enabled"] is False
|
|
assert data["reversible"] is False
|
|
|
|
def test_update_duplicate_label_409(self, client_no_auth, api_db_session):
|
|
self._make_route(api_db_session)
|
|
api_db_session.add(Route(from_label="Other", to_label="Pair"))
|
|
api_db_session.commit()
|
|
other = api_db_session.query(Route).filter(Route.from_label == "Other").first()
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{other.id}",
|
|
json={"from_label": "OldFrom", "to_label": "OldTo"},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
def test_update_observers(self, client_no_auth, api_db_session):
|
|
route = self._make_route(api_db_session)
|
|
obs = _make_node(api_db_session, "c" * 64, "Observer")
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{route.id}",
|
|
json={"observer_public_keys": [obs.public_key]},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["route_observers"]) == 1
|
|
assert data["route_observers"][0]["public_key"] == obs.public_key
|
|
|
|
def test_update_unresolved_path_nodes_400(self, client_no_auth, api_db_session):
|
|
route = self._make_route(api_db_session)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.put(
|
|
f"/api/v1/routes/{route.id}",
|
|
json={
|
|
"node_public_keys": ["ff" + "0" * 62, "ee" + "0" * 62],
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_update_not_found(self, client_no_auth):
|
|
resp = client_no_auth.put(
|
|
"/api/v1/routes/nonexistent",
|
|
json={"description": "x"},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestGetRouteVisibility:
|
|
def test_hidden_route_404(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session,
|
|
"Secret",
|
|
"EP",
|
|
["aa" + "0" * 62, "bb" + "0" * 62],
|
|
visibility="admin",
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert resp.status_code == 404
|
|
|
|
def test_admin_sees_hidden_route(self, client_no_auth, api_db_session):
|
|
route = _make_route_with_nodes(
|
|
api_db_session,
|
|
"Secret",
|
|
"EP",
|
|
["aa" + "0" * 62, "bb" + "0" * 62],
|
|
visibility="admin",
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(
|
|
f"/api/v1/routes/{route.id}",
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_contributing_observers(self, client_no_auth, api_db_session):
|
|
node_a = _make_node(api_db_session, "aa" + "0" * 62)
|
|
node_b = _make_node(api_db_session, "bb" + "0" * 62)
|
|
obs = _make_node(api_db_session, "cc" + "0" * 62, "Charlie")
|
|
route = Route(from_label="A", to_label="B", match_width=1)
|
|
api_db_session.add(route)
|
|
api_db_session.flush()
|
|
for pos, n in enumerate([node_a, node_b]):
|
|
api_db_session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=n.id,
|
|
position=pos,
|
|
expected_hash=derive_expected_hash(n.public_key, 1),
|
|
)
|
|
)
|
|
_make_reception(
|
|
api_db_session,
|
|
observer_node_id=obs.id,
|
|
packet_hash="pkt-contrib",
|
|
path_hashes=["AA", "BB"],
|
|
)
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["contributing_observers"]) == 1
|
|
assert data["contributing_observers"][0]["node_id"] == obs.id
|
|
assert data["contributing_observers"][0]["match_count"] == 1
|
|
assert len(data["recent_matches"]) == 1
|
|
|
|
|
|
class TestCreateWithObservers:
|
|
def test_create_with_observers(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
obs = _make_node(api_db_session, "c" * 64, "Observer")
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes",
|
|
json={
|
|
"from_label": "Alpha",
|
|
"to_label": "Beta",
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"observer_public_keys": [obs.public_key],
|
|
},
|
|
headers={"X-User-Roles": "admin"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert len(data["route_observers"]) == 1
|
|
assert data["route_observers"][0]["public_key"] == obs.public_key
|
|
|
|
|
|
class TestPreviewGuards:
|
|
def test_preview_unresolved_nodes(self, client_no_auth, api_db_session):
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes/preview",
|
|
json={
|
|
"node_public_keys": ["ff" + "0" * 62, "ee" + "0" * 62],
|
|
"match_width": 1,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["matched_count"] == 0
|
|
assert data["quality"] == "unknown"
|
|
assert data["state"] == "no_coverage"
|
|
|
|
def test_preview_with_observers(self, client_no_auth, api_db_session):
|
|
nodes = _sample_nodes(api_db_session)
|
|
obs = _make_node(api_db_session, "c" * 64, "Obs")
|
|
api_db_session.commit()
|
|
|
|
resp = client_no_auth.post(
|
|
"/api/v1/routes/preview",
|
|
json={
|
|
"node_public_keys": [n.public_key for n in nodes],
|
|
"observer_public_keys": [obs.public_key],
|
|
"match_width": 1,
|
|
"window_hours": 24,
|
|
"packet_count_threshold": 3,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|