feat(routes): add 'my routes only' filter

Adds a checkbox filter to the Routes page that narrows the list to
routes owned by the current user. The filter is URL-driven (?mine=true),
cached server-side automatically via the existing key builder, and only
shown to operators/admins (members can't own routes).

Backend: mine query param on GET /api/v1/routes filters by created_by
matching the caller's X-User-Id. Legacy NULL routes are excluded.

Tests:
- Backend: 6 new TestRouteMineFilter tests (own/other/null/admin/default)
- Vitest: 6 new tests (param passing, role gating, checkbox state)
- E2E: new seed route owned by pw-operator + mine filter spec
This commit is contained in:
Louis King
2026-07-24 22:22:17 +01:00
parent ca88e9f4e9
commit acacea0d85
9 changed files with 373 additions and 10 deletions
+32
View File
@@ -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 = [
+35 -1
View File
@@ -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();
});
});
+15 -2
View File
@@ -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(
@@ -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(<Routes />, {
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(<Routes />, { 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(<Routes />, { 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(<Routes />);
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(<Routes />, { 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(<Routes />, {
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);
});
});
@@ -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<RouteListResponse>(
"/api/v1/routes",
{},
mine ? { mine: "true" } : {},
{ signal },
);
return data.items || [];
@@ -1511,7 +1515,8 @@ export function RoutesPage() {
{error && <ErrorAlert message={error} />}
{canManage && (
<div className="flex justify-end mb-4">
<div className="flex items-center justify-between mb-4 gap-2">
<FilterToggle open={filterOpen} onChange={() => setFilterOpen((v) => !v)} />
<button
className="btn btn-primary btn-sm"
data-testid="add-route"
@@ -1522,6 +1527,28 @@ export function RoutesPage() {
</div>
)}
{filterOpen && canManage && (
<div className="mb-4">
<FilterForm basePath="/routes">
<FilterField label={t("routes.filter_mine")}>
<label className="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
name="mine"
value="true"
data-testid="routes-mine-toggle"
className="checkbox checkbox-sm"
key={`mine-${mine}`}
defaultChecked={mine}
onChange={autoSubmit}
/>
<span className="text-sm">{t("routes.filter_mine")}</span>
</label>
</FilterField>
</FilterForm>
</div>
)}
{routes.length === 0 && (
<EmptyState>
{t("common.no_entity_found", {
@@ -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,
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+149
View File
@@ -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)