mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-06 17:02:59 +02:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user