From 6a46fb65bed8f563c61f09a39213adb32c32b27d Mon Sep 17 00:00:00 2001 From: demigodmode Date: Mon, 2 Nov 2026 16:21:51 -0600 Subject: [PATCH 01/29] tag every route with who's allowed to call it test_route_access fails if a new route forgets. Also HealthResponse was stale, so fixed that while I was there. --- backend/app/api/access.py | 59 +++++++++++++++++++++++ backend/app/api/agent_protocol.py | 3 +- backend/app/api/agents.py | 19 ++++---- backend/app/api/auth.py | 23 +++++++-- backend/app/api/preview.py | 8 ++-- backend/app/api/search.py | 6 +-- backend/app/api/settings.py | 6 +-- backend/app/api/source_batches.py | 12 ++--- backend/app/api/sources.py | 28 +++++------ backend/app/api/status.py | 6 +-- backend/app/main.py | 5 +- backend/app/schemas.py | 11 ++++- backend/tests/test_api.py | 11 +++++ backend/tests/test_route_access.py | 75 ++++++++++++++++++++++++++++++ frontend/src/types/api.ts | 5 -- 15 files changed, 220 insertions(+), 57 deletions(-) create mode 100644 backend/app/api/access.py create mode 100644 backend/tests/test_route_access.py diff --git a/backend/app/api/access.py b/backend/app/api/access.py new file mode 100644 index 00000000..6f59cae4 --- /dev/null +++ b/backend/app/api/access.py @@ -0,0 +1,59 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +""" +Who may call a route. + +Every API route declares one access level through the dependency it uses: + +- ``dependencies=[Depends(allow_anyone)]`` for routes anyone can call +- ``require_signed_in`` for anything a signed-in user may do +- ``require_admin`` for changing how OneSearch is set up +- ``get_authenticated_agent`` from ``services.agent_auth`` for the agent protocol + +The dependencies themselves live in ``auth.py`` so routes there can use them too. + +``tests/test_route_access.py`` fails for a route that declares none of these. +""" + +from collections.abc import Callable +from enum import Enum +from typing import Any + +from fastapi.dependencies.models import Dependant +from fastapi.routing import APIRoute + +from ..services.agent_auth import get_authenticated_agent +from .auth import allow_anyone, require_admin, require_signed_in + + +class AccessLevel(Enum): + PUBLIC = "public" + SIGNED_IN = "signed_in" + ADMIN = "admin" + AGENT = "agent" + + +# Checked in this order, so a route that pulls in several gets the strictest. +_LEVELS = ( + (require_admin, AccessLevel.ADMIN), + (require_signed_in, AccessLevel.SIGNED_IN), + (get_authenticated_agent, AccessLevel.AGENT), + (allow_anyone, AccessLevel.PUBLIC), +) + + +def _dependency_calls(dependant: Dependant) -> set[Callable[..., Any]]: + calls: set[Callable[..., Any]] = set() + for sub in dependant.dependencies: + calls.add(sub.call) + calls |= _dependency_calls(sub) + return calls + + +def access_level_of(route: APIRoute) -> AccessLevel | None: + calls = _dependency_calls(route.dependant) + for dependency, level in _LEVELS: + if dependency in calls: + return level + return None diff --git a/backend/app/api/agent_protocol.py b/backend/app/api/agent_protocol.py index f3d22a8a..e74ef10a 100644 --- a/backend/app/api/agent_protocol.py +++ b/backend/app/api/agent_protocol.py @@ -60,6 +60,7 @@ ) from ..services.remote_ingest import RemoteIngestService, canonical_remote_path from ..services.search import meili_service +from .auth import allow_anyone router = APIRouter(prefix="/api/agent/v1", tags=["agent-protocol"]) Database = Annotated[Session, Depends(get_db)] @@ -154,7 +155,7 @@ def _validate_enrollment(request: AgentEnrollmentRequest) -> None: "/enroll", response_model=AgentEnrollmentResponse, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_remote_agents_enabled)], + dependencies=[Depends(allow_anyone), Depends(require_remote_agents_enabled)], ) async def enroll_agent(request: AgentEnrollmentRequest, db: Database): _validate_enrollment(request) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index fb9856d5..192df232 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -32,13 +32,14 @@ hash_token, require_remote_agents_enabled, ) -from .auth import get_current_user +from .auth import require_admin logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/agents", tags=["agents"]) + +CurrentAdmin = Annotated[User, Depends(require_admin)] Database = Annotated[Session, Depends(get_db)] -CurrentUser = Annotated[User, Depends(get_current_user)] RemoteAgentsEnabled = Annotated[None, Depends(require_remote_agents_enabled)] _DEGRADED_WINDOW = timedelta(hours=24) _AFFECTED_SOURCE_LIMIT = 99 @@ -250,7 +251,7 @@ def _get_agent(agent_id: str, db: Session) -> Agent: status_code=status.HTTP_201_CREATED, ) async def create_enrollment( - current_user: CurrentUser, + current_user: CurrentAdmin, feature_enabled: RemoteAgentsEnabled, db: Database, ): @@ -275,7 +276,7 @@ async def create_enrollment( @router.get("", response_model=list[AgentAdminResponse]) async def list_agents( db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, ): del current_user agents = db.query(Agent).order_by(Agent.created_at).all() @@ -289,7 +290,7 @@ async def list_agents( async def get_agent( agent_id: str, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, ): del current_user return _detail_response(_get_agent(agent_id, db), db) @@ -300,7 +301,7 @@ async def update_agent( agent_id: str, update: AgentAdminUpdate, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, ): """Update the defaults inherited by future remote-source jobs.""" del current_user @@ -319,7 +320,7 @@ async def update_agent( async def approve_agent( agent_id: str, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, ): del current_user agent = _get_agent(agent_id, db) @@ -346,7 +347,7 @@ async def approve_agent( async def disable_agent( agent_id: str, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, ): del current_user agent = _get_agent(agent_id, db) @@ -372,7 +373,7 @@ async def revoke_agent( agent_id: str, request: Request, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, delete_sources: bool = False, ): del current_user diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c60ba7a7..41b4a6a8 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -220,7 +220,20 @@ async def get_optional_user( return user -@router.get("/status") +def allow_anyone() -> None: + """Marks a route that needs no sign-in. See access.py.""" + + +async def require_signed_in(user: User = Depends(get_current_user)) -> User: # noqa: B008 + return user + + +async def require_admin(user: User = Depends(get_current_user)) -> User: # noqa: B008 + # There is only one user so far, and it runs the instance. Roles plug in here. + return user + + +@router.get("/status", dependencies=[Depends(allow_anyone)]) async def auth_status(db: Session = Depends(get_db)): """ Check authentication status and whether setup is required. @@ -233,7 +246,7 @@ async def auth_status(db: Session = Depends(get_db)): } -@router.post("/setup", response_model=AuthResponse) +@router.post("/setup", response_model=AuthResponse, dependencies=[Depends(allow_anyone)]) async def setup_admin( request: Request, setup_data: SetupRequest, @@ -302,7 +315,7 @@ async def setup_admin( ) -@router.post("/login", response_model=AuthResponse) +@router.post("/login", response_model=AuthResponse, dependencies=[Depends(allow_anyone)]) async def login( request: Request, login_data: LoginRequest, @@ -362,7 +375,7 @@ async def login( @router.post("/logout", response_model=MessageResponse) -async def logout(current_user: User = Depends(get_current_user)): +async def logout(current_user: User = Depends(require_signed_in)): """ Logout the current user. @@ -377,7 +390,7 @@ async def logout(current_user: User = Depends(get_current_user)): @router.get("/me", response_model=UserResponse) -async def get_current_user_info(current_user: User = Depends(get_current_user)): +async def get_current_user_info(current_user: User = Depends(require_signed_in)): """ Get information about the currently authenticated user. diff --git a/backend/app/api/preview.py b/backend/app/api/preview.py index 448acf7c..f1152ad9 100644 --- a/backend/app/api/preview.py +++ b/backend/app/api/preview.py @@ -32,7 +32,7 @@ remote_streams, ) from ..services.search import meili_service -from .auth import ALGORITHM, get_current_user, get_secret_key +from .auth import ALGORITHM, allow_anyone, get_secret_key, require_signed_in router = APIRouter(prefix="/api", tags=["preview"]) @@ -54,7 +54,7 @@ async def get_document_preview( request: Request, document_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_signed_in), ): """Stream a safe preview for an indexed image document.""" app_settings = AppSettingsService(db).get_settings() @@ -151,7 +151,7 @@ async def get_document_preview( async def create_document_download_link( document_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_signed_in), ): """Create a short-lived signed URL for downloading an indexed document.""" document = _document_to_dict(await meili_service.get_document(document_id)) @@ -174,7 +174,7 @@ async def create_document_download_link( } -@router.get("/documents/{document_id}/download") +@router.get("/documents/{document_id}/download", dependencies=[Depends(allow_anyone)]) async def download_document( request: Request, document_id: str, diff --git a/backend/app/api/search.py b/backend/app/api/search.py index f024e65a..7d092ee3 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -17,7 +17,7 @@ from ..services.agent_auth import effective_agent_status from ..services.app_settings import AppSettingsService from ..services.search import meili_service -from .auth import get_current_user +from .auth import require_signed_in logger = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def _apply_relevance_tiebreak(results: list[SearchResult]) -> None: @router.post("/search", response_model=SearchResponse) async def search( query: SearchQuery, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_signed_in), db: Session = Depends(get_db), ): """ @@ -190,7 +190,7 @@ async def search( @router.get("/documents/{document_id}", response_model=Document) -async def get_document(document_id: str, current_user: User = Depends(get_current_user)): +async def get_document(document_id: str, current_user: User = Depends(require_signed_in)): """ Get a single document by ID diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 7d5b1798..00a250d9 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -12,7 +12,7 @@ from ..schemas import AppSettingsResponse, AppSettingsUpdate from ..services.app_settings import AppSettingsService from ..services.scheduler import validate_interval, validate_schedule -from .auth import get_current_user +from .auth import require_admin, require_signed_in router = APIRouter(prefix="/api", tags=["settings"]) @@ -20,7 +20,7 @@ @router.get("/settings", response_model=AppSettingsResponse) def get_settings( db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_signed_in), ): """Get backend-managed indexing and preview settings.""" return AppSettingsService(db).get_settings() @@ -31,7 +31,7 @@ def update_settings( request: Request, update: AppSettingsUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """Update backend-managed indexing and preview settings.""" default_schedule = update.default_scan_schedule diff --git a/backend/app/api/source_batches.py b/backend/app/api/source_batches.py index e12e41ae..5382b8b6 100644 --- a/backend/app/api/source_batches.py +++ b/backend/app/api/source_batches.py @@ -17,7 +17,7 @@ from ..models import Agent, Source, SourceBatch, SourceBatchItem, User, _utcnow from ..schemas import SourceBatchCreate, SourceBatchItemCreateRequest, SourceCreate from ..services.scheduler import validate_interval, validate_schedule -from .auth import get_current_user +from .auth import require_admin router = APIRouter(prefix="/batches") logger = logging.getLogger(__name__) @@ -67,7 +67,7 @@ def _summary(batch: SourceBatch) -> dict: @router.post("", status_code=201) -def create_batch(payload: SourceBatchCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): # noqa: B008 +def create_batch(payload: SourceBatchCreate, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): # noqa: B008 # The ASGI body limiter checks raw bytes before JSON parsing; also bound the # normalized snapshot persisted to the database. snapshot = payload.model_dump(mode="json") @@ -112,7 +112,7 @@ def create_batch(payload: SourceBatchCreate, db: Session = Depends(get_db), curr @router.get("") -def list_batches(cursor: str | None = None, limit: int = 20, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): # noqa: B008 +def list_batches(cursor: str | None = None, limit: int = 20, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): # noqa: B008 if not 1 <= limit <= 20: raise HTTPException(status_code=422, detail="Page size must be 1–20") stmt = select(SourceBatch).options(selectinload(SourceBatch.items)).where(SourceBatch.owner_user_id == current_user.id) @@ -130,12 +130,12 @@ def list_batches(cursor: str | None = None, limit: int = 20, db: Session = Depen @router.get("/{batch_id}") -def get_batch(batch_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): # noqa: B008 +def get_batch(batch_id: str, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): # noqa: B008 return _detail(db, _owned(db, batch_id, current_user.id)) @router.post("/{batch_id}/dismiss") -def dismiss_batch(batch_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): # noqa: B008 +def dismiss_batch(batch_id: str, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): # noqa: B008 batch = _owned(db, batch_id, current_user.id) batch.dismissed = True batch.updated_at = _utcnow() @@ -311,7 +311,7 @@ def _write_item(db: Session, batch_id: str, item_id: str, owner_id: int, source: @router.post("/{batch_id}/items/{item_id}/create") def create_item(batch_id: str, item_id: str, data: SourceBatchItemCreateRequest, request: Request, - db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): # noqa: B008 + db: Session = Depends(get_db), current_user: User = Depends(require_admin)): # noqa: B008 owner_id = current_user.id batch = _owned(db, batch_id, owner_id) item = db.get(SourceBatchItem, (batch_id, item_id)) diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 2d1a2849..113dadb4 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -57,7 +57,7 @@ validate_schedule, ) from ..services.search import meili_service -from .auth import get_current_user +from .auth import require_admin, require_signed_in from .source_batches import router as batch_router logger = logging.getLogger(__name__) @@ -560,7 +560,7 @@ def _build_source(db: Session, data: SourceCreate, source_id: str, root_path: st @router.get("", response_model=list[SourceResponse]) async def list_sources( - db: Session = Depends(get_db), current_user: User = Depends(get_current_user) + db: Session = Depends(get_db), current_user: User = Depends(require_signed_in) ): """ List all configured sources @@ -596,7 +596,7 @@ async def list_sources( async def test_source_path( request_data: SourcePathTestRequest, db: Session = Depends(get_db), # noqa: B008 - FastAPI dependency declaration - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """Test a source path before saving it.""" if request_data.location_type == "local": @@ -626,7 +626,7 @@ async def test_source_path( async def get_source_path_test( job_id: str, db: Session = Depends(get_db), # noqa: B008 - FastAPI dependency declaration - current_user: User = Depends(get_current_user), # noqa: B008 + current_user: User = Depends(require_admin), # noqa: B008 ): """Return the sanitized result of a queued remote path validation.""" job = db.get(AgentJob, job_id) @@ -643,7 +643,7 @@ async def get_source_path_test( async def browse_source_directory( request_data: SourceBrowseRequest, db: Session = Depends(get_db), # noqa: B008 - FastAPI dependency declaration - current_user: User = Depends(get_current_user), # noqa: B008 + current_user: User = Depends(require_admin), # noqa: B008 ): """Queue a bounded directory-only browse; this never creates a source.""" if not AppSettingsService(db).get_settings().remote_agents_enabled: @@ -670,7 +670,7 @@ async def browse_source_directory( async def get_source_directory_browse( job_id: str, db: Session = Depends(get_db), # noqa: B008 - FastAPI dependency declaration - current_user: User = Depends(get_current_user), # noqa: B008 + current_user: User = Depends(require_admin), # noqa: B008 ): job = db.get(AgentJob, job_id) payload = _browse_payload(job) if job is not None else None @@ -681,7 +681,7 @@ async def get_source_directory_browse( @router.get("/local-roots", response_model=LocalSourceRootsResponse) async def get_local_source_roots( - current_user: User = Depends(get_current_user), # noqa: B008 + current_user: User = Depends(require_admin), # noqa: B008 ): """Configured local source roots the folder picker may browse.""" roots = _local_roots() @@ -697,7 +697,7 @@ async def get_local_source_roots( @router.post("/browse-local", response_model=LocalBrowseResponse) async def browse_local_directory( request_data: LocalBrowseRequest, - current_user: User = Depends(get_current_user), # noqa: B008 + current_user: User = Depends(require_admin), # noqa: B008 ): """List folders under a configured local root; never creates a source.""" roots = _local_roots() @@ -740,7 +740,7 @@ async def browse_local_directory( @router.get("/{source_id}", response_model=SourceResponse) async def get_source( - source_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) + source_id: str, db: Session = Depends(get_db), current_user: User = Depends(require_admin) ): """ Get a specific source by ID @@ -770,7 +770,7 @@ async def create_source( request: Request, source_data: SourceCreate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """ Create a new search source @@ -839,7 +839,7 @@ async def update_source( source_id: str, source_data: SourceUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """ Update an existing source configuration @@ -990,7 +990,7 @@ async def delete_source( request: Request, source_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """ Delete a source and all its indexed files @@ -1041,7 +1041,7 @@ async def reindex_source( source_id: str, full: bool = False, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """ Manually trigger reindexing for a source @@ -1156,7 +1156,7 @@ def sync_index_wrapper(): async def clear_stale_failed_files( source_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), ): """ Clean failed file entries for a source. diff --git a/backend/app/api/status.py b/backend/app/api/status.py index ada0982d..5fb45276 100644 --- a/backend/app/api/status.py +++ b/backend/app/api/status.py @@ -15,7 +15,7 @@ from ..models import User from ..services.indexer import IndexingService from ..services.search import SearchService, meili_service -from .auth import get_current_user +from .auth import require_admin logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ @router.get("/status") -async def get_status(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_status(db: Session = Depends(get_db), current_user: User = Depends(require_admin)): """ Get indexing status for all sources @@ -65,7 +65,7 @@ async def get_status(db: Session = Depends(get_db), current_user: User = Depends @router.get("/status/{source_id}") -async def get_source_status(source_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_source_status(source_id: str, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): """ Get detailed indexing status for a specific source diff --git a/backend/app/main.py b/backend/app/main.py index fda8fd3f..fc9bf954 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from . import __version__ from .api import agent_protocol, agents, auth, preview, search, sources, status from .api import settings as settings_api +from .api.auth import allow_anyone from .config import settings from .db.database import engine, get_db from .request_body_limits import RemoteAgentBodyLimitMiddleware @@ -173,7 +174,7 @@ async def log_requests(request: Request, call_next): app.include_router(agent_protocol.router) -@app.get("/api/health") +@app.get("/api/health", dependencies=[Depends(allow_anyone)]) async def health_check(db: Session = Depends(get_db)): """ Health check endpoint for container healthcheck @@ -210,7 +211,7 @@ async def health_check(db: Session = Depends(get_db)): return JSONResponse(content=response_data, status_code=status_code) -@app.get("/") +@app.get("/", dependencies=[Depends(allow_anyone)]) async def root(): """Root endpoint - API information""" return { diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 15b65580..965ecc18 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -280,13 +280,20 @@ class SourceStatus(BaseModel): next_scan_at: datetime | None = None +class MeilisearchHealth(BaseModel): + """Meilisearch status nested in the health response""" + + status: str + + class HealthResponse(BaseModel): """Schema for health check response""" status: str - meilisearch_connected: bool - database_connected: bool + service: str + version: str setup_required: bool = False + meilisearch: MeilisearchHealth # Authentication schemas diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9493fcc9..3f9b58bc 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -27,6 +27,7 @@ from app.db.database import get_db from app.main import app from app.models import AppSetting, Base, IndexedFile, Source, User +from app.schemas import HealthResponse from app.services.search import meili_service # In-memory SQLite database for testing @@ -168,6 +169,16 @@ def test_health_check_includes_meilisearch_status(self, client): assert "meilisearch" in data assert "status" in data["meilisearch"] + def test_health_check_matches_health_response_schema(self, client): + """Test the real /api/health payload validates against HealthResponse""" + response = client.get("/api/health") + + health = HealthResponse.model_validate(response.json()) + + assert health.service == "onesearch-backend" + assert health.status in ("healthy", "degraded") + assert isinstance(health.meilisearch.status, str) + class TestSourceEndpoints: """Tests for /api/sources endpoints""" diff --git a/backend/tests/test_route_access.py b/backend/tests/test_route_access.py new file mode 100644 index 00000000..d176dbe7 --- /dev/null +++ b/backend/tests/test_route_access.py @@ -0,0 +1,75 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Every API route has to say who may call it.""" + +import pytest +from fastapi import Depends, FastAPI +from fastapi.routing import APIRoute + +from app.api.access import AccessLevel, access_level_of +from app.api.auth import get_current_user +from app.main import app + + +def _api_routes(): + return [route for route in app.routes if isinstance(route, APIRoute)] + + +def _find(method: str, path: str) -> APIRoute: + for route in _api_routes(): + if route.path == path and method in route.methods: + return route + raise AssertionError(f"no route {method} {path}") + + +def test_every_route_declares_an_access_level(): + missing = [ + f"{','.join(sorted(route.methods))} {route.path}" + for route in _api_routes() + if access_level_of(route) is None + ] + assert missing == [] + + +def test_route_without_a_level_is_reported(): + # Using get_current_user directly skips the access levels, so it doesn't count. + scratch = FastAPI() + + @scratch.get("/bare") + def bare(): + return {} + + @scratch.get("/raw-auth") + def raw_auth(user=Depends(get_current_user)): # noqa: B008 + return {} + + routes = {route.path: route for route in scratch.routes if isinstance(route, APIRoute)} + assert access_level_of(routes["/bare"]) is None + assert access_level_of(routes["/raw-auth"]) is None + + +@pytest.mark.parametrize( + ("method", "path", "expected"), + [ + ("GET", "/api/health", AccessLevel.PUBLIC), + ("POST", "/api/auth/login", AccessLevel.PUBLIC), + ("POST", "/api/auth/setup", AccessLevel.PUBLIC), + ("GET", "/api/documents/{document_id}/download", AccessLevel.PUBLIC), + ("POST", "/api/agent/v1/enroll", AccessLevel.PUBLIC), + ("GET", "/api/auth/me", AccessLevel.SIGNED_IN), + ("POST", "/api/search", AccessLevel.SIGNED_IN), + ("GET", "/api/documents/{document_id}", AccessLevel.SIGNED_IN), + ("GET", "/api/documents/{document_id}/preview", AccessLevel.SIGNED_IN), + ("GET", "/api/sources", AccessLevel.SIGNED_IN), + ("GET", "/api/settings", AccessLevel.SIGNED_IN), + ("POST", "/api/sources", AccessLevel.ADMIN), + ("PUT", "/api/settings", AccessLevel.ADMIN), + ("GET", "/api/status", AccessLevel.ADMIN), + ("GET", "/api/agents", AccessLevel.ADMIN), + ("POST", "/api/agent/v1/heartbeat", AccessLevel.AGENT), + ("POST", "/api/agent/v1/jobs/claim", AccessLevel.AGENT), + ], +) +def test_route_has_expected_access_level(method, path, expected): + assert access_level_of(_find(method, path)) is expected diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 7b84bf47..0592a186 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -307,11 +307,6 @@ export interface HealthResponse { status: string [key: string]: unknown } - config: { - database: string - meilisearch_url: string - log_level: string - } } /** From 1f7e09a974ccb94c0252a934fdb25d7d5d6ee321 Mon Sep 17 00:00:00 2001 From: demigodmode Date: Mon, 2 Nov 2026 16:21:51 -0600 Subject: [PATCH 02/29] feat: admin and member roles Migration makes existing users admins. Members get 403 on admin routes and only see source names, and the admin pages are hidden for them. The token carries a session version now so sessions can be ended later. --- ...7f16_add_user_roles_and_session_version.py | 44 +++++ backend/app/api/auth.py | 37 +++- backend/app/api/sources.py | 8 +- backend/app/models.py | 15 +- backend/app/schemas.py | 9 + backend/tests/conftest.py | 55 ++++-- backend/tests/test_agent_models.py | 4 +- backend/tests/test_api.py | 5 +- backend/tests/test_auth.py | 10 +- backend/tests/test_preview_api.py | 2 +- backend/tests/test_preview_assets.py | 6 +- backend/tests/test_source_batches.py | 6 +- backend/tests/test_user_roles.py | 178 ++++++++++++++++++ backend/tests/test_user_roles_migration.py | 44 +++++ frontend/src/App.tsx | 2 +- frontend/src/components/MainLayout.test.tsx | 29 +++ frontend/src/components/MainLayout.tsx | 17 +- .../src/components/ProtectedRoute.test.tsx | 52 +++++ frontend/src/components/ProtectedRoute.tsx | 11 +- frontend/src/lib/utils.ts | 8 + frontend/src/types/api.ts | 2 + 21 files changed, 492 insertions(+), 52 deletions(-) create mode 100644 backend/alembic/versions/5b9e0c3d7f16_add_user_roles_and_session_version.py create mode 100644 backend/tests/test_user_roles.py create mode 100644 backend/tests/test_user_roles_migration.py create mode 100644 frontend/src/components/MainLayout.test.tsx create mode 100644 frontend/src/components/ProtectedRoute.test.tsx diff --git a/backend/alembic/versions/5b9e0c3d7f16_add_user_roles_and_session_version.py b/backend/alembic/versions/5b9e0c3d7f16_add_user_roles_and_session_version.py new file mode 100644 index 00000000..6548170a --- /dev/null +++ b/backend/alembic/versions/5b9e0c3d7f16_add_user_roles_and_session_version.py @@ -0,0 +1,44 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""add user roles and session version + +Revision ID: 5b9e0c3d7f16 +Revises: f9c23a14b980 +Create Date: 2026-11-02 10:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "5b9e0c3d7f16" +down_revision = "f9c23a14b980" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Existing users are the instance owner(s) from before roles existed, so they become admins. + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column(sa.Column("role", sa.String(16), nullable=False, server_default="admin")) + # The admin default only fills rows that existed before the upgrade, so new rows must set a role. + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.alter_column("role", server_default=None) + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column(sa.Column("session_version", sa.Integer(), nullable=False, server_default="0")) + batch_op.add_column(sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default="0")) + batch_op.add_column(sa.Column("created_by_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + "fk_users_created_by_id_users", "users", ["created_by_id"], ["id"], ondelete="SET NULL" + ) + batch_op.add_column(sa.Column("last_login_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("last_login_at") + batch_op.drop_constraint("fk_users_created_by_id_users", type_="foreignkey") + batch_op.drop_column("created_by_id") + batch_op.drop_column("must_change_password") + batch_op.drop_column("session_version") + batch_op.drop_column("role") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 41b4a6a8..95e0b7c8 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,7 +7,7 @@ """ import logging from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Any, Optional from collections import defaultdict import time @@ -19,7 +19,7 @@ import jwt from ..db.database import get_db -from ..models import User +from ..models import ROLE_ADMIN, User from ..schemas import ( SetupRequest, LoginRequest, @@ -67,7 +67,7 @@ def hash_password(password: str) -> str: return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') -def create_access_token(user_id: int, username: str) -> tuple[str, int]: +def create_access_token(user_id: int, username: str, session_version: int) -> tuple[str, int]: """Create a JWT access token""" expire_hours = settings.session_expire_hours now = datetime.now(timezone.utc) @@ -77,6 +77,7 @@ def create_access_token(user_id: int, username: str) -> tuple[str, int]: payload = { "sub": str(user_id), "username": username, + "sv": session_version, "exp": expire, "iat": now, } @@ -85,7 +86,7 @@ def create_access_token(user_id: int, username: str) -> tuple[str, int]: return token, expires_in -def decode_token(token: str) -> Optional[dict]: +def decode_token(token: str) -> Optional[dict[str, Any]]: """Decode and validate a JWT token""" try: payload = jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM]) @@ -152,6 +153,12 @@ def is_setup_required(db: Session) -> bool: return count == 0 +def _session_is_current(payload: dict[str, Any], user: User) -> bool: + """Tokens from before session versions existed have no sv and stay valid until they expire.""" + token_version = payload.get("sv") + return token_version is None or token_version == user.session_version + + async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), db: Session = Depends(get_db) @@ -191,6 +198,13 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + if not _session_is_current(payload, user): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user @@ -214,7 +228,7 @@ async def get_optional_user( return None user = db.get(User, int(user_id)) - if not user or not user.is_active: + if not user or not user.is_active or not _session_is_current(payload, user): return None return user @@ -228,8 +242,12 @@ async def require_signed_in(user: User = Depends(get_current_user)) -> User: # return user -async def require_admin(user: User = Depends(get_current_user)) -> User: # noqa: B008 - # There is only one user so far, and it runs the instance. Roles plug in here. +async def require_admin(user: User = Depends(require_signed_in)) -> User: # noqa: B008 + if not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) return user @@ -297,6 +315,7 @@ async def setup_admin( username=setup_data.username, password_hash=hash_password(setup_data.password), is_active=True, + role=ROLE_ADMIN, ) db.add(user) @@ -306,7 +325,7 @@ async def setup_admin( logger.info(f"Admin user created: {user.username}") # Generate token - token, expires_in = create_access_token(user.id, user.username) + token, expires_in = create_access_token(user.id, user.username, user.session_version) return AuthResponse( access_token=token, @@ -365,7 +384,7 @@ async def login( logger.info(f"User logged in: {user.username}") # Generate token - token, expires_in = create_access_token(user.id, user.username) + token, expires_in = create_access_token(user.id, user.username, user.session_version) return AuthResponse( access_token=token, diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 113dadb4..afcbcc10 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -36,6 +36,7 @@ SourcePathTestRequest, SourcePathTestResponse, SourceResponse, + SourceSummaryResponse, SourceUpdate, ) from ..services.agent_auth import effective_agent_status @@ -558,15 +559,18 @@ def _build_source(db: Session, data: SourceCreate, source_id: str, root_path: st return source -@router.get("", response_model=list[SourceResponse]) +@router.get("", response_model=list[SourceResponse] | list[SourceSummaryResponse]) async def list_sources( db: Session = Depends(get_db), current_user: User = Depends(require_signed_in) ): """ List all configured sources - Returns list of sources with configuration details + Admins get full source details. Members get only id and name. """ + if not current_user.is_admin: + rows = db.execute(select(Source.id, Source.name).order_by(Source.created_at.desc())).all() + return [SourceSummaryResponse(id=row.id, name=row.name) for row in rows] stmt = select(Source).options(selectinload(Source.agent)).order_by(Source.created_at.desc()) sources = db.execute(stmt).scalars().all() app_settings = AppSettingsService(db).get_settings() diff --git a/backend/app/models.py b/backend/app/models.py index 24b4e75f..b5ba98d4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -421,10 +421,14 @@ def __repr__(self): return f"" +ROLE_ADMIN = "admin" +ROLE_MEMBER = "member" + + class User(Base): """ User authentication table - Stores admin credentials for OneSearch + Stores login credentials and role for OneSearch users """ __tablename__ = "users" @@ -433,6 +437,11 @@ class User(Base): username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) is_active = Column(Boolean, default=True, nullable=False) + role = Column(String(16), default=ROLE_MEMBER, nullable=False) + session_version = Column(Integer, default=0, server_default="0", nullable=False) + must_change_password = Column(Boolean, default=False, server_default="0", nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + last_login_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=_utcnow, nullable=False) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow, nullable=False) @@ -440,6 +449,10 @@ class User(Base): "AgentEnrollment", back_populates="created_by_user", passive_deletes=True ) + @property + def is_admin(self) -> bool: + return self.role == ROLE_ADMIN + def __repr__(self): return f"" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 965ecc18..4452ec0d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -170,6 +170,13 @@ class LocalBrowseResponse(BaseModel): truncated: bool = False +class SourceSummaryResponse(BaseModel): + """What a member sees of a source: enough to filter search by it""" + + id: str + name: str + + class SourceResponse(SourceBase): """Schema for source response""" @@ -319,6 +326,8 @@ class UserResponse(BaseModel): id: int username: str is_active: bool + role: Literal["admin", "member"] + must_change_password: bool created_at: datetime diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 84aef782..63a1d2f0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,6 +6,7 @@ """ import json import pytest +from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory from datetime import datetime, timezone @@ -16,7 +17,7 @@ from fastapi.testclient import TestClient from app.main import app -from app.models import Base, Source, User +from app.models import ROLE_ADMIN, ROLE_MEMBER, Base, Source, User from app.db.database import get_db from app.config import settings from app.api.auth import hash_password, create_access_token @@ -51,6 +52,19 @@ def db_session(): Base.metadata.drop_all(bind=_engine) +@contextmanager +def _signed_in_client(db_session, headers: dict[str, str]): + """TestClient that reads from db_session and sends headers on every request""" + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + test_client.headers.update(headers) + yield test_client + app.dependency_overrides.clear() + + @pytest.fixture def test_user(db_session): """Create a test user and return auth headers""" @@ -58,11 +72,12 @@ def test_user(db_session): username="testuser", password_hash=hash_password("testpass"), is_active=True, + role=ROLE_ADMIN, ) db_session.add(user) db_session.commit() db_session.refresh(user) - token, _ = create_access_token(user.id, user.username) + token, _ = create_access_token(user.id, user.username, user.session_version) return { "user": user, "token": token, @@ -70,6 +85,29 @@ def test_user(db_session): } +@pytest.fixture +def member_user(db_session): + """A signed-in user with the member role, created directly in the database""" + user = User( + username="member", + password_hash=hash_password("memberpass"), + is_active=True, + role=ROLE_MEMBER, + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def member_client(db_session, member_user): + """Test client signed in as the member user""" + token, _ = create_access_token(member_user.id, member_user.username, member_user.session_version) + with _signed_in_client(db_session, {"Authorization": f"Bearer {token}"}) as test_client: + yield test_client + + @pytest.fixture def auth_headers(test_user): """Get auth headers for authenticated requests""" @@ -79,20 +117,9 @@ def auth_headers(test_user): @pytest.fixture def client(db_session, test_user): """Create test client with database dependency override and auth""" - def override_get_db(): - try: - yield db_session - finally: - pass - - app.dependency_overrides[get_db] = override_get_db - - with TestClient(app) as test_client: - test_client.headers.update(test_user["headers"]) + with _signed_in_client(db_session, test_user["headers"]) as test_client: yield test_client - app.dependency_overrides.clear() - @pytest.fixture def meili_spy(): diff --git a/backend/tests/test_agent_models.py b/backend/tests/test_agent_models.py index ead5f28b..8e4c5fcc 100644 --- a/backend/tests/test_agent_models.py +++ b/backend/tests/test_agent_models.py @@ -436,7 +436,7 @@ def alembic(*args): column[1] for column in connection.execute("PRAGMA table_info('indexed_files')") } assert row == ("local", None, None) - assert revision == "f9c23a14b980" + assert revision == "5b9e0c3d7f16" assert "modified_at_ns" in indexed_columns assert "ix_agent_jobs_agent_status_created" in index_names assert "ix_agent_jobs_status_lease_expires" in index_names @@ -450,4 +450,4 @@ def alembic(*args): alembic("check") with sqlite3.connect(database_path) as connection: revision = connection.execute("SELECT version_num FROM alembic_version").fetchone()[0] - assert revision == "f9c23a14b980" + assert revision == "5b9e0c3d7f16" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3f9b58bc..ab56738f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -26,7 +26,7 @@ from app.config import settings from app.db.database import get_db from app.main import app -from app.models import AppSetting, Base, IndexedFile, Source, User +from app.models import ROLE_ADMIN, AppSetting, Base, IndexedFile, Source, User from app.schemas import HealthResponse from app.services.search import meili_service @@ -72,11 +72,12 @@ def test_user(db_session): username="testuser", password_hash=hash_password("testpass"), is_active=True, + role=ROLE_ADMIN, ) db_session.add(user) db_session.commit() db_session.refresh(user) - token, _ = create_access_token(user.id, user.username) + token, _ = create_access_token(user.id, user.username, user.session_version) return {"user": user, "token": token, "headers": {"Authorization": f"Bearer {token}"}} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9e5f8d93..874e9a28 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -87,7 +87,7 @@ def sample_user(db_session): @pytest.fixture def auth_headers(sample_user): """Get authentication headers for sample user""" - token, _ = create_access_token(sample_user.id, sample_user.username) + token, _ = create_access_token(sample_user.id, sample_user.username, sample_user.session_version) return {"Authorization": f"Bearer {token}"} @@ -122,7 +122,7 @@ class TestJWTTokens: def test_create_access_token(self): """Test JWT token creation""" - token, expires_in = create_access_token(1, "testuser") + token, expires_in = create_access_token(1, "testuser", 0) assert token is not None assert len(token) > 0 @@ -130,7 +130,7 @@ def test_create_access_token(self): def test_decode_valid_token(self): """Test decoding a valid token""" - token, _ = create_access_token(1, "testuser") + token, _ = create_access_token(1, "testuser", 0) payload = decode_token(token) assert payload is not None @@ -530,7 +530,7 @@ async def test_inactive_user_via_token(self, db_session): db_session.commit() db_session.refresh(user) - token, _ = create_access_token(user.id, user.username) + token, _ = create_access_token(user.id, user.username, user.session_version) creds = Mock() creds.credentials = token @@ -558,7 +558,7 @@ async def test_invalid_token(self, db_session): @pytest.mark.asyncio async def test_valid_token(self, db_session, sample_user): from unittest.mock import Mock - token, _ = create_access_token(sample_user.id, sample_user.username) + token, _ = create_access_token(sample_user.id, sample_user.username, sample_user.session_version) creds = Mock() creds.credentials = token diff --git a/backend/tests/test_preview_api.py b/backend/tests/test_preview_api.py index b2c9b425..21cdc654 100644 --- a/backend/tests/test_preview_api.py +++ b/backend/tests/test_preview_api.py @@ -133,7 +133,7 @@ def auth_headers(db_session): user = User(username="previewer", password_hash=hash_password("testpass"), is_active=True) db_session.add(user) db_session.commit() - token, _ = create_access_token(user.id, user.username) + token, _ = create_access_token(user.id, user.username, user.session_version) return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_preview_assets.py b/backend/tests/test_preview_assets.py index 9de05b9c..6371a54f 100644 --- a/backend/tests/test_preview_assets.py +++ b/backend/tests/test_preview_assets.py @@ -21,7 +21,7 @@ from app.api.auth import create_access_token, hash_password from app.db.database import get_db from app.main import app -from app.models import Agent, AppSetting, Base, IndexedFile, Source, User +from app.models import ROLE_ADMIN, Agent, AppSetting, Base, IndexedFile, Source, User from app.services.preview_assets import ( PreviewKeyLockRegistry, delete_preview, @@ -209,10 +209,10 @@ def test_source_deletion_removes_preview_directory(db_session, tmp_path, monkeyp from fastapi.testclient import TestClient # Setup user and source with previews - user = User(username="test", password_hash=hash_password("test"), is_active=True) + user = User(username="test", password_hash=hash_password("test"), is_active=True, role=ROLE_ADMIN) db_session.add(user) db_session.commit() - token, _ = create_access_token(user.id, user.username) + token, _ = create_access_token(user.id, user.username, user.session_version) source = Source( id="local-source", diff --git a/backend/tests/test_source_batches.py b/backend/tests/test_source_batches.py index 635ea445..f23e723a 100644 --- a/backend/tests/test_source_batches.py +++ b/backend/tests/test_source_batches.py @@ -4,7 +4,7 @@ import pytest -from app.models import Source +from app.models import ROLE_ADMIN, Source @pytest.fixture @@ -68,10 +68,10 @@ def test_batch_is_owner_scoped_including_uuid_collision(client, db_session, temp from app.models import User payload = request_payload(temp_source_dir) assert client.post('/api/sources/batches', json=payload).status_code == 201 - other = User(username='other-owner', password_hash=hash_password('password'), is_active=True) + other = User(username='other-owner', password_hash=hash_password('password'), is_active=True, role=ROLE_ADMIN) db_session.add(other) db_session.commit() - token, _ = create_access_token(other.id, other.username) + token, _ = create_access_token(other.id, other.username, other.session_version) headers = {'Authorization': f'Bearer {token}'} item_url = f"/api/sources/batches/{payload['id']}/items/{payload['items'][0]['id']}/create" for method, url, body in [ diff --git a/backend/tests/test_user_roles.py b/backend/tests/test_user_roles.py new file mode 100644 index 00000000..d9585cfb --- /dev/null +++ b/backend/tests/test_user_roles.py @@ -0,0 +1,178 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import re + +import jwt +import pytest +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from app.api.access import AccessLevel, access_level_of +from app.api.auth import ALGORITHM, create_access_token, get_secret_key +from app.db.database import get_db +from app.main import app +from app.models import ROLE_ADMIN, ROLE_MEMBER, User + + +def _admin_routes(): + routes = [] + for route in app.routes: + if isinstance(route, APIRoute) and access_level_of(route) is AccessLevel.ADMIN: + for method in sorted(route.methods): + routes.append((method, route.path)) + return routes + + +ADMIN_ROUTES = _admin_routes() + + +def _fill_path(path: str) -> str: + return re.sub(r"\{[^}]+\}", "dummy", path) + + +def test_there_are_admin_routes_to_check(): + assert len(ADMIN_ROUTES) > 10 + + +@pytest.mark.parametrize(("method", "path"), ADMIN_ROUTES) +def test_member_gets_403_on_admin_route(member_client, method, path): + response = member_client.request(method, _fill_path(path)) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin access required" + + +def test_setup_creates_an_admin(db_session): + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as anonymous: + response = anonymous.post("/api/auth/setup", json={"username": "owner", "password": "longenough"}) + app.dependency_overrides.clear() + + assert response.status_code == 200 + user = db_session.query(User).filter_by(username="owner").one() + assert user.role == ROLE_ADMIN + + +def test_me_reports_admin_role(client): + response = client.get("/api/auth/me") + + assert response.status_code == 200 + body = response.json() + assert body["role"] == "admin" + assert body["must_change_password"] is False + + +def test_me_reports_member_role(member_client): + response = member_client.get("/api/auth/me") + + assert response.status_code == 200 + assert response.json()["role"] == ROLE_MEMBER + + +def test_member_can_search(member_client, monkeypatch): + async def search(**_kwargs): + return {"hits": [], "estimatedTotalHits": 0, "processingTimeMs": 1} + + monkeypatch.setattr("app.api.search.meili_service.search", search) + + response = member_client.post("/api/search", json={"q": "invoice"}) + + assert response.status_code == 200 + + +def test_member_can_open_a_document(member_client, monkeypatch): + async def get_document(document_id): + return { + "id": document_id, + "source_id": "photos", + "source_name": "Photos", + "path": "/photos/a.txt", + "basename": "a.txt", + "extension": "txt", + "type": "text", + "size_bytes": 3, + "modified_at": 1, + "indexed_at": 1, + "content": "abc", + "title": None, + "metadata": {}, + } + + monkeypatch.setattr("app.api.search.meili_service.get_document", get_document) + + response = member_client.get("/api/documents/photos--abc") + + assert response.status_code == 200 + assert response.json()["id"] == "photos--abc" + + +def test_member_reaches_preview_lookup(member_client, monkeypatch): + async def missing_document(document_id): + return None + + monkeypatch.setattr("app.api.preview.meili_service.get_document", missing_document) + + response = member_client.get("/api/documents/missing/preview") + + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "document_not_found" + + +def test_member_can_read_settings(member_client): + response = member_client.get("/api/settings") + + assert response.status_code == 200 + + +def test_member_sees_only_id_and_name_for_sources(member_client, sample_source): + response = member_client.get("/api/sources") + + assert response.status_code == 200 + assert response.json() == [{"id": "test-source", "name": "Test Source"}] + + +def test_admin_sees_full_sources(client, sample_source): + response = client.get("/api/sources") + + assert response.status_code == 200 + source = response.json()[0] + assert source["root_path"] == sample_source.root_path + assert "effective_schedule" in source + assert "created_at" in source + + +def test_stale_session_version_is_rejected(client, db_session, test_user): + user = test_user["user"] + stale_token, _ = create_access_token(user.id, user.username, user.session_version) + user.session_version += 1 + db_session.commit() + + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {stale_token}"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Session expired" + + +def test_current_session_version_is_accepted(client, db_session, test_user): + user = test_user["user"] + user.session_version = 3 + db_session.commit() + token, _ = create_access_token(user.id, user.username, user.session_version) + + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + + +def test_token_without_session_version_still_works(client, test_user): + user = test_user["user"] + payload = {"sub": str(user.id), "username": user.username, "exp": 4102444800} + token = jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM) + + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 diff --git a/backend/tests/test_user_roles_migration.py b/backend/tests/test_user_roles_migration.py new file mode 100644 index 00000000..6deae821 --- /dev/null +++ b/backend/tests/test_user_roles_migration.py @@ -0,0 +1,44 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +def test_user_roles_migration_makes_existing_users_admins_and_downgrades(tmp_path): + backend = Path(__file__).resolve().parents[1] + database = tmp_path / "upgrade.db" + environment = os.environ | {"DATABASE_URL": f"sqlite:///{database.as_posix()}"} + + def alembic(*arguments): + subprocess.run( + [sys.executable, "-m", "alembic", "-c", "alembic.ini", *arguments], + cwd=backend, + env=environment, + check=True, + ) + + alembic("upgrade", "f9c23a14b980") + with sqlite3.connect(database) as connection: + connection.execute( + "INSERT INTO users (username, password_hash, is_active, created_at, updated_at) " + "VALUES ('legacy', 'hash', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ) + connection.commit() + + alembic("upgrade", "head") + with sqlite3.connect(database) as connection: + row = connection.execute( + "SELECT role, session_version, must_change_password, last_login_at, created_by_id " + "FROM users WHERE username='legacy'" + ).fetchone() + assert row == ("admin", 0, 0, None, None) + + alembic("downgrade", "-1") + with sqlite3.connect(database) as connection: + columns = {info[1] for info in connection.execute("PRAGMA table_info(users)")} + assert not {"role", "session_version", "must_change_password", "created_by_id", "last_login_at"} & columns + assert "username" in columns diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2017b8a..ed034adb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,7 +47,7 @@ function App() { + } diff --git a/frontend/src/components/MainLayout.test.tsx b/frontend/src/components/MainLayout.test.tsx new file mode 100644 index 00000000..483017b5 --- /dev/null +++ b/frontend/src/components/MainLayout.test.tsx @@ -0,0 +1,29 @@ +// Copyright (C) 2026 demigodmode +// SPDX-License-Identifier: AGPL-3.0-only + +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import MainLayout from './MainLayout' + +let role: 'admin' | 'member' = 'admin' +vi.mock('@/contexts/useAuth', () => ({ + useAuth: () => ({ + user: { id: 1, username: 'alice', role, is_active: true, created_at: '', must_change_password: false }, + logout: vi.fn(), + }), +})) + +describe('MainLayout admin link', () => { + it('shows Admin to admins', () => { + role = 'admin' + render() + expect(screen.getByText('Admin')).toBeInTheDocument() + }) + + it('hides Admin from members', () => { + role = 'member' + render() + expect(screen.queryByText('Admin')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx index bf7ce98e..ab61c086 100644 --- a/frontend/src/components/MainLayout.tsx +++ b/frontend/src/components/MainLayout.tsx @@ -6,6 +6,7 @@ import { Settings, LogOut, User } from 'lucide-react' import { useAuth } from '@/contexts/useAuth' import { Button } from '@/components/ui/button' import { OneSearchLogo } from '@/components/OneSearchLogo' +import { isAdmin } from '@/lib/utils' export default function MainLayout() { const { user, logout } = useAuth() @@ -32,13 +33,15 @@ export default function MainLayout() { {/* Navigation */}