Merge pull request #318 from ipnet-mesh/chore/dashboard-routes-community-only

feat(dashboard): limit Route Health widget to community routes
This commit is contained in:
JingleManSweep
2026-07-19 21:59:57 +01:00
committed by GitHub
2 changed files with 81 additions and 38 deletions
+22 -16
View File
@@ -10,7 +10,6 @@ from sqlalchemy.sql.elements import ColumnElement
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.api.channel_visibility import (
VISIBILITY_LEVELS,
get_max_visibility_level,
get_visible_channel_indices,
resolve_user_role,
@@ -28,6 +27,7 @@ from meshcore_hub.common.models import (
NodeTag,
RawPacket,
Route,
RouteVisibility,
UserProfile,
)
from meshcore_hub.common.models.route_result_history import RouteResultHistory
@@ -76,15 +76,13 @@ def _dashboard_recent_activity_key_builder(request: Request) -> str:
def _dashboard_routes_overview_key_builder(request: Request) -> str:
"""Role-scoped key for ``GET /dashboard/routes-overview``.
"""Role-agnostic key for ``GET /dashboard/routes-overview``.
The endpoint filters admin/operator-only routes by visibility, so the
cache key must vary by role — otherwise an anonymous GET could fill the
cache with a response that hides admin routes, and a subsequent admin
GET would receive that same redacted response.
The endpoint surfaces only ``community``-visibility routes regardless
of the caller's role, so the response is identical for anonymous,
member, operator, and admin callers — no role dimension in the key.
"""
role = resolve_user_role(request) or "anonymous"
return f"dashboard/routes-overview:role={role}:{sorted_query_string(request)}"
return f"dashboard/routes-overview:{sorted_query_string(request)}"
def _flood_only_filter(
@@ -729,9 +727,12 @@ def get_routes_overview(
``days``-long history (includes today) for the trend chart and
per-route strip grid.
Role-visibility filtered the same way as ``GET /routes`` so members
don't see admin-only routes. ``days`` is clamped to the configured
raw-packet retention window so history queries can't scan purged data.
The dashboard widget only surfaces ``community``-visibility routes,
regardless of the caller's role — operators and admins still see
member/operator/admin-tier routes on the dedicated ``/routes`` page,
but the dashboard is intentionally limited to the community fleet.
``days`` is clamped to the configured raw-packet retention window so
history queries can't scan purged data.
History is read in a single bulk query against ``route_result_history``
for every visible route — no per-route scans of ``packet_path_hops``
@@ -742,11 +743,16 @@ def get_routes_overview(
retention = get_collector_settings().effective_raw_packet_retention_days
days = min(days, retention)
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all()
visible = [r for r in routes if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level]
routes = (
session.execute(
select(Route)
.where(Route.visibility == RouteVisibility.COMMUNITY.value)
.order_by(Route.from_label)
)
.scalars()
.all()
)
visible = list(routes)
# Bulk-load precomputed history for every visible route in one indexed
# query. ``read_route_history_from_db`` pads missing days and appends
+59 -22
View File
@@ -1499,8 +1499,13 @@ class TestRoutesOverview:
"no_coverage",
}
def test_visibility_filter_hides_admin_routes(self, client_no_auth, api_db_session):
"""Anonymous users must not see admin-only routes in the overview."""
def test_dashboard_only_shows_community_routes_regardless_of_role(
self, client_no_auth, api_db_session
):
"""The dashboard widget surfaces only ``community``-visibility routes,
regardless of the caller's role — admins still see member/operator/
admin-tier routes on the dedicated ``/routes`` page, but the dashboard
is intentionally limited to the community fleet."""
_make_route_with_nodes(
api_db_session,
"Public",
@@ -1510,25 +1515,36 @@ class TestRoutesOverview:
)
_make_route_with_nodes(
api_db_session,
"Secret",
"MembersOnly",
"Endpoint",
["c" * 64, "d" * 64],
visibility="member",
)
_make_route_with_nodes(
api_db_session,
"OperatorsOnly",
"Endpoint",
["e" * 64, "f" * 64],
visibility="operator",
)
_make_route_with_nodes(
api_db_session,
"AdminOnly",
"Endpoint",
["g" * 64, "h" * 64],
visibility="admin",
)
api_db_session.commit()
# Anonymous: only the community route.
anon = client_no_auth.get("/api/v1/dashboard/routes-overview").json()
labels = [r["from_label"] for r in anon["routes"]]
assert labels == ["Public"]
# Admin sees both.
admin = client_no_auth.get(
"/api/v1/dashboard/routes-overview",
headers={"X-User-Roles": "admin"},
).json()
admin_labels = sorted(r["from_label"] for r in admin["routes"])
assert admin_labels == ["Public", "Secret"]
for role_header in (None, "member", "operator", "admin"):
headers = {"X-User-Roles": role_header} if role_header else {}
resp = client_no_auth.get(
"/api/v1/dashboard/routes-overview", headers=headers
)
labels = [r["from_label"] for r in resp.json()["routes"]]
assert labels == [
"Public"
], f"role={role_header!r} saw {labels!r}; expected ['Public'] only"
def test_by_state_buckets_current_state(self, client_no_auth, api_db_session):
"""``by_state`` counts route current state across the fleet."""
@@ -1604,9 +1620,10 @@ class TestRoutesOverview:
assert route["state"] == "disabled"
assert route["matched_count"] is None
def test_cache_key_is_role_scoped(self, client_no_auth, api_db_session):
"""Different roles get independent cache entries (sanity: both
return 200, response differs when visibility-filtered)."""
def test_cache_key_is_role_agnostic(self, client_no_auth, api_db_session):
"""The dashboard widget returns the same payload for every role, so the
cache key must not vary by role — one cache fill is reused across
anonymous / member / operator / admin callers."""
from unittest.mock import MagicMock
_make_route_with_nodes(
@@ -1616,6 +1633,13 @@ class TestRoutesOverview:
["a" * 64, "b" * 64],
visibility="admin",
)
_make_route_with_nodes(
api_db_session,
"Public",
"End",
["c" * 64, "d" * 64],
visibility="community",
)
api_db_session.commit()
# Mock cache that records every set() call's key.
@@ -1631,15 +1655,28 @@ class TestRoutesOverview:
client_no_auth.app.state.redis_cache_ttl = 30
client_no_auth.get("/api/v1/dashboard/routes-overview")
client_no_auth.get(
"/api/v1/dashboard/routes-overview",
headers={"X-User-Roles": "member"},
)
client_no_auth.get(
"/api/v1/dashboard/routes-overview",
headers={"X-User-Roles": "operator"},
)
client_no_auth.get(
"/api/v1/dashboard/routes-overview",
headers={"X-User-Roles": "admin"},
)
# Two cache fills, role differs between them.
assert len(keys_seen) == 2
assert any("anonymous" in k for k in keys_seen)
assert any("admin" in k for k in keys_seen)
# Every caller produces the SAME key — no role dimension. The mock
# cache always misses (``get`` returns None), so all four requests
# re-fill; the point is that they all fill the SAME slot.
assert len(keys_seen) == 4
assert len(set(keys_seen)) == 1
key = keys_seen[0]
assert "role=" not in key
assert "anonymous" not in key
assert "admin" not in key
def test_history_served_from_precomputed_table(
self, client_no_auth, api_db_session