diff --git a/CHANGELOG.md b/CHANGELOG.md index 6073f57d..e5f83c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to OneSearch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Several user accounts. Each user is an admin or a member. Admins add users on **Admin > Users** with a temporary password, and the new user picks their own password at first sign-in. Admins can change roles, disable and re-enable accounts, reset passwords and delete users. Nobody can disable, demote or delete their own account, and the last active admin can't be removed. +- Members can search, open, preview and download documents. They don't see Sources, Status, Agents, Settings, Users or Audit log. +- An Account page for every user, with password change and API tokens. +- API tokens for scripts and other apps. They start with `os_`, can expire after 30 days, 90 days, a year, or never, and can only search and open documents. `onesearch login --token` and `ONESEARCH_TOKEN` accept them for `onesearch search`. Admins can see and revoke any user's tokens. +- An audit log for admins under **Admin > Audit log**. It records sign-ins and failed sign-ins, user and token changes, source and agent changes, and settings changes. Entries are kept for 90 days by default. The number of days is set under **Settings > Audit log**. +- `onesearch-admin reset-password` for when you're locked out. Run it with `docker exec`. It prints a temporary password and ends the user's sessions. + +### Changed + +- Upgrading from 1.7.x needs no steps if you set `SESSION_SECRET`. The existing account becomes an admin. Sessions from before the upgrade keep working until they expire. Installs without `SESSION_SECRET` are signed out once. +- Sessions and download links end right away when a user's role or status changes or an admin resets their password. Changing your own password ends your other sessions. +- The gear icon in the header now opens Account for everyone. Admins see the admin pages listed under it, and members get a Settings page with the Appearance and Search options, saved in their browser. +- Long text pages have Previous/Next under the text as well as above it. +- The Metadata card on document pages starts collapsed. + +### Fixed + +- Code files that start with a docstring got the docstring as their title, quotes included. Quote and comment markers are now stripped. Existing files pick up the new title on their next full reindex. +- `/api/health`, and the rest of the API with it, could hang forever when Meilisearch stopped answering. Meilisearch calls now time out, and the health check no longer blocks other requests. +- A file that changed and then failed to index kept showing up for its old text. Its old index entry is now removed. +- Photo metadata was missing aperture, exposure, focal length, ISO and lens for most camera photos, because those are stored in a part of the EXIF data that wasn't read. GPS from real photo files was broken for the same reason. +- Search snippets could end in a broken highlight tag. +- Document pages didn't always show the file extension, PDFs included. +- Markdown task list checkboxes had no name for screen readers, and ticked ones were hard to read. +- `onesearch ... --json` could print invalid JSON when piped, because long lines got wrapped. +- Clear filters on the search page came back after a reload. +- Reindex, full reindex and delete on the Sources page failed silently. They now show the error. +- Status didn't show "Next scan" for sources on an interval or the global default schedule. +- On a phone, the header buttons had no name for screen readers, and search results couldn't be opened from the keyboard. + +### Security + +- Sign-in rate limiting could be bypassed by sending a made-up `X-Forwarded-For` header. The client address now comes from the bundled proxy only, and anything sent by other clients is ignored. +- The fallback signing key was a fixed string in the source, so anyone could forge a session, admin included. Without `SESSION_SECRET`, a random key is now generated and saved in the data folder. +- For up to 60 seconds, a download link (the short-lived link behind the Download button) also worked as a normal sign-in. Sign-in now only accepts tokens issued for signing in, and anything else is rejected with a 401. This has been possible since download links were added. + +--- + ## [1.7.1] - 2026-10-25 ### Breaking changes 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/alembic/versions/7d2b9e4f1a63_add_audit_events.py b/backend/alembic/versions/7d2b9e4f1a63_add_audit_events.py new file mode 100644 index 00000000..a8267fce --- /dev/null +++ b/backend/alembic/versions/7d2b9e4f1a63_add_audit_events.py @@ -0,0 +1,43 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""add audit events + +Revision ID: 7d2b9e4f1a63 +Revises: c4e8a2d17b93 +Create Date: 2026-11-02 14:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "7d2b9e4f1a63" +down_revision = "c4e8a2d17b93" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("actor_username", sa.Text(), nullable=True), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("target_type", sa.String(32), nullable=True), + sa.Column("target_id", sa.Integer(), nullable=True), + sa.Column("target_label", sa.Text(), nullable=True), + sa.Column("detail", sa.Text(), nullable=True), + sa.Column("ip", sa.String(64), nullable=True), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_audit_events_created_at", "audit_events", ["created_at"], unique=False) + op.create_index("ix_audit_events_action", "audit_events", ["action"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_action", table_name="audit_events") + op.drop_index("ix_audit_events_created_at", table_name="audit_events") + op.drop_table("audit_events") diff --git a/backend/alembic/versions/c4e8a2d17b93_add_api_tokens.py b/backend/alembic/versions/c4e8a2d17b93_add_api_tokens.py new file mode 100644 index 00000000..f42ed92a --- /dev/null +++ b/backend/alembic/versions/c4e8a2d17b93_add_api_tokens.py @@ -0,0 +1,43 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""add api tokens + +Revision ID: c4e8a2d17b93 +Revises: 5b9e0c3d7f16 +Create Date: 2026-11-02 12:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "c4e8a2d17b93" +down_revision = "5b9e0c3d7f16" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("scope", sa.String(16), nullable=False, server_default="search"), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("display_prefix", sa.String(16), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"], unique=False) + op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_api_tokens_token_hash", table_name="api_tokens") + op.drop_index("ix_api_tokens_user_id", table_name="api_tokens") + op.drop_table("api_tokens") diff --git a/backend/app/admin_cli.py b/backend/app/admin_cli.py new file mode 100644 index 00000000..e90423cf --- /dev/null +++ b/backend/app/admin_cli.py @@ -0,0 +1,97 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +""" +Admin commands run inside the container, e.g. + docker exec onesearch onesearch-admin reset-password alice +""" +import argparse +import logging +import os +import pwd +import secrets +import sys + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .api.auth import hash_password +from .db.database import SessionLocal +from .models import User +from .services.audit import COMMAND_LINE_ACTOR, AuditAction, AuditTarget, record_event + +logger = logging.getLogger(__name__) + +# No 0/O/1/I/l so the password can be read off a terminal and typed back in +TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" +TEMP_PASSWORD_LENGTH = 16 + + +def generate_temporary_password() -> str: + return "".join(secrets.choice(TEMP_PASSWORD_ALPHABET) for _ in range(TEMP_PASSWORD_LENGTH)) + + +def record_password_reset_event(db: Session, user: User) -> None: + logger.info("Password reset from the command line for user: %s", user.username) + record_event( + db, + actor=COMMAND_LINE_ACTOR, + action=AuditAction.USER_PASSWORD_RESET, + target=AuditTarget.user(user.id, user.username), + ) + + +def reset_password(db: Session, username: str) -> str | None: + """Give the user a temporary password and end their sessions. None if no such user.""" + user = db.execute(select(User).where(User.username == username)).scalar_one_or_none() + if user is None: + return None + + password = generate_temporary_password() + user.password_hash = hash_password(password) + user.must_change_password = True + user.session_version += 1 + record_password_reset_event(db, user) + db.commit() + return password + + +def drop_root(app_user: str = "onesearch") -> None: + """docker exec runs as root, but the server runs as the app user. Writing the + database as root can leave root-owned SQLite files the server can't open.""" + if os.geteuid() != 0: + return + try: + account = pwd.getpwnam(app_user) + except KeyError: + return + os.setgroups([]) + os.setgid(account.pw_gid) + os.setuid(account.pw_uid) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="onesearch-admin") + commands = parser.add_subparsers(dest="command", required=True) + reset = commands.add_parser("reset-password", help="set a temporary password for a user") + reset.add_argument("username") + args = parser.parse_args(argv) + + drop_root() + with SessionLocal() as db: + password = reset_password(db, args.username) + if password is None: + names = db.execute(select(User.username).order_by(User.username)).scalars().all() + print( + f"No user named {args.username}. Users: {', '.join(names) or 'none'}", + file=sys.stderr, + ) + return 1 + + print(f"Temporary password for {args.username}: {password}") + print(f"{args.username} must change it at the next sign-in. Their old sessions have ended.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/api/access.py b/backend/app/api/access.py new file mode 100644 index 00000000..6ce4613a --- /dev/null +++ b/backend/app/api/access.py @@ -0,0 +1,64 @@ +# 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 (sessions only, API tokens get 403) +- ``require_search`` for the search-scope routes, which an API token may call too +- ``require_user`` for the few signed-in routes a user with a pending password change may still call +- ``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_search, require_signed_in, require_user + + +class AccessLevel(Enum): + PUBLIC = "public" + SIGNED_IN = "signed_in" + SEARCH = "search" + ADMIN = "admin" + AGENT = "agent" + + +# Checked in this order, so a route that pulls in several gets the strictest. +_LEVELS = ( + (require_admin, AccessLevel.ADMIN), + (require_search, AccessLevel.SEARCH), + (require_signed_in, AccessLevel.SIGNED_IN), + (require_user, 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..82b1a315 100644 --- a/backend/app/api/agent_protocol.py +++ b/backend/app/api/agent_protocol.py @@ -49,6 +49,7 @@ require_remote_agents_enabled, ) from ..services.agent_jobs import AgentJobService, JobConflict, JobLeaseError, JobNotFound +from ..services.audit import AuditAction, AuditTarget, record_event from ..services.remote_files import ( RemoteFileChanged, RemoteFileError, @@ -60,6 +61,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 +156,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) @@ -190,6 +192,13 @@ async def enroll_agent(request: AgentEnrollmentRequest, db: Database): async def revoke_self(agent: AuthenticatedAgent, db: Database): """Let a newly enrolled agent invalidate itself if local persistence fails.""" agent.status = "revoked" + record_event( + db, + actor=None, + action=AuditAction.AGENT_REVOKED, + target=AuditTarget.agent(agent.name), + detail={"by": "agent"}, + ) db.commit() diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index fb9856d5..a1f180d1 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -32,13 +32,15 @@ hash_token, require_remote_agents_enabled, ) -from .auth import get_current_user +from ..services.audit import AuditAction, AuditTarget, record_event +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 +252,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, ): @@ -265,6 +267,7 @@ async def create_enrollment( created_by_user_id=current_user.id, ) ) + record_event(db, actor=current_user, action=AuditAction.AGENT_ENROLLMENT_CREATED) db.commit() return AgentEnrollmentCodeResponse( code=code, @@ -275,7 +278,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 +292,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,11 +303,18 @@ 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 agent = _get_agent(agent_id, db) + if agent.default_processing_mode != update.default_processing_mode: + record_event( + db, + actor=current_user, + action=AuditAction.AGENT_UPDATED, + target=AuditTarget.agent(agent.name), + detail={"default_processing_mode": update.default_processing_mode}, + ) agent.default_processing_mode = update.default_processing_mode db.commit() db.refresh(agent) @@ -319,9 +329,8 @@ 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) if agent.status == "revoked": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Agent is revoked") @@ -333,6 +342,12 @@ async def approve_agent( agent.status = "offline" agent.approved_at = agent.approved_at or now agent.disabled_at = None + record_event( + db, + actor=current_user, + action=AuditAction.AGENT_APPROVED, + target=AuditTarget.agent(agent.name), + ) db.commit() db.refresh(agent) return _response( @@ -346,9 +361,8 @@ 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) if agent.status == "revoked": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Agent is revoked") @@ -358,6 +372,12 @@ async def disable_agent( ) agent.status = "disabled" agent.disabled_at = _utcnow() + record_event( + db, + actor=current_user, + action=AuditAction.AGENT_DISABLED, + target=AuditTarget.agent(agent.name), + ) db.commit() db.refresh(agent) return _response( @@ -372,10 +392,9 @@ async def revoke_agent( agent_id: str, request: Request, db: Database, - current_user: CurrentUser, + current_user: CurrentAdmin, delete_sources: bool = False, ): - del current_user from ..services.source_cleanup import purge_source # lazy: avoid api<->api import cycle agent = _get_agent(agent_id, db) @@ -385,13 +404,26 @@ async def revoke_agent( agent.status = "revoked" agent.token_hash = None agent.disabled_at = _utcnow() + record_event( + db, + actor=current_user, + action=AuditAction.AGENT_REVOKED, + target=AuditTarget.agent(agent.name), + ) db.commit() if delete_sources: scheduler = request.app.state.scheduler if hasattr(request.app.state, "scheduler") else None for source in list(agent.sources): + record_event( + db, + actor=current_user, + action=AuditAction.SOURCE_DELETED, + target=AuditTarget.source(source.name), + ) try: await purge_source(source, db, scheduler=scheduler) except Exception as error: + db.rollback() logger.exception(f"Failed to purge source {source.id} during agent revoke") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, diff --git a/backend/app/api/audit.py b/backend/app/api/audit.py new file mode 100644 index 00000000..6a2345d0 --- /dev/null +++ b/backend/app/api/audit.py @@ -0,0 +1,67 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Admin read access to the audit log.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from ..db.database import get_db +from ..models import AuditEvent, User +from ..schemas import AuditEventResponse, AuditPageResponse +from ..services.audit import action_clause +from .auth import require_admin + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + + +def _to_response(event: AuditEvent) -> AuditEventResponse: + return AuditEventResponse( + id=event.id, + created_at=event.created_at, + actor_user_id=event.actor_user_id, + actor_username=event.actor_username, + action=event.action, + target_type=event.target_type, + target_id=event.target_id, + target_label=event.target_label, + detail=event.detail_dict, + ip=event.ip, + ) + + +@router.get("", response_model=AuditPageResponse) +async def list_audit_events( + admin: Annotated[User, Depends(require_admin)], + db: Annotated[Session, Depends(get_db)], + user: Annotated[str | None, Query(max_length=50)] = None, + action: Annotated[str | None, Query(max_length=64)] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + before: Annotated[int | None, Query(ge=1)] = None, +) -> AuditPageResponse: + """ + Audit entries, newest first. + + `user` matches the actor or the target username. `action` is an exact name, or a prefix + when it ends with a dot (`user.` for every user change). `before` is the id from the + previous page's next_before. + """ + stmt = select(AuditEvent).order_by(AuditEvent.id.desc()) + if user: + stmt = stmt.where(or_(AuditEvent.actor_username == user, AuditEvent.target_label == user)) + if action: + stmt = stmt.where(action_clause(action)) + if before is not None: + stmt = stmt.where(AuditEvent.id < before) + + # One extra row tells us whether another page exists. + rows = list(db.execute(stmt.limit(limit + 1)).scalars()) + events = rows[:limit] + next_before = events[-1].id if len(rows) > limit else None + return AuditPageResponse( + events=[_to_response(event) for event in events], + next_before=next_before, + ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c60ba7a7..31e3734d 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -5,29 +5,39 @@ Authentication API endpoints Handles user setup, login, logout, and session management """ +import ipaddress import logging -from datetime import datetime, timedelta, timezone -from typing import Optional +import os +import secrets from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from datetime import datetime, timedelta, timezone +from typing import Any, Optional import time from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from sqlalchemy import select, func +from sqlalchemy import select, func, or_, update import bcrypt import jwt +from ..config import settings from ..db.database import get_db -from ..models import User +from ..models import ROLE_ADMIN, ApiToken, User, _utcnow from ..schemas import ( SetupRequest, LoginRequest, + PasswordChangeRequest, UserResponse, AuthResponse, MessageResponse, ) -from ..config import settings +from ..services.agent_auth import hash_token +from ..services.api_tokens import is_api_token +from ..services.audit import AuditAction, AuditTarget, record_and_commit, record_event logger = logging.getLogger(__name__) @@ -35,7 +45,10 @@ # JWT configuration ALGORITHM = "HS256" +SESSION_PURPOSE = "session" BCRYPT_ROUNDS = 12 # Cost factor for bcrypt +API_TOKEN_USE_WRITE_INTERVAL = timedelta(seconds=60) +TOKEN_SCOPE_ERROR = "API tokens can only be used for search" # Rate limiting storage (in-memory, resets on restart) rate_limit_store: dict[str, list[float]] = defaultdict(list) @@ -44,13 +57,66 @@ bearer_scheme = HTTPBearer(auto_error=False) +SESSION_SECRET_FILE = "session_secret" +_generated_secret: str | None = None + + +def _session_secret_path() -> Path: + # Kept next to the database, same as the preview cache. + prefix = "sqlite:///" + if settings.database_url.startswith(prefix): + return Path(settings.database_url.removeprefix(prefix)).parent / SESSION_SECRET_FILE + return Path("/app/data") / SESSION_SECRET_FILE + + +def _read_secret_file(path: Path) -> str: + # A writer may have created the file but not written it yet, so wait briefly. + deadline = time.monotonic() + 5 + while True: + secret = path.read_text().strip() + if secret: + return secret + if time.monotonic() > deadline: + raise RuntimeError(f"{path} is empty. Delete it or set SESSION_SECRET.") + time.sleep(0.05) + + +def _load_or_create_secret_file(path: Path) -> str: + """Return the key in path, creating it if no other process has yet.""" + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return _read_secret_file(path) + except OSError as exc: + raise RuntimeError( + f"SESSION_SECRET is not set and {path} can't be created ({exc.strerror}). " + "Set SESSION_SECRET or make the data directory writable." + ) from exc + + secret = secrets.token_urlsafe(48) + with os.fdopen(fd, "w") as f: + f.write(secret) + if hasattr(os, "geteuid") and os.geteuid() == 0: + # Root (docker exec) would leave a file the server user can't read. + owner = path.parent.stat() + os.chown(path, owner.st_uid, owner.st_gid) + return secret + + def get_secret_key() -> str: - """Get or generate session secret key""" + """Return the JWT signing key. SESSION_SECRET wins; otherwise a generated key kept in the data dir.""" + global _generated_secret if settings.session_secret: return settings.session_secret - # Generate a temporary key if not configured (dev only) - logger.warning("SESSION_SECRET not configured. Using temporary key (insecure for production)") - return "temporary-dev-key-do-not-use-in-production" + if _generated_secret is None: + path = _session_secret_path() + _generated_secret = _load_or_create_secret_file(path) + logger.warning( + "SESSION_SECRET is not set. Using a generated key from %s. " + "Set SESSION_SECRET so the key doesn't depend on this volume.", + path, + ) + return _generated_secret def verify_password(plain_password: str, hashed_password: str) -> bool: @@ -67,7 +133,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 +143,8 @@ def create_access_token(user_id: int, username: str) -> tuple[str, int]: payload = { "sub": str(user_id), "username": username, + "sv": session_version, + "purpose": SESSION_PURPOSE, "exp": expire, "iat": now, } @@ -85,7 +153,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]) @@ -94,28 +162,47 @@ def decode_token(token: str) -> Optional[dict]: return None +def _is_session_payload(payload: dict[str, Any]) -> bool: + """Tokens from before purposes existed have none and count as sessions.""" + return payload.get("purpose", SESSION_PURPOSE) == SESSION_PURPOSE + + _last_prune = 0.0 +def _is_loopback(host: str) -> bool: + try: + addr = ipaddress.ip_address(host) + except ValueError: + return False + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: + addr = addr.ipv4_mapped + return addr.is_loopback + + def get_client_ip(request: Request) -> str: """ - Extract real client IP from proxy headers. + Return the client address used for rate limits and the audit log. - Checks X-Forwarded-For and X-Real-IP headers (set by nginx/reverse proxies) - before falling back to request.client.host. Without this, all requests - behind a proxy share the same rate limit bucket (127.0.0.1). + Forwarding headers are only read when the connection comes from loopback, + which is where the bundled nginx connects from. Anything else can send its + own headers, so those are ignored. Behind a proxy on another address every + user shares that proxy's bucket. """ - # X-Forwarded-For can contain a chain: "client, proxy1, proxy2" - forwarded_for = request.headers.get("x-forwarded-for") - if forwarded_for: - # The leftmost IP is the original client - return forwarded_for.split(",")[0].strip() + if request.client is None: + return "unknown" - real_ip = request.headers.get("x-real-ip") - if real_ip: - return real_ip.strip() + peer = request.client.host + if _is_loopback(peer): + # nginx appends the address it saw, so the rightmost entry is the real one + hops = [hop.strip() for hop in request.headers.get("x-forwarded-for", "").split(",") if hop.strip()] + if hops: + return hops[-1] + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip - return request.client.host if request.client else "unknown" + return peer @@ -152,46 +239,123 @@ def is_setup_required(db: Session) -> bool: return count == 0 -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), - db: Session = Depends(get_db) -) -> User: - """ - Dependency to get the current authenticated user from JWT token. - Raises 401 if not authenticated. - """ - if not credentials: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, +def ending_sessions_values() -> dict[str, Any]: + """Values for an UPDATE that ends all of a user's sessions. Spread into .values().""" + return {"session_version": User.session_version + 1} + + +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 + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _record_api_token_use(token_id: int, now: datetime, db: Session) -> None: + """Best effort. A busy database must not fail the request that carried the token.""" + # One conditional UPDATE, so concurrent requests write at most once per interval. + try: + result = db.execute( + update(ApiToken) + .where( + ApiToken.id == token_id, + or_( + ApiToken.last_used_at.is_(None), + ApiToken.last_used_at <= now - API_TOKEN_USE_WRITE_INTERVAL, + ), + ) + .values(last_used_at=now) + .execution_options(synchronize_session=False) ) + db.commit() + except SQLAlchemyError: + db.rollback() + logger.debug("Could not record use of API token %s", token_id, exc_info=True) + return + if result.rowcount == 0: + logger.debug("API token %s last-used time not updated (used recently or deleted)", token_id) + + +def _user_from_api_token(credential: str, db: Session) -> User: + token = db.execute( + select(ApiToken).where(ApiToken.token_hash == hash_token(credential)) + ).scalar_one_or_none() + if token is None: + raise _unauthorized("Invalid API token") + if token.revoked_at is not None: + raise _unauthorized("API token revoked") + + now = _utcnow() + if token.expires_at is not None and token.expires_at <= now: + raise _unauthorized("API token expired") + + user = db.get(User, token.user_id) + if not user or not user.is_active: + raise _unauthorized("User not found or inactive") + + _record_api_token_use(token.id, now, db) + return user + + +@dataclass(frozen=True) +class Caller: + user: User + via_token: bool + + +def _authenticate(credentials: HTTPAuthorizationCredentials | None, db: Session) -> Caller: + """The one place that decides whether a request is a session or an API token.""" + if not credentials: + raise _unauthorized("Not authenticated") + + if is_api_token(credentials.credentials): + return Caller(user=_user_from_api_token(credentials.credentials, db), via_token=True) payload = decode_token(credentials.credentials) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired token", - headers={"WWW-Authenticate": "Bearer"}, - ) + if not payload or not _is_session_payload(payload): + raise _unauthorized("Invalid or expired token") user_id = payload.get("sub") if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token payload", - headers={"WWW-Authenticate": "Bearer"}, - ) + raise _unauthorized("Invalid token payload") user = db.get(User, int(user_id)) if not user or not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found or inactive", - headers={"WWW-Authenticate": "Bearer"}, - ) + raise _unauthorized("User not found or inactive") - return user + if not _session_is_current(payload, user): + raise _unauthorized("Session expired") + + return Caller(user=user, via_token=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: Session = Depends(get_db) +) -> User: + """ + Dependency to get the current user from a session. + Raises 401 if not authenticated, and 403 if the caller used an API token. + """ + caller = _authenticate(credentials, db) + if caller.via_token: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=TOKEN_SCOPE_ERROR) + return caller.user + + +async def get_search_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), # noqa: B008 + db: Session = Depends(get_db) # noqa: B008 +) -> User: + """Dependency for the search-scope routes. Accepts a session or an API token.""" + return _authenticate(credentials, db).user async def get_optional_user( @@ -199,14 +363,14 @@ async def get_optional_user( db: Session = Depends(get_db) ) -> Optional[User]: """ - Dependency to get the current user if authenticated, or None. - Does not raise exceptions for unauthenticated requests. + Dependency to get the current session user if authenticated, or None. + Does not raise exceptions for unauthenticated requests. API tokens count as unauthenticated. """ if not credentials: return None payload = decode_token(credentials.credentials) - if not payload: + if not payload or not _is_session_payload(payload): return None user_id = payload.get("sub") @@ -214,13 +378,49 @@ 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 -@router.get("/status") +def allow_anyone() -> None: + """Marks a route that needs no sign-in. See access.py.""" + + +async def require_user(user: User = Depends(get_current_user)) -> User: # noqa: B008 + """Signed in, even while a password change is pending. Only for the routes that must stay reachable.""" + return user + + +def _password_is_current(user: User) -> User: + if user.must_change_password: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Password change required", + ) + return user + + +async def require_signed_in(user: User = Depends(require_user)) -> User: # noqa: B008 + return _password_is_current(user) + + +async def require_search(user: User = Depends(get_search_user)) -> User: # noqa: B008 + """Signed in with a session or an API token. Only for the search-scope routes.""" + return _password_is_current(user) + + +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 + + +@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 +433,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, @@ -284,16 +484,27 @@ async def setup_admin( username=setup_data.username, password_hash=hash_password(setup_data.password), is_active=True, + role=ROLE_ADMIN, + # setup signs you straight in, so it's the first login + last_login_at=datetime.now(timezone.utc).replace(tzinfo=None), ) db.add(user) + db.flush() + record_event( + db, + actor=user, + action=AuditAction.SETUP_COMPLETED, + target=AuditTarget.user(user.id, user.username), + ip=get_client_ip(request), + ) db.commit() db.refresh(user) 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, @@ -302,7 +513,24 @@ async def setup_admin( ) -@router.post("/login", response_model=AuthResponse) +def _record_failed_sign_in( + db: Session, + *, + client_ip: str, + target: AuditTarget, + detail: dict[str, Any] | None = None, +) -> None: + record_and_commit( + db, + actor=None, + action=AuditAction.AUTH_LOGIN_FAILED, + target=target, + detail=detail, + ip=client_ip, + ) + + +@router.post("/login", response_model=AuthResponse, dependencies=[Depends(allow_anyone)]) async def login( request: Request, login_data: LoginRequest, @@ -336,23 +564,105 @@ async def login( # Verify credentials (bcrypt uses constant-time comparison internally) if not user or not verify_password(login_data.password, user.password_hash): logger.warning(f"Failed login attempt for user: {login_data.username}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Bearer"}, + _record_failed_sign_in( + db, + client_ip=client_ip, + target=AuditTarget.user(user.id if user else None, login_data.username), ) + raise _unauthorized("Invalid username or password") if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Account is disabled", - headers={"WWW-Authenticate": "Bearer"}, + _record_failed_sign_in( + db, + client_ip=client_ip, + target=AuditTarget.user(user.id, user.username), + detail={"reason": "account disabled"}, ) + raise _unauthorized("Account is disabled") + + user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None) + record_event(db, actor=user, action=AuditAction.AUTH_LOGIN, ip=client_ip) + db.commit() 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, + token_type="bearer", + expires_in=expires_in + ) + + +@router.post("/change-password", response_model=AuthResponse) +async def change_password( + request: Request, + password_data: PasswordChangeRequest, + current_user: User = Depends(require_user), # noqa: B008 + db: Session = Depends(get_db), +) -> AuthResponse: + """ + Change the signed-in user's password. Ends their other sessions. + + Returns a fresh access token so the current session stays signed in. + + Raises: + 400: Current password is wrong, or the new one is the same as the old + 409: Account changed while the request was running, try again + 429: Too many wrong current passwords from this user and IP + """ + rate_limit_key = f"change-password:{current_user.id}:{get_client_ip(request)}" + if not check_rate_limit(rate_limit_key): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many attempts. Try again later." + ) + + if not verify_password(password_data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + if password_data.new_password == password_data.current_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password must be different from the current password", + ) + + expected_version = current_user.session_version + result = db.execute( + update(User) + .where(User.id == current_user.id, User.session_version == expected_version) + .values( + password_hash=hash_password(password_data.new_password), + must_change_password=False, + **ending_sessions_values(), + ) + ) + if result.rowcount != 1: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Account changed. Sign in again and retry.", + ) + record_event( + db, + actor=current_user, + action=AuditAction.USER_PASSWORD_CHANGED, + target=AuditTarget.user(current_user.id, current_user.username), + ip=get_client_ip(request), + ) + db.commit() + db.refresh(current_user) + + logger.info(f"Password changed: {current_user.username}") + + token, expires_in = create_access_token( + current_user.id, current_user.username, current_user.session_version + ) return AuthResponse( access_token=token, @@ -362,7 +672,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_user)): # noqa: B008 """ Logout the current user. @@ -377,7 +687,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_user)): # noqa: B008 """ Get information about the currently authenticated user. diff --git a/backend/app/api/preview.py b/backend/app/api/preview.py index 448acf7c..a66c96fb 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_search 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_search), ): """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_search), ): """Create a short-lived signed URL for downloading an indexed document.""" document = _document_to_dict(await meili_service.get_document(document_id)) @@ -165,7 +165,7 @@ async def create_document_download_link( file_path = ( None if source.location_type == "agent" else _validated_document_path(document, source) ) - token = _create_download_token(current_user.id, document_id) + token = _create_download_token(current_user.id, document_id, current_user.session_version) url = f"/api/documents/{quote(document_id, safe='')}/download?{urlencode({'token': token})}" return { "url": url, @@ -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, @@ -182,7 +182,7 @@ async def download_document( db: Session = Depends(get_db), ): """Download the original file for an indexed document using a short-lived token.""" - _validate_download_token(token, document_id) + _validate_download_token(token, document_id, db) document = _document_to_dict(await meili_service.get_document(document_id)) if not document: _preview_error(status.HTTP_404_NOT_FOUND, "document_not_found", "Document not found") @@ -345,11 +345,12 @@ def _remote_headers(filename: str | None) -> dict[str, str]: return {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe_filename, safe='')}"} -def _create_download_token(user_id: int, document_id: str) -> str: +def _create_download_token(user_id: int, document_id: str, session_version: int) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "doc": document_id, + "sv": session_version, "purpose": _DOWNLOAD_TOKEN_PURPOSE, "iat": now, "exp": now + timedelta(seconds=_DOWNLOAD_TOKEN_EXPIRE_SECONDS), @@ -357,7 +358,7 @@ def _create_download_token(user_id: int, document_id: str) -> str: return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM) -def _validate_download_token(token: str | None, document_id: str) -> dict[str, Any]: +def _validate_download_token(token: str | None, document_id: str, db: Session) -> dict[str, Any]: if not token: _preview_error( status.HTTP_401_UNAUTHORIZED, "download_token_missing", "Download token is required" @@ -391,6 +392,17 @@ def _validate_download_token(token: str | None, document_id: str) -> dict[str, A status.HTTP_401_UNAUTHORIZED, "download_token_invalid", "Invalid download token" ) + user = db.get(User, int(payload["sub"])) + if ( + user is None + or not user.is_active + or user.must_change_password + or payload.get("sv") != user.session_version + ): + _preview_error( + status.HTTP_401_UNAUTHORIZED, "download_token_invalid", "Invalid download token" + ) + return payload diff --git a/backend/app/api/search.py b/backend/app/api/search.py index f024e65a..7a20fc77 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -7,6 +7,7 @@ """ import json import logging +import re from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -17,12 +18,39 @@ 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_search logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["search"]) +_HIGHLIGHT_TAG = re.compile(r"()") + + +def _trim_snippet(formatted: str, limit: int) -> str: + """Cut highlighted text after `limit` visible characters, keeping every closed""" + parts: list[str] = [] + visible = 0 + in_mark = False + for part in _HIGHLIGHT_TAG.split(formatted): + if part == "": + if visible >= limit: + break + in_mark = True + parts.append(part) + elif part == "": + in_mark = False + parts.append(part) + else: + room = limit - visible + parts.append(part[:room]) + visible += min(len(part), room) + if len(part) > room: + break + if in_mark: + parts.append("") + return "".join(parts) + # Rounding precision (decimal places) applied to the Meili _rankingScore when # grouping hits into a "near-equal relevance" band for the availability tie-breaker. _TIE_BREAK_PRECISION = 3 @@ -41,7 +69,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_search), db: Session = Depends(get_db), ): """ @@ -138,7 +166,7 @@ async def search( # Extract content snippet with highlighting # Meilisearch provides _formatted field with tags for matches formatted = hit.get("_formatted", hit) - snippet = formatted.get("content", "")[:query.snippet_length] + snippet = _trim_snippet(formatted.get("content", ""), query.snippet_length) if len(hit.get("content", "")) > query.snippet_length: snippet += "..." @@ -190,7 +218,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_search)): """ Get a single document by ID diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 7d5b1798..01e3d853 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 @@ -48,7 +48,7 @@ def update_settings( detail=f"Invalid scan schedule: {default_schedule.scan_schedule}", ) - result = AppSettingsService(db).update_settings(update) + result = AppSettingsService(db).update_settings(update, actor=current_user) if "default_scan_schedule" in update.model_fields_set and hasattr(request.app.state, "scheduler"): request.app.state.scheduler.sync_default_schedule_sources() diff --git a/backend/app/api/source_batches.py b/backend/app/api/source_batches.py index e12e41ae..152ada2e 100644 --- a/backend/app/api/source_batches.py +++ b/backend/app/api/source_batches.py @@ -14,10 +14,12 @@ from sqlalchemy.orm import Session, selectinload from ..db.database import get_db +from ..db.locks import begin_immediate from ..models import Agent, Source, SourceBatch, SourceBatchItem, User, _utcnow from ..schemas import SourceBatchCreate, SourceBatchItemCreateRequest, SourceCreate +from ..services.audit import AuditAction, AuditTarget, record_event 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 +69,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 +114,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 +132,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() @@ -174,20 +176,8 @@ def _local_identity_without_io(path: str) -> tuple: return ("local", os.path.normpath(os.path.abspath(os.path.expanduser(path)))) -def _begin_immediate(db: Session): - # Only wait briefly for the write lock. busy_timeout sticks to the pooled - # connection, so put the old value back before anything else gets it. - conn = db.connection() - previous = int(conn.exec_driver_sql("PRAGMA busy_timeout").scalar()) - conn.exec_driver_sql("PRAGMA busy_timeout=400") - try: - conn.exec_driver_sql("BEGIN IMMEDIATE") - finally: - conn.exec_driver_sql(f"PRAGMA busy_timeout={previous}") - - def _write_item(db: Session, batch_id: str, item_id: str, owner_id: int, source: Source | None, - preflight_error: str | None, proof: str | None): + preflight_error: str | None, proof: str | None, actor: User | None = None): from .sources import ( _configured_allowed_paths, _remote_agent, @@ -225,7 +215,7 @@ def _write_item(db: Session, batch_id: str, item_id: str, owner_id: int, source: for row in db.execute(select(Source).where(Source.location_type == "local")).scalars() } db.rollback() - _begin_immediate(db) + begin_immediate(db) batch = _owned(db, batch_id, owner_id) item = db.get(SourceBatchItem, (batch_id, item_id)) if item is None: @@ -289,6 +279,12 @@ def _write_item(db: Session, batch_id: str, item_id: str, owner_id: int, source: if db.get(Source, source.id): raise HTTPException(status_code=409, detail="Generated source ID is already used") db.add(source) + record_event( + db, + actor=actor, + action=AuditAction.SOURCE_CREATED, + target=AuditTarget.source(source.name), + ) item.state, item.source_id = "created", source.id item.last_error = None item.updated_at = batch.updated_at = _utcnow() @@ -311,7 +307,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)) @@ -327,7 +323,10 @@ def create_item(batch_id: str, item_id: str, data: SourceBatchItemCreateRequest, error = str(exc.detail)[:500] except (OSError, ValueError): error = "Path or schedule is unavailable; check settings and retry" - result, created = _write_item(db, batch_id, item_id, owner_id, source, error, data.path_validation_job_id) + result, created = _write_item( + db, batch_id, item_id, owner_id, source, error, data.path_validation_job_id, + actor=current_user, + ) if created and hasattr(request.app.state, "scheduler"): try: request.app.state.scheduler.update_source_schedule(result["source_id"]) diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 2d1a2849..4e25a61f 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -36,11 +36,13 @@ SourcePathTestRequest, SourcePathTestResponse, SourceResponse, + SourceSummaryResponse, SourceUpdate, ) from ..services.agent_auth import effective_agent_status from ..services.agent_jobs import AgentJobService, JobConflict from ..services.app_settings import AppSettingsService +from ..services.audit import AuditAction, AuditTarget, record_event from ..services.indexer import IndexingService from ..services.local_browse import LocalBrowseError, list_local_directories from ..services.scan_dispatcher import ( @@ -57,7 +59,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__) @@ -530,6 +532,39 @@ def generate_source_id(name: str) -> str: return source_id or "source" +# Only these fields decide whether an update is logged. Their values are never recorded. +SOURCE_AUDIT_FIELDS = ( + "name", + "location_type", + "agent_id", + "processing_mode", + "root_path", + "include_patterns", + "exclude_patterns", + "schedule_type", + "scan_schedule", + "interval_value", + "interval_unit", + "use_default_schedule", +) + + +def _audit_source( + db: Session, + actor: User, + action: AuditAction, + source: Source, + detail: dict | None = None, +) -> None: + record_event( + db, + actor=actor, + action=action, + target=AuditTarget.source(source.name), + detail=detail, + ) + + def _build_source(db: Session, data: SourceCreate, source_id: str, root_path: str) -> Source: """Build and schedule a complete source before its creation transaction.""" if not data.use_default_schedule: @@ -558,15 +593,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(get_current_user) + 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() @@ -596,7 +634,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 +664,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 +681,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 +708,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 +719,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 +735,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 +778,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 +808,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 @@ -817,6 +855,7 @@ async def create_source( source = _build_source(db, source_data, source_id, root_path) db.add(source) + _audit_source(db, current_user, AuditAction.SOURCE_CREATED, source) db.commit() db.refresh(source) @@ -839,7 +878,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 @@ -862,6 +901,8 @@ async def update_source( status_code=status.HTTP_404_NOT_FOUND, detail=f"Source '{source_id}' not found" ) + before = {field: getattr(source, field) for field in SOURCE_AUDIT_FIELDS} + # Validate the prospective combined binding before mutating the persistent object. prospective = { "location_type": source.location_type, @@ -969,6 +1010,13 @@ async def update_source( # Update timestamp source.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + changed_fields = sorted( + field for field in SOURCE_AUDIT_FIELDS if getattr(source, field) != before[field] + ) + if changed_fields: + _audit_source( + db, current_user, AuditAction.SOURCE_UPDATED, source, {"fields": changed_fields} + ) db.commit() db.refresh(source) @@ -990,7 +1038,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 @@ -1022,9 +1070,11 @@ async def delete_source( from ..services.source_cleanup import purge_source scheduler = request.app.state.scheduler if hasattr(request.app.state, "scheduler") else None + _audit_source(db, current_user, AuditAction.SOURCE_DELETED, source) try: await purge_source(source, db, scheduler=scheduler) except Exception as error: + db.rollback() logger.exception(f"Failed to purge source {source_id}") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -1041,7 +1091,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 @@ -1074,6 +1124,7 @@ async def reindex_source( raise HTTPException(status_code=409, detail="remote_agents_disabled") from exc except AgentUnavailableError as exc: raise HTTPException(status_code=409, detail="agent_unavailable") from exc + _audit_source(db, current_user, AuditAction.SOURCE_REINDEXED, source, {"full": full}) db.commit() return JSONResponse( status_code=202, @@ -1099,6 +1150,8 @@ async def reindex_source( from sqlalchemy.orm import sessionmaker from starlette.concurrency import run_in_threadpool + _audit_source(db, current_user, AuditAction.SOURCE_REINDEXED, source, {"full": full}) + db.commit() db_bind = db.get_bind() def sync_index_wrapper(): @@ -1156,7 +1209,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/api/tokens.py b/backend/app/api/tokens.py new file mode 100644 index 00000000..f49618ea --- /dev/null +++ b/backend/app/api/tokens.py @@ -0,0 +1,113 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +""" +API token management for the signed-in user. + +Tokens are checked on the search routes in auth.py. This module only creates and revokes them. +""" +import logging +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from ..db.database import get_db +from ..models import ApiToken, ApiTokenScope, User, _utcnow +from ..schemas import ApiTokenCreatedResponse, ApiTokenCreateRequest, ApiTokenResponse +from ..services.agent_auth import hash_token +from ..services.api_tokens import ( + API_TOKEN_DISPLAY_LENGTH, + api_token_response, + list_tokens_for_user, + new_api_token_secret, + revoke_token, +) +from ..services.audit import AuditAction, AuditTarget, record_event +from .auth import require_signed_in + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.get("/tokens", response_model=list[ApiTokenResponse]) +async def list_api_tokens( + current_user: User = Depends(require_signed_in), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> list[ApiTokenResponse]: + """List your API tokens, newest first. Revoked and expired tokens stay in the list with their status.""" + now = _utcnow() + return [api_token_response(token, now) for token in list_tokens_for_user(db, current_user.id)] + + +@router.post("/tokens", response_model=ApiTokenCreatedResponse, status_code=status.HTTP_201_CREATED) +async def create_api_token( + token_data: ApiTokenCreateRequest, + current_user: User = Depends(require_signed_in), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> ApiTokenCreatedResponse: + """ + Create a search-scope API token. The secret is only returned here. + + Raises: + 403: Sent with an API token, or the password change is still pending + """ + secret = new_api_token_secret() + expires_at = None + if token_data.expires_in_days is not None: + expires_at = _utcnow() + timedelta(days=token_data.expires_in_days) + + token = ApiToken( + user_id=current_user.id, + name=token_data.name, + scope=ApiTokenScope.SEARCH, + token_hash=hash_token(secret), + display_prefix=secret[:API_TOKEN_DISPLAY_LENGTH], + created_at=_utcnow(), + expires_at=expires_at, + ) + db.add(token) + record_event( + db, + actor=current_user, + action=AuditAction.TOKEN_CREATED, + target=AuditTarget.user(current_user.id, current_user.username), + detail={"name": token.name, "expires_in_days": token_data.expires_in_days}, + ) + db.commit() + db.refresh(token) + + logger.info(f"API token created: {token.display_prefix} by {current_user.username}") + + return ApiTokenCreatedResponse(**api_token_response(token).model_dump(), token=secret) + + +@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_api_token( + token_id: int, + current_user: User = Depends(require_signed_in), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """ + Revoke one of your API tokens. It stops working straight away. + + Raises: + 404: No active token with that id belongs to you + """ + name = revoke_token(db, current_user.id, token_id) + if name is None: + db.rollback() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API token not found") + record_event( + db, + actor=current_user, + action=AuditAction.TOKEN_REVOKED, + target=AuditTarget.user(current_user.id, current_user.username), + detail={"name": name}, + ) + db.commit() + + logger.info(f"API token revoked by {current_user.username}") + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 00000000..c3a84146 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,350 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Admin APIs for managing user accounts.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import delete, select, update +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session +from sqlalchemy.sql import Select + +from ..db.database import get_db +from ..db.locks import begin_immediate +from ..models import ROLE_ADMIN, AgentEnrollment, ApiToken, SourceBatch, User, _utcnow +from ..schemas import ( + ApiTokenResponse, + UserAdminResponse, + UserCreateRequest, + UserPasswordResetRequest, + UserUpdateRequest, +) +from ..services.api_tokens import api_token_response, list_tokens_for_user, revoke_token +from ..services.audit import AuditAction, AuditTarget, record_event +from .auth import ending_sessions_values, hash_password, require_admin + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +def _username_taken() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists", + ) + + +def _user_or_404(db: Session, user_id: int) -> User: + user = db.get(User, user_id, populate_existing=True) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +def _busy_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Someone else is changing users right now. Try again.", + ) + + +def _last_admin_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="There has to be at least one active admin", + ) + + +def _active_admins_lock_statement() -> Select: + return ( + select(User.id) + .where(User.role == ROLE_ADMIN, User.is_active.is_(True)) + .order_by(User.id) + .with_for_update() + ) + + +def _active_admin_ids_locked(db: Session) -> list[int]: + # SQLite has no row locks, so take the database write lock first. Postgres + # locks the admin rows in the SELECT itself. Either way it holds until commit. + if db.get_bind().dialect.name == "sqlite": + db.rollback() + try: + begin_immediate(db) + except OperationalError as exc: + db.rollback() + if "locked" not in str(exc).lower() and "busy" not in str(exc).lower(): + raise + raise _busy_error() from exc + return list(db.execute(_active_admins_lock_statement()).scalars()) + + +def _guard_admin_removal(admin: User, user: User, active_admin_ids: list[int]) -> None: + if user.id == admin.id: + raise _not_yourself_error() + if user.id in active_admin_ids and len(active_admin_ids) == 1: + raise _last_admin_error() + + +def _not_yourself_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You can't do that to your own account", + ) + + +def _created_by_name(db: Session, user: User) -> str | None: + if user.created_by_id is None: + return None + creator = db.get(User, user.created_by_id) + return creator.username if creator else None + + +def _to_admin_response(user: User, created_by: str | None) -> UserAdminResponse: + return UserAdminResponse( + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + must_change_password=user.must_change_password, + created_at=user.created_at, + created_by=created_by, + last_login_at=user.last_login_at, + ) + + +@router.get("", response_model=list[UserAdminResponse]) +async def list_users( + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> list[UserAdminResponse]: + """List every user, ordered by username.""" + users = db.execute(select(User).order_by(User.username)).scalars().all() + usernames = {user.id: user.username for user in users} + return [_to_admin_response(user, usernames.get(user.created_by_id)) for user in users] + + +@router.post("", response_model=UserAdminResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + user_data: UserCreateRequest, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> UserAdminResponse: + """ + Create a user with a temporary password. They must change it before anything else. + + Raises: + 409: Username already exists + """ + existing = db.execute(select(User.id).where(User.username == user_data.username)).first() + if existing: + raise _username_taken() + + user = User( + username=user_data.username, + password_hash=hash_password(user_data.password), + is_active=True, + role=user_data.role, + must_change_password=True, + created_by_id=admin.id, + ) + db.add(user) + try: + db.flush() + record_event( + db, + actor=admin, + action=AuditAction.USER_CREATED, + target=AuditTarget.user(user.id, user.username), + detail={"role": user.role}, + ) + db.commit() + except IntegrityError: + db.rollback() + raise _username_taken() from None + db.refresh(user) + + logger.info(f"User created: {user.username} by {admin.username}") + + return _to_admin_response(user, admin.username) + + +@router.patch("/{user_id}", response_model=UserAdminResponse) +async def update_user( + user_id: int, + update_data: UserUpdateRequest, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> UserAdminResponse: + """ + Change a user's role or turn their account on or off. + A role change or a disable ends their sessions. + + Raises: + 400: Trying to disable or demote your own account + 404: Unknown user + 409: The user is the last active admin, or another admin change is in progress + """ + active_admin_ids = _active_admin_ids_locked(db) + user = _user_or_404(db, user_id) + changes = update_data.model_dump(exclude_none=True) + old_role = user.role + new_role = changes.get("role", user.role) + was_active = user.is_active + new_active = changes.get("is_active", user.is_active) + role_changed = new_role != old_role + disabled_now = user.is_active and not new_active + + if user.is_active and user.is_admin and (new_role != ROLE_ADMIN or not new_active): + _guard_admin_removal(admin, user, active_admin_ids) + + if role_changed or disabled_now: + changes.update(ending_sessions_values()) + if changes: + db.execute(update(User).where(User.id == user.id).values(**changes)) + target = AuditTarget.user(user.id, user.username) + if role_changed: + record_event( + db, + actor=admin, + action=AuditAction.USER_ROLE_CHANGED, + target=target, + detail={"old": old_role, "new": new_role}, + ) + if new_active != was_active: + record_event( + db, + actor=admin, + action=AuditAction.USER_ENABLED if new_active else AuditAction.USER_DISABLED, + target=target, + ) + db.commit() + db.refresh(user) + + logger.info(f"User updated: {user.username} by {admin.username}") + + return _to_admin_response(user, _created_by_name(db, user)) + + +@router.post("/{user_id}/reset-password", response_model=UserAdminResponse) +async def reset_user_password( + user_id: int, + reset_data: UserPasswordResetRequest, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> UserAdminResponse: + """ + Set a new temporary password. The user must change it at next sign-in and their sessions end. + + Raises: + 404: Unknown user + """ + user = _user_or_404(db, user_id) + db.execute( + update(User) + .where(User.id == user.id) + .values( + password_hash=hash_password(reset_data.password), + must_change_password=True, + **ending_sessions_values(), + ) + ) + record_event( + db, + actor=admin, + action=AuditAction.USER_PASSWORD_RESET, + target=AuditTarget.user(user.id, user.username), + ) + db.commit() + db.refresh(user) + + logger.info(f"Password reset: {user.username} by {admin.username}") + + return _to_admin_response(user, _created_by_name(db, user)) + + +@router.get("/{user_id}/tokens", response_model=list[ApiTokenResponse]) +async def list_user_tokens( + user_id: int, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> list[ApiTokenResponse]: + """List a user's API tokens, newest first. Revoked and expired tokens are included. Secrets are never shown. + + Raises: + 404: Unknown user + """ + _user_or_404(db, user_id) + now = _utcnow() + return [api_token_response(token, now) for token in list_tokens_for_user(db, user_id)] + + +@router.delete("/{user_id}/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_user_token( + user_id: int, + token_id: int, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """ + Revoke one of a user's API tokens. It stops working straight away. + + Raises: + 404: No active token with that id belongs to the user + """ + owner = _user_or_404(db, user_id) + name = revoke_token(db, user_id, token_id) + if name is None: + db.rollback() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API token not found") + record_event( + db, + actor=admin, + action=AuditAction.TOKEN_REVOKED, + target=AuditTarget.user(owner.id, owner.username), + detail={"name": name}, + ) + db.commit() + + logger.info(f"API token revoked: user {user_id} by {admin.username}") + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + admin: User = Depends(require_admin), # noqa: B008 + db: Session = Depends(get_db), # noqa: B008 +) -> Response: + """ + Delete a user. Disabling is the normal way to remove someone; this is for mistakes. + + Raises: + 400: Trying to delete your own account + 404: Unknown user + 409: The user is the last active admin, or another admin change is in progress + """ + active_admin_ids = _active_admin_ids_locked(db) + user = _user_or_404(db, user_id) + _guard_admin_removal(admin, user, active_admin_ids) + target = AuditTarget.user(user.id, user.username) + + db.execute(delete(AgentEnrollment).where(AgentEnrollment.created_by_user_id == user.id)) + db.execute(delete(ApiToken).where(ApiToken.user_id == user.id)) + db.execute(update(User).where(User.created_by_id == user.id).values(created_by_id=None)) + db.delete(user) + # The before_delete hook on User reads the batches it owns, so flush before clearing the owner. + db.flush() + db.execute( + update(SourceBatch).where(SourceBatch.owner_user_id == user.id).values(owner_user_id=None) + ) + record_event(db, actor=admin, action=AuditAction.USER_DELETED, target=target) + db.commit() + + logger.info(f"User deleted: {user.username} by {admin.username}") + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/config.py b/backend/app/config.py index 736ec544..f07445be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -44,7 +44,7 @@ class Settings(BaseSettings): indexing_progress_interval: int = 100 # Authentication settings - session_secret: str = "" # Required in production + session_secret: str = "" # Recommended. If empty, a generated key is kept in the data dir session_expire_hours: int = 24 auth_rate_limit: int = 5 # Max attempts per minute diff --git a/backend/app/db/locks.py b/backend/app/db/locks.py new file mode 100644 index 00000000..bec9fb13 --- /dev/null +++ b/backend/app/db/locks.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Write-lock helpers shared by the API modules.""" + +from sqlalchemy.orm import Session + + +def begin_immediate(db: Session) -> None: + # Only wait briefly for the write lock. busy_timeout sticks to the pooled + # connection, so put the old value back before anything else gets it. + conn = db.connection() + previous = int(conn.exec_driver_sql("PRAGMA busy_timeout").scalar()) + conn.exec_driver_sql("PRAGMA busy_timeout=400") + try: + conn.exec_driver_sql("BEGIN IMMEDIATE") + finally: + conn.exec_driver_sql(f"PRAGMA busy_timeout={previous}") diff --git a/backend/app/extractors/images.py b/backend/app/extractors/images.py index 5df40192..0c029085 100644 --- a/backend/app/extractors/images.py +++ b/backend/app/extractors/images.py @@ -12,6 +12,7 @@ from typing import Any, Literal from PIL import ExifTags, Image, UnidentifiedImageError +from PIL.TiffImagePlugin import IFDRational from ..config import settings from ..schemas import Document @@ -23,6 +24,8 @@ _RAW_IMAGE_EXTENSIONS = [".cr2", ".cr3", ".nef", ".arw", ".raf", ".orf", ".rw2", ".dng"] _EXIF_TAGS = {value: key for key, value in ExifTags.TAGS.items()} _GPS_TAGS = ExifTags.GPSTAGS +_EXIF_SUB_IFD = 0x8769 +_GPS_IFD_POINTER = 0x8825 class ImageExtractor(BaseExtractor): @@ -171,6 +174,17 @@ def _normalize_exiftool_metadata(self, raw: dict[str, Any]) -> dict[str, Any]: return metadata def _add_exif_fields(self, metadata: dict[str, Any], exif: Image.Exif) -> None: + # Cameras put shooting settings in the Exif sub-IFD; IFD0 is the fallback + sub_ifd = exif.get_ifd(_EXIF_SUB_IFD) + + def lookup(tag_name: str) -> Any: + tag_id = _EXIF_TAGS.get(tag_name) + if tag_id is None: + return None + if tag_id in sub_ifd: + return sub_ifd[tag_id] + return exif.get(tag_id) + field_map = { "Make": "camera_make", "Model": "camera_model", @@ -181,23 +195,23 @@ def _add_exif_fields(self, metadata: dict[str, Any], exif: Image.Exif) -> None: "LensModel": "lens_model", } for tag_name, metadata_key in field_map.items(): - tag_id = _EXIF_TAGS.get(tag_name) - if tag_id in exif: - metadata[metadata_key] = _clean_value(exif.get(tag_id)) + value = lookup(tag_name) + if value is not None: + metadata[metadata_key] = _clean_value(value) - f_number = exif.get(_EXIF_TAGS.get("FNumber")) + f_number = lookup("FNumber") if f_number is not None: metadata["aperture"] = f"f/{_format_decimal(_to_float(f_number))}" - exposure = exif.get(_EXIF_TAGS.get("ExposureTime")) + exposure = lookup("ExposureTime") if exposure is not None: metadata["exposure_time"] = _format_exposure(exposure) - focal_length = exif.get(_EXIF_TAGS.get("FocalLength")) + focal_length = lookup("FocalLength") if focal_length is not None: metadata["focal_length"] = f"{_format_decimal(_to_float(focal_length))}mm" - orientation = exif.get(_EXIF_TAGS.get("Orientation")) + orientation = lookup("Orientation") if orientation is not None: metadata["orientation"] = _clean_value(orientation) @@ -218,6 +232,9 @@ def _image_metadata_max_size_bytes(self) -> int: def _extract_gps(self, exif: Image.Exif) -> dict[str, Any]: gps_tag = _EXIF_TAGS.get("GPSInfo") gps_info = exif.get(gps_tag) if gps_tag in exif else None + if isinstance(gps_info, int): + # Pillow hands back the raw IFD offset here; the decoded GPS IFD is behind get_ifd + gps_info = exif.get_ifd(_GPS_IFD_POINTER) if not gps_info: return {} gps: dict[str, Any] = {} @@ -300,8 +317,11 @@ def _format_exposure(value: Any) -> str: def _clean_value(value: Any) -> Any: if isinstance(value, bytes): return value.decode("utf-8", errors="replace").strip("\x00") - if isinstance(value, tuple): + if isinstance(value, (tuple, list)): return tuple(_clean_value(item) for item in value) + if isinstance(value, IFDRational): + # metadata goes to JSON, and IFDRational isn't serialisable + return None if value.denominator == 0 else float(value) return value diff --git a/backend/app/extractors/text.py b/backend/app/extractors/text.py index e94c0197..8cb1655e 100644 --- a/backend/app/extractors/text.py +++ b/backend/app/extractors/text.py @@ -214,10 +214,7 @@ def _extract_title(self, file_path: str, content: str) -> str: for line in lines[:5]: # Check first 5 lines line = line.strip() if line and len(line) <= 100: - # Remove common comment markers - for prefix in ['#', '//', '/*', '--', '', '*/') + + +def _strip_comment_markers(line: str) -> str: + """Drop comment and docstring markers from both ends of a title candidate""" + for opener in _OPENERS: + if line.startswith(opener): + line = line[len(opener):].strip() + break + for closer in _CLOSERS: + if line.endswith(closer): + line = line[: -len(closer)].strip() + break + return line + + # Register this extractor extractor_registry.register(TextExtractor) diff --git a/backend/app/main.py b/backend/app/main.py index fda8fd3f..bbeff3dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,8 +17,20 @@ from sqlalchemy.orm import Session from . import __version__ -from .api import agent_protocol, agents, auth, preview, search, sources, status +from .api import ( + agent_protocol, + agents, + audit, + auth, + preview, + search, + sources, + status, + tokens, + users, +) from .api import settings as settings_api +from .api.auth import allow_anyone, get_secret_key from .config import settings from .db.database import engine, get_db from .request_body_limits import RemoteAgentBodyLimitMiddleware @@ -53,6 +65,10 @@ async def lifespan(app: FastAPI): logger.info(f"Database: {settings.database_url.split('://')[0]}") logger.info(f"Meilisearch: {settings.meili_url}") + # Load or create the signing key now, so a missing SESSION_SECRET is logged + # and an unwritable data dir stops startup instead of the first sign-in. + get_secret_key() + # Initialize Meilisearch connection if meili_service.connect(): logger.info("Meilisearch connection established") @@ -164,6 +180,7 @@ async def log_requests(request: Request, call_next): # Include API routers app.add_middleware(RemoteAgentBodyLimitMiddleware) app.include_router(auth.router) +app.include_router(tokens.router) app.include_router(sources.router) app.include_router(search.router) app.include_router(status.router) @@ -171,9 +188,11 @@ async def log_requests(request: Request, call_next): app.include_router(preview.router) app.include_router(agents.router) app.include_router(agent_protocol.router) +app.include_router(users.router) +app.include_router(audit.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 @@ -184,7 +203,7 @@ async def health_check(db: Session = Depends(get_db)): from .api.auth import is_setup_required # Check Meilisearch health - meili_health = meili_service.health_check() + meili_health = await asyncio.to_thread(meili_service.health_check) # Check if initial setup is required setup_required = is_setup_required(db) @@ -210,7 +229,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/models.py b/backend/app/models.py index 24b4e75f..cd0b132c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,7 +5,10 @@ SQLAlchemy ORM models for OneSearch """ +import json from datetime import datetime, timezone +from enum import StrEnum +from typing import Any from sqlalchemy import ( BigInteger, @@ -421,10 +424,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 +440,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,10 +452,67 @@ 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"" +class ApiTokenScope(StrEnum): + SEARCH = "search" + + +class ApiToken(Base): + """A long-lived credential that acts as its owner, limited to a scope. Only the hash is stored.""" + + __tablename__ = "api_tokens" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(80), nullable=False) + scope = Column( + String(16), + nullable=False, + default=ApiTokenScope.SEARCH, + server_default=ApiTokenScope.SEARCH.value, + ) + token_hash = Column(String(64), unique=True, index=True, nullable=False) + display_prefix = Column(String(16), nullable=False) + created_at = Column(DateTime, default=_utcnow, nullable=False) + expires_at = Column(DateTime, nullable=True) + last_used_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + + +class AuditEvent(Base): + """One audit log entry. Keeps the actor's username so history survives a user delete.""" + + __tablename__ = "audit_events" + + id = Column(Integer, primary_key=True, autoincrement=True) + created_at = Column(DateTime, default=_utcnow, nullable=False, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + actor_username = Column(Text, nullable=True) + action = Column(String(64), nullable=False, index=True) + target_type = Column(String(32), nullable=True) + target_id = Column(Integer, nullable=True) + target_label = Column(Text, nullable=True) + detail = Column(Text, nullable=True) + ip = Column(String(64), nullable=True) + + @property + def detail_dict(self) -> dict[str, Any] | None: + if not self.detail: + return None + try: + decoded = json.loads(self.detail) + except ValueError: + return None + return decoded if isinstance(decoded, dict) else None + + @event.listens_for(User, "before_delete") def _scrub_deleted_user_batches(mapper, connection, target): """ORM account deletion removes private drafts in the same transaction.""" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 15b65580..c84b8fec 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -6,10 +6,12 @@ """ from datetime import datetime -from typing import Any, Literal, Optional +from typing import Annotated, Any, Literal, Optional from onesearch_shared import BrowseDirectoryEntry, BrowseResult -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator + +from .models import ApiTokenScope class Document(BaseModel): @@ -170,6 +172,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""" @@ -280,13 +289,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 @@ -304,6 +320,34 @@ class LoginRequest(BaseModel): password: str +class PasswordChangeRequest(BaseModel): + """Schema for changing the signed-in user's own password""" + + current_password: str + new_password: str = Field(..., min_length=8) + + +class UserCreateRequest(BaseModel): + """Schema for an admin creating a user with a temporary password""" + + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + role: Literal["admin", "member"] + + +class UserUpdateRequest(BaseModel): + """Schema for an admin changing a user's role or active state. Send only what changes.""" + + role: Literal["admin", "member"] | None = None + is_active: bool | None = None + + +class UserPasswordResetRequest(BaseModel): + """Schema for an admin setting a new temporary password for a user""" + + password: str = Field(..., min_length=8) + + class UserResponse(BaseModel): """Schema for user info response""" @@ -312,9 +356,77 @@ class UserResponse(BaseModel): id: int username: str is_active: bool + role: Literal["admin", "member"] + must_change_password: bool created_at: datetime +class UserAdminResponse(BaseModel): + """A user as an admin sees them in the Users list""" + + id: int + username: str + role: Literal["admin", "member"] + is_active: bool + must_change_password: bool + created_at: datetime + created_by: str | None = None + last_login_at: datetime | None = None + + +class ApiTokenCreateRequest(BaseModel): + """Schema for creating an API token for the signed-in user""" + + name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=80)] + expires_in_days: Literal[30, 90, 365] | None = None + + +ApiTokenStatus = Literal["active", "revoked", "expired"] + + +class ApiTokenResponse(BaseModel): + """An API token as its owner sees it. Never includes the secret.""" + + id: int + name: str + scope: ApiTokenScope + display_prefix: str + created_at: datetime + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked: bool + revoked_at: datetime | None = None + status: ApiTokenStatus + + +class ApiTokenCreatedResponse(ApiTokenResponse): + """A new API token. The secret is only in this response.""" + + token: str + + +class AuditEventResponse(BaseModel): + """One audit log entry. Actor is null for failed sign-ins; the name is still kept.""" + + id: int + created_at: datetime + actor_user_id: int | None = None + actor_username: str | None = None + action: str + target_type: str | None = None + target_id: int | None = None + target_label: str | None = None + detail: dict[str, Any] | None = None + ip: str | None = None + + +class AuditPageResponse(BaseModel): + """A page of audit entries, newest first. Pass next_before back as `before` next time.""" + + events: list[AuditEventResponse] + next_before: int | None = None + + class AuthResponse(BaseModel): """Schema for authentication response""" @@ -344,6 +456,7 @@ class AppSettingsResponse(BaseModel): long_text_pagination_threshold_chars: int = Field(default=20000, ge=1000) default_scan_schedule: ScheduleConfig | None = None remote_agents_enabled: bool = False + audit_retention_days: int = Field(default=90, ge=1, le=3650) class AppSettingsUpdate(BaseModel): @@ -367,6 +480,7 @@ class AppSettingsUpdate(BaseModel): long_text_pagination_threshold_chars: int | None = Field(default=None, ge=1000) default_scan_schedule: ScheduleConfig | None = None remote_agents_enabled: bool | None = None + audit_retention_days: int | None = Field(default=None, ge=1, le=3650) class AgentEnrollmentCodeResponse(BaseModel): diff --git a/backend/app/services/api_tokens.py b/backend/app/services/api_tokens.py new file mode 100644 index 00000000..6c788804 --- /dev/null +++ b/backend/app/services/api_tokens.py @@ -0,0 +1,79 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +""" +API tokens: the prefix that tells them apart from session JWTs, new secrets, and the list and revoke +that both the owner's routes and the admin routes call. +""" + +import secrets +from datetime import datetime + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from ..models import ApiToken, _utcnow +from ..schemas import ApiTokenResponse, ApiTokenStatus + +API_TOKEN_PREFIX = "os_" +# The start of the secret that is kept in the clear so people can tell tokens apart. +API_TOKEN_DISPLAY_LENGTH = len(API_TOKEN_PREFIX) + 4 + + +def is_api_token(credential: str) -> bool: + return credential.startswith(API_TOKEN_PREFIX) + + +def new_api_token_secret() -> str: + return f"{API_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + + +def token_status(token: ApiToken, now: datetime) -> ApiTokenStatus: + # Same order as the checks in _user_from_api_token, so a token that is both shows as revoked. + if token.revoked_at is not None: + return "revoked" + if token.expires_at is not None and token.expires_at <= now: + return "expired" + return "active" + + +def api_token_response(token: ApiToken, now: datetime | None = None) -> ApiTokenResponse: + return ApiTokenResponse( + id=token.id, + name=token.name, + scope=token.scope, + display_prefix=token.display_prefix, + created_at=token.created_at, + expires_at=token.expires_at, + last_used_at=token.last_used_at, + revoked=token.revoked_at is not None, + revoked_at=token.revoked_at, + status=token_status(token, now or _utcnow()), + ) + + +def list_tokens_for_user(db: Session, user_id: int) -> list[ApiToken]: + """Every token the user has, newest first. Revoked and expired ones are included.""" + stmt = select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.id.desc()) + return list(db.execute(stmt).scalars()) + + +def revoke_token(db: Session, user_id: int, token_id: int) -> str | None: + """ + Mark one active token that belongs to user_id as revoked. + + Returns the token's name, or None when there is no such active token. + Does not commit. The caller commits with whatever else the request changes. + """ + token = db.scalar( + select(ApiToken).where( + ApiToken.id == token_id, + ApiToken.user_id == user_id, + ApiToken.revoked_at.is_(None), + ) + ) + if token is None: + return None + name = token.name + db.execute(update(ApiToken).where(ApiToken.id == token_id).values(revoked_at=_utcnow())) + return name diff --git a/backend/app/services/app_settings.py b/backend/app/services/app_settings.py index 827b8271..99b2ddcd 100644 --- a/backend/app/services/app_settings.py +++ b/backend/app/services/app_settings.py @@ -10,8 +10,9 @@ from sqlalchemy.orm import Session from ..config import settings -from ..models import AppSetting +from ..models import AppSetting, User from ..schemas import AppSettingsResponse, AppSettingsUpdate +from .audit import AuditAction, record_event SETTING_KEYS = ( "remote_agents_enabled", @@ -33,6 +34,7 @@ "readable_preview_page_chars", "long_text_pagination_threshold_chars", "default_scan_schedule", + "audit_retention_days", ) @@ -90,6 +92,7 @@ def _coerce_value(key: str, value: str) -> Any: "comic_extraction_max_size_mb", "readable_preview_page_chars", "long_text_pagination_threshold_chars", + "audit_retention_days", }: return int(value) return value @@ -116,8 +119,15 @@ def get_settings(self) -> AppSettingsResponse: values["comic_extraction_max_size_mb"] = legacy_archive_limit return AppSettingsResponse(**values) - def update_settings(self, update: AppSettingsUpdate) -> AppSettingsResponse: + def update_settings( + self, update: AppSettingsUpdate, actor: User | None = None + ) -> AppSettingsResponse: + """Save the given keys. + + With an actor, the audit log records the keys that changed, never their values. + """ update_values = update.model_dump(exclude_unset=True) + before = self.get_settings().model_dump() for key, value in update_values.items(): row = self.db.get(AppSetting, key) if row is None: @@ -125,5 +135,20 @@ def update_settings(self, update: AppSettingsUpdate) -> AppSettingsResponse: self.db.add(row) else: row.value = _serialize(value) + self.db.flush() + after = self.get_settings() + if actor is not None: + after_values = after.model_dump() + changed = sorted( + key for key in update_values if before.get(key) != after_values.get(key) + ) + if changed: + record_event( + self.db, + actor=actor, + action=AuditAction.SETTINGS_CHANGED, + target=None, + detail={"keys": changed}, + ) self.db.commit() - return self.get_settings() + return after diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 00000000..723d1193 --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,151 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +""" +The audit log writer. + +record_event queues an entry on the caller's session, so it commits with the change it describes. +Entries never fail the request: a bad entry is logged and the change goes ahead without it. +""" + +import json +import logging +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from ..models import AuditEvent, User + +logger = logging.getLogger(__name__) + +COMMAND_LINE_ACTOR = "command line" +NAME_LIMIT = 50 +DETAIL_LIMIT = 500 + + +class AuditAction(StrEnum): + AUTH_LOGIN = "auth.login" + AUTH_LOGIN_FAILED = "auth.login_failed" + USER_CREATED = "user.created" + USER_ROLE_CHANGED = "user.role_changed" + USER_PASSWORD_RESET = "user.password_reset" + USER_PASSWORD_CHANGED = "user.password_changed" + USER_DISABLED = "user.disabled" + USER_ENABLED = "user.enabled" + USER_DELETED = "user.deleted" + SETUP_COMPLETED = "setup.completed" + TOKEN_CREATED = "token.created" + TOKEN_REVOKED = "token.revoked" + SOURCE_CREATED = "source.created" + SOURCE_UPDATED = "source.updated" + SOURCE_DELETED = "source.deleted" + SOURCE_REINDEXED = "source.reindexed" + AGENT_ENROLLMENT_CREATED = "agent.enrollment_created" + AGENT_UPDATED = "agent.updated" + AGENT_APPROVED = "agent.approved" + AGENT_DISABLED = "agent.disabled" + AGENT_REVOKED = "agent.revoked" + SETTINGS_CHANGED = "settings.changed" + + +# A signed-in user, a plain name such as COMMAND_LINE_ACTOR, or None for nobody (failed sign-ins). +Actor = User | str | None + + +@dataclass(frozen=True) +class AuditTarget: + type: str + id: int | None + label: str | None + + @classmethod + def user(cls, user_id: int | None, username: str) -> "AuditTarget": + return cls(type="user", id=user_id, label=username) + + # Sources and agents have text ids, so only the name is kept. + @classmethod + def source(cls, name: str) -> "AuditTarget": + return cls(type="source", id=None, label=name) + + @classmethod + def agent(cls, name: str) -> "AuditTarget": + return cls(type="agent", id=None, label=name) + + +def action_clause(action: str): + """Matches one action name, or every action under a prefix when it ends with a dot.""" + if action.endswith("."): + return AuditEvent.action.startswith(action, autoescape=True) + return AuditEvent.action == action + + +def _clip(value: str | None, column: str) -> str | None: + """Cut a free-text value to its column. Text columns have no size, so they get NAME_LIMIT.""" + if value is None: + return None + return value[: AuditEvent.__table__.c[column].type.length or NAME_LIMIT] + + +def _detail_json(detail: dict[str, Any] | None) -> str | None: + if detail is None: + return None + encoded = json.dumps(detail, default=str) + if len(encoded) > DETAIL_LIMIT: + logger.warning("Audit detail over %s characters, not stored", DETAIL_LIMIT) + return None + return encoded + + +def _queue_entry(db: Session, **fields: Any) -> None: + db.add(AuditEvent(**fields)) + + +def record_event( + db: Session, + *, + actor: Actor, + action: AuditAction, + target: AuditTarget | None = None, + detail: dict[str, Any] | None = None, + ip: str | None = None, +) -> None: + """Queue an entry. It is written when the caller commits.""" + if isinstance(actor, User): + actor_user_id, actor_username = actor.id, actor.username + else: + actor_user_id, actor_username = None, actor + try: + _queue_entry( + db, + actor_user_id=actor_user_id, + actor_username=_clip(actor_username, "actor_username"), + action=_clip(action, "action"), + target_type=_clip(target.type, "target_type") if target else None, + target_id=target.id if target else None, + target_label=_clip(target.label, "target_label") if target else None, + detail=_detail_json(detail), + ip=_clip(ip, "ip"), + ) + except Exception: + logger.exception("Could not record audit event %s", action) + + +def record_and_commit( + db: Session, + *, + actor: Actor, + action: AuditAction, + target: AuditTarget | None = None, + detail: dict[str, Any] | None = None, + ip: str | None = None, +) -> None: + """For events with nothing else to commit, like a failed sign-in. A failed commit is logged.""" + record_event(db, actor=actor, action=action, target=target, detail=detail, ip=ip) + try: + db.commit() + except SQLAlchemyError: + db.rollback() + logger.exception("Could not save audit event %s", action) diff --git a/backend/app/services/audit_retention.py b/backend/app/services/audit_retention.py new file mode 100644 index 00000000..620383db --- /dev/null +++ b/backend/app/services/audit_retention.py @@ -0,0 +1,40 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Deletes audit entries older than the audit_retention_days setting.""" + +from datetime import datetime, timedelta + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from ..models import AuditEvent +from .app_settings import AppSettingsService + +PURGE_BATCH_SIZE = 1000 + + +def purge_old_audit_events( + db: Session, now: datetime, batch_size: int = PURGE_BATCH_SIZE +) -> int: + """Delete entries created before now minus the retention period. + + `now` is naive UTC. Each batch is committed on its own so SQLite's write lock + is held briefly. Returns the number removed. + """ + days = AppSettingsService(db).get_settings().audit_retention_days + cutoff = now - timedelta(days=days) + removed = 0 + while True: + ids = list( + db.scalars( + select(AuditEvent.id) + .where(AuditEvent.created_at < cutoff) + .limit(batch_size) + ) + ) + if not ids: + return removed + db.execute(delete(AuditEvent).where(AuditEvent.id.in_(ids))) + db.commit() + removed += len(ids) diff --git a/backend/app/services/indexer.py b/backend/app/services/indexer.py index 3ac4b3ef..ab44e95c 100644 --- a/backend/app/services/indexer.py +++ b/backend/app/services/indexer.py @@ -28,6 +28,11 @@ logger = logging.getLogger(__name__) +def document_id_for_path(source_id: str, file_path: str) -> str: + path_hash = hashlib.sha256(file_path.encode()).hexdigest()[:12] + return f"{source_id}--{path_hash}" + + class IndexingStats: """Statistics for an indexing run""" @@ -198,6 +203,9 @@ async def index_source(self, source_id: str, full: bool = False) -> IndexingStat ) continue + # A value that can't be serialised would fail the whole batch later, so catch it here + document.model_dump(mode="json") + # Add to batch for indexing documents_to_index.append(document) total_bytes_processed += document.size_bytes @@ -240,6 +248,8 @@ async def index_source(self, source_id: str, full: bool = False) -> IndexingStat # Update indexed_files with error self._update_indexed_file(source_id, file_path, status="failed", error=str(e)) + if file_path in indexed_files_map: + await self._remove_stale_document(source_id, file_path) # Index remaining documents if documents_to_index: @@ -428,6 +438,7 @@ async def retry_failed_file(self, source: Source, file_path: str) -> str: extra={"file_path": file_path, "error": str(e)}, ) self._update_indexed_file(source.id, file_path, status="failed", error=str(e)) + await self._remove_stale_document(source.id, file_path) return "failed" def _update_indexed_file( @@ -490,6 +501,14 @@ def _update_indexed_file( ) self.db.add(indexed_file) + async def _remove_stale_document(self, source_id: str, file_path: str) -> None: + """Drop the old index entry for a file that can no longer be indexed""" + try: + await self.search_service.delete_document(document_id_for_path(source_id, file_path)) + except Exception as e: + # Left for the next successful run or clear-stale to pick up + logger.warning(f"Could not remove stale index entry for '{Path(file_path).name}': {e}") + async def _handle_deleted_files( self, source_id: str, current_files: set, indexed_files_map: dict[str, IndexedFile] ) -> int: @@ -511,10 +530,7 @@ async def _handle_deleted_files( # File was deleted logger.info(f"File deleted: {indexed_path}") - # Remove from Meilisearch using hash-based ID - path_hash = hashlib.sha256(indexed_path.encode()).hexdigest()[:12] - doc_id = f"{source_id}--{path_hash}" - await self.search_service.delete_document(doc_id) + await self.search_service.delete_document(document_id_for_path(source_id, indexed_path)) # Remove from database self.db.delete(indexed_file) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 8468a54d..12eff1e2 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -18,7 +18,8 @@ from sqlalchemy.orm import Session, sessionmaker from ..config import settings -from ..models import Source +from ..models import Source, _utcnow +from ..services.audit_retention import purge_old_audit_events from ..services.indexer import IndexingService # noqa: F401 - legacy patch target from ..services.scan_dispatcher import ( AgentUnavailableError, @@ -235,6 +236,9 @@ def __init__(self, engine): self._session_factory = sessionmaker(bind=engine) def start(self): + # Runs with the scheduler off too, so retention still applies after a restart. + self._purge_audit_log() + if not settings.scheduler_enabled: logger.info("Scheduler disabled via SCHEDULER_ENABLED=false") return @@ -246,6 +250,14 @@ def start(self): self.scheduler.start() logger.info(f"Scheduler started (timezone={settings.schedule_timezone})") + self.scheduler.add_job( + func=self._purge_audit_log, + trigger=build_cron_trigger("17 3 * * *"), + id="audit-retention", + name="Delete old audit log entries", + replace_existing=True, + ) + self._sync_all_jobs() except Exception as e: logger.warning(f"Failed to start scheduler: {e}") @@ -256,6 +268,17 @@ def shutdown(self): self.scheduler.shutdown(wait=False) logger.info("Scheduler shut down") + def _purge_audit_log(self, now: datetime | None = None): + """Drop audit entries past the retention setting. Errors are logged, never raised.""" + db = self._session_factory() + try: + removed = purge_old_audit_events(db, now or _utcnow()) + logger.info(f"Audit log retention removed {removed} entries") + except Exception: + logger.exception("Audit log retention failed") + finally: + db.close() + def _sync_all_jobs(self): """Load schedules from DB and sync with APScheduler state.""" db = self._session_factory() diff --git a/backend/app/services/search.py b/backend/app/services/search.py index 579bea61..49aceafa 100644 --- a/backend/app/services/search.py +++ b/backend/app/services/search.py @@ -23,6 +23,11 @@ FILTERABLE_FIELDS = ["source_id", "type", "extension", "modified_at"] SORTABLE_FIELDS = ["modified_at", "size_bytes", "basename"] +# Health probes must answer fast enough for the container healthcheck (10s). +HEALTH_TIMEOUT_SECONDS = 5 +# Searches, task polling and batch indexing requests share this budget. +REQUEST_TIMEOUT_SECONDS = 30 + class MeilisearchService: """ @@ -34,6 +39,7 @@ class MeilisearchService: def __init__(self): """Initialize Meilisearch client""" self.client: Client | None = None + self.health_client: Client | None = None self.index: Index | None = None def connect(self) -> bool: @@ -44,7 +50,12 @@ def connect(self) -> bool: bool: True if connection successful """ try: - self.client = Client(settings.meili_url, settings.meili_master_key) + self.client = Client( + settings.meili_url, settings.meili_master_key, timeout=REQUEST_TIMEOUT_SECONDS + ) + self.health_client = Client( + settings.meili_url, settings.meili_master_key, timeout=HEALTH_TIMEOUT_SECONDS + ) # Test connection self.client.health() @@ -105,11 +116,11 @@ def health_check(self) -> dict[str, Any]: dict: Health status information """ try: - if not self.client: + if not self.health_client: return {"status": "disconnected", "error": "Client not initialized"} - health = self.client.health() - stats = self.index.get_stats() if self.index else None + health = self.health_client.health() + stats = self.health_client.index(INDEX_NAME).get_stats() if self.index else None # Handle both dict and object responses from meilisearch client health_status = ( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f1f55d51..c182178a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "APScheduler>=3.10.0,<4.0.0", ] +[project.scripts] +onesearch-admin = "app.admin_cli:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 84aef782..34486ae1 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,12 +17,39 @@ 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 +@pytest.fixture(autouse=True) +def generated_session_secret_in_tmp(tmp_path_factory): + """Keep the generated session key out of /app/data during tests.""" + import app.api.auth as auth_module + # Not tmp_path: some tests check their tmp dir is empty after cleanup. + secret_path = tmp_path_factory.mktemp("secret") / "session_secret" + # Own MonkeyPatch, so a test calling monkeypatch.undo() doesn't swap the key mid-test. + with pytest.MonkeyPatch.context() as mp: + mp.setattr(auth_module, "_session_secret_path", lambda: secret_path) + mp.setattr(auth_module, "_generated_secret", None) + yield + + +@pytest.fixture(autouse=True) +def _no_startup_purge_on_the_configured_db(monkeypatch): + # The app's own scheduler uses DATABASE_URL, not the test database, so keep + # its startup audit purge from deleting rows in whatever database that is. + import app.main as main + from app.services.scheduler import SchedulerService + + class TestScheduler(SchedulerService): + def _purge_audit_log(self, now=None): + return None + + monkeypatch.setattr(main, "SchedulerService", TestScheduler) + + # In-memory SQLite for tests SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" @@ -51,6 +79,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 +99,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 +112,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 +144,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/route_helpers.py b/backend/tests/route_helpers.py new file mode 100644 index 00000000..eaf4cc95 --- /dev/null +++ b/backend/tests/route_helpers.py @@ -0,0 +1,22 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Route listings by access level, for the tests that check who may call what.""" + +from fastapi.routing import APIRoute + +from app.api.access import AccessLevel, access_level_of +from app.main import app + + +def api_routes() -> list[APIRoute]: + return [route for route in app.routes if isinstance(route, APIRoute)] + + +def routes_at(*levels: AccessLevel) -> list[tuple[str, str]]: + return [ + (method, route.path) + for route in api_routes() + if access_level_of(route) in levels + for method in sorted(route.methods) + ] diff --git a/backend/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py new file mode 100644 index 00000000..6609b254 --- /dev/null +++ b/backend/tests/test_admin_cli.py @@ -0,0 +1,188 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from app.api.auth import hash_password, rate_limit_store +from app.db.database import get_db +from app.main import app +from app.models import ROLE_ADMIN, ROLE_MEMBER, AuditEvent, User + +BACKEND = Path(__file__).resolve().parents[1] +OLD_PASSWORD = "old-password-123" + + +@pytest.fixture(autouse=True) +def clear_rate_limit(): + rate_limit_store.clear() + yield + rate_limit_store.clear() + + +@pytest.fixture +def database(tmp_path): + path = tmp_path / "admin.db" + environment = os.environ | {"DATABASE_URL": f"sqlite:///{path.as_posix()}"} + subprocess.run( + [sys.executable, "-m", "alembic", "-c", "alembic.ini", "upgrade", "head"], + cwd=BACKEND, + env=environment, + check=True, + ) + engine = create_engine(f"sqlite:///{path.as_posix()}", connect_args={"check_same_thread": False}) + session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + with session_factory() as db: + db.add_all([ + User(username="alice", password_hash=hash_password(OLD_PASSWORD), role=ROLE_MEMBER), + User(username="root", password_hash=hash_password(OLD_PASSWORD), role=ROLE_ADMIN), + ]) + db.commit() + + def override_get_db(): + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + yield path, session_factory + app.dependency_overrides.clear() + engine.dispose() + + +def run_admin(database_path: Path, *arguments: str) -> subprocess.CompletedProcess: + environment = os.environ | {"DATABASE_URL": f"sqlite:///{database_path.as_posix()}"} + return subprocess.run( + [sys.executable, "-m", "app.admin_cli", *arguments], + cwd=BACKEND, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def login(client: TestClient, username: str, password: str): + return client.post("/api/auth/login", json={"username": username, "password": password}) + + +def temporary_password_from(stdout: str, username: str) -> str: + prefix = f"Temporary password for {username}: " + line = next(line for line in stdout.splitlines() if line.startswith(prefix)) + return line[len(prefix):] + + +@pytest.mark.parametrize("username", ["alice", "root"]) +def test_reset_password_issues_a_working_temporary_password_and_ends_old_sessions(database, username): + database_path, session_factory = database + client = TestClient(app) + old_token = login(client, username, OLD_PASSWORD).json()["access_token"] + + result = run_admin(database_path, "reset-password", username) + + assert result.returncode == 0, result.stderr + temporary = temporary_password_from(result.stdout, username) + assert len(temporary) == 16 + assert "must change" in result.stdout + + headers = {"Authorization": f"Bearer {old_token}"} + assert client.get("/api/auth/me", headers=headers).status_code == 401 + assert login(client, username, OLD_PASSWORD).status_code == 401 + + signed_in = login(client, username, temporary) + assert signed_in.status_code == 200 + me = client.get( + "/api/auth/me", headers={"Authorization": f"Bearer {signed_in.json()['access_token']}"} + ) + assert me.json()["must_change_password"] is True + assert me.json()["username"] == username + + with session_factory() as db: + user = db.execute(select(User).where(User.username == username)).scalar_one() + assert user.must_change_password is True + + +def test_reset_password_is_logged_as_done_from_the_command_line(database): + database_path, session_factory = database + + result = run_admin(database_path, "reset-password", "alice") + + assert result.returncode == 0, result.stderr + with session_factory() as db: + event = db.execute(select(AuditEvent).where(AuditEvent.action == "user.password_reset")).scalar_one() + assert event.actor_user_id is None + assert event.actor_username == "command line" + assert event.target_label == "alice" + assert event.target_type == "user" + + +def test_reset_password_for_unknown_user_lists_the_usernames(database): + database_path, _ = database + + result = run_admin(database_path, "reset-password", "nobody") + + assert result.returncode == 1 + assert result.stdout == "" + assert result.stderr.strip() == "No user named nobody. Users: alice, root" + + +def test_reset_password_for_unknown_user_leaves_accounts_alone(database): + database_path, session_factory = database + client = TestClient(app) + + run_admin(database_path, "reset-password", "nobody") + + assert login(client, "alice", OLD_PASSWORD).status_code == 200 + with session_factory() as db: + assert db.execute(select(User).where(User.username == "alice")).scalar_one().must_change_password is False + + +def test_drops_to_the_app_user_when_run_as_root(monkeypatch): + import types + + from app import admin_cli + + calls = [] + monkeypatch.setattr(admin_cli.os, "geteuid", lambda: 0) + monkeypatch.setattr( + admin_cli.pwd, "getpwnam", lambda name: types.SimpleNamespace(pw_uid=1000, pw_gid=1001) + ) + monkeypatch.setattr(admin_cli.os, "setgroups", lambda groups: calls.append(("groups", groups))) + monkeypatch.setattr(admin_cli.os, "setgid", lambda gid: calls.append(("gid", gid))) + monkeypatch.setattr(admin_cli.os, "setuid", lambda uid: calls.append(("uid", uid))) + + admin_cli.drop_root() + + assert calls == [("groups", []), ("gid", 1001), ("uid", 1000)] + + +def test_leaves_privileges_alone_when_not_root(monkeypatch): + from app import admin_cli + + monkeypatch.setattr(admin_cli.os, "geteuid", lambda: 1000) + monkeypatch.setattr(admin_cli.os, "setuid", lambda uid: pytest.fail("setuid called")) + + admin_cli.drop_root() + + +def test_stays_root_when_there_is_no_app_user(monkeypatch): + from app import admin_cli + + monkeypatch.setattr(admin_cli.os, "geteuid", lambda: 0) + + def missing(name): + raise KeyError(name) + + monkeypatch.setattr(admin_cli.pwd, "getpwnam", missing) + monkeypatch.setattr(admin_cli.os, "setuid", lambda uid: pytest.fail("setuid called")) + + admin_cli.drop_root() diff --git a/backend/tests/test_admin_tokens_api.py b/backend/tests/test_admin_tokens_api.py new file mode 100644 index 00000000..44e2ce0a --- /dev/null +++ b/backend/tests/test_admin_tokens_api.py @@ -0,0 +1,159 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import pytest + +from app.api import auth as auth_module + + +@pytest.fixture(autouse=True) +def clear_rate_limit(): + auth_module.rate_limit_store.clear() + yield + auth_module.rate_limit_store.clear() + + +@pytest.fixture +def search_stub(monkeypatch): + async def search(**_kwargs): + return {"hits": [], "estimatedTotalHits": 0, "processingTimeMs": 1} + + monkeypatch.setattr("app.api.search.meili_service.search", search) + + +def _as(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _create_token(client, name="laptop"): + response = client.post("/api/auth/tokens", json={"name": name}) + assert response.status_code == 201 + return response.json() + + +def _search(client, token: str): + return client.post("/api/search", json={"q": "invoice"}, headers=_as(token)) + + +def _list(client, user_id: int): + return client.get(f"/api/users/{user_id}/tokens") + + +def test_admin_lists_only_that_users_tokens_with_status_and_no_secrets(client, member_user, member_client): + _create_token(client, name="admin laptop") + created = _create_token(member_client, name="member laptop") + revoked = _create_token(member_client, name="old laptop") + assert client.delete(f"/api/users/{member_user.id}/tokens/{revoked['id']}").status_code == 204 + + response = _list(client, member_user.id) + + assert response.status_code == 200 + rows = response.json() + assert [(row["name"], row["status"]) for row in rows] == [("old laptop", "revoked"), ("member laptop", "active")] + assert rows == member_client.get("/api/auth/tokens").json() + assert rows[1]["id"] == created["id"] + assert rows[0]["revoked_at"] is not None + assert "token" not in rows[0] + assert created["token"] not in response.text + + +def test_admin_list_for_unknown_user_is_404(client): + response = client.get("/api/users/9999/tokens") + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_admin_revokes_a_members_token_and_it_gets_401(client, member_user, member_client, search_stub): + created = _create_token(member_client) + assert _search(member_client, created["token"]).status_code == 200 + + revoke = client.delete(f"/api/users/{member_user.id}/tokens/{created['id']}") + + assert revoke.status_code == 204 + response = _search(member_client, created["token"]) + assert response.status_code == 401 + assert response.json()["detail"] == "API token revoked" + assert [row["status"] for row in _list(client, member_user.id).json()] == ["revoked"] + + +def test_revoke_with_the_wrong_user_is_404_and_leaves_the_token_alone( + client, test_user, member_client, search_stub +): + created = _create_token(member_client) + + response = client.delete(f"/api/users/{test_user['user'].id}/tokens/{created['id']}") + + assert response.status_code == 404 + assert response.json()["detail"] == "API token not found" + assert _search(member_client, created["token"]).status_code == 200 + + +def test_revoking_twice_or_an_unknown_token_is_404(client, member_user, member_client): + created = _create_token(member_client) + path = f"/api/users/{member_user.id}/tokens/{created['id']}" + + assert client.delete(path).status_code == 204 + assert client.delete(path).status_code == 404 + assert client.delete(f"/api/users/{member_user.id}/tokens/9999").status_code == 404 + + +def test_admin_revoke_does_not_touch_the_admins_own_tokens(client, member_user, member_client, search_stub): + own = _create_token(client, name="admin laptop") + member = _create_token(member_client) + + client.delete(f"/api/users/{member_user.id}/tokens/{own['id']}") + + assert _search(client, own["token"]).status_code == 200 + assert _search(member_client, member["token"]).status_code == 200 + + +def test_disabled_owner_tokens_fail_and_work_again_after_enable( + client, member_user, member_client, search_stub +): + created = _create_token(member_client) + + client.patch(f"/api/users/{member_user.id}", json={"is_active": False}) + assert _search(client, created["token"]).status_code == 401 + + client.patch(f"/api/users/{member_user.id}", json={"is_active": True}) + assert _search(client, created["token"]).status_code == 200 + + +def test_token_revoked_while_disabled_stays_dead_after_enable( + client, member_user, member_client, search_stub +): + created = _create_token(member_client) + client.patch(f"/api/users/{member_user.id}", json={"is_active": False}) + assert client.delete(f"/api/users/{member_user.id}/tokens/{created['id']}").status_code == 204 + + client.patch(f"/api/users/{member_user.id}", json={"is_active": True}) + + response = _search(client, created["token"]) + assert response.status_code == 401 + assert response.json()["detail"] == "API token revoked" + + +def test_demoted_owner_tokens_keep_searching(client, member_user, search_stub): + client.patch(f"/api/users/{member_user.id}", json={"role": "admin"}) + admin_session = client.post( + "/api/auth/login", json={"username": "member", "password": "memberpass"} + ).json()["access_token"] + created = client.post( + "/api/auth/tokens", json={"name": "scripts"}, headers=_as(admin_session) + ).json() + + demoted = client.patch(f"/api/users/{member_user.id}", json={"role": "member"}) + + assert demoted.status_code == 200 + assert _search(client, created["token"]).status_code == 200 + + +def test_members_get_403_for_admin_token_routes(client, member_user, member_client): + created = _create_token(member_client) + + assert member_client.get(f"/api/users/{member_user.id}/tokens").status_code == 403 + revoke = member_client.delete(f"/api/users/{member_user.id}/tokens/{created['id']}") + + assert revoke.status_code == 403 + assert _list(client, member_user.id).json()[0]["id"] == created["id"] diff --git a/backend/tests/test_agent_models.py b/backend/tests/test_agent_models.py index ead5f28b..0d7958d8 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 == "7d2b9e4f1a63" 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 == "7d2b9e4f1a63" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9493fcc9..ab56738f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -26,7 +26,8 @@ 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 # In-memory SQLite database for testing @@ -71,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}"}} @@ -168,6 +170,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_api_tokens.py b/backend/tests/test_api_tokens.py new file mode 100644 index 00000000..29d1934d --- /dev/null +++ b/backend/tests/test_api_tokens.py @@ -0,0 +1,387 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import sqlite3 +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import event, update +from sqlalchemy.exc import OperationalError + +from app.api import auth as auth_module +from app.api.auth import ending_sessions_values +from app.models import ROLE_MEMBER, ApiToken, User +from app.services.agent_auth import hash_token + + +@pytest.fixture(autouse=True) +def clear_rate_limit(): + auth_module.rate_limit_store.clear() + yield + auth_module.rate_limit_store.clear() + + +@pytest.fixture +def search_stub(monkeypatch): + async def search(**_kwargs): + return {"hits": [], "estimatedTotalHits": 0, "processingTimeMs": 1} + + monkeypatch.setattr("app.api.search.meili_service.search", search) + + +@pytest.fixture +def document_stub(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) + + +def _as(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _create_token(client, name="laptop", expires_in_days=None): + response = client.post("/api/auth/tokens", json={"name": name, "expires_in_days": expires_in_days}) + assert response.status_code == 201 + return response.json() + + +def _search(client, token: str): + return client.post("/api/search", json={"q": "invoice"}, headers=_as(token)) + + +def test_create_returns_secret_once_and_list_never_shows_it(client): + created = _create_token(client) + + assert created["token"].startswith("os_") + assert created["display_prefix"] == created["token"][:7] + assert created["scope"] == "search" + assert created["revoked"] is False + assert created["expires_at"] is None + assert created["last_used_at"] is None + + listed = client.get("/api/auth/tokens") + assert listed.status_code == 200 + assert len(listed.json()) == 1 + row = listed.json()[0] + assert "token" not in row + assert created["token"] not in listed.text + assert row["id"] == created["id"] + assert row["name"] == "laptop" + + +def test_only_the_hash_is_stored(client, db_session): + created = _create_token(client) + + row = db_session.query(ApiToken).one() + assert row.token_hash == hash_token(created["token"]) + assert row.token_hash != created["token"] + assert created["token"] not in {row.token_hash, row.display_prefix, row.name} + + +@pytest.mark.parametrize( + ("days", "expected_days"), + [(None, None), (30, 30), (90, 90), (365, 365)], +) +def test_expiry_is_never_or_a_fixed_number_of_days(client, days, expected_days): + created = _create_token(client, expires_in_days=days) + + if expected_days is None: + assert created["expires_at"] is None + return + expires_at = datetime.fromisoformat(created["expires_at"]).replace(tzinfo=None) + now = datetime.now(UTC).replace(tzinfo=None) + assert abs((expires_at - (now + timedelta(days=expected_days))).total_seconds()) < 60 + + +@pytest.mark.parametrize("days", [1, 7, 60, 0]) +def test_other_expiry_choices_are_rejected(client, days): + response = client.post("/api/auth/tokens", json={"name": "x", "expires_in_days": days}) + + assert response.status_code == 422 + + +@pytest.mark.parametrize("name", ["", " ", "x" * 81]) +def test_name_is_required_and_short(client, name): + response = client.post("/api/auth/tokens", json={"name": name}) + + assert response.status_code == 422 + + +def test_name_is_trimmed(client): + created = _create_token(client, name=" laptop ") + + assert created["name"] == "laptop" + + +def test_token_searches_and_opens_documents(client, search_stub, document_stub): + token = _create_token(client)["token"] + + assert _search(client, token).status_code == 200 + document = client.get("/api/documents/photos--abc", headers=_as(token)) + assert document.status_code == 200 + assert document.json()["id"] == "photos--abc" + + +def test_token_reaches_preview_and_download_link_routes(client, monkeypatch): + async def missing_document(document_id): + return None + + monkeypatch.setattr("app.api.preview.meili_service.get_document", missing_document) + token = _create_token(client)["token"] + + preview = client.get("/api/documents/missing/preview", headers=_as(token)) + link = client.post("/api/documents/missing/download-link", headers=_as(token)) + + assert preview.status_code == 404 + assert preview.json()["detail"]["code"] == "document_not_found" + assert link.status_code == 404 + assert link.json()["detail"]["code"] == "document_not_found" + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/api/auth/me"), + ("GET", "/api/auth/tokens"), + ("POST", "/api/auth/tokens"), + ("DELETE", "/api/auth/tokens/1"), + ("POST", "/api/auth/change-password"), + ("POST", "/api/auth/logout"), + ("GET", "/api/users"), + ("GET", "/api/sources"), + ("GET", "/api/settings"), + ], +) +def test_token_gets_403_outside_search(client, method, path): + token = _create_token(client)["token"] + + response = client.request(method, path, headers=_as(token), json={}) + + assert response.status_code == 403 + assert response.json()["detail"] == "API tokens can only be used for search" + + +def test_token_cannot_make_another_token(client): + token = _create_token(client)["token"] + + response = client.post("/api/auth/tokens", json={"name": "child"}, headers=_as(token)) + + assert response.status_code == 403 + assert [row["name"] for row in client.get("/api/auth/tokens").json()] == ["laptop"] + + +def test_revoked_token_is_401_and_drops_off_the_list(client, search_stub): + created = _create_token(client) + assert _search(client, created["token"]).status_code == 200 + + revoke = client.delete(f"/api/auth/tokens/{created['id']}") + + assert revoke.status_code == 204 + response = _search(client, created["token"]) + assert response.status_code == 401 + assert response.json()["detail"] == "API token revoked" + rows = client.get("/api/auth/tokens").json() + assert [(row["id"], row["status"], row["revoked"]) for row in rows] == [(created["id"], "revoked", True)] + assert rows[0]["revoked_at"] is not None + assert client.delete(f"/api/auth/tokens/{created['id']}").status_code == 404 + + +def test_list_keeps_revoked_and_expired_tokens_newest_first(client, db_session): + active = _create_token(client, name="backup-script") + revoked = _create_token(client, name="old-laptop") + expired = _create_token(client, name="ci-job", expires_in_days=30) + assert client.delete(f"/api/auth/tokens/{revoked['id']}").status_code == 204 + db_session.execute( + update(ApiToken) + .where(ApiToken.id == expired["id"]) + .values(expires_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)) + ) + db_session.commit() + + rows = client.get("/api/auth/tokens").json() + + assert [(row["name"], row["status"]) for row in rows] == [ + ("ci-job", "expired"), + ("old-laptop", "revoked"), + ("backup-script", "active"), + ] + assert rows[0]["revoked_at"] is None + assert rows[0]["revoked"] is False + assert rows[2]["id"] == active["id"] + assert rows[2]["revoked_at"] is None + + +def test_revoked_and_expired_token_reports_revoked(client, db_session): + created = _create_token(client, expires_in_days=30) + assert client.delete(f"/api/auth/tokens/{created['id']}").status_code == 204 + db_session.execute( + update(ApiToken) + .where(ApiToken.id == created["id"]) + .values(expires_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)) + ) + db_session.commit() + + assert client.get("/api/auth/tokens").json()[0]["status"] == "revoked" + + +def test_expired_token_is_401(client, db_session, search_stub): + created = _create_token(client, expires_in_days=30) + db_session.execute( + update(ApiToken) + .where(ApiToken.id == created["id"]) + .values(expires_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=1)) + ) + db_session.commit() + + response = _search(client, created["token"]) + + assert response.status_code == 401 + assert response.json()["detail"] == "API token expired" + + +def test_unknown_token_is_401(client, search_stub): + response = _search(client, "os_not-a-real-token") + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid API token" + + +def test_user_cannot_revoke_someone_elses_token(client, member_client): + member_token = _create_token(member_client, name="member laptop") + + response = client.delete(f"/api/auth/tokens/{member_token['id']}") + + assert response.status_code == 404 + assert [row["name"] for row in member_client.get("/api/auth/tokens").json()] == ["member laptop"] + + +def test_lists_only_your_own_tokens(client, member_client): + _create_token(client, name="admin one") + _create_token(member_client, name="member one") + + assert [row["name"] for row in client.get("/api/auth/tokens").json()] == ["admin one"] + assert [row["name"] for row in member_client.get("/api/auth/tokens").json()] == ["member one"] + + +def test_disabled_owner_is_401(client, db_session, member_user, member_client, search_stub): + token = _create_token(member_client)["token"] + member_user.is_active = False + db_session.commit() + + response = _search(client, token) + + assert response.status_code == 401 + assert response.json()["detail"] == "User not found or inactive" + + +def test_owner_who_must_change_password_gets_403(client, db_session, search_stub): + token = _create_token(client)["token"] + user = db_session.query(User).filter_by(username="testuser").one() + user.must_change_password = True + db_session.commit() + + response = _search(client, token) + + assert response.status_code == 403 + assert response.json()["detail"] == "Password change required" + + +def test_demoted_owner_keeps_search(client, db_session, search_stub): + token = _create_token(client)["token"] + user = db_session.query(User).filter_by(username="testuser").one() + user.role = ROLE_MEMBER + db_session.commit() + + assert _search(client, token).status_code == 200 + assert user.role == ROLE_MEMBER + + +def test_password_change_does_not_revoke_tokens(client, search_stub): + token = _create_token(client)["token"] + + changed = client.post( + "/api/auth/change-password", + json={"current_password": "testpass", "new_password": "testpass2"}, + ) + + assert changed.status_code == 200 + assert _search(client, token).status_code == 200 + + +def test_session_version_bump_does_not_revoke_tokens(client, db_session, search_stub): + token = _create_token(client)["token"] + db_session.execute(update(User).where(User.username == "testuser").values(**ending_sessions_values())) + db_session.commit() + + assert _search(client, token).status_code == 200 + + +def test_last_used_is_written_at_most_once_a_minute(client, db_session, search_stub): + created = _create_token(client) + token_row = db_session.get(ApiToken, created["id"]) + assert token_row.last_used_at is None + + assert _search(client, created["token"]).status_code == 200 + db_session.expire_all() + first_use = db_session.get(ApiToken, created["id"]).last_used_at + assert first_use is not None + + assert _search(client, created["token"]).status_code == 200 + db_session.expire_all() + assert db_session.get(ApiToken, created["id"]).last_used_at == first_use + + db_session.execute( + update(ApiToken) + .where(ApiToken.id == created["id"]) + .values(last_used_at=first_use - timedelta(minutes=2)) + ) + db_session.commit() + assert _search(client, created["token"]).status_code == 200 + db_session.expire_all() + assert db_session.get(ApiToken, created["id"]).last_used_at > first_use - timedelta(minutes=1) + + +def test_deleting_a_user_deletes_their_tokens(client, db_session, member_user, member_client, search_stub): + token = _create_token(member_client)["token"] + assert db_session.query(ApiToken).filter_by(user_id=member_user.id).count() == 1 + + response = client.delete(f"/api/users/{member_user.id}") + + assert response.status_code == 204 + assert db_session.query(ApiToken).filter_by(user_id=member_user.id).count() == 0 + assert _search(client, token).status_code == 401 + + + +def test_search_still_works_when_recording_last_used_hits_a_locked_database( + client, db_engine, search_stub +): + token = _create_token(client)["token"] + + def locked(conn, cursor, statement, parameters, context, executemany): + if statement.lstrip().upper().startswith("UPDATE API_TOKENS"): + raise OperationalError(statement, parameters, sqlite3.OperationalError("database is locked")) + + event.listen(db_engine, "before_cursor_execute", locked) + try: + response = _search(client, token) + finally: + event.remove(db_engine, "before_cursor_execute", locked) + + assert response.status_code == 200 diff --git a/backend/tests/test_api_tokens_migration.py b/backend/tests/test_api_tokens_migration.py new file mode 100644 index 00000000..d6eb5bc7 --- /dev/null +++ b/backend/tests/test_api_tokens_migration.py @@ -0,0 +1,75 @@ +# 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_api_tokens_migration_upgrades_and_downgrades(tmp_path): + backend = Path(__file__).resolve().parents[1] + database = tmp_path / "tokens.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, + ) + + def table_columns(): + with sqlite3.connect(database) as connection: + return {info[1]: info for info in connection.execute("PRAGMA table_info(api_tokens)")} + + alembic("upgrade", "5b9e0c3d7f16") + with sqlite3.connect(database) as connection: + connection.execute( + "INSERT INTO users (username, password_hash, is_active, role, created_at, updated_at) " + "VALUES ('legacy', 'hash', 1, 'admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ) + connection.commit() + + alembic("upgrade", "head") + alembic("check") + columns = table_columns() + assert set(columns) == { + "id", + "user_id", + "name", + "scope", + "token_hash", + "display_prefix", + "created_at", + "expires_at", + "last_used_at", + "revoked_at", + } + assert columns["user_id"][3] == 1 # NOT NULL + assert columns["expires_at"][3] == 0 + with sqlite3.connect(database) as connection: + connection.execute("PRAGMA foreign_keys=ON") + user_id = connection.execute("SELECT id FROM users WHERE username='legacy'").fetchone()[0] + connection.execute( + "INSERT INTO api_tokens (user_id, name, token_hash, display_prefix, created_at) " + "VALUES (?, 'laptop', 'hash-1', 'os_AbCd', CURRENT_TIMESTAMP)", + (user_id,), + ) + connection.commit() + scope = connection.execute("SELECT scope FROM api_tokens").fetchone()[0] + assert scope == "search" + connection.execute("DELETE FROM users WHERE id=?", (user_id,)) + remaining = connection.execute("SELECT COUNT(*) FROM api_tokens").fetchone()[0] + assert remaining == 0 + + alembic("downgrade", "5b9e0c3d7f16") + with sqlite3.connect(database) as connection: + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "api_tokens" not in tables + assert "users" in tables + + alembic("upgrade", "head") + assert "token_hash" in table_columns() diff --git a/backend/tests/test_app_settings.py b/backend/tests/test_app_settings.py index 3abb9492..477668c7 100644 --- a/backend/tests/test_app_settings.py +++ b/backend/tests/test_app_settings.py @@ -4,6 +4,8 @@ """ Tests for backend-managed app settings. """ +import pytest + from app.models import AppSetting @@ -30,6 +32,7 @@ def test_get_settings_returns_defaults(client): "long_text_pagination_threshold_chars": 20000, "default_scan_schedule": None, "remote_agents_enabled": False, + "audit_retention_days": 90, } @@ -60,6 +63,7 @@ def test_update_settings_persists_values(client): **update, "default_scan_schedule": None, "remote_agents_enabled": False, + "audit_retention_days": 90, } assert response.json() == expected_response @@ -139,6 +143,18 @@ def test_update_settings_rejects_invalid_limit_values(client): assert response.status_code == 422 +@pytest.mark.parametrize("days", [0, 3651]) +def test_update_rejects_audit_retention_outside_1_to_3650(client, days): + assert client.put("/api/settings", json={"audit_retention_days": days}).status_code == 422 + + +def test_update_persists_audit_retention(client): + response = client.put("/api/settings", json={"audit_retention_days": 365}) + + assert response.status_code == 200 + assert response.json()["audit_retention_days"] == 365 + + def test_settings_requires_auth(client): client.headers.pop("Authorization", None) diff --git a/backend/tests/test_audit_events.py b/backend/tests/test_audit_events.py new file mode 100644 index 00000000..18461618 --- /dev/null +++ b/backend/tests/test_audit_events.py @@ -0,0 +1,364 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.api import auth as auth_module +from app.config import settings +from app.models import AuditEvent, Source + + +@pytest.fixture(autouse=True) +def clear_rate_limit() -> None: + auth_module.rate_limit_store.clear() + yield + auth_module.rate_limit_store.clear() + + +@pytest.fixture(autouse=True) +def no_path_restriction(monkeypatch) -> None: + monkeypatch.setattr(settings, "allowed_source_paths", "") + + +@pytest.fixture +def source_dir(tmp_path) -> str: + (tmp_path / "notes.txt").write_text("hello") + return str(tmp_path) + + +@pytest.fixture +def stored_source(db_session: Session, source_dir: str) -> Source: + source = Source( + id="docs", name="Docs", root_path=source_dir, include_patterns="[]", exclude_patterns="[]" + ) + db_session.add(source) + db_session.commit() + return source + + +class FakeStats: + successful = failed = skipped = 0 + + def to_dict(self) -> dict[str, int]: + return {"successful": 0, "failed": 0, "skipped": 0} + + +@pytest.fixture +def fake_reindex(monkeypatch) -> None: + class FakeDispatcher: + def __init__(self, *args, **kwargs): + pass + + async def dispatch(self, *args, **kwargs): + return FakeStats() + + monkeypatch.setattr("app.api.sources.ScanDispatcher", FakeDispatcher) + + +def _events(db_session: Session, action: str) -> list[AuditEvent]: + return db_session.query(AuditEvent).filter_by(action=action).order_by(AuditEvent.id).all() + + +def _only(db_session: Session, action: str) -> AuditEvent: + rows = _events(db_session, action) + assert len(rows) == 1, f"expected one {action} entry, found {len(rows)}" + return rows[0] + + +def _count_prefix(db_session: Session, prefix: str) -> int: + return sum(1 for row in db_session.query(AuditEvent).all() if row.action.startswith(prefix)) + + +def _enable_agents(client: TestClient) -> None: + assert client.put("/api/settings", json={"remote_agents_enabled": True}).status_code == 200 + + +def _enroll_agent(client: TestClient, db_session: Session, name: str = "office-pc") -> dict: + code = client.post("/api/agents/enrollments").json()["code"] + response = client.post( + "/api/agent/v1/enroll", + json={ + "protocol_version": 3, + "enrollment_token": code, + "agent_name": name, + "agent_version": "1.4.0", + "platform": "windows-amd64", + "allowed_roots": [{"root_id": "documents", "path": "C:/Users/Ada/Documents"}], + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +# Tokens + +def test_token_create_is_logged_once_with_name_and_expiry(client, db_session): + response = client.post("/api/auth/tokens", json={"name": "ci", "expires_in_days": 30}) + + assert response.status_code == 201 + event = _only(db_session, "token.created") + assert event.actor_username == "testuser" + assert event.target_type == "user" + assert event.target_label == "testuser" + assert event.detail_dict == {"name": "ci", "expires_in_days": 30} + + +def test_own_token_revoke_is_logged(client, db_session): + token_id = client.post("/api/auth/tokens", json={"name": "ci"}).json()["id"] + + assert client.delete(f"/api/auth/tokens/{token_id}").status_code == 204 + + event = _only(db_session, "token.revoked") + assert event.actor_username == "testuser" + assert event.target_label == "testuser" + assert event.detail_dict == {"name": "ci"} + + +def test_admin_revoking_a_members_token_names_the_owner( + client, member_client, member_user, db_session +): + token_id = member_client.post("/api/auth/tokens", json={"name": "phone"}).json()["id"] + + assert client.delete(f"/api/users/{member_user.id}/tokens/{token_id}").status_code == 204 + + event = _only(db_session, "token.revoked") + assert event.actor_username == "testuser" + assert event.target_id == member_user.id + assert event.target_label == "member" + assert event.detail_dict == {"name": "phone"} + + +def test_failed_token_revoke_writes_nothing(client, db_session): + assert client.delete("/api/auth/tokens/9999").status_code == 404 + + assert _events(db_session, "token.revoked") == [] + + +def test_searches_and_token_uses_are_not_logged(client, db_session, monkeypatch): + async def search(**_kwargs): + return {"hits": [], "estimatedTotalHits": 0, "processingTimeMs": 1} + + monkeypatch.setattr("app.api.search.meili_service.search", search) + token = client.post("/api/auth/tokens", json={"name": "script"}).json()["token"] + db_session.query(AuditEvent).delete() + db_session.commit() + + assert client.post( + "/api/search", json={"q": "notes"}, headers={"Authorization": f"Bearer {token}"} + ).status_code == 200 + assert client.post("/api/search", json={"q": "notes"}).status_code == 200 + + assert db_session.query(AuditEvent).count() == 0 + + +# Sources + +def test_source_create_is_logged_with_its_name(client, db_session, source_dir): + response = client.post("/api/sources", json={"name": "Notes", "root_path": source_dir}) + + assert response.status_code == 201 + event = _only(db_session, "source.created") + assert event.actor_username == "testuser" + assert event.target_type == "source" + assert event.target_label == "Notes" + + +def test_source_update_is_logged_with_the_new_name(client, db_session, stored_source): + response = client.put(f"/api/sources/{stored_source.id}", json={"name": "Documents"}) + + assert response.status_code == 200 + event = _only(db_session, "source.updated") + assert event.target_label == "Documents" + + +def test_source_update_with_no_changes_writes_nothing(client, db_session, stored_source): + assert client.put(f"/api/sources/{stored_source.id}", json={"name": "Docs"}).status_code == 200 + + assert _events(db_session, "source.updated") == [] + + +def test_source_update_records_the_changed_field_names_only(client, db_session, stored_source): + response = client.put( + f"/api/sources/{stored_source.id}", + json={"name": "Documents", "exclude_patterns": ["*.tmp"]}, + ) + + assert response.status_code == 200 + event = _only(db_session, "source.updated") + assert event.detail_dict == {"fields": ["exclude_patterns", "name"]} + assert "*.tmp" not in event.detail + + +def test_source_delete_is_logged_with_its_name(client, db_session, stored_source, meili_spy): + assert client.delete(f"/api/sources/{stored_source.id}").status_code == 204 + + event = _only(db_session, "source.deleted") + assert event.actor_username == "testuser" + assert event.target_label == "Docs" + + +def test_reindex_is_logged_with_the_full_flag(client, db_session, stored_source, fake_reindex): + assert client.post(f"/api/sources/{stored_source.id}/reindex?full=true").status_code == 200 + + event = _only(db_session, "source.reindexed") + assert event.target_label == "Docs" + assert event.detail_dict == {"full": True} + + +def test_failed_source_changes_write_nothing(client, db_session, stored_source, fake_reindex): + assert client.put("/api/sources/missing", json={"name": "x"}).status_code == 404 + assert client.delete("/api/sources/missing").status_code == 404 + assert client.post("/api/sources/missing/reindex").status_code == 404 + assert client.put( + f"/api/sources/{stored_source.id}", + json={ + "schedule_type": "cron", + "scan_schedule": "not a cron", + "use_default_schedule": False, + }, + ).status_code == 400 + + assert _count_prefix(db_session, "source.") == 0 + + +def test_batch_item_that_creates_a_source_is_logged(client, db_session, source_dir, monkeypatch): + payload = { + "id": str(uuid4()), + "location_type": "local", + "common_settings": {"schedule_type": "cron", "scan_schedule": "@daily"}, + "items": [{"id": str(uuid4()), "name": "Folder A", "root_path": source_dir}], + } + assert client.post("/api/sources/batches", json=payload).status_code == 201 + url = f"/api/sources/batches/{payload['id']}/items/{payload['items'][0]['id']}/create" + + assert client.post(url, json={}).status_code == 200 + assert client.post(url, json={}).status_code == 200 + + event = _only(db_session, "source.created") + assert event.actor_username == "testuser" + assert event.target_label == "Folder A" + + +# Agents + +def test_enrollment_code_is_logged(client, db_session): + _enable_agents(client) + + assert client.post("/api/agents/enrollments").status_code == 201 + + event = _only(db_session, "agent.enrollment_created") + assert event.actor_username == "testuser" + assert event.target_label is None + + +def test_enrollment_code_refused_while_agents_are_off_is_not_logged(client, db_session): + assert client.post("/api/agents/enrollments").status_code == 409 + + assert _events(db_session, "agent.enrollment_created") == [] + + +def test_approve_disable_and_revoke_are_logged_with_the_agent_name(client, db_session): + _enable_agents(client) + agent_id = _enroll_agent(client, db_session)["agent_id"] + + assert client.post(f"/api/agents/{agent_id}/approve").status_code == 200 + assert client.post(f"/api/agents/{agent_id}/disable").status_code == 200 + assert client.post(f"/api/agents/{agent_id}/revoke").status_code == 200 + + for action in ("agent.approved", "agent.disabled", "agent.revoked"): + event = _only(db_session, action) + assert event.actor_username == "testuser" + assert event.target_type == "agent" + assert event.target_label == "office-pc" + + +def test_agent_revoking_itself_is_logged_without_a_user(client, db_session): + _enable_agents(client) + enrolled = _enroll_agent(client, db_session) + agent_headers = {"Authorization": f"Bearer {enrolled['agent_token']}"} + + assert client.post("/api/agent/v1/revoke-self", headers=agent_headers).status_code == 204 + + event = _only(db_session, "agent.revoked") + assert event.actor_user_id is None + assert event.actor_username is None + assert event.target_type == "agent" + assert event.target_label == "office-pc" + assert event.detail_dict == {"by": "agent"} + + +def test_refused_agent_transitions_write_nothing(client, db_session): + _enable_agents(client) + agent_id = _enroll_agent(client, db_session)["agent_id"] + assert client.post(f"/api/agents/{agent_id}/revoke").status_code == 200 + before = _count_prefix(db_session, "agent.") + + assert client.post(f"/api/agents/{agent_id}/approve").status_code == 409 + assert client.post(f"/api/agents/{agent_id}/disable").status_code == 409 + assert client.post(f"/api/agents/{agent_id}/revoke").status_code == 409 + + assert _count_prefix(db_session, "agent.") == before + + +def test_default_processing_mode_change_is_logged_only_when_it_changes(client, db_session): + _enable_agents(client) + agent_id = _enroll_agent(client, db_session)["agent_id"] + + client.patch(f"/api/agents/{agent_id}", json={"default_processing_mode": "on_server"}) + client.patch(f"/api/agents/{agent_id}", json={"default_processing_mode": "on_server"}) + + event = _only(db_session, "agent.updated") + assert event.target_label == "office-pc" + assert event.detail_dict == {"default_processing_mode": "on_server"} + + +# Settings + +def test_settings_change_logs_keys_and_never_values(client, db_session): + response = client.put( + "/api/settings", + json={ + "readable_preview_page_chars": 8123, + "default_scan_schedule": {"schedule_type": "cron", "scan_schedule": "@hourly"}, + }, + ) + + assert response.status_code == 200 + event = _only(db_session, "settings.changed") + assert event.actor_username == "testuser" + assert event.detail_dict == {"keys": ["default_scan_schedule", "readable_preview_page_chars"]} + assert "8123" not in event.detail + assert "hourly" not in event.detail + + +def test_settings_that_do_not_change_are_not_logged(client, db_session): + client.put("/api/settings", json={"show_previews": False}) + db_session.query(AuditEvent).delete() + db_session.commit() + + assert client.put("/api/settings", json={"show_previews": False}).status_code == 200 + + assert _events(db_session, "settings.changed") == [] + + +def test_invalid_settings_are_not_logged(client, db_session): + assert client.put("/api/settings", json={"max_text_file_size_mb": 0}).status_code == 422 + assert client.put("/api/settings", json={"max_preview_size_mb": 10}).status_code == 422 + + assert _events(db_session, "settings.changed") == [] + + +def test_settings_change_to_the_retention_days_is_logged_by_key(client, db_session): + assert client.put("/api/settings", json={"audit_retention_days": 30}).status_code == 200 + + assert _only(db_session, "settings.changed").detail_dict == {"keys": ["audit_retention_days"]} + + +def test_member_cannot_change_settings_so_nothing_is_logged(member_client, db_session): + assert member_client.put("/api/settings", json={"show_previews": False}).status_code == 403 + + assert _events(db_session, "settings.changed") == [] diff --git a/backend/tests/test_audit_log.py b/backend/tests/test_audit_log.py new file mode 100644 index 00000000..4b4f5b9a --- /dev/null +++ b/backend/tests/test_audit_log.py @@ -0,0 +1,453 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import pytest +from fastapi.testclient import TestClient + +from app.api import auth as auth_module +from app.db.database import get_db +from app.main import app +from app.models import AuditEvent, User +from app.services import audit + +TEMPORARY = "temporary-pass-1" +FORWARDED = {"X-Forwarded-For": "203.0.113.7"} +# Forwarded headers are only read from loopback, which is where the bundled nginx connects from. +BEHIND_NGINX = ("127.0.0.1", 50000) + + +@pytest.fixture(autouse=True) +def clear_rate_limit(): + auth_module.rate_limit_store.clear() + yield + auth_module.rate_limit_store.clear() + + +@pytest.fixture +def signed_out(db_session): + """Anonymous client on the same session as `client`, for the public routes.""" + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app, client=BEHIND_NGINX) as anonymous: + yield anonymous + app.dependency_overrides.clear() + + +def _as(token): + return {"Authorization": f"Bearer {token}"} + + +def _sign_in(username, password, headers=None): + response = TestClient(app, client=BEHIND_NGINX).post( + "/api/auth/login", json={"username": username, "password": password}, headers=headers or {} + ) + assert response.status_code == 200, response.text + return response.json()["access_token"] + + +def _events(client, **params): + response = client.get("/api/audit", params=params) + assert response.status_code == 200, response.text + return response.json() + + +def _latest(client, action, **params): + matches = [event for event in _events(client, **params)["events"] if event["action"] == action] + assert matches, f"no {action} entry" + return matches[0] + + +def _create_member(client, username="alice", password=TEMPORARY): + response = client.post( + "/api/users", json={"username": username, "password": password, "role": "member"} + ) + assert response.status_code == 201, response.text + return response.json() + + +def test_admin_creates_user_is_logged(client): + created = _create_member(client) + + event = _latest(client, "user.created") + + assert event["actor_username"] == "testuser" + assert event["actor_user_id"] is not None + assert event["target_type"] == "user" + assert event["target_id"] == created["id"] + assert event["target_label"] == "alice" + assert event["detail"] == {"role": "member"} + + +def test_role_change_records_old_and_new_role(client): + created = _create_member(client) + + response = client.patch(f"/api/users/{created['id']}", json={"role": "admin"}) + + assert response.status_code == 200 + event = _latest(client, "user.role_changed") + assert event["actor_username"] == "testuser" + assert event["target_label"] == "alice" + assert event["target_id"] == created["id"] + assert event["detail"] == {"old": "member", "new": "admin"} + + +def test_disable_and_enable_are_logged_separately(client): + created = _create_member(client) + + client.patch(f"/api/users/{created['id']}", json={"is_active": False}) + assert _latest(client, "user.disabled")["target_label"] == "alice" + + client.patch(f"/api/users/{created['id']}", json={"is_active": True}) + assert _latest(client, "user.enabled")["actor_username"] == "testuser" + + +def test_admin_reset_is_logged(client): + created = _create_member(client) + + response = client.post( + f"/api/users/{created['id']}/reset-password", json={"password": "another-temp-1"} + ) + + assert response.status_code == 200 + event = _latest(client, "user.password_reset") + assert event["actor_username"] == "testuser" + assert event["target_label"] == "alice" + + +def test_delete_is_logged(client): + created = _create_member(client) + + assert client.delete(f"/api/users/{created['id']}").status_code == 204 + + event = _latest(client, "user.deleted") + assert event["target_label"] == "alice" + assert event["actor_username"] == "testuser" + + +def test_user_changing_own_password_is_logged_as_them(client): + created = _create_member(client) + member = _sign_in("alice", TEMPORARY) + + response = TestClient(app).post( + "/api/auth/change-password", + json={"current_password": TEMPORARY, "new_password": "alice-new-pass"}, + headers=_as(member), + ) + + assert response.status_code == 200, response.text + event = _latest(client, "user.password_changed") + assert event["actor_username"] == "alice" + assert event["actor_user_id"] == created["id"] + assert event["target_label"] == "alice" + + +def test_successful_sign_in_is_logged_with_ip(client): + created = _create_member(client) + + _sign_in("alice", TEMPORARY, headers=FORWARDED) + + event = _latest(client, "auth.login") + assert event["actor_username"] == "alice" + assert event["actor_user_id"] == created["id"] + assert event["ip"] == "203.0.113.7" + + +def test_failed_sign_in_for_a_known_user(client, signed_out): + created = _create_member(client) + + response = signed_out.post( + "/api/auth/login", json={"username": "alice", "password": "wrong-password"}, headers=FORWARDED + ) + + assert response.status_code == 401 + event = _latest(client, "auth.login_failed") + assert event["actor_user_id"] is None + assert event["actor_username"] is None + assert event["target_id"] == created["id"] + assert event["target_label"] == "alice" + assert event["ip"] == "203.0.113.7" + + +def test_failed_sign_in_for_an_unknown_user_keeps_the_name(client, signed_out): + response = signed_out.post( + "/api/auth/login", json={"username": "ghost", "password": "whatever"}, headers=FORWARDED + ) + + assert response.status_code == 401 + event = _latest(client, "auth.login_failed") + assert event["target_id"] is None + assert event["target_label"] == "ghost" + assert event["ip"] == "203.0.113.7" + + +def test_failed_sign_in_truncates_a_long_name(client, signed_out): + signed_out.post("/api/auth/login", json={"username": "x" * 200, "password": "whatever"}) + + assert _latest(client, "auth.login_failed")["target_label"] == "x" * 50 + + +def test_sign_in_to_a_disabled_account_is_a_failed_sign_in(client, signed_out): + created = _create_member(client) + client.patch(f"/api/users/{created['id']}", json={"is_active": False}) + + response = signed_out.post("/api/auth/login", json={"username": "alice", "password": TEMPORARY}) + + assert response.status_code == 401 + event = _latest(client, "auth.login_failed") + assert event["target_label"] == "alice" + assert event["detail"] == {"reason": "account disabled"} + + +def test_setup_is_logged(db_session, signed_out): + response = signed_out.post("/api/auth/setup", json={"username": "owner", "password": "longenough"}) + + assert response.status_code == 200 + event = db_session.query(AuditEvent).filter_by(action="setup.completed").one() + owner = db_session.query(User).filter_by(username="owner").one() + assert event.actor_user_id == owner.id + assert event.actor_username == "owner" + assert event.target_label == "owner" + + +def test_entries_keep_the_name_after_the_person_is_deleted(client): + created = _create_member(client, username="carol") + client.patch(f"/api/users/{created['id']}", json={"role": "admin"}) + _sign_in("carol", TEMPORARY) + + assert client.delete(f"/api/users/{created['id']}").status_code == 204 + + assert _latest(client, "auth.login", user="carol")["actor_username"] == "carol" + assert _latest(client, "user.role_changed", user="carol")["target_label"] == "carol" + + +def test_filter_by_user_matches_actor_or_target(client, signed_out): + _create_member(client) + signed_out.post("/api/auth/login", json={"username": "alice", "password": "nope"}) + + actions = {event["action"] for event in _events(client, user="alice")["events"]} + + assert actions == {"user.created", "auth.login_failed"} + + +def test_filter_by_exact_action(client, signed_out): + _create_member(client) + signed_out.post("/api/auth/login", json={"username": "alice", "password": "nope"}) + + events = _events(client, action="auth.login_failed")["events"] + + assert [event["action"] for event in events] == ["auth.login_failed"] + + +def test_filter_by_action_prefix(client, signed_out): + created = _create_member(client) + client.patch(f"/api/users/{created['id']}", json={"is_active": False}) + signed_out.post("/api/auth/login", json={"username": "alice", "password": "nope"}) + + actions = {event["action"] for event in _events(client, action="user.")["events"]} + + assert actions == {"user.created", "user.disabled"} + + +def test_action_without_a_dot_is_matched_exactly(client): + _create_member(client) + + assert _events(client, action="auth.login_")["events"] == [] + + +def test_paging_is_newest_first_with_a_cursor(client): + for index in range(5): + _create_member(client, username=f"user{index}") + + first = _events(client, action="user.created", limit=2) + second = _events(client, action="user.created", limit=2, before=first["next_before"]) + last = _events(client, action="user.created", limit=2, before=second["next_before"]) + + labels = [event["target_label"] for event in first["events"] + second["events"] + last["events"]] + assert labels == ["user4", "user3", "user2", "user1", "user0"] + assert first["next_before"] is not None + assert second["next_before"] is not None + assert last["next_before"] is None + + +def test_page_without_more_entries_has_no_cursor(client): + _create_member(client) + + assert _events(client)["next_before"] is None + + +def test_limit_is_capped_at_200(client): + assert client.get("/api/audit", params={"limit": 201}).status_code == 422 + + +def test_member_gets_403(member_client): + response = member_client.get("/api/audit") + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin access required" + + +def test_recording_failure_does_not_break_sign_in(client, signed_out, monkeypatch): + _create_member(client) + + def broken(db, **fields): + raise RuntimeError("audit table is gone") + + monkeypatch.setattr(audit, "_queue_entry", broken) + + assert signed_out.post( + "/api/auth/login", json={"username": "alice", "password": TEMPORARY} + ).status_code == 200 + assert signed_out.post( + "/api/auth/login", json={"username": "alice", "password": "wrong"} + ).status_code == 401 + + +def test_recording_failure_does_not_break_user_changes(client, monkeypatch): + def broken(db, **fields): + raise RuntimeError("audit table is gone") + + monkeypatch.setattr(audit, "_queue_entry", broken) + + created = _create_member(client) + assert client.patch(f"/api/users/{created['id']}", json={"role": "admin"}).status_code == 200 + assert client.post( + f"/api/users/{created['id']}/reset-password", json={"password": "another-temp-1"} + ).status_code == 200 + assert client.patch(f"/api/users/{created['id']}", json={"is_active": False}).status_code == 200 + assert client.delete(f"/api/users/{created['id']}").status_code == 204 + + monkeypatch.undo() + assert _events(client)["events"] == [] + + +def test_recording_failure_does_not_break_a_password_change(client, monkeypatch): + _create_member(client) + member = _sign_in("alice", TEMPORARY) + + def broken(db, **fields): + raise RuntimeError("audit table is gone") + + monkeypatch.setattr(audit, "_queue_entry", broken) + + response = TestClient(app).post( + "/api/auth/change-password", + json={"current_password": TEMPORARY, "new_password": "alice-new-pass"}, + headers=_as(member), + ) + + assert response.status_code == 200, response.text + monkeypatch.undo() + _sign_in("alice", "alice-new-pass") + + +def test_no_entry_when_nothing_changes(client, monkeypatch): + created = _create_member(client) + calls = [] + monkeypatch.setattr(audit, "_queue_entry", lambda db, **fields: calls.append(fields)) + + response = client.patch(f"/api/users/{created['id']}", json={"role": "member", "is_active": True}) + + assert response.status_code == 200 + assert calls == [] + + +def test_action_names_are_the_stored_strings(): + assert {action.value for action in audit.AuditAction} == { + "auth.login", + "auth.login_failed", + "user.created", + "user.role_changed", + "user.password_reset", + "user.password_changed", + "user.disabled", + "user.enabled", + "user.deleted", + "setup.completed", + "token.created", + "token.revoked", + "source.created", + "source.updated", + "source.deleted", + "source.reindexed", + "agent.enrollment_created", + "agent.updated", + "agent.approved", + "agent.disabled", + "agent.revoked", + "settings.changed", + } + + +def test_free_text_is_clamped_to_the_column_sizes(db_session): + audit.record_event( + db_session, + actor="x" * 500, + action="a" * 100, + target=audit.AuditTarget(type="t" * 100, id=None, label="y" * 500), + ip="1" * 500, + ) + db_session.commit() + + event = db_session.query(AuditEvent).one() + for name in ("actor_username", "target_label", "ip", "action", "target_type"): + column_length = AuditEvent.__table__.c[name].type.length or audit.NAME_LIMIT + assert len(getattr(event, name)) <= column_length, name + + +def test_oversized_detail_is_dropped(db_session): + audit.record_event( + db_session, + actor=None, + action=audit.AuditAction.AUTH_LOGIN_FAILED, + detail={"reason": "r" * 5000}, + ) + db_session.commit() + + assert db_session.query(AuditEvent).one().detail is None + + +def test_detail_dict_decodes_the_stored_json(db_session): + audit.record_event( + db_session, + actor=None, + action=audit.AuditAction.USER_ROLE_CHANGED, + detail={"old": "member", "new": "admin"}, + ) + db_session.commit() + + assert db_session.query(AuditEvent).one().detail_dict == {"old": "member", "new": "admin"} + + +def test_corrupt_detail_does_not_break_the_list(client, db_session): + db_session.add(AuditEvent(action="auth.login", detail="{not json")) + db_session.commit() + + events = _events(client, action="auth.login")["events"] + + assert events[0]["detail"] is None + + +def test_huge_forwarded_for_still_signs_in(client, signed_out): + _create_member(client) + + response = signed_out.post( + "/api/auth/login", + json={"username": "alice", "password": TEMPORARY}, + headers={"X-Forwarded-For": "9" * 5000}, + ) + + assert response.status_code == 200 + assert len(_latest(client, "auth.login")["ip"]) <= 64 + + +def test_huge_forwarded_for_on_a_failed_sign_in_still_returns_401(client, signed_out): + response = signed_out.post( + "/api/auth/login", + json={"username": "ghost", "password": "whatever"}, + headers={"X-Forwarded-For": "9" * 5000}, + ) + + assert response.status_code == 401 + assert len(_latest(client, "auth.login_failed")["ip"]) <= 64 diff --git a/backend/tests/test_audit_log_migration.py b/backend/tests/test_audit_log_migration.py new file mode 100644 index 00000000..604c31dc --- /dev/null +++ b/backend/tests/test_audit_log_migration.py @@ -0,0 +1,79 @@ +# 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_audit_events_migration_upgrades_and_downgrades(tmp_path): + backend = Path(__file__).resolve().parents[1] + database = tmp_path / "audit.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, + ) + + def tables(): + with sqlite3.connect(database) as connection: + return {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + + alembic("upgrade", "c4e8a2d17b93") + with sqlite3.connect(database) as connection: + connection.execute( + "INSERT INTO users (username, password_hash, is_active, role, created_at, updated_at) " + "VALUES ('legacy', 'hash', 1, 'admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ) + connection.commit() + + alembic("upgrade", "head") + alembic("check") + with sqlite3.connect(database) as connection: + connection.execute("PRAGMA foreign_keys=ON") + columns = {info[1]: info for info in connection.execute("PRAGMA table_info(audit_events)")} + assert set(columns) == { + "id", + "created_at", + "actor_user_id", + "actor_username", + "action", + "target_type", + "target_id", + "target_label", + "detail", + "ip", + } + assert columns["created_at"][3] == 1 # NOT NULL + assert columns["action"][3] == 1 + assert columns["actor_user_id"][3] == 0 + index_names = {row[1] for row in connection.execute("PRAGMA index_list('audit_events')")} + assert {"ix_audit_events_created_at", "ix_audit_events_action"} <= index_names + + user_id = connection.execute("SELECT id FROM users WHERE username='legacy'").fetchone()[0] + connection.execute( + "INSERT INTO audit_events (created_at, actor_user_id, actor_username, action) " + "VALUES (CURRENT_TIMESTAMP, ?, 'legacy', 'auth.login')", + (user_id,), + ) + connection.commit() + connection.execute("DELETE FROM users WHERE id=?", (user_id,)) + connection.commit() + row = connection.execute( + "SELECT actor_user_id, actor_username FROM audit_events WHERE action='auth.login'" + ).fetchone() + assert row == (None, "legacy") + + alembic("downgrade", "c4e8a2d17b93") + assert "audit_events" not in tables() + assert "api_tokens" in tables() + assert "users" in tables() + + alembic("upgrade", "head") + assert "audit_events" in tables() diff --git a/backend/tests/test_audit_retention.py b/backend/tests/test_audit_retention.py new file mode 100644 index 00000000..ad06b1a9 --- /dev/null +++ b/backend/tests/test_audit_retention.py @@ -0,0 +1,209 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +from datetime import datetime, timedelta +from unittest.mock import Mock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.config import settings +from app.models import AuditEvent, _utcnow +from app.services.audit_retention import purge_old_audit_events +from app.services.scheduler import SchedulerService, build_cron_trigger + +NOW = datetime(2026, 11, 2, 12, 0, 0) + + +def _add_event( + db_session: Session, + age_days: int, + now: datetime = NOW, + action: str = "auth.login", + label: str = "alice", +) -> None: + db_session.add( + AuditEvent( + created_at=now - timedelta(days=age_days), + action=action, + actor_username=label, + ) + ) + db_session.commit() + + +def _set_retention(client: TestClient, db_session: Session, days: int) -> None: + assert client.put("/api/settings", json={"audit_retention_days": days}).status_code == 200 + db_session.query(AuditEvent).delete() + db_session.commit() + + +def _remaining_ages(db_session: Session) -> list[int]: + return sorted((NOW - row.created_at).days for row in db_session.query(AuditEvent).all()) + + +def test_default_retention_is_90_days(client): + response = client.get("/api/settings") + + assert response.json()["audit_retention_days"] == 90 + + +def test_purge_removes_only_entries_older_than_the_default(client, db_session): + _add_event(db_session, 89) + _add_event(db_session, 91) + _add_event(db_session, 400) + + removed = purge_old_audit_events(db_session, NOW) + + assert removed == 2 + assert _remaining_ages(db_session) == [89] + + +def test_purge_keeps_recent_entries_when_nothing_is_old(client, db_session): + _add_event(db_session, 1) + _add_event(db_session, 30) + + assert purge_old_audit_events(db_session, NOW) == 0 + assert _remaining_ages(db_session) == [1, 30] + + +def test_purge_keeps_an_entry_exactly_at_the_cutoff(client, db_session): + _add_event(db_session, 90) + _add_event(db_session, 91) + + assert purge_old_audit_events(db_session, NOW) == 1 + assert _remaining_ages(db_session) == [90] + + +def test_purge_deletes_in_batches(client, db_session): + db_session.add_all( + AuditEvent( + created_at=NOW - timedelta(days=200), action="auth.login", actor_username="alice" + ) + for _ in range(2500) + ) + db_session.commit() + _add_event(db_session, 1) + + assert purge_old_audit_events(db_session, NOW) == 2500 + assert _remaining_ages(db_session) == [1] + + +def test_purge_batch_size_does_not_change_which_entries_go(client, db_session): + _add_event(db_session, 200) + _add_event(db_session, 100) + _add_event(db_session, 5) + + assert purge_old_audit_events(db_session, NOW, batch_size=1) == 2 + assert _remaining_ages(db_session) == [5] + + +def test_purge_uses_the_retention_setting(client, db_session): + _set_retention(client, db_session, 30) + _add_event(db_session, 20) + _add_event(db_session, 40) + + removed = purge_old_audit_events(db_session, NOW) + + assert removed == 1 + assert _remaining_ages(db_session) == [20] + + +def test_a_longer_retention_keeps_more_entries(client, db_session): + _set_retention(client, db_session, 365) + _add_event(db_session, 200) + + assert purge_old_audit_events(db_session, NOW) == 0 + assert _remaining_ages(db_session) == [200] + + +def test_purge_does_not_audit_itself(client, db_session): + _add_event(db_session, 500, action="auth.login_failed") + + purge_old_audit_events(db_session, NOW) + + assert db_session.query(AuditEvent).count() == 0 + + +def test_retention_setting_accepts_the_bounds(client): + assert client.put("/api/settings", json={"audit_retention_days": 1}).status_code == 200 + assert client.put("/api/settings", json={"audit_retention_days": 3650}).status_code == 200 + assert client.get("/api/settings").json()["audit_retention_days"] == 3650 + + +@pytest.mark.parametrize("days", [0, -1, 3651]) +def test_retention_setting_rejects_values_outside_the_bounds(client, days): + assert client.put("/api/settings", json={"audit_retention_days": days}).status_code == 422 + assert client.get("/api/settings").json()["audit_retention_days"] == 90 + + +def test_retention_setting_must_be_a_whole_number(client): + assert client.put("/api/settings", json={"audit_retention_days": 30.5}).status_code == 422 + + +def test_scheduler_job_runs_the_purge_as_of_now(db_session): + _add_event(db_session, 120) + _add_event(db_session, 2) + service = SchedulerService(db_session.get_bind()) + + service._purge_audit_log(NOW) + + assert _remaining_ages(db_session) == [2] + + +def test_startup_purges_even_when_the_scheduler_is_disabled(db_session, monkeypatch): + monkeypatch.setattr(settings, "scheduler_enabled", False) + _add_event(db_session, 120, now=_utcnow()) + _add_event(db_session, 2, now=_utcnow()) + + SchedulerService(db_session.get_bind()).start() + + assert db_session.query(AuditEvent).count() == 1 + + +def test_startup_purge_failure_is_logged_and_does_not_stop_startup(db_session, monkeypatch): + monkeypatch.setattr(settings, "scheduler_enabled", False) + + def fail(*_args, **_kwargs): + raise RuntimeError("database is locked") + + monkeypatch.setattr("app.services.scheduler.purge_old_audit_events", fail) + + SchedulerService(db_session.get_bind()).start() + + +@patch("app.services.scheduler.BackgroundScheduler") +def test_scheduler_registers_a_daily_audit_retention_job(mock_scheduler_class, monkeypatch): + monkeypatch.setattr(settings, "scheduler_enabled", True) + mock_db = Mock() + mock_db.query.return_value.all.return_value = [] + mock_db.query.return_value.filter.return_value.all.return_value = [] + service = SchedulerService(Mock()) + service._session_factory = Mock(return_value=mock_db) + + service.start() + + calls = mock_scheduler_class.return_value.add_job.call_args_list + jobs = [call.kwargs for call in calls if call.kwargs.get("id") == "audit-retention"] + assert len(jobs) == 1 + assert jobs[0]["func"] == service._purge_audit_log + assert jobs[0]["replace_existing"] is True + assert str(jobs[0]["trigger"]) == str(build_cron_trigger("17 3 * * *")) + + +def test_app_startup_in_tests_never_purges_the_configured_database(monkeypatch): + from fastapi.testclient import TestClient + + from app.main import app + + calls = [] + monkeypatch.setattr( + "app.services.scheduler.purge_old_audit_events", + lambda *args, **kwargs: calls.append(args) or 0, + ) + + with TestClient(app): + pass + + assert calls == [] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9e5f8d93..a9f4d35f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -20,6 +20,9 @@ ) import app.api.auth as auth_module +# conftest replaces this for every test, so keep the real one for the path tests +real_session_secret_path = auth_module._session_secret_path + # In-memory SQLite database for testing SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" @@ -87,7 +90,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 +125,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 +133,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 @@ -185,6 +188,15 @@ def test_setup_success(self, client): assert data["token_type"] == "bearer" assert data["expires_in"] > 0 + def test_setup_counts_as_the_first_sign_in(self, client): + response = client.post("/api/auth/setup", json={"username": "admin", "password": "securepassword123"}) + headers = {"Authorization": f"Bearer {response.json()['access_token']}"} + + users = client.get("/api/users", headers=headers).json() + + assert users[0]["username"] == "admin" + assert users[0]["last_login_at"] is not None + def test_setup_forbidden_after_completion(self, client, sample_user): """Test setup is forbidden after first user exists""" setup_data = { @@ -415,10 +427,42 @@ def test_x_forwarded_for_single(self): req = self._make_request(headers={"x-forwarded-for": "1.2.3.4"}) assert get_client_ip(req) == "1.2.3.4" - def test_x_forwarded_for_chain(self): + def test_x_forwarded_for_uses_rightmost_entry(self): + # From a trusted proxy the rightmost entry is the one the proxy appended. + req = self._make_request(headers={"x-forwarded-for": "9.9.9.9, 1.2.3.4"}) + assert get_client_ip(req) == "1.2.3.4" + + def test_x_forwarded_for_chain_takes_last_hop(self): req = self._make_request(headers={"x-forwarded-for": "1.2.3.4, 10.0.0.1, 10.0.0.2"}) + assert get_client_ip(req) == "10.0.0.2" + + def test_client_sent_forwarded_for_is_not_trusted_from_public_peer(self): + req = self._make_request( + headers={"x-forwarded-for": "1.2.3.4"}, client_host="203.0.113.9" + ) + assert get_client_ip(req) == "203.0.113.9" + + def test_client_sent_real_ip_is_not_trusted_from_public_peer(self): + req = self._make_request(headers={"x-real-ip": "1.2.3.4"}, client_host="203.0.113.9") + assert get_client_ip(req) == "203.0.113.9" + + def test_rotating_forwarded_for_from_public_peer_gives_same_ip(self): + ips = { + get_client_ip(self._make_request( + headers={"x-forwarded-for": f"10.0.0.{i}"}, client_host="203.0.113.9" + )) + for i in range(10) + } + assert ips == {"203.0.113.9"} + + def test_ipv6_loopback_peer_is_trusted(self): + req = self._make_request(headers={"x-forwarded-for": "1.2.3.4"}, client_host="::1") assert get_client_ip(req) == "1.2.3.4" + def test_non_ip_peer_is_not_trusted(self): + req = self._make_request(headers={"x-forwarded-for": "1.2.3.4"}, client_host="testclient") + assert get_client_ip(req) == "testclient" + def test_x_real_ip(self): req = self._make_request(headers={"x-real-ip": "5.6.7.8"}) assert get_client_ip(req) == "5.6.7.8" @@ -442,6 +486,52 @@ def test_forwarded_for_takes_priority_over_real_ip(self): assert get_client_ip(req) == "1.1.1.1" +class TestProxiedRateLimit: + """Login rate limit when the client can send its own forwarding headers.""" + + @pytest.fixture + def make_client(self, db_session): + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + yield lambda peer: TestClient(app, client=(peer, 50000)) + app.dependency_overrides.clear() + + def _wrong_login(self, client, headers=None): + return client.post( + "/api/auth/login", + json={"username": "testuser", "password": "wrongpassword"}, + headers=headers or {}, + ) + + def test_rotating_forwarded_for_on_direct_connection_still_limited(self, make_client, sample_user): + client = make_client("203.0.113.9") + for i in range(5): + response = self._wrong_login(client, {"X-Forwarded-For": f"10.1.1.{i}"}) + assert response.status_code == 401 + response = self._wrong_login(client, {"X-Forwarded-For": "10.1.1.99"}) + assert response.status_code == 429 + + def test_rotating_forwarded_for_behind_bundled_nginx_still_limited(self, make_client, sample_user): + # nginx appends the address it saw, so the rightmost entry stays the real client. + client = make_client("127.0.0.1") + for i in range(5): + response = self._wrong_login(client, {"X-Forwarded-For": f"10.1.1.{i}, 203.0.113.9"}) + assert response.status_code == 401 + response = self._wrong_login(client, {"X-Forwarded-For": "10.1.1.99, 203.0.113.9"}) + assert response.status_code == 429 + + def test_spoofed_headers_from_one_peer_do_not_reset_another_peers_bucket(self, make_client, sample_user): + attacker = make_client("203.0.113.9") + for i in range(6): + self._wrong_login(attacker, {"X-Forwarded-For": f"10.1.1.{i}", "X-Real-IP": f"10.2.2.{i}"}) + + other = make_client("198.51.100.7") + response = self._wrong_login(other) + assert response.status_code == 401 + + class TestGetSecretKey: def test_with_session_secret(self, monkeypatch): @@ -449,11 +539,105 @@ def test_with_session_secret(self, monkeypatch): monkeypatch.setattr(settings, "session_secret", "my-secret-key") assert get_secret_key() == "my-secret-key" - def test_without_session_secret(self, monkeypatch): + def test_without_session_secret_uses_generated_file(self, monkeypatch, tmp_path): from app.config import settings monkeypatch.setattr(settings, "session_secret", "") + monkeypatch.setattr(auth_module, "_generated_secret", None) + monkeypatch.setattr(auth_module, "_session_secret_path", lambda: tmp_path / "session_secret") key = get_secret_key() - assert "temporary" in key + assert key + assert (tmp_path / "session_secret").read_text() == key + + +class TestGeneratedSessionSecret: + """SESSION_SECRET unset: the key is generated once and kept in the data dir.""" + + @pytest.fixture + def secret_file(self, monkeypatch, tmp_path): + from app.config import settings + path = tmp_path / "session_secret" + monkeypatch.setattr(settings, "session_secret", "") + monkeypatch.setattr(auth_module, "_generated_secret", None) + monkeypatch.setattr(auth_module, "_session_secret_path", lambda: path) + return path + + def test_env_secret_wins_over_file(self, monkeypatch, secret_file): + from app.config import settings + secret_file.write_text("file-secret") + monkeypatch.setattr(settings, "session_secret", "env-secret") + assert get_secret_key() == "env-secret" + + def test_file_is_created_with_0600_and_reused(self, secret_file): + first = get_secret_key() + assert secret_file.stat().st_mode & 0o777 == 0o600 + + auth_module._generated_secret = None + assert get_secret_key() == first + + def test_key_is_cached_between_calls(self, secret_file): + first = get_secret_key() + secret_file.unlink() + assert get_secret_key() == first + + def test_warning_is_logged_once_and_key_is_not_logged(self, secret_file, caplog): + with caplog.at_level("WARNING", logger="app.api.auth"): + key = get_secret_key() + get_secret_key() + warnings = [r for r in caplog.records if r.levelname == "WARNING" and "SESSION_SECRET" in r.getMessage()] + assert len(warnings) == 1 + assert all(key not in r.getMessage() for r in caplog.records) + + def test_concurrent_creation_keeps_one_key(self, secret_file): + import threading + from concurrent.futures import ThreadPoolExecutor + + barrier = threading.Barrier(12) + + def start(_): + barrier.wait() + return auth_module._load_or_create_secret_file(secret_file) + + with ThreadPoolExecutor(max_workers=12) as pool: + keys = set(pool.map(start, range(12))) + + assert len(keys) == 1 + assert secret_file.read_text() == keys.pop() + + def test_empty_file_is_read_once_the_writer_finishes(self, secret_file): + import threading + import time + + secret_file.touch() + + def finish_write(): + time.sleep(0.2) + secret_file.write_text("late-writer-key") + + writer = threading.Thread(target=finish_write) + writer.start() + assert auth_module._load_or_create_secret_file(secret_file) == "late-writer-key" + writer.join() + + def test_unwritable_data_dir_fails_loudly(self, monkeypatch, tmp_path): + from app.config import settings + monkeypatch.setattr(settings, "session_secret", "") + monkeypatch.setattr(auth_module, "_generated_secret", None) + monkeypatch.setattr(auth_module, "_session_secret_path", lambda: tmp_path / "missing" / "session_secret") + with pytest.raises(RuntimeError, match="SESSION_SECRET"): + get_secret_key() + + def test_root_created_file_goes_to_data_dir_owner(self, monkeypatch, secret_file): + import os + calls = [] + monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(os, "chown", lambda path, uid, gid: calls.append((path, uid, gid))) + get_secret_key() + owner = secret_file.parent.stat() + assert calls == [(secret_file, owner.st_uid, owner.st_gid)] + + def test_hardcoded_fallback_is_gone(self): + import inspect + assert "temporary-dev-key" not in inspect.getsource(auth_module) class TestCheckRateLimitPruning: @@ -497,6 +681,21 @@ def test_expired_token(self): class TestGetCurrentUserEdgeCases: + @pytest.mark.asyncio + async def test_download_token_is_not_a_session(self, db_session, sample_user): + from unittest.mock import Mock + + from fastapi import HTTPException + + from app.api.preview import _create_download_token + + creds = Mock() + creds.credentials = _create_download_token(sample_user.id, "photos--image123", sample_user.session_version) + + with pytest.raises(HTTPException) as exc_info: + await get_current_user(credentials=creds, db=db_session) + assert exc_info.value.status_code == 401 + @pytest.mark.asyncio async def test_token_missing_sub(self, db_session): """Token with no 'sub' field should fail""" @@ -530,7 +729,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,10 +757,47 @@ 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 result = await get_optional_user(credentials=creds, db=db_session) assert result is not None assert result.username == "testuser" + + @pytest.mark.asyncio + async def test_download_token_is_not_a_session(self, db_session, sample_user): + from unittest.mock import Mock + + from app.api.preview import _create_download_token + + creds = Mock() + creds.credentials = _create_download_token(sample_user.id, "photos--image123", sample_user.session_version) + + result = await get_optional_user(credentials=creds, db=db_session) + assert result is None + + @pytest.mark.asyncio + async def test_token_without_purpose_still_works(self, db_session, sample_user): + from unittest.mock import Mock + + import jwt as pyjwt + + payload = {"sub": str(sample_user.id), "username": sample_user.username, "exp": 4102444800} + creds = Mock() + creds.credentials = pyjwt.encode(payload, get_secret_key(), algorithm="HS256") + + result = await get_optional_user(credentials=creds, db=db_session) + assert result is not None + + +class TestSessionSecretPath: + def test_lives_next_to_the_sqlite_database(self, monkeypatch, tmp_path): + monkeypatch.setattr(auth_module, "_session_secret_path", real_session_secret_path) + monkeypatch.setattr(auth_module.settings, "database_url", f"sqlite:///{tmp_path}/onesearch.db") + assert auth_module._session_secret_path() == tmp_path / "session_secret" + + def test_falls_back_to_app_data_for_other_databases(self, monkeypatch): + monkeypatch.setattr(auth_module, "_session_secret_path", real_session_secret_path) + monkeypatch.setattr(auth_module.settings, "database_url", "postgresql://db/onesearch") + assert str(auth_module._session_secret_path()) == "/app/data/session_secret" diff --git a/backend/tests/test_dockerfile.py b/backend/tests/test_dockerfile.py index 688c81ca..6d2b99ed 100644 --- a/backend/tests/test_dockerfile.py +++ b/backend/tests/test_dockerfile.py @@ -7,6 +7,7 @@ import os import shutil import subprocess +import tomllib from pathlib import Path import pytest @@ -61,6 +62,14 @@ def test_runtime_image_ships_ffprobe_for_media_metadata(): assert "COPY --from=ffprobe-builder /out/licenses /usr/share/licenses/ffmpeg" in dockerfile +def test_admin_console_script_is_declared_and_copied_onto_path(): + pyproject = tomllib.loads(Path("backend/pyproject.toml").read_text(encoding="utf-8")) + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + + assert pyproject["project"]["scripts"]["onesearch-admin"] == "app.admin_cli:main" + assert "COPY --from=backend-builder /usr/local/bin /usr/local/bin" in dockerfile + + def test_runtime_python_packages_are_not_editable_build_paths(): dockerfile = Path("Dockerfile").read_text(encoding="utf-8") diff --git a/backend/tests/test_extractors.py b/backend/tests/test_extractors.py index fcb8103a..d29d18ec 100644 --- a/backend/tests/test_extractors.py +++ b/backend/tests/test_extractors.py @@ -200,6 +200,34 @@ async def test_very_long_first_line_title_fallback(self, temp_dir): # "short second line" is <= 100 chars, so that becomes the title. assert doc.title == "short second line" + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("name", "text", "title"), + [ + ("one_line.py", '"""Tiny helper for parsing dates."""\nimport os\n', "Tiny helper for parsing dates."), + ("single.py", "'''Single-quoted docstring'''\n", "Single-quoted docstring"), + ("opening.py", '"""\nLoads the config file.\n"""\n', "Loads the config file."), + ("block.js", "/* Builds the bundle */\nconst x = 1\n", "Builds the bundle"), + ("page.html", "\n\n", "Landing page"), + ], + ) + async def test_title_drops_docstring_and_comment_markers(self, temp_dir, name, text, title): + f = temp_dir / name + f.write_text(text) + + doc = await TextExtractor("src", "Source").extract_with_timeout(str(f)) + + assert doc.title == title + + @pytest.mark.asyncio + async def test_title_falls_back_to_filename_when_only_markers(self, temp_dir): + f = temp_dir / "empty_doc.py" + f.write_text('"""\n"""\n') + + doc = await TextExtractor("src", "Source").extract_with_timeout(str(f)) + + assert doc.title == "empty_doc" + @pytest.mark.asyncio async def test_timeout(self, temp_dir, monkeypatch): """Test extraction timeout""" diff --git a/backend/tests/test_image_extractor.py b/backend/tests/test_image_extractor.py index 5a80362d..fdc193f4 100644 --- a/backend/tests/test_image_extractor.py +++ b/backend/tests/test_image_extractor.py @@ -33,6 +33,74 @@ def write_jpeg_with_exif(path: Path) -> None: image.save(path, exif=exif) +def write_jpeg_with_sub_ifd_exif(path: Path) -> None: + """Camera-style layout: shooting settings live in the Exif sub-IFD, GPS in its own IFD""" + image = Image.new("RGB", (64, 32), color="red") + exif = Image.Exif() + exif[271] = "Canon" + exif[272] = "EOS R6" + exif[306] = "2024:08:12 18:43:00" + sub = exif.get_ifd(0x8769) + sub[36867] = "2024:08:12 18:43:00" + sub[33434] = (1, 125) + sub[33437] = (4, 1) + sub[34855] = 800 + sub[37386] = (50, 1) + sub[42036] = "RF24-105mm F4" + gps = exif.get_ifd(0x8825) + gps[1] = "N" + gps[2] = (40.0, 42.0, 30.0) + gps[3] = "W" + gps[4] = (74.0, 0.0, 0.0) + image.save(path, exif=exif) + + +@pytest.mark.asyncio +async def test_extract_jpeg_exif_reads_sub_ifd_camera_fields(temp_dir, monkeypatch): + file_path = temp_dir / "IMG_5678.jpg" + write_jpeg_with_sub_ifd_exif(file_path) + monkeypatch.setattr(ImageExtractor, "_gps_enabled", lambda self: True) + + doc = await ImageExtractor("src", "Photos").extract_with_timeout(str(file_path)) + + assert doc.metadata["camera_make"] == "Canon" + assert doc.metadata["camera_model"] == "EOS R6" + assert doc.metadata["date_taken"] == "2024:08:12 18:43:00" + assert doc.metadata["iso"] == 800 + assert doc.metadata["aperture"] == "f/4" + assert doc.metadata["exposure_time"] == "1/125" + assert doc.metadata["focal_length"] == "50mm" + assert doc.metadata["lens_model"] == "RF24-105mm F4" + assert doc.metadata["gps"]["GPSLatitudeRef"] == "N" + assert doc.metadata["gps"]["GPSLongitudeRef"] == "W" + + +@pytest.mark.asyncio +async def test_gps_ifd_offset_is_decoded_from_real_jpeg(temp_dir, monkeypatch): + file_path = temp_dir / "geo.jpg" + write_jpeg_with_sub_ifd_exif(file_path) + monkeypatch.setattr(ImageExtractor, "_gps_enabled", lambda self: True) + + doc = await ImageExtractor("src", "Photos").extract_with_timeout(str(file_path)) + + assert "raw" not in doc.metadata["gps"] + assert doc.metadata["gps"]["GPSLatitude"] == (40.0, 42.0, 30.0) + + +@pytest.mark.asyncio +async def test_sub_ifd_value_wins_over_ifd0_copy(temp_dir): + file_path = temp_dir / "IMG_9999.jpg" + image = Image.new("RGB", (16, 16), color="red") + exif = Image.Exif() + exif[33437] = (2, 1) + exif.get_ifd(0x8769)[33437] = (4, 1) + image.save(file_path, exif=exif) + + doc = await ImageExtractor("src", "Photos").extract_with_timeout(str(file_path)) + + assert doc.metadata["aperture"] == "f/4" + + @pytest.mark.asyncio async def test_extract_jpeg_exif_metadata_is_searchable(temp_dir): file_path = temp_dir / "IMG_1234.jpg" @@ -284,3 +352,20 @@ def test_image_and_raw_extensions_are_registered(temp_dir): ]: assert ImageExtractor.supports_file(str(temp_dir / name)), name assert extractor_registry.get_extractor(str(temp_dir / name), "src", "Source") is not None + + +@pytest.mark.asyncio +async def test_gps_from_real_jpeg_is_plain_json(temp_dir, monkeypatch): + import json + + file_path = temp_dir / "geo.jpg" + write_jpeg_with_sub_ifd_exif(file_path) + monkeypatch.setattr(ImageExtractor, "_gps_enabled", lambda self: True) + + doc = await ImageExtractor("src", "Photos").extract_with_timeout(str(file_path)) + + # Pillow hands back IFDRational, which neither json nor pydantic can serialise + dumped = json.loads(json.dumps(doc.model_dump(mode="json"))) + assert dumped["metadata"]["gps"]["GPSLatitude"] == [40.0, 42.0, 30.0] + assert all(type(v) is float for v in doc.metadata["gps"]["GPSLatitude"]) + json.dumps(doc.metadata) diff --git a/backend/tests/test_indexer.py b/backend/tests/test_indexer.py index 72877632..ab037df2 100644 --- a/backend/tests/test_indexer.py +++ b/backend/tests/test_indexer.py @@ -4,6 +4,7 @@ """ Tests for indexing service """ +import hashlib import pytest import asyncio from pathlib import Path @@ -659,3 +660,154 @@ async def test_index_source_full_flow( source_id="test_source2" ).all() assert len(indexed_files) == 2 + + +class TestStaleDocumentCleanup: + """A file that changed and now fails must not keep matching its old text""" + + @staticmethod + def _source(db_session, source_id, root): + source = Source( + id=source_id, + name="Stale Source", + root_path=str(root), + include_patterns=json.dumps(["**/*.txt"]), + exclude_patterns=json.dumps([]), + ) + db_session.add(source) + db_session.commit() + return source + + @pytest.mark.asyncio + @patch('app.services.indexer.FileScanner') + async def test_modified_file_that_fails_removes_old_document( + self, mock_scanner_class, db_session, mock_search_service + ): + with TemporaryDirectory() as tmp: + root = Path(tmp) + file_path = root / "notes.txt" + file_path.write_text("ambergloam") + self._source(db_session, "stale_src", root) + mock_scanner = Mock() + mock_scanner.scan.return_value = [str(file_path)] + mock_scanner_class.return_value = mock_scanner + + service = IndexingService(db_session, mock_search_service) + await service.index_source("stale_src") + + db_session.add(AppSetting(key="max_text_file_size_mb", value="1")) + db_session.commit() + file_path.write_text("x" * 2_400_000) + mock_search_service.delete_document.reset_mock() + + stats = await service.index_source("stale_src") + + path_hash = hashlib.sha256(str(file_path).encode()).hexdigest()[:12] + assert stats.failed == 1 + mock_search_service.delete_document.assert_awaited_once_with(f"stale_src--{path_hash}") + record = db_session.query(IndexedFile).filter_by(source_id="stale_src").one() + assert record.status == "failed" + + @pytest.mark.asyncio + @patch('app.services.indexer.FileScanner') + async def test_first_index_failure_does_not_touch_search( + self, mock_scanner_class, db_session, mock_search_service + ): + with TemporaryDirectory() as tmp: + root = Path(tmp) + file_path = root / "huge.txt" + file_path.write_text("x" * 2_400_000) + db_session.add(AppSetting(key="max_text_file_size_mb", value="1")) + self._source(db_session, "fresh_src", root) + mock_scanner = Mock() + mock_scanner.scan.return_value = [str(file_path)] + mock_scanner_class.return_value = mock_scanner + + stats = await IndexingService(db_session, mock_search_service).index_source("fresh_src") + + assert stats.failed == 1 + mock_search_service.delete_document.assert_not_awaited() + + @pytest.mark.asyncio + @patch('app.services.indexer.FileScanner') + async def test_failed_stale_delete_does_not_abort_run( + self, mock_scanner_class, db_session, mock_search_service + ): + with TemporaryDirectory() as tmp: + root = Path(tmp) + file_path = root / "notes.txt" + file_path.write_text("ambergloam") + self._source(db_session, "flaky_src", root) + mock_scanner = Mock() + mock_scanner.scan.return_value = [str(file_path)] + mock_scanner_class.return_value = mock_scanner + service = IndexingService(db_session, mock_search_service) + await service.index_source("flaky_src") + + db_session.add(AppSetting(key="max_text_file_size_mb", value="1")) + db_session.commit() + file_path.write_text("x" * 2_400_000) + mock_search_service.delete_document.side_effect = RuntimeError("meili down") + + stats = await service.index_source("flaky_src") + + assert stats.failed == 1 + assert stats.errors[0]["file"] == str(file_path) + + @pytest.mark.asyncio + async def test_retry_failed_file_removes_old_document(self, db_session, mock_search_service): + with TemporaryDirectory() as tmp: + root = Path(tmp) + file_path = root / "notes.txt" + file_path.write_text("x" * 2_400_000) + db_session.add(AppSetting(key="max_text_file_size_mb", value="1")) + source = self._source(db_session, "retry_src", root) + service = IndexingService(db_session, mock_search_service) + + result = await service.retry_failed_file(source, str(file_path)) + + path_hash = hashlib.sha256(str(file_path).encode()).hexdigest()[:12] + assert result == "failed" + mock_search_service.delete_document.assert_awaited_once_with(f"retry_src--{path_hash}") + + +class TestOneBadDocument: + @pytest.mark.asyncio + @patch('app.services.indexer.FileScanner') + async def test_document_that_cant_be_serialised_fails_only_that_file( + self, mock_scanner_class, db_session, mock_search_service, monkeypatch + ): + with TemporaryDirectory() as tmp: + root = Path(tmp) + good = root / "good.txt" + bad = root / "bad.txt" + good.write_text("fine") + bad.write_text("not fine") + db_session.add(Source( + id="mixed_src", name="Mixed", root_path=str(root), + include_patterns=json.dumps(["**/*.txt"]), exclude_patterns=json.dumps([]), + )) + db_session.commit() + mock_scanner = Mock() + mock_scanner.scan.return_value = [str(good), str(bad)] + mock_scanner_class.return_value = mock_scanner + + service = IndexingService(db_session, mock_search_service) + real_extract = service._extract_document + + async def extract(file_path, source_id, source_name): + doc = await real_extract(file_path, source_id, source_name) + if file_path == str(bad): + doc.metadata["oops"] = object() + return doc + + monkeypatch.setattr(service, "_extract_document", extract) + + stats = await service.index_source("mixed_src") + + assert stats.successful == 1 + assert stats.failed == 1 + indexed = [d for call in mock_search_service.index_documents.await_args_list for d in call.args[0]] + assert [d.path for d in indexed] == [str(good)] + statuses = {f.path: f.status for f in db_session.query(IndexedFile).filter_by(source_id="mixed_src")} + assert statuses == {str(good): "success", str(bad): "failed"} diff --git a/backend/tests/test_preview_api.py b/backend/tests/test_preview_api.py index b2c9b425..2edd4196 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}"} @@ -450,6 +450,52 @@ async def get_document(document_id): assert 'filename="photo.jpg"' in response.headers["content-disposition"] +def test_download_link_stops_working_after_session_version_bump( + client, db_session, source, temp_source, monkeypatch +): + _, image_path, _ = temp_source + + async def get_document(document_id): + return image_doc(source, image_path) + + monkeypatch.setattr("app.api.preview.meili_service.get_document", get_document) + url = client.post("/api/documents/photos--image123/download-link").json()["url"] + + previewer = db_session.query(User).filter_by(username="previewer").one() + previewer.session_version += 1 + db_session.commit() + + response = client.get(url) + + assert response.status_code == 401 + assert response.json()["detail"]["code"] == "download_token_invalid" + + +@pytest.mark.parametrize("change", ["disabled", "must_change_password"]) +def test_download_link_rejected_when_user_state_blocks_signed_in_use( + client, db_session, source, temp_source, monkeypatch, change +): + _, image_path, _ = temp_source + + async def get_document(document_id): + return image_doc(source, image_path) + + monkeypatch.setattr("app.api.preview.meili_service.get_document", get_document) + url = client.post("/api/documents/photos--image123/download-link").json()["url"] + + previewer = db_session.query(User).filter_by(username="previewer").one() + if change == "disabled": + previewer.is_active = False + else: + previewer.must_change_password = True + db_session.commit() + + response = client.get(url) + + assert response.status_code == 401 + assert response.json()["detail"]["code"] == "download_token_invalid" + + @pytest.mark.parametrize("agent_state", ["offline", "pending", "disabled", "revoked", "missing"]) def test_remote_download_link_rejects_unavailable_agent_without_enqueuing( client, db_session, remote_download, agent_state @@ -1759,3 +1805,12 @@ def override_get_db(): assert response2.status_code in {400, 409} app.dependency_overrides.clear() + + +def test_download_rejects_session_token(client, test_user): + response = client.get( + "/api/documents/photos--image123/download", params={"token": test_user["token"]} + ) + + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "download_token_wrong_purpose" 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_route_access.py b/backend/tests/test_route_access.py new file mode 100644 index 00000000..3a3bd26c --- /dev/null +++ b/backend/tests/test_route_access.py @@ -0,0 +1,128 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""Every API route has to say who may call it.""" + +import re + +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 .route_helpers import api_routes, routes_at + +SEARCH_SCOPE_ROUTES = { + ("POST", "/api/search"), + ("GET", "/api/documents/{document_id}"), + ("GET", "/api/documents/{document_id}/preview"), + ("POST", "/api/documents/{document_id}/download-link"), +} + + +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/auth/logout", AccessLevel.SIGNED_IN), + ("POST", "/api/auth/change-password", AccessLevel.SIGNED_IN), + ("POST", "/api/auth/tokens", AccessLevel.SIGNED_IN), + ("GET", "/api/auth/tokens", AccessLevel.SIGNED_IN), + ("DELETE", "/api/auth/tokens/{token_id}", AccessLevel.SIGNED_IN), + ("POST", "/api/search", AccessLevel.SEARCH), + ("GET", "/api/documents/{document_id}", AccessLevel.SEARCH), + ("GET", "/api/documents/{document_id}/preview", AccessLevel.SEARCH), + ("POST", "/api/documents/{document_id}/download-link", AccessLevel.SEARCH), + ("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), + ("GET", "/api/users", AccessLevel.ADMIN), + ("POST", "/api/users", AccessLevel.ADMIN), + ("PATCH", "/api/users/{user_id}", AccessLevel.ADMIN), + ("POST", "/api/users/{user_id}/reset-password", AccessLevel.ADMIN), + ("DELETE", "/api/users/{user_id}", AccessLevel.ADMIN), + ("GET", "/api/users/{user_id}/tokens", AccessLevel.ADMIN), + ("DELETE", "/api/users/{user_id}/tokens/{token_id}", AccessLevel.ADMIN), + ("GET", "/api/audit", 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 + + +def test_search_scope_routes_are_exactly_the_four(): + assert set(routes_at(AccessLevel.SEARCH)) == SEARCH_SCOPE_ROUTES + + +def _token_refused_routes(): + return routes_at(AccessLevel.SIGNED_IN, AccessLevel.ADMIN) + + +@pytest.fixture +def search_token_headers(client): + response = client.post("/api/auth/tokens", json={"name": "route sweep"}) + assert response.status_code == 201 + return {"Authorization": f"Bearer {response.json()['token']}"} + + +@pytest.mark.parametrize(("method", "path"), _token_refused_routes()) +def test_api_token_is_refused_outside_search_scope(client, search_token_headers, method, path): + response = client.request(method, re.sub(r"\{[^}]+\}", "dummy", path), headers=search_token_headers) + + assert response.status_code == 403 + assert response.json()["detail"] == "API tokens can only be used for search" + + +@pytest.mark.parametrize(("method", "path"), routes_at(AccessLevel.AGENT)) +def test_api_token_is_not_an_agent_credential(client, search_token_headers, method, path): + assert client.put("/api/settings", json={"remote_agents_enabled": True}).status_code == 200 + + response = client.request(method, re.sub(r"\{[^}]+\}", "dummy", path), headers=search_token_headers) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid agent token" diff --git a/backend/tests/test_search_service.py b/backend/tests/test_search_service.py index e08f1aca..d7575ff9 100644 --- a/backend/tests/test_search_service.py +++ b/backend/tests/test_search_service.py @@ -7,6 +7,7 @@ import asyncio import json +import time from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -16,7 +17,13 @@ from app.models import Agent, AppSetting, Source from app.schemas import Document from app.services.agent_auth import AGENT_ONLINE_MAX_AGE -from app.services.search import INDEX_NAME, MeilisearchService, meili_service +from app.services.search import ( + HEALTH_TIMEOUT_SECONDS, + INDEX_NAME, + REQUEST_TIMEOUT_SECONDS, + MeilisearchService, + meili_service, +) def _fake_task(**kwargs): @@ -68,6 +75,8 @@ def connected_service(): svc = MeilisearchService() svc.client = Mock() svc.index = Mock() + svc.health_client = Mock() + svc.health_client.index.return_value = svc.index return svc @@ -111,9 +120,54 @@ def test_connect_health_check_failure(self, MockClient, service): assert service.index is None +class TestTimeouts: + @patch("app.services.search.Client") + def test_connect_uses_request_and_health_timeouts(self, MockClient, service): + mock_client = MockClient.return_value + mock_client.health.return_value = {"status": "available"} + mock_client.get_index.return_value = Mock() + + service.connect() + + assert MockClient.call_args_list[0].kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS + assert MockClient.call_args_list[1].kwargs["timeout"] == HEALTH_TIMEOUT_SECONDS + assert service.health_client is MockClient.return_value + assert HEALTH_TIMEOUT_SECONDS < REQUEST_TIMEOUT_SECONDS + + +class TestHealthRoute: + @pytest.mark.asyncio + async def test_health_route_does_not_block_event_loop(self, monkeypatch): + from app import main + + def slow_health_check(): + time.sleep(0.5) + return {"status": "error"} + + monkeypatch.setattr(main.meili_service, "health_check", slow_health_check) + monkeypatch.setattr("app.api.auth.is_setup_required", lambda db: False) + + ticks = 0 + + async def ticker(): + nonlocal ticks + while True: + await asyncio.sleep(0.01) + ticks += 1 + + task = asyncio.create_task(ticker()) + try: + response = await main.health_check(db=None) + finally: + task.cancel() + + assert response.status_code == 503 + assert ticks >= 5 + + class TestHealthCheck: def test_health_check_connected(self, connected_service): - connected_service.client.health.return_value = {"status": "available"} + connected_service.health_client.health.return_value = {"status": "available"} mock_stats = Mock() mock_stats.number_of_documents = 42 mock_stats.is_indexing = False @@ -132,7 +186,7 @@ def test_health_check_disconnected(self, service): assert "error" in result def test_health_check_error(self, connected_service): - connected_service.client.health.side_effect = Exception("timeout /srv/private/token.txt") + connected_service.health_client.health.side_effect = Exception("timeout /srv/private/token.txt") result = connected_service.health_check() @@ -141,7 +195,7 @@ def test_health_check_error(self, connected_service): def test_health_check_dict_response(self, connected_service): """Handle Meilisearch returning dicts instead of objects""" - connected_service.client.health.return_value = {"status": "available"} + connected_service.health_client.health.return_value = {"status": "available"} connected_service.index.get_stats.return_value = { "numberOfDocuments": 10, "isIndexing": True, @@ -694,3 +748,48 @@ async def test_raises_when_task_fails(self): with pytest.raises(RuntimeError): await service.delete_documents_by_filter_confirmed("source_id = 'x'") + + +class TestSnippetTrim: + """Snippets are cut on visible text and never leave a broken highlight tag""" + + def test_cut_inside_highlight_closes_the_mark(self): + from app.api.search import _trim_snippet + + formatted = "x" * 45 + "matched tail words" + + assert _trim_snippet(formatted, 50) == "x" * 45 + "match" + + def test_cut_never_leaves_partial_tag(self): + from app.api.search import _trim_snippet + + formatted = "x" * 48 + "hit more" + + snippet = _trim_snippet(formatted, 50) + + assert snippet == "x" * 48 + "hi" + assert "", "") + + def test_limit_reached_before_a_mark_drops_it(self): + from app.api.search import _trim_snippet + + assert _trim_snippet("x" * 50 + "y", 50) == "x" * 50 + + def test_short_snippet_is_unchanged(self): + from app.api.search import _trim_snippet + + formatted = "a match b" + + assert _trim_snippet(formatted, 300) == formatted + + def test_visible_length_ignores_markup(self): + import re + + from app.api.search import _trim_snippet + + formatted = "aa" * 20 + + snippet = _trim_snippet(formatted, 25) + + assert len(re.sub(r"", "", snippet)) == 25 + assert snippet.count("") == snippet.count("") 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..5c3cab70 --- /dev/null +++ b/backend/tests/test_user_roles.py @@ -0,0 +1,188 @@ +# 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 + + +def test_download_token_cannot_be_used_as_session(client, test_user): + from app.api.preview import _create_download_token + + token = _create_download_token(test_user["user"].id, "photos--image123", test_user["user"].session_version) + + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 401 diff --git a/backend/tests/test_user_roles_migration.py b/backend/tests/test_user_roles_migration.py new file mode 100644 index 00000000..96a575ed --- /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", "f9c23a14b980") + 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/backend/tests/test_users_api.py b/backend/tests/test_users_api.py new file mode 100644 index 00000000..f8806604 --- /dev/null +++ b/backend/tests/test_users_api.py @@ -0,0 +1,540 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +import re +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event, update +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import sessionmaker + +from app.api import auth as auth_module +from app.api.access import AccessLevel +from app.api.auth import create_access_token, hash_password +from app.api.users import _active_admins_lock_statement +from app.db.database import get_db +from app.main import app +from app.models import ROLE_ADMIN, AgentEnrollment, Base, User + +from .route_helpers import routes_at + +PASSWORD_CHANGE_EXEMPT = { + ("GET", "/api/auth/me"), + ("POST", "/api/auth/logout"), + ("POST", "/api/auth/change-password"), +} + + +@pytest.fixture(autouse=True) +def clear_rate_limit(): + auth_module.rate_limit_store.clear() + yield + auth_module.rate_limit_store.clear() + + +@pytest.fixture +def search_stub(monkeypatch): + async def search(**_kwargs): + return {"hits": [], "estimatedTotalHits": 0, "processingTimeMs": 1} + + monkeypatch.setattr("app.api.search.meili_service.search", search) + + +def _create_user(client, username="alice", password="alicepass1", role="member"): + return client.post("/api/users", json={"username": username, "password": password, "role": role}) + + +def _sign_in(client, username="alice", password="alicepass1"): + response = client.post("/api/auth/login", json={"username": username, "password": password}) + assert response.status_code == 200 + return response.json()["access_token"] + + +def _as(token): + return {"Authorization": f"Bearer {token}"} + + +def _change_password(client, token, current, new): + return client.post( + "/api/auth/change-password", + json={"current_password": current, "new_password": new}, + headers=_as(token), + ) + + +def _gated_routes(): + return [ + route + for route in routes_at(AccessLevel.SIGNED_IN, AccessLevel.SEARCH, AccessLevel.ADMIN) + if route not in PASSWORD_CHANGE_EXEMPT + ] + + +def _fill_path(path: str) -> str: + return re.sub(r"\{[^}]+\}", "dummy", path) + + +def test_admin_creates_member_with_temporary_password(client): + response = _create_user(client) + + assert response.status_code == 201 + body = response.json() + assert body["username"] == "alice" + assert body["role"] == "member" + assert body["is_active"] is True + assert body["must_change_password"] is True + assert body["created_by"] == "testuser" + assert body["last_login_at"] is None + assert isinstance(body["created_at"], str) + + +def test_new_member_is_stopped_until_password_changed(client, search_stub): + _create_user(client) + token = _sign_in(client) + + search = client.post("/api/search", json={"q": "invoice"}, headers=_as(token)) + assert search.status_code == 403 + assert search.json()["detail"] == "Password change required" + + me = client.get("/api/auth/me", headers=_as(token)) + assert me.status_code == 200 + assert me.json()["must_change_password"] is True + + logout = client.post("/api/auth/logout", headers=_as(token)) + assert logout.status_code == 200 + + +def test_member_changes_password_and_old_session_ends(client, search_stub): + _create_user(client) + old_token = _sign_in(client) + + response = _change_password(client, old_token, "alicepass1", "alicepass2") + + assert response.status_code == 200 + body = response.json() + assert body["token_type"] == "bearer" + assert body["expires_in"] > 0 + new_token = body["access_token"] + + old_session = client.get("/api/auth/me", headers=_as(old_token)) + assert old_session.status_code == 401 + assert old_session.json()["detail"] == "Session expired" + + me = client.get("/api/auth/me", headers=_as(new_token)) + assert me.status_code == 200 + assert me.json()["must_change_password"] is False + + search = client.post("/api/search", json={"q": "invoice"}, headers=_as(new_token)) + assert search.status_code == 200 + + assert client.post("/api/auth/login", json={"username": "alice", "password": "alicepass1"}).status_code == 401 + assert client.post("/api/auth/login", json={"username": "alice", "password": "alicepass2"}).status_code == 200 + + +def test_wrong_current_password_is_rejected(client): + _create_user(client) + token = _sign_in(client) + + response = _change_password(client, token, "not-the-password", "alicepass2") + + assert response.status_code == 400 + assert response.json() == {"detail": "Current password is incorrect"} + assert client.post("/api/auth/login", json={"username": "alice", "password": "alicepass1"}).status_code == 200 + + +def test_new_password_must_differ_from_current(client): + _create_user(client) + token = _sign_in(client) + + response = _change_password(client, token, "alicepass1", "alicepass1") + + assert response.status_code == 400 + assert response.json()["detail"] == "New password must be different from the current password" + + +def test_new_password_must_be_at_least_eight_characters(client): + _create_user(client) + token = _sign_in(client) + + response = _change_password(client, token, "alicepass1", "short") + + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "payload", + [ + {"username": "ab", "password": "alicepass1", "role": "member"}, + {"username": "x" * 51, "password": "alicepass1", "role": "member"}, + {"username": "alice", "password": "short", "role": "member"}, + {"username": "alice", "password": "alicepass1", "role": "owner"}, + ], +) +def test_create_user_validates_input(client, payload): + response = client.post("/api/users", json=payload) + + assert response.status_code == 422 + + +def test_duplicate_username_is_rejected(client): + assert _create_user(client).status_code == 201 + + duplicate = _create_user(client) + taken_by_admin = _create_user(client, username="testuser") + + assert duplicate.status_code == 409 + assert duplicate.json() == {"detail": "Username already exists"} + assert taken_by_admin.status_code == 409 + + +def test_list_shows_creator_and_last_login(client): + _create_user(client) + + before = client.get("/api/users") + assert before.status_code == 200 + rows = {row["username"]: row for row in before.json()} + assert [row["username"] for row in before.json()] == ["alice", "testuser"] + assert rows["alice"]["created_by"] == "testuser" + assert rows["alice"]["last_login_at"] is None + assert rows["testuser"]["created_by"] is None + + _sign_in(client) + + after = {row["username"]: row for row in client.get("/api/users").json()} + assert isinstance(after["alice"]["last_login_at"], str) + + +def test_must_change_user_is_blocked_from_every_gated_route(client): + _create_user(client) + token = _sign_in(client) + + gated = _gated_routes() + assert len(gated) > 10 + + for method, path in gated: + response = client.request(method, _fill_path(path), headers=_as(token)) + assert response.status_code == 403, f"{method} {path}" + assert response.json()["detail"] == "Password change required", f"{method} {path}" + + +def test_wrong_current_password_attempts_are_rate_limited(client): + _create_user(client) + token = _sign_in(client) + + for _ in range(5): + assert _change_password(client, token, "not-the-password", "alicepass2").status_code == 400 + + response = _change_password(client, token, "not-the-password", "alicepass2") + assert response.status_code == 429 + + +def test_password_change_limit_does_not_lock_out_other_users(client, test_user): + _create_user(client) + alice_token = _sign_in(client) + for _ in range(6): + _change_password(client, alice_token, "not-the-password", "alicepass2") + + admin_attempt = _change_password(client, test_user["token"], "not-the-password", "adminpass2") + + assert admin_attempt.status_code == 400 + + +def test_password_change_fails_when_session_version_moves_mid_request(client, db_session, monkeypatch): + _create_user(client) + token = _sign_in(client) + real_hash_password = auth_module.hash_password + + def bump_then_hash(password): + db_session.execute( + update(User).where(User.username == "alice").values(session_version=User.session_version + 1) + ) + db_session.commit() + return real_hash_password(password) + + monkeypatch.setattr(auth_module, "hash_password", bump_then_hash) + response = _change_password(client, token, "alicepass1", "alicepass2") + monkeypatch.undo() + + assert response.status_code == 409 + assert client.post("/api/auth/login", json={"username": "alice", "password": "alicepass1"}).status_code == 200 + + +def _admin_user_id(client, username="alice"): + return next(row["id"] for row in client.get("/api/users").json() if row["username"] == username) + + +def test_admin_disables_and_enables_a_member(client): + _create_user(client) + alice_id = _admin_user_id(client) + _sign_in(client) + + disabled = client.patch(f"/api/users/{alice_id}", json={"is_active": False}) + + assert disabled.status_code == 200 + assert disabled.json()["is_active"] is False + assert disabled.json()["username"] == "alice" + assert disabled.json()["created_by"] == "testuser" + assert {row["username"]: row["is_active"] for row in client.get("/api/users").json()}["alice"] is False + + refused = client.post("/api/auth/login", json={"username": "alice", "password": "alicepass1"}) + assert refused.status_code == 401 + assert refused.json()["detail"] == "Account is disabled" + + enabled = client.patch(f"/api/users/{alice_id}", json={"is_active": True}) + assert enabled.json()["is_active"] is True + assert client.post("/api/auth/login", json={"username": "alice", "password": "alicepass1"}).status_code == 200 + + +def test_disabled_user_session_gets_401_and_stays_dead_after_enable(client): + _create_user(client) + alice_id = _admin_user_id(client) + token = _sign_in(client) + + client.patch(f"/api/users/{alice_id}", json={"is_active": False}) + assert client.get("/api/auth/me", headers=_as(token)).status_code == 401 + + client.patch(f"/api/users/{alice_id}", json={"is_active": True}) + response = client.get("/api/auth/me", headers=_as(token)) + assert response.status_code == 401 + assert response.json()["detail"] == "Session expired" + + +def test_role_change_ends_existing_sessions(client): + _create_user(client) + alice_id = _admin_user_id(client) + first = _sign_in(client) + member_token = _change_password(client, first, "alicepass1", "alicepass2").json()["access_token"] + + response = client.patch(f"/api/users/{alice_id}", json={"role": "admin"}) + + assert response.status_code == 200 + assert response.json()["role"] == "admin" + assert client.get("/api/auth/me", headers=_as(member_token)).status_code == 401 + new_token = _sign_in(client, password="alicepass2") + assert client.get("/api/users", headers=_as(new_token)).status_code == 200 + + +def test_same_role_keeps_sessions(client): + _create_user(client) + alice_id = _admin_user_id(client) + token = _sign_in(client) + + assert client.patch(f"/api/users/{alice_id}", json={"role": "member"}).status_code == 200 + + assert client.get("/api/auth/me", headers=_as(token)).status_code == 200 + + +def test_reset_password_ends_old_session_and_forces_a_change(client, search_stub): + _create_user(client) + alice_id = _admin_user_id(client) + old_token = _sign_in(client) + _change_password(client, old_token, "alicepass1", "alicepass2") + old_token = _sign_in(client, password="alicepass2") + + response = client.post(f"/api/users/{alice_id}/reset-password", json={"password": "tempreset1"}) + + assert response.status_code == 200 + assert response.json()["must_change_password"] is True + assert client.get("/api/auth/me", headers=_as(old_token)).status_code == 401 + + old_password = client.post("/api/auth/login", json={"username": "alice", "password": "alicepass2"}) + assert old_password.status_code == 401 + + new_token = _sign_in(client, password="tempreset1") + blocked = client.post("/api/search", json={"q": "invoice"}, headers=_as(new_token)) + assert blocked.status_code == 403 + assert blocked.json()["detail"] == "Password change required" + + _change_password(client, new_token, "tempreset1", "alicepass3") + fresh = _sign_in(client, password="alicepass3") + assert client.post("/api/search", json={"q": "invoice"}, headers=_as(fresh)).status_code == 200 + + +def test_reset_password_rejects_short_password(client): + _create_user(client) + alice_id = _admin_user_id(client) + + response = client.post(f"/api/users/{alice_id}/reset-password", json={"password": "short"}) + + assert response.status_code == 422 + + +def test_delete_user_clears_who_created_others(client): + assert _create_user(client, username="carol", password="carolpass1", role="admin").status_code == 201 + carol_token = _sign_in(client, username="carol", password="carolpass1") + _change_password(client, carol_token, "carolpass1", "carolpass2") + carol_token = _sign_in(client, username="carol", password="carolpass2") + dave = client.post( + "/api/users", + json={"username": "dave", "password": "davepass1", "role": "member"}, + headers=_as(carol_token), + ) + assert dave.json()["created_by"] == "carol" + carol_id = _admin_user_id(client, "carol") + + response = client.delete(f"/api/users/{carol_id}") + + assert response.status_code == 204 + rows = {row["username"]: row for row in client.get("/api/users").json()} + assert "carol" not in rows + assert rows["dave"]["created_by"] is None + + +def test_deleting_a_user_removes_their_enrollment_codes(client, db_session): + _create_user(client) + alice_id = _admin_user_id(client) + db_session.add( + AgentEnrollment( + id="alice-code", + code_hash="a" * 64, + expires_at=datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=15), + created_by_user_id=alice_id, + ) + ) + db_session.commit() + + response = client.delete(f"/api/users/{alice_id}") + + assert response.status_code == 204 + assert db_session.get(AgentEnrollment, "alice-code") is None + assert db_session.get(User, alice_id) is None + + +@pytest.mark.parametrize( + ("method", "suffix", "body"), + [ + ("PATCH", "", {"is_active": False}), + ("PATCH", "", {"role": "member"}), + ("DELETE", "", None), + ], +) +def test_admin_cannot_disable_demote_or_delete_themselves(client, db_session, method, suffix, body): + me_id = _admin_user_id(client, "testuser") + + response = client.request(method, f"/api/users/{me_id}{suffix}", json=body) + + assert response.status_code == 400 + assert response.json() == {"detail": "You can't do that to your own account"} + assert db_session.get(User, me_id).is_active is True + assert db_session.get(User, me_id).role == "admin" + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("PATCH", "/api/users/{id}", {"is_active": False}), + ("PATCH", "/api/users/{id}", {"role": "member"}), + ("DELETE", "/api/users/{id}", None), + ], +) +def test_last_active_admin_is_protected_from_demotion_disable_and_delete(client, db_session, monkeypatch, method, path, body): + # The acting admin is always an active admin, so this only fires when another + # admin is removed between the check and the write. Simulate that by reporting none left. + assert _create_user(client, username="carol", password="carolpass1", role="admin").status_code == 201 + carol_id = _admin_user_id(client, "carol") + monkeypatch.setattr("app.api.users._active_admin_ids_locked", lambda db: [carol_id]) + + response = client.request(method, path.format(id=carol_id), json=body) + + assert response.status_code == 409 + assert response.json() == {"detail": "There has to be at least one active admin"} + assert db_session.get(User, carol_id).role == "admin" + assert db_session.get(User, carol_id).is_active is True + + +@pytest.mark.parametrize( + ("method", "body"), + [ + ("PATCH", {"is_active": False}), + ("POST", {"password": "tempreset1"}), + ("DELETE", None), + ], +) +def test_unknown_user_is_404(client, method, body): + path = "/api/users/999/reset-password" if method == "POST" else "/api/users/999" + + response = client.request(method, path, json=body) + + assert response.status_code == 404 + + +def test_disabled_admin_can_be_demoted_because_they_are_not_active(client): + _create_user(client, username="carol", password="carolpass1", role="admin") + carol_id = _admin_user_id(client, "carol") + + assert client.patch(f"/api/users/{carol_id}", json={"is_active": False}).status_code == 200 + assert client.patch(f"/api/users/{carol_id}", json={"role": "member"}).status_code == 200 + + +@pytest.fixture +def file_client(tmp_path): + # Real file so a second connection can hold SQLite's write lock. + engine = create_engine(f"sqlite:///{tmp_path / 'users.db'}", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + sessions = sessionmaker(bind=engine, autoflush=False) + with sessions() as session: + admin = User(username="testuser", password_hash=hash_password("testpass"), is_active=True, role=ROLE_ADMIN) + carol = User(username="carol", password_hash=hash_password("carolpass1"), is_active=True, role=ROLE_ADMIN) + session.add_all([admin, carol]) + session.commit() + token, _ = create_access_token(admin.id, admin.username, admin.session_version) + carol_id = carol.id + + def override_get_db(): + session = sessions() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_get_db + try: + with TestClient(app) as client: + client.headers.update({"Authorization": f"Bearer {token}"}) + yield client, engine, carol_id + finally: + app.dependency_overrides.clear() + engine.dispose() + + +def test_admin_check_runs_after_the_write_lock(file_client): + client, engine, carol_id = file_client + statements = [] + + def record(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", record) + try: + response = client.patch(f"/api/users/{carol_id}", json={"role": "member"}) + finally: + event.remove(engine, "before_cursor_execute", record) + + assert response.status_code == 200 + lock = statements.index("BEGIN IMMEDIATE") + admin_check = next(i for i, s in enumerate(statements) if " ".join(s.split()).startswith("SELECT users.id FROM users WHERE users.role")) + assert lock < admin_check + + +def test_admin_change_gives_409_while_another_writer_holds_the_lock(file_client): + client, engine, carol_id = file_client + blocker = engine.connect() + try: + blocker.exec_driver_sql("BEGIN IMMEDIATE") + response = client.patch(f"/api/users/{carol_id}", json={"role": "member"}) + finally: + blocker.rollback() + blocker.close() + + assert response.status_code == 409 + assert response.json() == {"detail": "Someone else is changing users right now. Try again."} + with engine.connect() as conn: + assert conn.exec_driver_sql(f"SELECT role FROM users WHERE id={carol_id}").scalar() == ROLE_ADMIN + + +def test_admin_lock_query_takes_row_locks_on_postgresql(): + sql = str(_active_admins_lock_statement().compile(dialect=postgresql.dialect())) + + assert "FOR UPDATE" in sql and "ORDER BY users.id" in sql diff --git a/cli/onesearch/commands/search.py b/cli/onesearch/commands/search.py index 69c626e2..dc67c075 100644 --- a/cli/onesearch/commands/search.py +++ b/cli/onesearch/commands/search.py @@ -90,7 +90,7 @@ def search( out = ctx.get_console() if as_json: - console.print(json.dumps(result, indent=2)) + click.echo(json.dumps(result, indent=2)) return # Backend returns: {results, total, limit, offset, processing_time_ms} diff --git a/cli/onesearch/commands/status.py b/cli/onesearch/commands/status.py index bb46326c..fb22a523 100644 --- a/cli/onesearch/commands/status.py +++ b/cli/onesearch/commands/status.py @@ -54,7 +54,7 @@ def status(ctx: Context, source_id: str | None, as_json: bool): # Backend returns flat: {source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, failed_files} result = api.source_status(source_id) if as_json: - console.print(json.dumps(result, indent=2, default=str)) + click.echo(json.dumps(result, indent=2, default=str)) return console.print(f"\n[bold cyan]{result.get('source_name', 'Unknown')}[/bold cyan] (ID: {result.get('source_id', source_id)})") @@ -92,7 +92,7 @@ def status(ctx: Context, source_id: str | None, as_json: bool): # Backend returns: {sources: [{source_id, source_name, total_files, successful, failed, skipped, last_indexed_at, ...}]} result = api.status() if as_json: - console.print(json.dumps(result, indent=2, default=str)) + click.echo(json.dumps(result, indent=2, default=str)) return sources = result.get("sources", []) @@ -172,7 +172,7 @@ def health(ctx: Context, as_json: bool): "config_exists": config_path.exists(), "backend_url": ctx.url, } - console.print(json.dumps(result, indent=2)) + click.echo(json.dumps(result, indent=2)) return # Essential status always shown (even in quiet mode) diff --git a/cli/tests/test_api.py b/cli/tests/test_api.py index dea35d98..ee1dfad1 100644 --- a/cli/tests/test_api.py +++ b/cli/tests/test_api.py @@ -2,6 +2,7 @@ import requests from onesearch.api import APIError, OneSearchAPI +from onesearch.config import get_auth_token class DummyResponse: @@ -23,6 +24,13 @@ def test_api_client_sets_bearer_token(): assert api.session.headers["Authorization"] == "Bearer secret" +def test_api_token_is_sent_unchanged_as_bearer(monkeypatch): + token = "os_AbCd-efGh_ijKlMnOpQrStUvWxYz0123456789abcdefgh" + monkeypatch.setenv("ONESEARCH_TOKEN", token) + api = OneSearchAPI(base_url="http://localhost:8000", token=get_auth_token()) + assert api.session.headers["Authorization"] == f"Bearer {token}" + + def test_auth_error_message_is_actionable(monkeypatch): api = OneSearchAPI(base_url="http://localhost:8000") diff --git a/cli/tests/test_integration_auth.py b/cli/tests/test_integration_auth.py index c300b3ed..e8072a4c 100644 --- a/cli/tests/test_integration_auth.py +++ b/cli/tests/test_integration_auth.py @@ -51,7 +51,13 @@ def db_session(): @pytest.fixture -def backend_client(db_session): +def backend_client(db_session, tmp_path, monkeypatch): + # The app writes a generated session key to /app/data at startup when + # SESSION_SECRET isn't set. Keep it in tmp instead. + import app.api.auth as auth_module + monkeypatch.setattr(auth_module, "_session_secret_path", lambda: tmp_path / "session_secret") + monkeypatch.setattr(auth_module, "_generated_secret", None) + def override_get_db(): try: yield db_session diff --git a/cli/tests/test_json_output.py b/cli/tests/test_json_output.py new file mode 100644 index 00000000..024689e8 --- /dev/null +++ b/cli/tests/test_json_output.py @@ -0,0 +1,68 @@ +# Copyright (C) 2026 demigodmode +# SPDX-License-Identifier: AGPL-3.0-only + +"""--json output has to stay parseable when stdout isn't a terminal.""" + +import json + +from click.testing import CliRunner + +from onesearch.context import Context +from onesearch.main import cli + +LONG_TEXT = "lorem ipsum dolor sit amet " * 10 + + +class FakeAPI: + def search(self, **kwargs): + return { + "results": [{"path": "/docs/notes.txt", "snippet": LONG_TEXT}], + "total": 1, + "limit": 20, + "offset": 0, + "processing_time_ms": 3, + } + + def status(self): + return {"sources": [{"source_id": "s1", "source_name": LONG_TEXT, "total_files": 1}]} + + def source_status(self, source_id): + return {"source_id": source_id, "source_name": LONG_TEXT, "total_files": 1, "failed_files": []} + + def health(self, allow_degraded=False): + return {"status": "ok", "version": "0.0.0", "message": LONG_TEXT} + + +def _invoke(monkeypatch, args): + monkeypatch.setattr(Context, "get_api", lambda self: FakeAPI()) + runner = CliRunner() + return runner.invoke(cli, ["--url", "http://testserver", *args], obj=Context()) + + +def test_search_json_is_valid_with_long_strings(monkeypatch): + result = _invoke(monkeypatch, ["search", "lorem", "--json"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["results"][0]["snippet"] == LONG_TEXT + + +def test_status_json_is_valid_with_long_strings(monkeypatch): + result = _invoke(monkeypatch, ["status", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["sources"][0]["source_name"] == LONG_TEXT + + +def test_source_status_json_is_valid_with_long_strings(monkeypatch): + result = _invoke(monkeypatch, ["status", "s1", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["source_name"] == LONG_TEXT + + +def test_health_json_is_valid_with_long_strings(monkeypatch): + result = _invoke(monkeypatch, ["health", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["message"] == LONG_TEXT diff --git a/docs/about/privacy-security.md b/docs/about/privacy-security.md index 0e49d125..ab898405 100644 --- a/docs/about/privacy-security.md +++ b/docs/about/privacy-security.md @@ -24,6 +24,7 @@ Indexed data stays on infrastructure you control: - Derived remote image previews: Stored in the server data directory so previews remain available while an agent is offline - Agent configuration and credential: Stored on the agent machine - Logs: Stored only on your systems +- Audit log: Stored in the server database. It records sign-ins (including the username typed and the IP address of failed ones) and admin changes, and keeps them for 90 days by default. See [Audit log](../administration/authentication.md#audit-log). With on-agent processing, the agent sends extracted text and metadata to the server. Remote image sources may also send reduced JPEG previews for offline viewing; these derived assets remain in the server data directory until replaced or the source is removed. With on-server processing, the agent sends changed originals to temporary server storage for extraction. The server does not retain those original-file transfers after processing. Searchable text and metadata remain in the central index in both modes. @@ -56,7 +57,7 @@ The backend and the bundled Meilisearch run as an unprivileged `onesearch` user, ### Built-in authentication -OneSearch includes JWT-based authentication with bcrypt password hashing. A setup wizard creates the initial admin account on first launch. Login is rate-limited to prevent brute force attacks. +OneSearch includes JWT-based authentication with bcrypt password hashing. A setup wizard creates the first admin account on first launch, and admins can add more users as admins or members. Login is rate-limited to slow down password guessing. API tokens for scripts can only search, and only their hashes are stored. For additional security layers (especially if exposing to the internet), consider pairing with a reverse proxy, VPN, or firewall rules. diff --git a/docs/about/roadmap.md b/docs/about/roadmap.md index 23660b58..f604fec6 100644 --- a/docs/about/roadmap.md +++ b/docs/about/roadmap.md @@ -26,6 +26,7 @@ Recent milestones that are already in the product: - a folder browser for picking local source paths - Markdown previews with tables and task lists, plus a Rendered/Raw toggle - a Clean action for failed files +- several user accounts with admin and member roles, search-only API tokens, and a basic audit log See the [changelog](changelog.md) for release-by-release details. @@ -82,12 +83,11 @@ Cloud and remote storage would probably go through rclone or a similar layer: ### Access control -Right now, if a user can log in, they can search the indexed content. More granular access would need careful design: +Accounts have two roles, admin and member, and everyone can search the indexed content. Admins also get a basic audit log. More granular access would need careful design: - per-source permissions -- read-only users - SSO/LDAP/OAuth -- audit logs +- more audit log options, beyond the basic log that shipped in 1.8.0 ## Not planned right now diff --git a/docs/administration/authentication.md b/docs/administration/authentication.md index 3472b159..bfa049e8 100644 --- a/docs/administration/authentication.md +++ b/docs/administration/authentication.md @@ -1,12 +1,12 @@ # Authentication -OneSearch uses JWT-based authentication for users. User-facing API requests require a valid user token except for setup, login, and health checks. The remote-agent enrollment endpoint uses a single-use enrollment code instead, and the rest of the agent protocol uses a separate agent token. +OneSearch uses JWT-based authentication for users. User-facing API requests require a valid user token except for setup, login, and health checks. Scripts can also use an API token, which only works for search. See [API tokens](#api-tokens). The remote-agent enrollment endpoint uses a single-use enrollment code instead, and the rest of the agent protocol uses a separate agent token. ## Setup wizard -The first time you open OneSearch, you'll see a setup wizard asking you to create an admin account. Pick a username (3 to 50 characters) and a password (at least 8 characters), confirm the password, and you're the admin. You're logged in straight away. +The first time you open OneSearch, you'll see a setup wizard asking you to create the first account. Pick a username (3 to 50 characters) and a password (at least 8 characters), confirm the password, and you're the admin. You're logged in straight away. -This only works once. After the initial account is created, the setup endpoint is disabled. If you need to reset your credentials, you'll need to clear the `users` table in the database. +Setup only works when there are no users at all. After the first account exists, the setup endpoint is disabled. If you lose access, see [Recovering access](#recovering-access). ## Logging in @@ -38,18 +38,127 @@ curl http://localhost:8000/api/sources \ -H "Authorization: Bearer eyJhbGciOi..." ``` +## Users and roles + +Each person who signs in has their own account, and each account is either an admin or a member. Admins run the instance: they manage sources, agents, settings, users and the audit log, and they can search too. Members can search and open, preview and download documents. They also get the Account page and the Settings page, which holds the theme and search display options saved in their browser. + +Members only see Account and Settings in the settings sidebar. Sources, Status, Agents, Users and Audit log are hidden. If a member opens one of those pages by URL, they're sent back to search. The API returns 403 for those routes. Server-wide settings such as indexing and previews are admin-only too, so the Settings page doesn't show them to members. + +Everyone searches the same documents. Per-source access isn't available yet. + +You can have as many admins as you need. The last active admin can't be demoted, disabled, or deleted. You also can't disable, demote, or delete your own account. These rules are enforced by the API, so scripts get the same answer as the web UI. + +## Adding users + +Go to **Settings > Users** and select **Create user**. Enter a username, pick a role, and set a temporary password. Give the username and the temporary password to the person through a channel you trust. + +The new user has to choose their own password the first time they sign in. Until they do, they can't search or do anything else except change the password or sign out. + +## Managing users + +Each row on the Users page has an actions menu: + +- **Make admin** or **Make member** changes the role. +- **Disable** or **Enable** turns the account off or back on. A disabled user can't sign in, and their API tokens stop working. Disabling is the normal way to remove someone. It can be undone, and their name stays on old audit entries. +- **Reset password** gives the user a new temporary password. They have to change it at their next sign-in. +- **Tokens** lists the user's API tokens. You can revoke any of them, but you can't create tokens for someone else. +- **Delete** removes the account. Use it for mistakes. For people who have left, disable the account instead. + +Changing a role, disabling a user, or resetting a password ends that user's sessions and download links right away. Their next request is refused, and they have to sign in again. + +## Sessions + +Signing in creates a session that lasts for `SESSION_EXPIRE_HOURS`, 24 hours by default. A session also ends early when the account's role, status, or password changes, as described above. + +When you change your own password, the browser you changed it from stays signed in. Your other sessions end, and so do download links you made earlier. + +Sessions that were created before you upgraded to 1.8.0 keep working until they expire. + +## Changing your password + +Open **Account** and use **Change password**. Everyone can reach it from the gear icon in the header, and admins can also pick **Account** in the settings sidebar. You need your current password to change it. + +## API tokens + +Scripts and other apps can use an API token instead of a password. Tokens are made on the **Account** page, under **API tokens**. + +- Give the token a name and choose when it expires: never (the default), 30 days, 90 days, or 1 year. +- The token is shown once. Copy it before you close the dialog. OneSearch only stores a hash, so it can't show the token again. +- Tokens start with `os_`, so you can tell them apart from other secrets. +- A token can search, open, preview, and download documents. It can't do anything else. It can't change settings, manage sources, or create more tokens. +- You can revoke a token at any time from the Account page. Revoked and expired tokens get a 401. +- Revoked and expired tokens stay in the list, greyed out, so you can see when they stopped working. +- A token acts as the user who made it. If that user is disabled or deleted, the token stops working. A password change doesn't revoke tokens. + +Send the token as a bearer token: + +```bash +curl -X POST http://localhost:8000/api/search \ + -H "Authorization: Bearer os_your_token" \ + -H "Content-Type: application/json" \ + -d '{"q": "kubernetes"}' +``` + +The CLI accepts tokens too. See [Using an API token](../cli/commands.md#using-an-api-token). + +## Audit log + +Admins can see the audit log under **Settings > Audit log**. The newest entries come first. You can filter by user or by kind of event, and page through older entries. + +The log records: + +- sign-ins and failed sign-ins +- users created, role changes, disabling, enabling, deleting, and password resets +- password changes made by a user +- API tokens created and revoked +- source added, changed, removed, and reindex requests +- agent enrollment codes created, agents approved, disabled, revoked, and changed +- settings changes, with the names of the settings that changed. The log never stores the values. + +Searches and individual uses of API tokens aren't logged. + +Entries are kept for 90 days by default. To change that, go to **Settings > Audit log** and set **Keep audit log for (days)**. Old entries are deleted once a day and when the server starts. Lowering the number removes older entries at the next cleanup. + +Two things to know if other people use your instance: + +- A failed sign-in stores the username that was typed, cut to 50 characters, and the client's IP address. If someone types their password into the username box by mistake, that password ends up in the log until it ages out. +- The IP address is the one the connection came from. OneSearch only trusts the `X-Forwarded-For` header from the nginx bundled inside its own container, so a client can't pick its own address. If you put your own reverse proxy in front, even on the same host, every request comes from the proxy's address. Users then share one sign-in rate limit, and the log shows the proxy's address. + +## Recovering access + +If you forget the password for the only admin, or every admin is locked out, reset a user's password from inside the container: + +```bash +docker exec onesearch-app onesearch-admin reset-password alice +``` + +Use the container name from your compose file. The command works for any user, admin or member. It prints a temporary password: + +``` +Temporary password for alice: Xk3dP9mQw2Lr7TcV +alice must change it at the next sign-in. Their old sessions have ended. +``` + +The user has to choose a new password at their next sign-in, and their old sessions stop working. The command is written to the audit log as a password reset done from the command line. + +If the username doesn't exist, the command says so and lists the usernames that do exist. + +You can run the command as root. It switches to the app user before it touches the database, so it doesn't leave root-owned files behind. + ## Configuration Three environment variables control auth behavior: -**SESSION_SECRET**: The key used to sign JWT tokens. If you don't set this, a hardcoded fallback is used and you'll see a warning in the logs. Fine for development, but set a proper secret for anything else. +**SESSION_SECRET**: The key used to sign login tokens and download links. If you don't set this, OneSearch generates a key and saves it as `session_secret` in the data folder, then reuses it on later starts. You'll see a warning in the logs. Setting your own secret is still recommended, so sessions don't depend on the data volume. ```bash # Generate a good secret openssl rand -base64 32 ``` -**SESSION_EXPIRE_HOURS**: How long tokens last before you need to log in again. Default is 24 hours. +Changing this value ends every session. API tokens keep working, because they don't depend on it. + +**SESSION_EXPIRE_HOURS**: How long sessions last before you need to log in again. Default is 24 hours. **AUTH_RATE_LIMIT**: Max login attempts per minute from one IP address. Default is 5. Successful logins count too, and so do setup requests. After that, requests get rejected with a 429 status until the window resets. @@ -62,6 +171,7 @@ This is a simple in-memory rate limiter. It resets when the server restarts. Goo ## Security notes - Passwords are hashed with bcrypt (not stored in plain text) +- API tokens are stored as hashes, the same way agent tokens are - Tokens are signed with HS256 (HMAC-SHA256) - The `/api/auth/setup` endpoint only works when no users exist - The remote-agent protocol uses agent credentials and cannot be called with a user JWT diff --git a/docs/administration/monitoring.md b/docs/administration/monitoring.md index e8871ec7..f49ff23e 100644 --- a/docs/administration/monitoring.md +++ b/docs/administration/monitoring.md @@ -4,7 +4,7 @@ OneSearch does not need much babysitting, but there are a few places worth check ## Web UI -Use **Admin → Status** for day-to-day monitoring. +Use **Settings → Status** for day-to-day monitoring. It shows: diff --git a/docs/administration/remote-agents.md b/docs/administration/remote-agents.md index 253cb2dc..4e860d2f 100644 --- a/docs/administration/remote-agents.md +++ b/docs/administration/remote-agents.md @@ -6,7 +6,7 @@ An agent represents a machine connection. It does not turn the whole machine int ## Before you enroll -1. Enable **Remote agents** under **Admin > Settings**. +1. Enable **Remote agents** under **Settings**. 2. Download the native agent and updater assets for your platform from a OneSearch release when those assets are included: - Linux amd64 @@ -73,7 +73,7 @@ Create the state and allowed-root directories before checking the configuration. ## Enroll and approve -In **Admin > Agents**, choose **Add agent enrollment**. The code is valid for 15 minutes and can be used once. +In **Settings > Agents**, choose **Add agent enrollment**. The code is valid for 15 minutes and can be used once. Run enrollment on the machine that will run the agent: @@ -83,7 +83,7 @@ onesearch-agent --config /etc/onesearch-agent/config.toml enroll --server https: The command prompts for the enrollment code without echoing it. Do not put the code in an argument, compose file, or shell history. The `--server` value must match `server_url` in the configuration. -The server returns an agent token during enrollment, and the agent saves it locally. The machine then appears with **Pending approval** status. It cannot claim work until an administrator approves it in **Admin > Agents**. +The server returns an agent token during enrollment, and the agent saves it locally. The machine then appears with **Pending approval** status. It cannot claim work until an administrator approves it in **Settings > Agents**. The enrollment code, agent token, and user login token are separate credentials: @@ -130,7 +130,7 @@ For Docker setup, see [Docker agent](../configuration/agent-docker.md). ## Create remote sources -The server owns source configuration and scheduling. After the agent is online, create a source under **Admin > Sources**: +The server owns source configuration and scheduling. After the agent is online, create a source under **Settings > Sources**: 1. Select **Remote agent** as the location. 2. Select an approved agent. @@ -203,7 +203,7 @@ Agent state contains credentials and update recovery files. Treat a Docker state To retire an agent: -1. Open its details in **Admin > Agents** and choose **Revoke credential**. +1. Open its details in **Settings > Agents** and choose **Revoke credential**. 2. Leave **Also delete this agent's sources** unchecked to keep their indexed content and stored previews, or select it to remove the sources and indexed documents. Choose **Confirm revoke**. 3. Uninstall the native service or stop and remove the container. 4. Remove the local state and configuration only after confirming that the credential was revoked. diff --git a/docs/administration/troubleshooting.md b/docs/administration/troubleshooting.md index 2490956a..9748a2d3 100644 --- a/docs/administration/troubleshooting.md +++ b/docs/administration/troubleshooting.md @@ -2,7 +2,7 @@ ## Remote agents -Start with the state shown under **Admin > Agents**: +Start with the state shown under **Settings > Agents**: - `pending`: enrollment saved a credential, but an administrator has not approved the agent. Approve it before expecting jobs to run. - `degraded`: the agent is connected, but a source had a recent indexing failure. Open the agent details, find the affected source in recent scan jobs, and run another scan after correcting the path, permissions, storage, or extraction problem. @@ -93,7 +93,7 @@ MEILI_MASTER_KEY=... SESSION_SECRET=... ``` -A missing `MEILI_MASTER_KEY` stops the container at startup. A missing `SESSION_SECRET` doesn't: OneSearch logs a warning and signs logins with a built-in development key, which is not safe to leave in place. +A missing `MEILI_MASTER_KEY` stops the container at startup. A missing `SESSION_SECRET` doesn't. OneSearch generates a key, saves it as `session_secret` in the data volume, and logs a warning. Set your own `SESSION_SECRET` so sessions don't depend on that volume. Then check the compose service: @@ -115,7 +115,7 @@ Then open `http://localhost:8080`. In Docker, source paths are container paths. -Use **Test** in the source form under **Admin → Sources** first. It reports whether the path is inside allowed roots, exists, is a directory, and is readable from inside the container. +Use **Test** in the source form under **Settings → Sources** first. It reports whether the path is inside allowed roots, exists, is a directory, and is readable from inside the container. If compose has: @@ -137,7 +137,7 @@ Check these in order: 1. Is the source added? 2. Did you run **Reindex**? -3. Does **Admin → Status** show successful files? +3. Does **Settings → Status** show successful files? 4. Are include patterns too narrow? 5. Are you filtering by the wrong source or type? 6. Are files failing because of size limits or extractor errors? @@ -173,6 +173,18 @@ For deeper drift, run a full reindex: onesearch source reindex documents --full ``` +## Locked out + +If you forgot the password, or the only admin can't sign in, reset a password from inside the container. Use your container name from your compose file: + +```bash +docker exec onesearch-app onesearch-admin reset-password +``` + +The command prints a temporary password. Sign in with it and you'll be asked to choose a new password. If the username doesn't exist, the command lists the usernames that do. This works for any account, not only admins. See [Recovering access](authentication.md#recovering-access). + +If you get a 429 error instead of a wrong-password message, you've hit the login rate limit. Wait a minute and try again. + ## Login stopped working Tokens expire. Run: @@ -181,7 +193,7 @@ Tokens expire. Run: onesearch login ``` -If every token becomes invalid after restart, check that `SESSION_SECRET` is set and stable in `.env`. +If every token becomes invalid after restart, check that `SESSION_SECRET` is set and stable in `.env`. If you didn't set it, check that `session_secret` is still in the data volume. Replacing the volume signs everyone out. ## Health endpoint is degraded diff --git a/docs/api/preview.md b/docs/api/preview.md index d3a464b8..26de88b2 100644 --- a/docs/api/preview.md +++ b/docs/api/preview.md @@ -90,7 +90,7 @@ Common codes: ## Settings -Preview behavior is controlled by the [Settings API](settings.md) and **Admin → Settings → File Previews**: +Preview behavior is controlled by the [Settings API](settings.md) and **Settings → File Previews**: - `show_previews` - `raw_preview_enabled` diff --git a/docs/api/settings.md b/docs/api/settings.md index 48298150..f98204de 100644 --- a/docs/api/settings.md +++ b/docs/api/settings.md @@ -1,6 +1,6 @@ # Settings API -The settings API manages app-level indexing, preview, and search defaults that are normally changed from **Admin → Settings**. +The settings API manages app-level indexing, preview, and search defaults that are normally changed from **Settings**. All settings endpoints require authentication. diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 36facc13..e6d8c73c 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -30,6 +30,18 @@ onesearch login --token For scripts, set `ONESEARCH_TOKEN` instead. Nothing is prompted and nothing is written to the config file. +### Using an API token + +You can use an API token from the Account page instead of a password login. Tokens only work for search, so `onesearch search` works with one and the other commands still need a password login. + +```bash +export ONESEARCH_URL=http://infra-stack:8000 +export ONESEARCH_TOKEN=os_your_token +onesearch search "compose" --json +``` + +`onesearch login --token` works the same way if you'd rather store the token in the config file. Tokens are created and revoked on the Account page. See [API tokens](../administration/authentication.md#api-tokens). + ### `onesearch logout` Removes the stored token. diff --git a/docs/configuration/agent-docker.md b/docs/configuration/agent-docker.md index 3c918cdb..217d3a2e 100644 --- a/docs/configuration/agent-docker.md +++ b/docs/configuration/agent-docker.md @@ -63,7 +63,7 @@ docker compose run --rm onesearch-agent config check ## Enroll once -Enable remote agents in the OneSearch settings, create an enrollment code in **Admin > Agents**, then run: +Enable remote agents in the OneSearch settings, create an enrollment code in **Settings > Agents**, then run: ```bash docker compose run --rm onesearch-agent enroll --server https://search.example @@ -71,7 +71,7 @@ docker compose run --rm onesearch-agent enroll --server https://search.example Enter the code at the hidden prompt. Compose uses the same persistent state volume for this one-shot command and the long-running service, so the saved agent credential remains available. Do not put the code or agent token in compose YAML, environment variables, or command arguments. -Approve the pending machine in **Admin > Agents**, then start it: +Approve the pending machine in **Settings > Agents**, then start it: ```bash docker compose up -d onesearch-agent @@ -105,7 +105,7 @@ Keep the state volume and configuration mount in place. Review the release notes ## Remove the container -Revoke its credential from **Admin > Agents**. The confirmation dialog lets you keep its sources or select **Also delete this agent's sources** to remove their indexed data without deleting original files. If cleanup fails, use **Delete remaining sources** on the revoked agent to retry. See [Agent removal](../administration/remote-agents.md#backup-restore-and-removal) for backup and preview-cleanup details. Stop the container after revocation: +Revoke its credential from **Settings > Agents**. The confirmation dialog lets you keep its sources or select **Also delete this agent's sources** to remove their indexed data without deleting original files. If cleanup fails, use **Delete remaining sources** on the revoked agent to retry. See [Agent removal](../administration/remote-agents.md#backup-restore-and-removal) for backup and preview-cleanup details. Stop the container after revocation: ```bash docker compose down diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 2010d23b..0133660c 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -108,7 +108,7 @@ Use `DEBUG` for troubleshooting, `INFO` for normal operation, `WARNING` or `ERRO ### File Size Limits -These set the default indexing size limits. The text, PDF, Office, image, EPUB, comic and media probe limits can also be changed at runtime in **Admin → Settings → Indexing**; saved app settings override these environment defaults. +These set the default indexing size limits. The text, PDF, Office, image, EPUB, comic and media probe limits can also be changed at runtime in **Settings → Indexing**; saved app settings override these environment defaults. **MAX_TEXT_FILE_SIZE_MB** @@ -137,7 +137,7 @@ Maximum size for Office files (docx, xlsx, pptx). ### Rich Media Defaults -Most rich media behavior can also be changed in Admin → Settings. Environment variables set the defaults used before an app setting is saved. +Most rich media behavior can also be changed in Settings. Environment variables set the defaults used before an app setting is saved. **UNSUPPORTED_FILE_POLICY** @@ -241,9 +241,13 @@ Plain text longer than this uses the paginated reader. **SESSION_SECRET** -Secret key used for signing JWT tokens. If not set, a hardcoded fallback is used (fine for dev, but you'll want to set this in production). +Secret key used for signing login tokens and download links. -- Default: hardcoded fallback (logs a warning) +If you don't set it, OneSearch generates a random key the first time it starts, saves it to `session_secret` in the data folder (`/app/data` in the container), and reuses that file on later starts. The server logs a warning when this happens. Setting `SESSION_SECRET` is still recommended, so the key doesn't depend on that volume. If the data volume is lost or replaced, everyone is signed out. + +Installs that didn't set `SESSION_SECRET` before 1.8.0 are signed out once after upgrading. + +- Default: a generated key saved in the data folder - Example: `SESSION_SECRET=your-random-secret-here` Generate one: @@ -493,7 +497,7 @@ If search goes down while a big source is indexing and the container log shows M ### Files being skipped -Check file size limits. Files over the limit are recorded as failed with a "File too large" error. They show up in the failed files list under **Admin → Status**, and in the logs: +Check file size limits. Files over the limit are recorded as failed with a "File too large" error. They show up in the failed files list under **Settings → Status**, and in the logs: ```bash docker compose logs onesearch | grep "File too large" diff --git a/docs/configuration/performance-tuning.md b/docs/configuration/performance-tuning.md index 390eab9e..10baac9f 100644 --- a/docs/configuration/performance-tuning.md +++ b/docs/configuration/performance-tuning.md @@ -29,7 +29,7 @@ The default managed install stores app data in: ## File size limits -Large files are where extraction gets expensive. The text, PDF, Office, image, EPUB, comic and media probe limits can be changed in **Admin → Settings → Indexing**. The matching environment variables set their defaults before an app setting is saved: +Large files are where extraction gets expensive. The text, PDF, Office, image, EPUB, comic and media probe limits can be changed in **Settings → Indexing**. The matching environment variables set their defaults before an app setting is saved: ```env MAX_TEXT_FILE_SIZE_MB=10 @@ -48,10 +48,10 @@ RAW metadata uses `exiftool`; audio/video metadata uses `ffprobe` when available If media-heavy sources are slow, try: -- turn RAW metadata off in **Admin → Settings → Indexing** +- turn RAW metadata off in **Settings → Indexing** - turn media metadata off in the same page - keep GPS metadata disabled unless you need it -- reduce preview limits in **Admin → Settings → File Previews** +- reduce preview limits in **Settings → File Previews** Run a full reindex if you want existing documents refreshed after changing indexing settings. diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2892c3a4..ceed23d4 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -204,7 +204,7 @@ frontend/src/ │ ├── StatusPage.tsx # Indexing status │ └── SettingsPage.tsx # Appearance, preview, indexing, search settings ├── components/ -│ ├── AdminLayout.tsx +│ ├── SettingsLayout.tsx │ ├── MainLayout.tsx │ ├── ProtectedRoute.tsx │ ├── OneSearchLogo.tsx diff --git a/docs/development/frontend-dev.md b/docs/development/frontend-dev.md index 0a9b788e..15189de0 100644 --- a/docs/development/frontend-dev.md +++ b/docs/development/frontend-dev.md @@ -51,7 +51,7 @@ frontend/ │ │ ├── StatusPage.tsx # Indexing status │ │ └── SettingsPage.tsx # Appearance/indexing/search settings │ ├── components/ # Reusable components and layouts -│ │ ├── AdminLayout.tsx +│ │ ├── SettingsLayout.tsx │ │ ├── MainLayout.tsx │ │ ├── ProtectedRoute.tsx │ │ ├── OneSearchLogo.tsx diff --git a/docs/getting-started/first-time-setup.md b/docs/getting-started/first-time-setup.md index 1c61174d..50d968c6 100644 --- a/docs/getting-started/first-time-setup.md +++ b/docs/getting-started/first-time-setup.md @@ -14,7 +14,7 @@ A source is a directory that OneSearch will index. You can have multiple sources ### Using the Web UI -After logging in, click **Admin** in the top nav, then **Sources**. +After logging in, click the gear icon in the top bar. It opens **Account** for everyone. Admins find **Sources** and the other admin pages in the sidebar there. Click **Add Source** and fill in: @@ -64,7 +64,7 @@ Adding a source doesn't automatically index it unless you set up a schedule. You ### Web UI -Go to **Admin → Sources**, find your source, and click the **Reindex** button. +Go to **Settings → Sources**, find your source, and click the **Reindex** button. ### CLI @@ -87,7 +87,7 @@ Indexing large directories takes time, especially for PDFs and Office documents. ### Web UI -Go to **Admin → Status** to see a compact status bar showing API health, search engine status, total documents, source count, and last index time. Below it, each source gets its own row with total/indexed/skipped/failed counts. Click a source to expand and see failed file details. +Go to **Settings → Status** to see a compact status bar showing API health, search engine status, total documents, source count, and last index time. Below it, each source gets its own row with total/indexed/skipped/failed counts. Click a source to expand and see failed file details. The page auto-refreshes every 30 seconds. diff --git a/docs/getting-started/migrate-to-managed-meilisearch.md b/docs/getting-started/migrate-to-managed-meilisearch.md index 9d542ed5..3f3d97b8 100644 --- a/docs/getting-started/migrate-to-managed-meilisearch.md +++ b/docs/getting-started/migrate-to-managed-meilisearch.md @@ -129,7 +129,7 @@ The health response should show Meilisearch as available. After switching modes, run a full reindex for each source. Source configuration and indexed-file metadata are kept in OneSearch's SQLite database, but the managed Meilisearch index starts with its own `/app/meili_data` volume. A normal incremental reindex may skip unchanged files because SQLite still remembers them from the old index. -From the admin UI, go to **Admin → Sources** and use the **Full reindex** action for each source. Confirm the source path still exists inside the container before starting. +From the admin UI, go to **Settings → Sources** and use the **Full reindex** action for each source. Confirm the source path still exists inside the container before starting. From the CLI, list sources and run full reindex per source: diff --git a/docs/getting-started/upgrading.md b/docs/getting-started/upgrading.md index 2d896495..126e24a0 100644 --- a/docs/getting-started/upgrading.md +++ b/docs/getting-started/upgrading.md @@ -67,6 +67,18 @@ There is no major-only tag like `1`. --- +## Upgrading to 1.8.0 + +Nothing to set up. Pull the new image and restart as usual. + +Startup runs a migration that adds user roles, API tokens and the audit log. The account you set up becomes an admin, and it's the only account there is until you add more from **Settings > Users**. Sessions you already have keep working until they expire. The exception is installs that never set `SESSION_SECRET`: they get signed out once, because the key they used before was a built-in one that is now replaced. Setting `SESSION_SECRET` avoids that on future upgrades. + +Code files that start with a docstring or comment get a cleaner title now. Files that are already indexed keep their old title until you run a full reindex on their source. + +This release also fixes a sign-in issue with download links. See the [changelog](../about/changelog.md) for details. + +--- + ## Upgrading from 1.5.0 or earlier The compose files changed after 1.5.0, so do this once. diff --git a/docs/index.md b/docs/index.md index 5f7a0b3c..6365b83e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ Search across all your files, documents, and notes from a single interface. No c **Three ways to use it**: Web UI for browsing and searching, REST API for integrations, and CLI for automation. -**Themeable**: choose Light, Dark, or System mode, then pick an accent color preset or dial in a custom hue from Admin → Settings. Persists across reloads. +**Themeable**: choose Light, Dark, or System mode, then pick an accent color preset or dial in a custom hue from Settings. Persists across reloads. **Privacy first**: Everything runs locally. No telemetry, no cloud services, and the server makes no outbound connections. Your data never leaves your network. diff --git a/docs/supported-formats/images-raw.md b/docs/supported-formats/images-raw.md index 008a072c..6b8ed118 100644 --- a/docs/supported-formats/images-raw.md +++ b/docs/supported-formats/images-raw.md @@ -46,7 +46,7 @@ RAW_PREVIEW_ENABLED=true MAX_PREVIEW_SIZE_MB=50 ``` -Most of these can also be changed in **Admin → Settings**. +Most of these can also be changed in **Settings**. ## Failure behavior diff --git a/docs/supported-formats/index.md b/docs/supported-formats/index.md index cceaa3c3..f8f37daa 100644 --- a/docs/supported-formats/index.md +++ b/docs/supported-formats/index.md @@ -27,8 +27,8 @@ OneSearch supports searchable text extraction, format metadata, and safe preview - RAW metadata extraction uses `exiftool` when available. The Docker image includes it; non-Docker installs can leave it unavailable and RAW files will still index with basic metadata. - Media metadata extraction uses `ffprobe` when available. If it is missing or fails, media files fall back to filename/path metadata instead of failing the whole index run. -- GPS photo metadata is off by default. Enable it in Admin → Settings → Indexing only if you want location data searchable. -- Image and RAW previews are served only for indexed documents, require authentication, and respect the preview size limits in Admin → Settings → File Previews. +- GPS photo metadata is off by default. Enable it in Settings → Indexing only if you want location data searchable. +- Image and RAW previews are served only for indexed documents, require authentication, and respect the preview size limits in Settings → File Previews. - Changing indexing settings affects future indexing. Run a full reindex if you want existing documents updated with new metadata behavior. ## Format Details diff --git a/docs/supported-formats/markdown.md b/docs/supported-formats/markdown.md index b40c6c22..c3525c39 100644 --- a/docs/supported-formats/markdown.md +++ b/docs/supported-formats/markdown.md @@ -40,6 +40,6 @@ Markdown previews render as formatted content in the document page, including ta ## Limits -Markdown uses the text extraction limit shown in **Admin → Settings → Indexing**. `MAX_TEXT_FILE_SIZE_MB` provides the default value, and `TEXT_EXTRACTION_TIMEOUT` controls how long extraction can run. +Markdown uses the text extraction limit shown in **Settings → Indexing**. `MAX_TEXT_FILE_SIZE_MB` provides the default value, and `TEXT_EXTRACTION_TIMEOUT` controls how long extraction can run. If the front matter can't be parsed, OneSearch still indexes the body of the file and just skips the front matter fields. A file over the size limit is recorded as failed, not indexed. diff --git a/docs/supported-formats/metadata-only.md b/docs/supported-formats/metadata-only.md index 3e37896f..b7bdba92 100644 --- a/docs/supported-formats/metadata-only.md +++ b/docs/supported-formats/metadata-only.md @@ -26,7 +26,7 @@ Options: - `metadata_only`: index unknown files by filename/path metadata - `skip`: ignore unknown files -You can also change this in **Admin → Settings → Indexing**. +You can also change this in **Settings → Indexing**. ## When this helps diff --git a/docs/supported-formats/office-documents.md b/docs/supported-formats/office-documents.md index 8eaa74ba..3837fa14 100644 --- a/docs/supported-formats/office-documents.md +++ b/docs/supported-formats/office-documents.md @@ -44,7 +44,7 @@ RTF counts as a text file for limits: it uses the text size limit and `TEXT_EXTR ## Limits -Office extraction uses the Office size limit shown in **Admin → Settings → Indexing**. `MAX_OFFICE_FILE_SIZE_MB` provides the default value, and `OFFICE_EXTRACTION_TIMEOUT` controls how long extraction can run. +Office extraction uses the Office size limit shown in **Settings → Indexing**. `MAX_OFFICE_FILE_SIZE_MB` provides the default value, and `OFFICE_EXTRACTION_TIMEOUT` controls how long extraction can run. Password-protected or corrupt files are handled as extraction failures. OneSearch keeps going and records enough metadata to show what failed. diff --git a/docs/supported-formats/pdf.md b/docs/supported-formats/pdf.md index bd9988d3..0dd0a6ba 100644 --- a/docs/supported-formats/pdf.md +++ b/docs/supported-formats/pdf.md @@ -20,7 +20,7 @@ PDF text extraction depends on the PDF. A born-digital PDF usually works well. A ## Limits -PDF extraction is bounded by the PDF size limit shown in **Admin → Settings → Indexing**. `MAX_PDF_FILE_SIZE_MB` provides the default value, and `PDF_EXTRACTION_TIMEOUT` controls how long extraction can run. +PDF extraction is bounded by the PDF size limit shown in **Settings → Indexing**. `MAX_PDF_FILE_SIZE_MB` provides the default value, and `PDF_EXTRACTION_TIMEOUT` controls how long extraction can run. Encrypted PDFs are tried with an empty password. If that fails, the file is recorded as an extraction failure and can still show basic filename/path metadata. diff --git a/docs/supported-formats/subtitles.md b/docs/supported-formats/subtitles.md index 928a431d..7b672092 100644 --- a/docs/supported-formats/subtitles.md +++ b/docs/supported-formats/subtitles.md @@ -25,4 +25,4 @@ This is useful for finding dialogue across downloaded videos, lectures, conferen ## Limits -Subtitles use the text extraction limit shown in **Admin → Settings → Indexing**. `MAX_TEXT_FILE_SIZE_MB` provides the default value, and `TEXT_EXTRACTION_TIMEOUT` controls how long extraction can run. +Subtitles use the text extraction limit shown in **Settings → Indexing**. `MAX_TEXT_FILE_SIZE_MB` provides the default value, and `TEXT_EXTRACTION_TIMEOUT` controls how long extraction can run. diff --git a/docs/supported-formats/text-files.md b/docs/supported-formats/text-files.md index 472010db..db035d98 100644 --- a/docs/supported-formats/text-files.md +++ b/docs/supported-formats/text-files.md @@ -56,4 +56,4 @@ Configuration and data files. - Automatic encoding detection (UTF-8, Latin-1, etc.) - Full-text indexing - Filter by type (`text`, `code`, `config`) in the search UI -- Size limits configurable in **Admin → Settings → Indexing**; `MAX_TEXT_FILE_SIZE_MB` sets the default for new installs +- Size limits configurable in **Settings → Indexing**; `MAX_TEXT_FILE_SIZE_MB` sets the default for new installs diff --git a/docs/user-guide/adding-sources.md b/docs/user-guide/adding-sources.md index 1ab90344..fe24cbc2 100644 --- a/docs/user-guide/adding-sources.md +++ b/docs/user-guide/adding-sources.md @@ -23,7 +23,7 @@ The `:ro` part is recommended. OneSearch only needs to read your files. ## Add a source in the web UI -Go to **Admin → Sources**, click **Add Source**, then fill in: +Go to **Settings → Sources**, click **Add Source**, then fill in: - **Name**: something you recognize later, like `Documents` or `NAS Photos` - **Path**: the container path, such as `/data/documents` @@ -39,7 +39,7 @@ After saving, run a reindex from the same page unless you set a schedule and are ## Add several folders at once -In **Admin > Sources**, choose **Add folders as sources**. Browse a local allowed root, or select one approved online remote agent if remote agents are enabled. Check up to 25 folders, then review the selection. Give each folder a name and set the patterns and schedule shared by the batch. Remote batches also share a processing mode. Choose **Create sources** to see a result for each folder. +In **Settings > Sources**, choose **Add folders as sources**. Browse a local allowed root, or select one approved online remote agent if remote agents are enabled. Check up to 25 folders, then review the selection. Give each folder a name and set the patterns and schedule shared by the batch. Remote batches also share a processing mode. Choose **Create sources** to see a result for each folder. Each checked folder becomes one source. OneSearch scans files in its subfolders recursively; checking a parent does not create a separate source for every child. You can check a child as well if you want it to be a separate source. Local paths must still be under `ALLOWED_SOURCE_PATHS`. Remote paths must be under the selected agent's allowed roots, and the agent must be online for each path test. @@ -51,7 +51,7 @@ The batch dialog creates source records. Run a reindex on a new source to index ## Add a remote source -Remote agents are optional. Local remains the default and uses a path mounted read-only on the server. To use a remote machine instead, enable **Remote agents** in Settings, enroll and approve its agent, and wait for it to show `online` under **Admin > Agents**. +Remote agents are optional. Local remains the default and uses a path mounted read-only on the server. To use a remote machine instead, enable **Remote agents** in Settings, enroll and approve its agent, and wait for it to show `online` under **Settings > Agents**. In the source form: diff --git a/docs/user-guide/document-preview.md b/docs/user-guide/document-preview.md index 8249e19a..c273d468 100644 --- a/docs/user-guide/document-preview.md +++ b/docs/user-guide/document-preview.md @@ -45,7 +45,7 @@ When you open a result from the search page, OneSearch carries the query into th ## Preview limits -Admins can disable previews, turn RAW embedded previews on or off, and set size limits in **Admin → Settings → File Previews**. The same panel sets how many characters a page of a long text preview holds, and how long a text has to be before it gets split into pages. +Admins can disable previews, turn RAW embedded previews on or off, and set size limits in **Settings → File Previews**. The same panel sets how many characters a page of a long text preview holds, and how long a text has to be before it gets split into pages. RAW previews use embedded JPEGs when available. OneSearch does not decode RAW sensor data, which keeps previews fast and avoids a lot of format-specific trouble. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 23e3ec71..59ae148b 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -24,7 +24,7 @@ Search your documents, use filters to narrow results, and click results to previ ### Adding New Content -After adding files to your source directories, trigger a reindex from **Admin → Sources**. Use incremental reindex (the default) for speed - OneSearch only processes changed files. +After adding files to your source directories, trigger a reindex from **Settings → Sources**. Use incremental reindex (the default) for speed - OneSearch only processes changed files. ### Maintaining Your Index @@ -78,7 +78,7 @@ Incremental reindex is fast for regular updates. Full reindex rebuilds everythin The main page has a search box (press `Ctrl+K` or `Cmd+K` to focus it), filters for source and file type, and clickable result cards with highlighted snippets. Pagination lets you browse through results. -### Admin Section +### Admin pages **Sources**: Manage sources - test root paths, add, edit, delete, trigger incremental reindexing, or run a full reindex when rebuilding the search index. diff --git a/docs/user-guide/indexing.md b/docs/user-guide/indexing.md index 51bb26f7..dbda5d54 100644 --- a/docs/user-guide/indexing.md +++ b/docs/user-guide/indexing.md @@ -16,7 +16,7 @@ Reindexing is incremental by default. OneSearch tracks each file's path, modifie That makes regular indexing cheap after the first scan. A big source might take a while the first time, then finish quickly on later runs. -Run it from the web UI with **Admin → Sources → Reindex**, or from the CLI: +Run it from the web UI with **Settings → Sources → Reindex**, or from the CLI: ```bash onesearch source reindex documents @@ -50,7 +50,7 @@ curl -X POST "http://localhost:8000/api/sources/documents/reindex?full=true" \ Supported formats get full text extraction where possible. PDFs, Office docs, Markdown, text, code, RTF, EPUB, subtitles, comics, images, RAW photos, and media files all have dedicated handling. -Unsupported files can still be indexed as metadata-only entries if **Admin → Settings → Indexing → Unsupported files** is set to metadata-only. That makes filenames and paths searchable even when OneSearch cannot read the file contents. +Unsupported files can still be indexed as metadata-only entries if **Settings → Indexing → Unsupported files** is set to metadata-only. That makes filenames and paths searchable even when OneSearch cannot read the file contents. ## Failures and skipped files @@ -64,7 +64,7 @@ Common reasons: - file is corrupt or encrypted - source mount is no longer available -Check **Admin → Status** for failed file details. If a file is too large, raise the matching size limit in **Admin → Settings → Indexing**, then use **Clean** in that source's failed files list on the Status page, or run a reindex. +Check **Settings → Status** for failed file details. If a file is too large, raise the matching size limit in **Settings → Indexing**, then use **Clean** in that source's failed files list on the Status page, or run a reindex. ## Schedules diff --git a/docs/user-guide/searching.md b/docs/user-guide/searching.md index e4230455..4cf781e3 100644 --- a/docs/user-guide/searching.md +++ b/docs/user-guide/searching.md @@ -40,7 +40,7 @@ With relevance sorting, local results and results from available agents take pri ## Search settings -Go to **Admin → Settings → Search** to tune the search page: +Go to **Settings → Search** to tune the search page: - results per page - sort order diff --git a/docs/user-guide/web-interface.md b/docs/user-guide/web-interface.md index bdd467fc..72e3d3d2 100644 --- a/docs/user-guide/web-interface.md +++ b/docs/user-guide/web-interface.md @@ -13,7 +13,7 @@ You can: - open result cards for previews - use keyboard shortcuts like `Ctrl+K`, `Cmd+K`, `/`, arrows, and `Enter` -The page remembers display preferences from **Admin → Settings → Search** in your browser. +The page remembers display preferences from **Settings → Search** in your browser. ## Document page @@ -23,29 +23,27 @@ If you opened the document from search, matching terms are highlighted where the **Copy path** and **Download** sit at the top of the page. Markdown documents also get a **Rendered** / **Raw** switch. See [Document Preview](document-preview.md) for details. -## Admin → Sources +## Sources Use this page to add, edit, delete, and reindex sources. Each source has a **Reindex** button for the usual incremental run and a **Full reindex** button that clears the source from the index and rebuilds it from scratch, after a confirmation. This is also where you set per-source schedules. Use container paths such as `/data/documents` when running in Docker, and use the **Test** button in the source form to confirm OneSearch can see and read the path before saving. -## Admin → Agents +## Agents -This page only appears after you turn on **Enable remote agents** under **Admin → Settings → Remote Agents**. It's where you enroll, approve, disable and revoke agents. See [Remote agents](../administration/remote-agents.md). +This page only appears after you turn on **Enable remote agents** under **Settings → Remote Agents**. It's where you enroll, approve, disable and revoke agents. See [Remote agents](../administration/remote-agents.md). -## Admin → Status +## Status Status shows API/search health and per-source indexing counts. Expand a source to inspect failed files. The page refreshes automatically, which is useful during a long first index. -## Admin → Settings +## Settings -Settings is where app-level preferences live: +Settings has two kinds of options. Appearance (Light, Dark, or System mode and the accent color) and the search page display options are saved in each browser, so members get them too. Admins also see the server-wide options: -- Light, Dark, or System appearance mode plus theme accent - preview behavior and size limits - indexing behavior for unsupported files, RAW metadata, media metadata, and GPS metadata -- search page display preferences Indexing-related changes apply to future indexing. Run a full reindex if you want old documents refreshed with the new settings. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17c237c2..3f4d1db9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/outfit": "^5.3.0", "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-dropdown-menu": "^2.2.1", "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.2.0", @@ -1521,6 +1522,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz", + "integrity": "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.2.1", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", @@ -1602,6 +1632,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.2.1.tgz", + "integrity": "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", @@ -1704,6 +1774,39 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz", + "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz", @@ -1844,6 +1947,21 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 72d4899d..c75ccd2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/outfit": "^5.3.0", "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-dropdown-menu": "^2.2.1", "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.2.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 00000000..11777b43 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,75 @@ +// 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 App from './App' + +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 }, + isAuthenticated: true, + isLoading: false, + setupRequired: false, + logout: vi.fn(), + }), +})) +vi.mock('./contexts/AuthContext', () => ({ AuthProvider: ({ children }: { children: React.ReactNode }) => children })) +vi.mock('@/hooks/useApi', () => ({ useAppSettings: () => ({ data: { remote_agents_enabled: false } }) })) +vi.mock('./pages/SearchPage', () => ({ default: () =>

