From fdea04674951fce27178743de552d87d8e8543e4 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 21:57:03 +0100 Subject: [PATCH] feat(dashboard): limit Route Health widget to community routes The Route Health and Routes Trend dashboard widgets pull from GET /api/v1/dashboard/routes-overview, which previously filtered routes by the caller's role tier (anonymous saw community, admin saw all four tiers). Operators and admins therefore saw member/operator/ admin-tier routes mixed into the dashboard, even though the dedicated /routes page is the working surface for managing those private tiers. This change makes the dashboard widget surface ONLY community-tier routes, regardless of the caller's role. Operators and admins still see all four tiers on the /routes management page (unchanged). Implementation: - get_routes_overview: drop resolve_user_role/get_max_visibility_level; push the filter into the SQL query (where Route.visibility == RouteVisibility.COMMUNITY.value) so higher-tier rows are no longer loaded just to be discarded. - _dashboard_routes_overview_key_builder: drop the role dimension from the cache key. The response is now identical across roles, so the per-role cache slots were storing four identical copies. The prefix is preserved so invalidate_routes' pattern invalidation still hits. - Drop unused VISIBILITY_LEVELS import; add RouteVisibility to the models import. Tests: - test_visibility_filter_hides_admin_routes renamed to test_dashboard_only_shows_community_routes_regardless_of_role; now seeds one route per visibility tier and asserts every role (anonymous/member/operator/admin) sees only ['Public']. - test_cache_key_is_role_scoped renamed to test_cache_key_is_role_agnostic; asserts all four role-variants produce the SAME cache key (no 'role=' dimension). --- src/meshcore_hub/api/routes/dashboard.py | 38 ++++++----- tests/test_api/test_dashboard.py | 81 +++++++++++++++++------- 2 files changed, 81 insertions(+), 38 deletions(-) diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index dad62ac..a19df26 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -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 diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 5db0fa3..81798fd 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -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