mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
Merge pull request #205 from ipnet-mesh/chore/hide-test-users
feat: hide users with test OIDC role from public views
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user