search page

})) +vi.mock('./pages/AccountPage', () => ({ default: () =>

account page

})) +vi.mock('./pages/admin/SettingsPage', () => ({ default: () =>

settings page

})) +vi.mock('./pages/admin/SourcesPage', () => ({ default: () =>

sources page

})) +vi.mock('./pages/admin/StatusPage', () => ({ default: () =>

status page

})) +vi.mock('./pages/admin/AgentsPage', () => ({ default: () =>

agents page

})) +vi.mock('./pages/admin/UsersPage', () => ({ default: () =>

users page

})) +vi.mock('./pages/admin/AuditLogPage', () => ({ default: () =>

audit page

})) + +function renderAt(path: string) { + return render() +} + +describe('member routes', () => { + it.each([ + ['/account', 'account page'], + ['/settings', 'settings page'], + ])('renders %s', (path, text) => { + role = 'member' + renderAt(path) + expect(screen.getByText(text)).toBeInTheDocument() + }) + + it.each(['/admin', '/admin/sources', '/admin/users', '/admin/audit', '/admin/settings'])( + 'sends %s back to search', + (path) => { + role = 'member' + renderAt(path) + expect(screen.getByText('search page')).toBeInTheDocument() + for (const text of ['sources page', 'status page', 'users page', 'audit page', 'settings page', 'agents page']) { + expect(screen.queryByText(text)).not.toBeInTheDocument() + } + } + ) +}) + +describe('admin routes', () => { + it('keeps /admin/settings working as the settings page', () => { + role = 'admin' + renderAt('/admin/settings') + expect(screen.getByText('settings page')).toBeInTheDocument() + }) + + it('keeps /admin opening Sources', () => { + role = 'admin' + renderAt('/admin') + expect(screen.getByText('sources page')).toBeInTheDocument() + }) + + it('renders /settings for admins too', () => { + role = 'admin' + renderAt('/settings') + expect(screen.getByText('settings page')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2017b8a..86f71c9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,22 +2,25 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Suspense, lazy } from 'react' -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, Outlet } from 'react-router-dom' import { AuthProvider } from './contexts/AuthContext' import { ThemeProvider } from './contexts/ThemeContext' import { SearchSettingsProvider } from './contexts/SearchSettingsContext' import ProtectedRoute from './components/ProtectedRoute' import MainLayout from './components/MainLayout' -import AdminLayout from './components/AdminLayout' +import SettingsLayout from './components/SettingsLayout' import SearchPage from './pages/SearchPage' import SourcesPage from './pages/admin/SourcesPage' import StatusPage from './pages/admin/StatusPage' import SettingsPage from './pages/admin/SettingsPage' import AgentsPage from './pages/admin/AgentsPage' +import UsersPage from './pages/admin/UsersPage' +import AuditLogPage from './pages/admin/AuditLogPage' +import AccountPage from './pages/AccountPage' import LoginPage from './pages/LoginPage' import SetupPage from './pages/SetupPage' -// Lazy-load DocumentPage — it pulls in react-syntax-highlighter + react-markdown (~400KB) +// Lazy-load DocumentPage, it pulls in react-syntax-highlighter + react-markdown (~400KB) const DocumentPage = lazy(() => import('./pages/DocumentPage')) function App() { @@ -43,20 +46,32 @@ function App() { } /> - {/* Protected admin routes */} + {/* Settings area: account and preferences for everyone, admin pages under /admin */} - + } > - } /> - } /> - } /> - } /> + } /> } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Catch-all 404 */} diff --git a/frontend/src/components/AdminLayout.test.tsx b/frontend/src/components/AdminLayout.test.tsx deleted file mode 100644 index 3b0193f1..00000000 --- a/frontend/src/components/AdminLayout.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it, vi } from 'vitest' -import AdminLayout from './AdminLayout' - -let enabled = false -vi.mock('@/contexts/useAuth', () => ({ useAuth: () => ({ user: null, logout: vi.fn() }) })) -vi.mock('@/hooks/useApi', () => ({ useAppSettings: () => ({ data: { remote_agents_enabled: enabled } }) })) - -describe('AdminLayout remote navigation', () => { - it('hides Agents while disabled and shows it once enabled', () => { - const { rerender } = render() - expect(screen.queryByText('Agents')).not.toBeInTheDocument() - enabled = true - rerender() - expect(screen.getByText('Agents')).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/AdminLayout.tsx b/frontend/src/components/AdminLayout.tsx deleted file mode 100644 index bdabde33..00000000 --- a/frontend/src/components/AdminLayout.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (C) 2025 demigodmode -// SPDX-License-Identifier: AGPL-3.0-only - -import { Outlet, Link, useLocation } from 'react-router-dom' -import { Database, Activity, ArrowLeft, Terminal, LogOut, User, Settings, Cpu } from 'lucide-react' -import { cn } from '@/lib/utils' -import { useAuth } from '@/contexts/useAuth' -import { Button } from '@/components/ui/button' -import { OneSearchLogo } from '@/components/OneSearchLogo' -import { useAppSettings } from '@/hooks/useApi' - -const adminNavItems = [ - { path: '/admin/sources', label: 'Sources', icon: Database }, - { path: '/admin/status', label: 'Status', icon: Activity }, - { path: '/admin/settings', label: 'Settings', icon: Settings }, -] - -export default function AdminLayout() { - const location = useLocation() - const { user, logout } = useAuth() - const { data: appSettings } = useAppSettings() - const visibleNavItems = appSettings?.remote_agents_enabled - ? [...adminNavItems.slice(0, 2), { path: '/admin/agents', label: 'Agents', icon: Cpu }, adminNavItems[2]] - : adminNavItems - - return ( -
- {/* Header */} -
-
-
- {/* Logo & breadcrumb */} -
- -
-
- - OneSearch - - - / -
- - Admin -
-
- - {/* Right side actions */} -
- - - Back to Search - - {user && ( - <> - - - {user.username} - - - - )} -
-
-
-
- -
-
- {/* Sidebar — horizontal pills on mobile, vertical list on md+ */} - - - {/* Main content */} -
- -
-
-
-
- ) -} diff --git a/frontend/src/components/ForcedPasswordChange.test.tsx b/frontend/src/components/ForcedPasswordChange.test.tsx new file mode 100644 index 00000000..0e7125f5 --- /dev/null +++ b/frontend/src/components/ForcedPasswordChange.test.tsx @@ -0,0 +1,76 @@ +// Copyright (C) 2026 demigodmode +// SPDX-License-Identifier: AGPL-3.0-only + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProtectedRoute from './ProtectedRoute' +import { AuthProvider } from '@/contexts/AuthContext' +import type { User } from '@/types/api' + +const api = vi.hoisted(() => ({ + getAuthStatus: vi.fn(), + getToken: vi.fn(), + getCurrentUser: vi.fn(), + changePassword: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + logout: vi.fn(), +})) +vi.mock('@/lib/api', async (importOriginal) => ({ ...(await importOriginal()), ...api })) + +const temporaryUser: User = { id: 2, username: 'bob', role: 'member', is_active: true, created_at: '2026-01-04T10:00:00Z', must_change_password: true } +const settledUser: User = { ...temporaryUser, must_change_password: false } + +function renderApp() { + return render( + + + +
Search page
} /> + Login page} /> +
+
+
, + ) +} + +describe('forced password change', () => { + beforeEach(() => { + vi.clearAllMocks() + api.getAuthStatus.mockResolvedValue({ setup_required: false }) + api.getToken.mockReturnValue('old-token') + api.getCurrentUser.mockResolvedValue(temporaryUser) + }) + + it('explains that the password is temporary', async () => { + renderApp() + expect( + await screen.findByText("You're signed in with a temporary password. Choose a new one to continue."), + ).toBeInTheDocument() + }) + + it('changes the password, stores the new token and lands on search', async () => { + api.getCurrentUser.mockResolvedValueOnce(temporaryUser).mockResolvedValueOnce(settledUser) + api.changePassword.mockResolvedValue({ access_token: 'new-token', token_type: 'bearer', expires_in: 3600 }) + renderApp() + await screen.findByText('Choose a new password') + + await userEvent.type(screen.getByLabelText('Current password'), 'temp-pass-1') + await userEvent.type(screen.getByLabelText('New password'), 'new-password-1') + await userEvent.type(screen.getByLabelText('Confirm new password'), 'new-password-1') + await userEvent.click(screen.getByRole('button', { name: 'Save password' })) + + expect(api.changePassword).toHaveBeenCalledWith({ current_password: 'temp-pass-1', new_password: 'new-password-1' }) + expect(api.setToken).toHaveBeenCalledWith('new-token') + expect(await screen.findByText('Search page')).toBeInTheDocument() + }) + + it('signs out from the change password screen', async () => { + renderApp() + await userEvent.click(await screen.findByRole('button', { name: 'Sign out' })) + expect(api.logout).toHaveBeenCalled() + expect(await screen.findByText('Login page')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ForcedPasswordChange.tsx b/frontend/src/components/ForcedPasswordChange.tsx new file mode 100644 index 00000000..895e2ce9 --- /dev/null +++ b/frontend/src/components/ForcedPasswordChange.tsx @@ -0,0 +1,39 @@ +// Copyright (C) 2026 demigodmode +// SPDX-License-Identifier: AGPL-3.0-only + +import { useNavigate } from 'react-router-dom' +import { useAuth } from '@/contexts/useAuth' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { PasswordChangeForm } from '@/components/PasswordChangeForm' + +export default function ForcedPasswordChange() { + const { logout } = useAuth() + const navigate = useNavigate() + + return ( +
+ + + Choose a new password + + You're signed in with a temporary password. Choose a new one to continue. + + + + navigate('/', { replace: true })} /> +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/components/MainLayout.test.tsx b/frontend/src/components/MainLayout.test.tsx new file mode 100644 index 00000000..ae748b74 --- /dev/null +++ b/frontend/src/components/MainLayout.test.tsx @@ -0,0 +1,56 @@ +// 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 settings link', () => { + it('opens Account from the gear for admins', () => { + role = 'admin' + render() + expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/account') + }) + + it('opens Account from the gear for members', () => { + role = 'member' + render() + expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/account') + }) + + it('has no separate Account link for either role', () => { + for (const r of ['admin', 'member'] as const) { + role = r + const { unmount } = render() + expect(screen.queryByRole('link', { name: 'Account' })).not.toBeInTheDocument() + unmount() + } + }) + + it('does not show the old Admin wording', () => { + role = 'admin' + render() + expect(screen.queryByText('Admin')).not.toBeInTheDocument() + }) +}) + +describe('MainLayout header controls', () => { + it('shows the username and a named Logout button to every role', () => { + for (const r of ['admin', 'member'] as const) { + role = r + const { unmount } = render() + expect(screen.getByText('alice')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument() + unmount() + } + }) +}) diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx index bf7ce98e..4b5ecd4b 100644 --- a/frontend/src/components/MainLayout.tsx +++ b/frontend/src/components/MainLayout.tsx @@ -32,13 +32,15 @@ export default function MainLayout() { {/* Navigation */}