mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user