From cee487ef420a6b5bffa589cde9fd5772e748bb53 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sat, 9 May 2026 00:31:03 +0100 Subject: [PATCH] feat: hide users with test OIDC role from public views Add OIDC_ROLE_TEST config var (default: 'test') to exclude test users from dashboard stats, member counts, and the Members page. Uses server-side filtering with exclude_test query param (default: true) and client-side defense-in-depth filter in members.js. - Add oidc_role_test to WebSettings in config.py - Exclude test users from operator/member count queries in dashboard.py - Add exclude_test param to GET /api/v1/user/profiles in user_profiles.py - Filter test users client-side in members.js via role_names.test config - Wire oidc_role_test into app.state and frontend config in web/app.py - Document OIDC_ROLE_TEST in AGENTS.md and .env.example --- .env.example | 4 + AGENTS.md | 1 + .../20260508-2300-hide-test-users/plan.md | 225 ++++++++++++++++++ .../20260508-2300-hide-test-users/tasks.md | 53 +++++ src/meshcore_hub/api/routes/dashboard.py | 33 +-- src/meshcore_hub/api/routes/user_profiles.py | 23 +- src/meshcore_hub/common/config.py | 4 + src/meshcore_hub/web/app.py | 3 + .../web/static/js/spa/pages/members.js | 5 +- tests/test_api/test_dashboard.py | 69 ++++++ tests/test_api/test_user_profiles.py | 103 ++++++++ tests/test_web/test_app.py | 19 ++ 12 files changed, 526 insertions(+), 16 deletions(-) create mode 100644 docs/plans/20260508-2300-hide-test-users/plan.md create mode 100644 docs/plans/20260508-2300-hide-test-users/tasks.md diff --git a/.env.example b/.env.example index 1d1e740..e43dd9f 100644 --- a/.env.example +++ b/.env.example @@ -390,6 +390,10 @@ WEB_PORT=8080 # Default: member # OIDC_ROLE_MEMBER=member +# IdP role name for test users (excluded from public member views and counts) +# Default: test +# OIDC_ROLE_TEST=test + # Secret for signing session cookies (required when OIDC_ENABLED=true) # Generate with: openssl rand -hex 32 # OIDC_SESSION_SECRET= diff --git a/AGENTS.md b/AGENTS.md index ef05944..4c76b26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -658,6 +658,7 @@ Key variables: - `OIDC_ROLE_ADMIN` - IdP role name for admin access (default: `admin`) - `OIDC_ROLE_OPERATOR` - IdP role name for operator access (default: `operator`) - `OIDC_ROLE_MEMBER` - IdP role name for member access (default: `member`) +- `OIDC_ROLE_TEST` - IdP role name for test users, excluded from public views (default: `test`) - `OIDC_SESSION_SECRET` - Secret for signing session cookies (required if OIDC_ENABLED=true) - `OIDC_SESSION_MAX_AGE` - Session lifetime in seconds (default: `86400`) - `OIDC_COOKIE_SECURE` - HTTPS-only cookies (default: `false`) diff --git a/docs/plans/20260508-2300-hide-test-users/plan.md b/docs/plans/20260508-2300-hide-test-users/plan.md new file mode 100644 index 0000000..08f891b --- /dev/null +++ b/docs/plans/20260508-2300-hide-test-users/plan.md @@ -0,0 +1,225 @@ +# Plan: Hide Users with "test" OIDC Role + +**Date:** 2025-05-08 +**Status:** Draft + +## Problem + +Users with a `test` OIDC role should be completely hidden from the Members page and excluded from all member/operator counts across the UI. This applies regardless of whether the user also holds `member` or `operator` roles. Currently, there is no mechanism to filter out test users — they appear alongside real members and operators. + +## Current Behavior + +### How Roles Work + +- OIDC roles are stored as a **comma-separated string** in `user_profiles.roles` (e.g., `"member,test"`, `"operator,member,test"`) +- Roles are synced from the IdP on every authenticated request via the `X-User-Roles` header +- Role names are configurable via env vars (`OIDC_ROLE_ADMIN`, `OIDC_ROLE_OPERATOR`, `OIDC_ROLE_MEMBER`) but default to `"admin"`, `"operator"`, `"member"` + +### Where Member/Operator Data is Displayed + +There are **three surfaces** that show user data or counts: + +1. **Homepage stats panel** (`home.js` → `renderMembersPanel()`) — shows `total_operators` and `total_members` from the `/api/v1/dashboard/stats` endpoint +2. **Members page** (`members.js`) — fetches all profiles from `/api/v1/user/profiles`, then client-side filters into "Operators" and "Members" groups +3. **Dashboard stats API** (`dashboard.py` → `get_stats()`) — counts profiles where `roles.contains("operator")` and `roles.contains("member")` independently + +### Existing Discrepancy (pre-existing bug) + +The server-side counts (`dashboard.py`) count a user with both `operator` and `member` in **both** totals. The client-side Members page excludes operators from the "Members" group. This means homepage totals can be higher than what the Members page shows. This plan does not fix that bug but should not make it worse. + +## Approach + +Add a configurable "test role" that, when present on a user profile, excludes that user from all public-facing member displays and counts. The filtering should happen at the **API level** so both the homepage stats and the Members page list are consistent. + +### New Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `OIDC_ROLE_TEST` | IdP role name that marks a user as a test user | `test` | + +This follows the existing pattern of `OIDC_ROLE_ADMIN`, `OIDC_ROLE_OPERATOR`, `OIDC_ROLE_MEMBER`. + +### Scope of Changes + +#### 1. Configuration (`common/config.py`) + +- Add `oidc_role_test: str = Field(default="test", ...)` to `WebSettings` +- Add `test_role` to the `role_names` dict in `_build_config_json()` (in `web/app.py`) so the frontend knows which role marks test users +- Update `AGENTS.md` and `.env.example` with the new env var + +#### 2. Dashboard Stats API (`api/routes/dashboard.py`) + +**File:** `src/meshcore_hub/api/routes/dashboard.py` (~lines 209-246) + +Read `test_role` from config using the existing `get_web_settings()` pattern (already imported at line 209). Guard against empty string to prevent accidental universal exclusion. + +Add a `test_role` variable alongside `operator_role` and `member_role`: + +```python +test_role = web_settings.oidc_role_test +``` + +Modify the `total_operators` and `total_members` queries to **exclude** profiles whose `roles` column contains the test role string. Only apply the filter when `test_role` is non-empty: + +```python +# Current: +total_operators = ... .where(UserProfile.roles.contains(operator_role)) + +# New: +total_operators = (select(func.count()).select_from(UserProfile) + .where(UserProfile.roles.contains(operator_role))) +if test_role: + total_operators = total_operators.where(~UserProfile.roles.contains(test_role)) +``` + +Same pattern for `total_members`. This ensures the homepage stats panel shows correct counts. + +> **Note on `contains()`**: Since `roles` is a comma-separated string (e.g., `"operator,member,test"`), `contains("test")` will correctly match. A false positive could theoretically occur if a role name is a substring of another (e.g., `"test"` matching `"contest"`), but this is the same approach already used for operator/member counts and is acceptable given role names are short, well-known strings. +> +> **Guard condition**: If `OIDC_ROLE_TEST` is set to an empty string, the filter is skipped entirely (no profiles excluded). This allows disabling the feature without removing the config variable. + +#### 3. User Profiles List API (`api/routes/user_profiles.py`) + +**File:** `src/meshcore_hub/api/routes/user_profiles.py` + +The `GET /api/v1/user/profiles` endpoint returns all profiles. Add an optional query parameter or internal filter to exclude test users from the response. Two options: + +- **Option A (recommended):** Add a `?exclude_test=true` query parameter (default `true`) that filters out profiles with the test role. This keeps the API general-purpose while defaulting to the desired behavior. +- **Option B:** Always exclude test users from the list endpoint. Simpler but less flexible. + +**Chosen: Option A** — default to excluding test users but allow explicit opt-out. + +Get the `test_role` string from config using the same `get_web_settings()` pattern as `dashboard.py`. Apply the filter only when both `exclude_test=true` and `test_role` is non-empty (guard against empty string matching all rows). + +Implementation: + +```python +from meshcore_hub.common.config import get_web_settings + +@router.get("", response_model=UserProfileList) +async def list_profiles( + db: Annotated[AsyncSession, Depends(get_db)], + _: Annotated[None, Depends(require_read)], + exclude_test: bool = Query(default=True), + limit: int = Query(default=50, le=500), + offset: int = Query(default=0, ge=0), +) -> UserProfileList: + web_settings = get_web_settings() + test_role = web_settings.oidc_role_test + + count_query = select(func.count(UserProfile.id)) + if exclude_test and test_role: + count_query = count_query.where(~UserProfile.roles.contains(test_role)) + total = session.execute(count_query).scalar() or 0 + + query = ( + select(UserProfile) + .options( + selectinload(UserProfile.node_associations).selectinload( + UserProfileNode.node + ) + ) + .order_by(UserProfile.name) + .offset(offset) + .limit(limit) + ) + if exclude_test and test_role: + query = query.where(~UserProfile.roles.contains(test_role)) + # ... rest unchanged ... +``` + +The `total` in the paginated response must also exclude test users when `exclude_test=true`. + +#### 4. Members Page Frontend (`web/static/js/spa/pages/members.js`) + +**File:** `src/meshcore_hub/web/static/js/spa/pages/members.js` + +The Members page already fetches profiles and does client-side filtering into operator/member groups. After the API change (step 3), test users will already be excluded from the API response by default. + +However, as a **defense-in-depth** measure, also filter client-side using the `role_names.test` from the frontend config: + +```javascript +const testRole = config.role_names.test; +const realProfiles = profiles.filter(p => !p.roles || !p.roles.includes(testRole)); +``` + +This ensures test users are never displayed even if the API defaults change. + +Update the Members page total stats (the "X Operators, Y Members" summary) to use the already-filtered lists. + +#### 5. Homepage Frontend (`web/static/js/spa/pages/home.js`) + +**File:** `src/meshcore_hub/web/static/js/spa/pages/home.js` (~lines 169-194) + +The homepage stats panel (`renderMembersPanel()`) reads `total_operators` and `total_members` from the dashboard stats API. After step 2, these counts will already exclude test users. **No frontend changes needed** for the homepage. + +#### 6. Profile Page (`web/static/js/spa/pages/profile.js`) + +No changes needed — individual profile pages should still be accessible via direct URL. Test users are only hidden from aggregate views (lists and counts), not from their own profile page. + +#### 7. Frontend Config (`web/app.py`) + +**File:** `src/meshcore_hub/web/app.py` (`_build_config_json()`) + +Add `test` to the `role_names` dict: + +```python +role_names = { + "admin": app.state.oidc_role_admin, + "operator": app.state.oidc_role_operator, + "member": app.state.oidc_role_member, + "test": app.state.oidc_role_test, # NEW +} +``` + +#### 8. App State Initialization (`web/app.py`) + +Ensure `oidc_role_test` is stored in `app.state` alongside the existing role settings, so it's available to route handlers and the config builder. + +### Files Changed (Summary) + +| File | Change | +|------|--------| +| `src/meshcore_hub/common/config.py` | Add `oidc_role_test` setting | +| `src/meshcore_hub/api/routes/dashboard.py` | Exclude test users from operator/member count queries | +| `src/meshcore_hub/api/routes/user_profiles.py` | Add `exclude_test` query param, filter test users from list | +| `src/meshcore_hub/web/app.py` | Add `test` to `role_names` config; store `oidc_role_test` in app state | +| `src/meshcore_hub/web/static/js/spa/pages/members.js` | Client-side defense-in-depth filter for test role | +| `AGENTS.md` | Document `OIDC_ROLE_TEST` env var | +| `.env.example` | Add `OIDC_ROLE_TEST` entry | + +### Tests to Add/Update + +| Test File | Change | +|-----------|--------| +| `tests/test_api/test_dashboard.py` | Add test: users with test role excluded from counts | +| `tests/test_api/test_user_profiles.py` | Add test: `exclude_test=true` filters test users from list; verify `total` count is correct | +| `tests/test_web/` | Verify frontend config includes `role_names.test` | + +### Edge Cases + +- **User with `operator,test` roles**: Should be excluded from both the Operators section and the operator count +- **User with `member,test` roles**: Should be excluded from both the Members section and the member count +- **User with `operator,member,test` roles**: Should be excluded from everything +- **Test user accessing their own profile**: Should still work — they can view and edit their own profile +- **Test user adopting nodes**: Should still work — adoption is a separate concern from display +- **`OIDC_ROLE_TEST` not configured**: Defaults to `"test"`, matching the common convention +- **No users have the test role**: Behavior is identical to current — no regressions + +### Out of Scope + +- Fixing the pre-existing discrepancy between server-side and client-side member counts (operators counted in both server-side totals) +- Hiding test users from the admin API or database — they should still exist and be manageable +- Revoking test user permissions or access — they should still be able to log in and use the dashboard +- Migration: No schema changes needed — the `roles` column already stores arbitrary comma-separated role strings + +## Implementation Order + +1. Add `oidc_role_test` to `WebSettings` in `config.py` +2. Wire `oidc_role_test` into `app.state` and `_build_config_json()` in `web/app.py` +3. Update dashboard stats queries in `api/routes/dashboard.py` +4. Add `exclude_test` filter to `GET /api/v1/user/profiles` in `api/routes/user_profiles.py` +5. Update Members page client-side filter in `members.js` +6. Add/update tests +7. Update documentation (`AGENTS.md`, `.env.example`) +8. Run `pre-commit run --all-files` and `pytest` diff --git a/docs/plans/20260508-2300-hide-test-users/tasks.md b/docs/plans/20260508-2300-hide-test-users/tasks.md new file mode 100644 index 0000000..27f93c9 --- /dev/null +++ b/docs/plans/20260508-2300-hide-test-users/tasks.md @@ -0,0 +1,53 @@ +# Tasks: Hide Users with "test" OIDC Role + +## Implementation + +- [ ] **T1: Add `oidc_role_test` to `WebSettings`** (`src/meshcore_hub/common/config.py`) + - Add `oidc_role_test: str = Field(default="test", description="IdP role name for test users")` to `WebSettings` + - Follow existing pattern of `oidc_role_admin`, `oidc_role_operator`, `oidc_role_member` + +- [ ] **T2: Wire `oidc_role_test` into `app.state`** (`src/meshcore_hub/web/app.py`) + - Add `app.state.oidc_role_test = settings.oidc_role_test` in both OIDC-enabled branch (~line 418-420) and OIDC-disabled branch (~line 423-425) + - Add `"test": app.state.oidc_role_test` to `role_names` dict in `_build_config_json()` (~line 241) + +- [ ] **T3: Exclude test users from dashboard stats** (`src/meshcore_hub/api/routes/dashboard.py`) + - In `get_stats()`, read `test_role = get_web_settings().oidc_role_test` alongside existing `operator_role`/`member_role` + - Add `~UserProfile.roles.contains(test_role)` filter to `total_operators` and `total_members` count queries + - Guard: only apply filter when `test_role` is non-empty (`if test_role:`) + +- [ ] **T4: Add `exclude_test` filter to profiles list endpoint** (`src/meshcore_hub/api/routes/user_profiles.py`) + - Add `exclude_test: bool = Query(default=True)` parameter to `list_profiles()` + - Import `get_web_settings` from config + - Read `test_role = get_web_settings().oidc_role_test` + - Filter both count query and data query: `if exclude_test and test_role: query = query.where(~UserProfile.roles.contains(test_role))` + +- [ ] **T5: Client-side defense-in-depth filter in Members page** (`src/meshcore_hub/web/static/js/spa/pages/members.js`) + - Import `config` from `../app.js` (or access via existing pattern) + - Filter out profiles where `p.roles` includes `config.role_names.test` + - Use filtered list for rendering and total badge + +## Tests + +- [ ] **T6: Dashboard stats tests** (`tests/test_api/test_dashboard.py`) + - Test: users with test role excluded from `total_operators` and `total_members` + - Test: users without test role still counted normally + - Test: empty `oidc_role_test` does not filter any users + +- [ ] **T7: User profiles list tests** (`tests/test_api/test_user_profiles.py`) + - Test: `exclude_test=true` (default) filters test users from list + - Test: `exclude_test=false` includes test users + - Test: `total` count in paginated response excludes test users + - Test: empty `oidc_role_test` does not filter + +- [ ] **T8: Frontend config test** (`tests/test_web/`) + - Test: `/api/v1/web/config` response includes `role_names.test` + +## Documentation & Quality + +- [ ] **T9: Update documentation** (`AGENTS.md`, `.env.example`) + - Add `OIDC_ROLE_TEST` to environment variables table in `AGENTS.md` + - Add `OIDC_ROLE_TEST` entry to `.env.example` + +- [ ] **T10: Run quality checks** + - `pre-commit run --all-files` + - `pytest tests/test_api/test_dashboard.py tests/test_api/test_user_profiles.py` diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 76887c7..7da7ac8 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -211,24 +211,29 @@ async def get_stats( web_settings = get_web_settings() operator_role = web_settings.oidc_role_operator member_role = web_settings.oidc_role_member + test_role = web_settings.oidc_role_test - total_operators = ( - session.execute( - select(func.count()) - .select_from(UserProfile) - .where(UserProfile.roles.contains(operator_role)) - ).scalar() - or 0 + total_operators_query = ( + select(func.count()) + .select_from(UserProfile) + .where(UserProfile.roles.contains(operator_role)) ) + if test_role: + total_operators_query = total_operators_query.where( + ~UserProfile.roles.contains(test_role) + ) + total_operators = session.execute(total_operators_query).scalar() or 0 - total_members = ( - session.execute( - select(func.count()) - .select_from(UserProfile) - .where(UserProfile.roles.contains(member_role)) - ).scalar() - or 0 + total_members_query = ( + select(func.count()) + .select_from(UserProfile) + .where(UserProfile.roles.contains(member_role)) ) + if test_role: + total_members_query = total_members_query.where( + ~UserProfile.roles.contains(test_role) + ) + total_members = session.execute(total_members_query).scalar() or 0 return DashboardStats( total_nodes=total_nodes, diff --git a/src/meshcore_hub/api/routes/user_profiles.py b/src/meshcore_hub/api/routes/user_profiles.py index 2024082..75d1c5a 100644 --- a/src/meshcore_hub/api/routes/user_profiles.py +++ b/src/meshcore_hub/api/routes/user_profiles.py @@ -4,12 +4,13 @@ import logging from fastapi import APIRouter, HTTPException, Query, Request, status from pydantic import AnyUrl -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import selectinload from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER from meshcore_hub.api.dependencies import DbSession from meshcore_hub.api.profile_utils import get_or_create_profile +from meshcore_hub.common.config import get_web_settings from meshcore_hub.common.models import UserProfile from meshcore_hub.common.models.user_profile_node import UserProfileNode from meshcore_hub.common.schemas.user_profiles import ( @@ -46,11 +47,24 @@ def _build_adopted_nodes(profile: UserProfile) -> list[AdoptedNodeRead]: async def list_profiles( _: RequireRead, session: DbSession, + exclude_test: bool = Query( + default=True, description="Exclude test users from results" + ), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), ) -> UserProfileList: """List all user profiles with node counts. No user_id exposed.""" + web_settings = get_web_settings() + test_role = web_settings.oidc_role_test + count_query = select(func.count(UserProfile.id)) + if exclude_test and test_role: + count_query = count_query.where( + or_( + UserProfile.roles.is_(None), + ~UserProfile.roles.contains(test_role), + ) + ) total = session.execute(count_query).scalar() or 0 query = ( @@ -64,6 +78,13 @@ async def list_profiles( .offset(offset) .limit(limit) ) + if exclude_test and test_role: + query = query.where( + or_( + UserProfile.roles.is_(None), + ~UserProfile.roles.contains(test_role), + ) + ) profiles = session.execute(query).scalars().all() items = [] diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index a2dea86..5898478 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -311,6 +311,10 @@ class WebSettings(CommonSettings): oidc_role_member: str = Field( default="member", description="IdP role name for member access" ) + oidc_role_test: str = Field( + default="test", + description="IdP role name for test users (excluded from public views)", + ) oidc_session_secret: Optional[str] = Field( default=None, description="Secret key for signing session cookies" ) diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index ec27bfa..a52870e 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -310,6 +310,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "admin": app.state.oidc_role_admin, "operator": app.state.oidc_role_operator, "member": app.state.oidc_role_member, + "test": app.state.oidc_role_test, } if getattr(app.state, "oidc_enabled", False): @@ -418,11 +419,13 @@ def create_app( app.state.oidc_role_admin = settings.oidc_role_admin app.state.oidc_role_operator = settings.oidc_role_operator app.state.oidc_role_member = settings.oidc_role_member + app.state.oidc_role_test = settings.oidc_role_test else: app.state.oidc_enabled = False app.state.oidc_role_admin = settings.oidc_role_admin app.state.oidc_role_operator = settings.oidc_role_operator app.state.oidc_role_member = settings.oidc_role_member + app.state.oidc_role_test = settings.oidc_role_test app.state.endpoint_access = _build_endpoint_access( role_admin=settings.oidc_role_admin, diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js index a0a230e..7fa8347 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/members.js @@ -69,9 +69,12 @@ export async function render(container, params, router) { const roleNames = config.role_names || {}; const operatorRole = roleNames.operator || 'operator'; const memberRole = roleNames.member || 'member'; + const testRole = roleNames.test || 'test'; const resp = await apiGet('/api/v1/user/profiles', { limit: 500 }); - const profiles = resp.items || []; + const allProfiles = resp.items || []; + + const profiles = allProfiles.filter(p => !p.roles || !p.roles.includes(testRole)); if (profiles.length === 0) { litRender(html` diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 3b2feef..a486509 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -1,10 +1,12 @@ """Tests for dashboard API routes.""" from datetime import datetime, timedelta, timezone +from unittest.mock import patch import pytest from meshcore_hub.common.models import Advertisement, Message, Node +from meshcore_hub.common.models import UserProfile class TestDashboardStats: @@ -282,3 +284,70 @@ class TestNodeCountHistory: # At least one day should have a count > 0 (cumulative) # The last day should have count >= 1 assert data["data"][-1]["count"] >= 1 + + +class TestDashboardTestUserExclusion: + """Tests for test user exclusion from dashboard stats.""" + + @pytest.fixture + def profiles_with_roles(self, api_db_session): + """Create profiles with various role combinations.""" + profiles = [] + for user_id, name, roles in [ + ("op-1", "Operator One", "operator"), + ("op-2", "Operator Two", "operator,member"), + ("mem-1", "Member One", "member"), + ("test-1", "Test Operator", "operator,test"), + ("test-2", "Test Member", "member,test"), + ("test-3", "Test Both", "operator,member,test"), + ("none-1", "No Roles", ""), + ]: + p = UserProfile(user_id=user_id, name=name, roles=roles) + api_db_session.add(p) + profiles.append((user_id, roles)) + api_db_session.commit() + return profiles + + def test_test_users_excluded_from_operator_count( + self, client_no_auth, profiles_with_roles + ): + """Test that users with the test role are excluded from operator count.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_operator = "operator" + settings.oidc_role_member = "member" + settings.oidc_role_test = "test" + + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_operators"] == 2 + assert data["total_members"] == 2 + + def test_empty_test_role_excludes_no_one(self, client_no_auth, profiles_with_roles): + """Test that an empty test role does not filter any users.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_operator = "operator" + settings.oidc_role_member = "member" + settings.oidc_role_test = "" + + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_operators"] == 4 + assert data["total_members"] == 4 + + def test_no_profiles(self, client_no_auth): + """Test stats with no profiles returns zero counts.""" + with patch("meshcore_hub.common.config.get_web_settings") as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_operator = "operator" + settings.oidc_role_member = "member" + settings.oidc_role_test = "test" + + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_operators"] == 0 + assert data["total_members"] == 0 diff --git a/tests/test_api/test_user_profiles.py b/tests/test_api/test_user_profiles.py index 75bb1e7..e69e764 100644 --- a/tests/test_api/test_user_profiles.py +++ b/tests/test_api/test_user_profiles.py @@ -1,5 +1,11 @@ """Tests for user profile API routes.""" +from unittest.mock import patch + +import pytest + +from meshcore_hub.common.models import UserProfile + TEST_USER_ID = "oidc-user-123" OTHER_USER_ID = "oidc-user-456" USER_HEADERS = {"X-User-Id": TEST_USER_ID, "X-User-Roles": "operator"} @@ -351,3 +357,100 @@ class TestUpdateProfile: assert response.status_code == 200 data = response.json() assert data["callsign"] == "NR1" + + +class TestListProfilesExcludeTest: + """Tests for exclude_test query parameter on GET /user/profiles.""" + + @pytest.fixture + def profiles_with_test_role(self, api_db_session): + """Create profiles including some with the test role.""" + profiles = [] + for user_id, name, roles in [ + ("real-op", "Real Operator", "operator"), + ("real-mem", "Real Member", "member"), + ("test-op", "Test Operator", "operator,test"), + ("test-mem", "Test Member", "member,test"), + ]: + p = UserProfile(user_id=user_id, name=name, roles=roles) + api_db_session.add(p) + profiles.append(p) + api_db_session.commit() + return profiles + + def test_exclude_test_true_filters_test_users( + self, client_no_auth, profiles_with_test_role + ): + """Test that exclude_test=true (default) filters test users.""" + with patch( + "meshcore_hub.api.routes.user_profiles.get_web_settings" + ) as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_test = "test" + + response = client_no_auth.get( + "/api/v1/user/profiles?exclude_test=true", + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + names = [item["name"] for item in data["items"]] + assert "Real Operator" in names + assert "Real Member" in names + assert "Test Operator" not in names + assert "Test Member" not in names + assert data["total"] == 2 + + def test_exclude_test_default_is_true( + self, client_no_auth, profiles_with_test_role + ): + """Test that exclude_test defaults to true.""" + with patch( + "meshcore_hub.api.routes.user_profiles.get_web_settings" + ) as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_test = "test" + + response = client_no_auth.get( + "/api/v1/user/profiles", + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + + def test_exclude_test_false_includes_test_users( + self, client_no_auth, profiles_with_test_role + ): + """Test that exclude_test=false includes test users.""" + with patch( + "meshcore_hub.api.routes.user_profiles.get_web_settings" + ) as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_test = "test" + + response = client_no_auth.get( + "/api/v1/user/profiles?exclude_test=false", + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 4 + + def test_empty_test_role_does_not_filter( + self, client_no_auth, profiles_with_test_role + ): + """Test that an empty test role does not filter any users.""" + with patch( + "meshcore_hub.api.routes.user_profiles.get_web_settings" + ) as mock_settings: + settings = mock_settings.return_value + settings.oidc_role_test = "" + + response = client_no_auth.get( + "/api/v1/user/profiles?exclude_test=true", + headers=USER_HEADERS, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 4 diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py index 1737d71..f2e21e0 100644 --- a/tests/test_web/test_app.py +++ b/tests/test_web/test_app.py @@ -149,6 +149,25 @@ class TestConfigJsonXssEscaping: assert parsed["network_name"] == "Test Network" assert parsed["network_city"] == "Test City" + def test_build_config_json_includes_test_role_name(self, web_app: Any) -> None: + """_build_config_json includes role_names.test in the config.""" + from starlette.requests import Request + + scope = { + "type": "http", + "method": "GET", + "path": "/", + "query_string": b"", + "headers": [], + } + request = Request(scope) + + result = _build_config_json(web_app, request) + parsed = json.loads(result) + assert "role_names" in parsed + assert "test" in parsed["role_names"] + assert parsed["role_names"]["test"] == "test" + class TestCheckApiAccess: """Unit tests for check_api_access with _OPEN, _AUTHENTICATED, and role-based levels."""