diff --git a/e2e/seed_data.py b/e2e/seed_data.py index 3ec4ed2..6c15c2c 100644 --- a/e2e/seed_data.py +++ b/e2e/seed_data.py @@ -407,6 +407,38 @@ def seed_routes( ) session.flush() + # A second route owned by the e2e operator session (pw-operator). + # Used by the "mine" filter test: operator-owned routes stay visible + # when ?mine=true is active, while the legacy NULL-created_by route above + # disappears. + op_route = Route( + from_label="Op North", + to_label="Op South", + description="Operator-owned e2e route", + visibility="operator", + match_width=1, + window_hours=24, + packet_count_threshold=3, + clear_threshold=6, + max_hop_span=8, + enabled=True, + reversible=False, + created_by="pw-operator", + ) + session.add(op_route) + session.flush() + for position, pk in enumerate((CHARLIE, DELTA)): + session.add( + RouteNode( + route_id=op_route.id, + node_id=nodes[pk].id, + position=position, + expected_hash=pk[:4].upper(), + ) + ) + session.add(RouteObserver(route_id=op_route.id, node_id=nodes[NORTH_2].id)) + session.flush() + def seed_profiles(session: Session, nodes: dict[str, Node]) -> None: specs = [ diff --git a/e2e/tests/routes-operator.spec.ts b/e2e/tests/routes-operator.spec.ts index acdc571..fecdc5e 100644 --- a/e2e/tests/routes-operator.spec.ts +++ b/e2e/tests/routes-operator.spec.ts @@ -4,6 +4,8 @@ import { OPERATOR_STATE } from "../utils/helpers"; test.use({ storageState: OPERATOR_STATE }); const ROUTE_LABEL = "Op From \u2192 Op To"; +const SEEDED_LEGACY = "Alpha Site \u2192 Bravo Site"; +const SEEDED_OWNED = "Op North \u2192 Op South"; test.describe.serial("routes (operator)", () => { test("operator can manage routes; admin visibility tier is hidden", async ({ @@ -13,7 +15,7 @@ test.describe.serial("routes (operator)", () => { // Operators see the seeded community route and the add button. const seededCard = page.locator( - '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]', + `[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`, ); await expect(seededCard).toBeVisible(); await expect(page.getByTestId("add-route")).toBeVisible(); @@ -61,4 +63,36 @@ test.describe.serial("routes (operator)", () => { await confirm.getByRole("button", { name: "Delete" }).click(); await expect(card).toHaveCount(0); }); + + test("mine filter shows only routes the operator owns", async ({ page }) => { + await page.goto("/routes"); + + const legacyCard = page.locator( + `[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`, + ); + const ownedCard = page.locator( + `[data-testid="route-card"][data-route-label="${SEEDED_OWNED}"]`, + ); + + // Both the legacy (NULL created_by) and operator-owned routes are visible. + await expect(legacyCard).toBeVisible(); + await expect(ownedCard).toBeVisible(); + + // Open the filter panel and toggle "mine". + await page.locator("#filter-toggle").check(); + await page.getByTestId("routes-mine-toggle").check(); + + // URL reflects the filter state. + await expect(page).toHaveURL(/mine=true/); + + // Legacy route (NULL created_by) disappears; owned route stays. + await expect(legacyCard).toHaveCount(0); + await expect(ownedCard).toBeVisible(); + + // Turn the filter off — both routes return. + await page.getByTestId("routes-mine-toggle").uncheck(); + await expect(page).not.toHaveURL(/mine=true/); + await expect(legacyCard).toBeVisible(); + await expect(ownedCard).toBeVisible(); + }); }); diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index a64d0d6..7e7fe7d 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta, timezone from typing import Any -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from sqlalchemy import select from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead, X_USER_ID_HEADER @@ -316,13 +316,26 @@ def list_routes( _: RequireRead, session: DbSession, request: Request, + mine: bool = Query( + default=False, description="Only return routes created by the caller" + ), ) -> RouteList: - """List routes, filtered by user role visibility.""" + """List routes, filtered by user role visibility. + + When ``mine`` is true, only routes whose ``created_by`` matches the + caller's user ID are returned (legacy routes with a NULL ``created_by`` + are always excluded in this mode). + """ role = resolve_user_role(request) max_level = get_max_visibility_level(role) + caller_id = request.headers.get(X_USER_ID_HEADER, "") 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] + if mine: + visible = [ + r for r in visible if r.created_by is not None and r.created_by == caller_id + ] owners_by_id = _resolve_owners_batch(session, visible) filtered = [ _route_to_read( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx index 7412fbf..85892b1 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx @@ -305,3 +305,109 @@ describe("Routes per-route ownership gating", () => { expect(links.filter((l) => l.textContent === "Test User")).toHaveLength(0); }); }); + +describe("Routes mine filter", () => { + function buildConfig(roles: string[], userSub: string) { + return makeConfig({ + oidc_enabled: true, + roles, + role_names: { admin: "admin", operator: "operator", member: "member" }, + user: { sub: userSub, name: "Test User" }, + }); + } + + afterEach(() => { + window.__APP_CONFIG__ = makeConfig(); + }); + + it("passes mine=true to apiGet when URL has ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return ROUTES; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { + config: cfg, + route: "/routes?mine=true", + }); + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + "/api/v1/routes", + expect.objectContaining({ mine: "true" }), + expect.anything(), + ); + }); + }); + + it("omits mine param when URL has no ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return ROUTES; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg, route: "/routes" }); + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + "/api/v1/routes", + {}, + expect.anything(), + ); + }); + }); + + it("hides filter toggle for members", async () => { + const cfg = buildConfig(["member"], "mem-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("routes-mine-toggle")).toBeNull(); + expect(screen.queryByLabelText(/filters/i)).toBeNull(); + }); + + it("hides filter toggle when OIDC is disabled", async () => { + window.__APP_CONFIG__ = makeConfig(); + mockRoutesApi(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("routes-mine-toggle")).toBeNull(); + }); + + it("shows the mine toggle for operators when filter panel is open", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { config: cfg }); + await screen.findByTestId("add-route"); + fireEvent.click(screen.getByLabelText(/filters/i)); + expect(await screen.findByTestId("routes-mine-toggle")).toBeInTheDocument(); + }); + + it("checkbox is checked on load when URL has ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { + config: cfg, + route: "/routes?mine=true", + }); + await screen.findByTestId("add-route"); + fireEvent.click(screen.getByLabelText(/filters/i)); + const toggle = (await screen.findByTestId( + "routes-mine-toggle", + )) as HTMLInputElement; + expect(toggle.checked).toBe(true); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index 24bae55..921120e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -7,7 +7,7 @@ import { } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; @@ -16,6 +16,7 @@ import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { EmptyState } from "@/components/EmptyState"; +import { FilterForm, FilterField, FilterToggle, autoSubmit } from "@/components/FilterForm"; import { Modal } from "@/components/Modal"; import { PageHeader } from "@/components/PageHeader"; import { SectionGroup } from "@/components/SectionGroup"; @@ -1181,6 +1182,7 @@ function DeleteRouteModal({ export function RoutesPage() { const { t } = useTranslation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const config = useAppConfig(); const packetsEnabled = config.features?.packets !== false; const canManage = hasRole("admin") || hasRole("operator"); @@ -1188,6 +1190,8 @@ export function RoutesPage() { const isAdmin = hasRole("admin"); const canEditRoute = (r: RouteItem) => isAdmin || (!!r.created_by && r.created_by === currentUserId); + const mine = searchParams.get("mine") === "true"; + const [filterOpen, setFilterOpen] = useState(false); usePageTitle("routes.title"); const queryClient = useQueryClient(); @@ -1197,11 +1201,11 @@ export function RoutesPage() { isLoading: loading, error: queryError, } = useQuery({ - queryKey: qk.routes.list(), + queryKey: qk.routes.list({ mine }), queryFn: async ({ signal }) => { const data = await apiGet( "/api/v1/routes", - {}, + mine ? { mine: "true" } : {}, { signal }, ); return data.items || []; @@ -1511,7 +1515,8 @@ export function RoutesPage() { {error && } {canManage && ( - + + setFilterOpen((v) => !v)} /> )} + {filterOpen && canManage && ( + + + + + + {t("routes.filter_mine")} + + + + + )} + {routes.length === 0 && ( {t("common.no_entity_found", { diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts index 7428c8a..b00bd1d 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts @@ -17,7 +17,7 @@ export const qk = { }, routes: { all: ["routes"] as const, - list: () => ["routes", "list"] as const, + list: (params: unknown = {}) => ["routes", "list", params] as const, detail: (id: string) => ["routes", "detail", id] as const, history: (id: string, days: number) => ["routes", "history", id, days] as const, diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 7d45834..deb2d85 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -361,7 +361,8 @@ "recent_packets": "Recent Packets", "last_n_hours": "Last {{n}}h", "min_nodes_error": "At least 2 path nodes are required.", - "other_routes": "Other routes" + "other_routes": "Other routes", + "filter_mine": "Show only my routes" }, "not_found": { "description": "The page you're looking for doesn't exist or has been moved." diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 4d08958..5c6495b 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -283,7 +283,8 @@ "recent_packets": "Recente packets", "last_n_hours": "Laatste {{n}}u", "min_nodes_error": "Minimaal 2 padknooppunten zijn vereist.", - "other_routes": "Overige routes" + "other_routes": "Overige routes", + "filter_mine": "Toon alleen mijn routes" }, "not_found": { "description": "De pagina die u zoekt bestaat niet of is verplaatst." diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 86dae7f..4dce916 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -103,6 +103,155 @@ class TestListRoutes: assert "Secret" in labels +class TestRouteMineFilter: + """``?mine=true`` narrows the list to the caller's own routes.""" + + def test_mine_returns_only_caller_routes(self, client_no_auth, api_db_session): + api_db_session.add( + Route( + from_label="Mine", + to_label="End", + visibility="operator", + created_by="test-operator", + ) + ) + api_db_session.add( + Route( + from_label="Theirs", + to_label="End", + visibility="operator", + created_by="someone-else", + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes?mine=true", headers=OPERATOR_HEADERS) + assert resp.status_code == 200 + labels = [r["from_label"] for r in resp.json()["items"]] + assert "Mine" in labels + assert "Theirs" not in labels + assert resp.json()["total"] == len(resp.json()["items"]) + + def test_mine_false_returns_all_visible(self, client_no_auth, api_db_session): + api_db_session.add( + Route( + from_label="Mine", + to_label="End", + visibility="operator", + created_by="test-operator", + ) + ) + api_db_session.add( + Route( + from_label="Theirs", + to_label="End", + visibility="operator", + created_by="someone-else", + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes?mine=false", headers=OPERATOR_HEADERS) + assert resp.status_code == 200 + labels = [r["from_label"] for r in resp.json()["items"]] + assert "Mine" in labels + assert "Theirs" in labels + + def test_mine_excludes_legacy_null_routes(self, client_no_auth, api_db_session): + api_db_session.add( + Route( + from_label="Owned", + to_label="End", + visibility="operator", + created_by="test-operator", + ) + ) + api_db_session.add( + Route( + from_label="Legacy", + to_label="End", + visibility="operator", + created_by=None, + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes?mine=true", headers=OPERATOR_HEADERS) + assert resp.status_code == 200 + labels = [r["from_label"] for r in resp.json()["items"]] + assert "Owned" in labels + assert "Legacy" not in labels + + def test_mine_empty_when_user_has_no_routes(self, client_no_auth, api_db_session): + api_db_session.add( + Route( + from_label="Theirs", + to_label="End", + visibility="operator", + created_by="someone-else", + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes?mine=true", headers=OPERATOR_HEADERS) + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_mine_admin_sees_own_routes(self, client_no_auth, api_db_session): + api_db_session.add( + Route( + from_label="AdminRoute", + to_label="End", + visibility="admin", + created_by="test-admin", + ) + ) + api_db_session.add( + Route( + from_label="OpRoute", + to_label="End", + visibility="operator", + created_by="test-operator", + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes?mine=true", headers=ADMIN_HEADERS) + assert resp.status_code == 200 + labels = [r["from_label"] for r in resp.json()["items"]] + assert "AdminRoute" in labels + assert "OpRoute" not in labels + + def test_without_mine_param_returns_all_visible( + self, client_no_auth, api_db_session + ): + api_db_session.add( + Route( + from_label="Mine", + to_label="End", + visibility="operator", + created_by="test-operator", + ) + ) + api_db_session.add( + Route( + from_label="Theirs", + to_label="End", + visibility="operator", + created_by="someone-else", + ) + ) + api_db_session.commit() + + resp = client_no_auth.get("/api/v1/routes", headers=OPERATOR_HEADERS) + assert resp.status_code == 200 + labels = [r["from_label"] for r in resp.json()["items"]] + assert "Mine" in labels + assert "Theirs" in labels + + class TestCreateRoute: def test_create_success(self, client_no_auth, api_db_session): nodes = _sample_nodes(api_db_session)